The model¶
The model is a set of YAML (or JSON) documents describing user needs,
requirements, risks, mitigations and test methods. This page is the reference
for every field, the config: section, the supported file layouts and the
validation rules. Concepts explains what the verdicts mean.
File layouts¶
Model files are merged, so you can organise them however suits review:
Section documents hold any mix of the top-level sections:
project: {name: Thermostat}
config: {acceptable_risk_score: 6}
user_needs: [...]
requirements: [...]
risks: [...]
mitigations: [...]
test_methods: [...]
Single-object documents hold one entity, named by kind (user_need,
requirement, risk, mitigation, test_method; the plural section names
are accepted too):
# requirements/REQ-0007.yaml
kind: requirement
id: REQ-0007
title: Lock the door while heating
satisfies: [UN-0002]
One object per file makes merges and reviews of individual items trivial; it is the layout the web editor writes.
Loading rules:
Paths may be files or directories. Directories are searched recursively for
*.yaml,*.ymland*.json, skipping directories whose name starts with a dot, in sorted order.A file may contain several YAML documents separated by
---.config:may appear in only one document.project:(or its aliasmeta:) mappings from all documents are merged.An id may be defined only once across all files and all kinds.
Every problem is reported with its
file:line.
Fields common to every entity¶
Field |
Type |
Notes |
|---|---|---|
|
string, required |
Matches the kind’s id pattern (default |
|
string, required |
One line. |
|
string |
Free text; shown in reports. |
|
enum |
|
|
string |
Who is responsible. |
|
list of strings |
Free-form labels. |
|
list |
See notes. |
Every list-valued field (tags, satisfies, refines, modules,
mitigates, implemented_by, mitigated_by) also accepts a single string,
split on commas: satisfies: UN-1, UN-2.
Notes¶
Notes attach review findings and follow-up work to the object they concern. A note is a string or a mapping:
notes:
- "plain comment"
- text: The cutoff threshold is not justified anywhere.
kind: gap # comment (default) | gap | question | todo
status: open # open (default) | resolved
author: reviewer-bot
created: 2026-09-30
id: n2 # defaults to n<position>
Open notes are listed with their entity in the reports.
User needs¶
user_needs: entries — what a user must be able to do.
Field |
Type |
Notes |
|---|---|---|
|
string |
Why the need exists. |
|
list of claims |
Validation evidence (a usability study, an acceptance run): claims in the same namespace as |
Requirements¶
requirements: entries — verifiable statements the product must meet.
Field |
Type |
Notes |
|---|---|---|
|
string |
Why the requirement exists. |
|
string |
Free-form ( |
|
list of UN ids |
The needs this requirement helps meet. |
|
list of REQ ids |
The parent requirement this one decomposes: one id, so |
|
TM id or level |
The verification rigor demanded. Default: |
|
list of claims |
The test cases that verify it: see claims. |
|
list of strings |
Implementing modules (documentation aid; drives the per-module rollup). |
A requirement must trace upward — satisfies a need, refines another
requirement, or be implemented_by a mitigation — or it is an orphan.
Risks¶
risks: entries — a hazard → hazardous situation → harm chain (ISO 14971).
Field |
Type |
Notes |
|---|---|---|
|
string |
Potential source of harm. |
|
string |
Circumstance of exposure. |
|
string |
The injury or damage. |
|
enum |
One of |
|
enum |
One of |
|
enum |
Severity after risk control; if omitted, the initial |
|
enum |
Likelihood after risk control; if omitted, the initial |
|
string |
Residual-risk note or acceptance rationale. |
|
list of MIT ids |
Optional back-reference; must list exactly the mitigations whose |
The risk score is (severity index + 1) × (likelihood index + 1) on the
configured scales (so 1–25 on the default five-point scales). The residual score
uses the residual fields, falling back to the initial ones.
Mitigations¶
mitigations: entries — risk control measures.
Field |
Type |
Notes |
|---|---|---|
|
enum |
|
|
list of RISK ids |
Required (at least one). |
|
list of REQ ids |
The requirements that realise the control. A requirement implements at most one mitigation, and one that does refines nothing ( |
|
list of claims |
Effectiveness evidence of the control (ISO 14971 §7.2): see claims. |
Test methods¶
test_methods: entries — named verification procedures.
Field |
Type |
Notes |
|---|---|---|
|
level, required |
The rigor this method provides and demands. |
|
string |
How the method is carried out. |
Claims: which test cases verify an entity¶
A test case verifies at most one requirement. A set of test cases may
together verify one requirement, but no case ever counts toward two. Claims
are how the model says which cases belong to whom: verified_by on
requirements and mitigations, validated_by on user needs — one namespace,
so a need, a requirement and a mitigation can no more share a case than two
requirements can. Risks and test methods hold no claims.
Each item names one target and either the cases it claims or the whole target:
requirements:
- id: PR-29
verified_by:
- target: //web:improv_provision_test
cases: ["improv_provision::provisionViaBle: survives Android's first-attempt GATT flake via retry"]
- target: //pi/hitl/tests:hitl_test
cases: ["pi.hitl.tests.test_improv::test_retry_*"] # '*' is the only wildcard
- target: //pi/hitl/harness:e2e_netstack
level: hitl # for cases that declare no level
cases: [hitl_e2e.improv_provision::readiness_retry]
- id: PR-25
verified_by:
- {target: //requirements:model_test, whole: true, reason: "rr validate runs as one test"}
user_needs:
- {id: UN-5, validated_by: [{target: "record:usability_study", cases: ["*"]}]}
Key |
Meaning |
|---|---|
|
A Bazel label, or a pseudo-target: |
|
A non-empty list of case selectors (below). |
|
Every result of the target — its single synthetic result when it reports no per-case results (Bazel’s generated |
|
The level provided by claimed cases that declare none (default |
|
Why a |
An item has exactly one of cases and whole: true. The 0.2 forms still
parse — a bare label, {target} or {target, level} — as a whole-target claim,
with a bare-target-reference warning (an error from 0.4).
A malformed item — both or neither of cases and whole: true, an empty or
non-list cases, whole: false, a reason without whole: true — is a
bad-selector error, and until it is fixed it claims the whole target,
whatever cases it lists: it can only add shared-case conflicts, never hide
one. The web editor shows such an item exactly as written and refuses to
rewrite the entity until it is fixed by hand.
Case selectors match the whole case path (<classname>::<name>, see
rr cases), case-sensitively. * matches any string, including the empty
string, ::, / and blanks; ** is the same as *. \* is a literal star
and \\ a literal backslash; any other backslash is a bad-selector. ?, [
and ] are literals, so test_x[*] matches the pytest id test_x[a]. A
selector is never empty, never padded with blanks, and never [target] (claim
a synthetic result with whole: true). Like case paths it is in Unicode NFC
(an editor that writes é decomposed would otherwise claim a case that never
exists), and it never ends with an [rr:ID] name tag, which ingest strips
from case names.
Labels are compared in one spelling: @@//p:n, @//p:n and
@<config.main_repo>//p:n are //p:n; //p is //p:p; a module
repository’s canonical @@name+// (Bazel 8) or @@name~// (Bazel 7) is
@name//. Anything else (:n, p:n, //p/...) is a bad-target.
No two entities can claim one case. rr validate compares the claims of
different entities on each target: a whole claim overlaps anything, and two
selectors overlap when some case path matches both — decided exactly (the
grammar has * as its only wildcard), with a shortest such path as the
example:
requirements.yaml:411: error: [shared-case] PR-29 and PR-13 both claim cases of //web:clocksync_test ('clocksync::*' vs 'clocksync::offset*'), e.g. 'clocksync::offset' (PR-13 claims it at requirements.yaml:233). A test case verifies at most one requirement: narrow one selector.
config.variants declares targets that run the same test code under another
configuration; claims of different entities across one group are compared the
same way (same-code-multiple-owners). Both are errors no configuration can
turn off. Overlapping selectors of one entity are only redundant-selector.
Who owns a case: hybrid and model¶
config.attribution says what happens to a case that no claim selects
(One test case, one requirement has the full decision):
Mode |
A case no claim selects |
A tag on a claimed case |
|---|---|---|
|
owned by its tag, when it declares exactly one id |
a cross-check ( |
|
unowned ( |
a cross-check ( |
In both modes a case whose evidence names two ids is quarantined, and two
entities can never claim one case. Hybrid keeps a 0.2 project’s tag-based
attribution working while it moves to claims; model mode is the end state, in
which the model is the single, reviewable record of which case verifies what.
rr attribution --suggest prints the selector to add for every tag-owned
case, and rr migrate apply --stage model writes them all
(Migrating to one requirement per test case).
The thermostat example is in model mode. Its configuration and one requirement’s claims, a literal pytest case, a glob over a parametrized test’s ids and two Rust tests:
config:
attribution: model
sets_lock: verification.rrlock
requirements:
- id: REQ-4
title: Reject setpoints outside 5-30 °C
verified_by:
- target: //:controller_test
cases:
- "tests.test_controller::test_rejects_setpoints_outside_range[*]"
- "tests.test_controller::test_accepts_range_limits"
- target: //:setpoint_test
cases:
- "tests::rejects_out_of_range"
- "tests::checks_the_range_after_converting"
Its tests keep their single-id tags (@pytest.mark.rr("REQ-4"),
rr::verifies!("REQ-4")) as cross-checks; a test without one is owned just
the same.
The verification-set lock¶
Globs, whole-target claims and tags select whatever cases the evidence holds,
so a deleted, renamed or filtered test would silently leave a requirement’s
verification set. The lock pins the sets: config.sets_lock names a
generated file (conventionally verification.rrlock, next to the model) that
maps each locked case to the one entity whose set holds it:
# Generated by `rr sets lock --write`; review its diff like a golden file.
schema: rules_requirements/verification-lock/v1
cases:
//web:improv_provision_test:
"improv_provision::provisionViaBle: survives Android's first-attempt GATT flake via retry": PR-29
//requirements:model_test:
"[target]": PR-25
Each case maps to exactly one id: a list, a repeated case, one case under two
spellings of its target, or a case path that is not canonical is
lock-invalid, so the file cannot express two owners. The extension is not one
rr validate reads as a model file.
The lock never creates ownership. Attribution decides every owner from the
claims (and, in hybrid mode, single tags); a lock entry only adds an
expected member to its entity’s set. A locked case the target ran without
reads missing, one whose target did not run not-run, and one that now has
another owner or none moved (with lock-owner-changed) — each makes the set
INCOMPLETE until the lock is regenerated and its diff reviewed. An owned case
the lock does not list is an unlocked-member gap. Without a lock, reports
carry one unpinned-sets gap naming the entities whose sets are not pinned.
The workflow: run the tests, then lock, then review the diff with the change that caused it.
$ bazel test //...
$ rr sets lock --model requirements/ --evidence bazel-testlogs --write
$ git diff requirements/verification.rrlock
In a hermetic Bazel project the same is bazel run //:sets_lock_test.update
(rr_sets_lock_test, as in the thermostat example), and bazel test //...
then checks the lock against the evidence.
From the command line:
$ rr sets lock --model requirements/ --evidence bazel-testlogs # print the diff
$ rr sets lock --model requirements/ --evidence bazel-testlogs --write # write it
$ rr sets check --model requirements/ --evidence bazel-testlogs # the CI gate
$ rr sets show PR-13 --model requirements/ --evidence bazel-testlogs # one set
rr sets lock refuses while a case is quarantined (exit 3) and keeps every
entry the evidence no longer holds unless --allow-removals is given (it
lists them), so a crashed or filtered run never shrinks a set silently; owner
changes are listed and allowed. A target absent from the evidence (the lane
that did not run) keeps an entry even with --allow-removals while a claim
of its owner selects it, and, in hybrid mode, while its owner claims nothing
on that target and no other claim selects it (a tag may own it there). That
last rule does not hold for a suite:/record: pseudo-target, which names
no build target, so its absence cannot be told from a move:
--allow-removals drops such an entry, and one whose case now runs under
another target for the same owner (JUnit moved into a testlogs tree) is
listed as a removal and reads lock-stale until it is dropped. rr sets check exits 1, restricted to the
targets present in the evidence, on a missing case, an unlocked member, an
owner change or a stale entry. --sets-lock PATH reads (and writes) another
lock than config.sets_lock. In Bazel, rr_model(lock = ...) checks the lock
statically and pins the reports’ sets with it, and rr_sets_lock_test runs
rr sets check over rr_evidence (Bazel rules).
In Python, rules_requirements.lock.plan_lock() computes the lock for
an attribution (refusing while any case is quarantined, and listing removals
and owner changes for review) and rules_requirements.lock.write_lock()
writes it. An owner change is allowed, also for a target the evidence at hand
did not run (the entry follows the one entity whose claims now select it);
a removal — a case missing from a target that ran, or an entry no claim
selects any more — is kept in the lock until removals are allowed.
rules_requirements.trace.build_matrix() reads the configured lock
unless it is passed one; lock=rules_requirements.lock.NO_LOCK reads none
(the sets are not pinned). NO_LOCK is recognised by its none flag
(rules_requirements.lock.is_no_lock), so a copy or a pickled round trip of
it still means “no lock”.
The config: section¶
Everything project-specific is configured in one config: mapping (in any one
model document). Omitted keys keep their defaults.
Key |
Default |
Meaning |
|---|---|---|
|
|
Id prefix per kind (keys may also be the section names). Prefixes must be distinct. |
|
|
Regular expression for ids; |
|
|
The verification ladder, lowest first. Items are names or |
|
|
Demanded by requirements without a |
|
|
Provided by evidence without a |
|
|
Gaps demanding at most this level route |
|
|
Requirements demanding at least this level must have cheap backing evidence. Empty disables the policy. |
|
|
What counts as cheap backing evidence. |
|
|
Ordinal severity scale, lowest first. |
|
|
Ordinal likelihood scale, lowest first. |
|
|
Residual scores above this raise |
|
|
Severities hoisted into the “not mitigated” banner. |
|
see rules |
Severity ( |
|
|
Extra regular expressions for source annotations; capture group 1 holds the id list (Source annotations). |
|
|
Who owns a test case no claim covers: |
|
|
This repository’s apparent name in other modules: |
|
|
The verification-set lock ( |
|
|
A pass that needed a retry: |
|
|
Members of one set stamped with different builds: |
|
|
Groups (lists of two or more labels) of targets that run the same test code. |
Example — a project that keeps its PR- ids, uses its own ladder and starts
with coverage rules as warnings:
config:
prefixes: {requirement: PR}
levels:
- analysis
- unit
- bench
- field
- {name: review, ordered: false, description: Signed design review}
default_level: unit
default_provided_level: unit
autonomous_max_level: unit
pyramid_min_level: bench
pyramid_cheap_levels: [analysis, unit]
acceptable_risk_score: 6
rules:
need-unsatisfied: warning
requirement-orphan: warning
annotation_patterns:
- 'Requirements:\s*([A-Z0-9,\s-]+)'
The top level of a section document may also carry schema_version, which is
accepted and currently ignored.
Validation¶
rr validate (and the <model>_test target that rr_model creates) reports
every problem at once, each with a stable code:
$ rr validate requirements/
requirements/reqs.yaml:14: error: [dangling-reference] REQ-4: satisfies unknown user_need UN-9
requirements/risks.yaml:3: error: [risk-unmitigated] RISK-2: no mitigation controls this risk
--format json prints the issues as a list of {severity, code, message, entity, path, line}; --strict promotes warnings to errors.
Always errors¶
Code |
Raised when |
|---|---|
|
A document or entity is malformed: not a mapping, a missing |
|
An id does not match its kind’s pattern. |
|
One id names entities in two sections (a model built in Python; the loader reports a duplicate id as |
|
|
|
A reference names an id that does not exist. |
|
A reference names an entity of the wrong kind, or a requirement refines itself. |
|
A requirement’s |
|
A claim’s |
|
A test method has no |
|
A risk’s severity/likelihood (initial or residual) is off-scale, or a mitigation’s |
|
A risk’s |
|
Requirements refine each other in a cycle. |
|
A mitigation mitigates nothing. |
|
Claims of two entities can select one test case (the message names a witness). |
|
Claims of two entities on targets of one |
|
A claim item with both or neither of |
|
A claim’s or |
|
With |
|
The configured |
|
|
These cannot be configured: naming one under config.rules is itself an
error, and so is naming a report-time quarantine (multi-tag,
attribution-conflict).
Configurable coverage rules¶
Rule |
Default |
Raised when |
|---|---|---|
|
error |
No requirement satisfies a user need — it can never be validated. |
|
error |
A requirement satisfies no need, refines nothing and implements no mitigation. |
|
error |
No mitigation controls a risk. |
|
error |
No requirement implements a mitigation. |
|
warning |
The residual risk score exceeds |
|
error |
A document, entity, note or claim item has a key the model does not know — usually a typo ( |
|
warning |
A claim in a 0.2 form (bare label, |
|
warning |
A |
|
off |
A selector with a |
|
warning |
Two selectors of one entity on one target overlap. |
|
error |
A requirement refines two or more parents. Refines must form a tree, so each test case’s evidence rolls up one chain of requirements (Derived verdicts are not ownership); |
|
error |
A requirement that implements a mitigation has another parent: a second mitigation, or a requirement it refines. A mitigation’s VERIFIED is derived from its implementing requirements, so each case would roll up two chains (Derived verdicts are not ownership); |
|
warning |
A requirement refined by others also claims cases of its own. |
|
error |
|
The 0.3 rules coarse-claim, tag-mismatch, unclaimed-tag,
suite-level-requirement, duplicate-case, level-mismatch,
same-path-multiple-owners and multi-verifies-annotation are configured
here too; they are raised when evidence is attributed or annotations are scanned.
Projects that deliberately carry extra keys (a jira: link, a note’s
priority:) set the rule to warning or off; the tools keep such keys
intact, including the web editor. Like every rule, unknown-field findings are issues returned by
rules_requirements.validate.validate() (and shown by rr validate,
including --format json); --strict promotes them to errors when the rule is
a warning. A key repeated within one mapping is always an error: YAML would
otherwise silently keep only the last value.
JSON Schema and editor support¶
The repository ships a JSON Schema for model files at
schema/rules_requirements.schema.json (exported to Bazel as
@rules_requirements//:schema/rules_requirements.schema.json), and this site
serves it at https://studio-fug.github.io/rules_requirements/schema/rules_requirements.schema.json.
It checks shape — field names, types, enums — and gives completion in editors;
referential integrity and coverage are rr validate’s job.
With the YAML language server (VS Code’s YAML extension, and most editors via LSP), add a modeline to each model file:
# yaml-language-server: $schema=https://studio-fug.github.io/rules_requirements/schema/rules_requirements.schema.json
or map the schema to your model directory in the editor settings, for example in VS Code:
{
"yaml.schemas": {
"https://studio-fug.github.io/rules_requirements/schema/rules_requirements.schema.json": "requirements/**/*.yaml"
}
}
The schema describes the default vocabulary: if you change severities,
likelihoods or levels, it still accepts any string there, and
rr validate checks the values against your configuration.
For claims it accepts what the loader accepts: target labels follow the same
rules as bad-target (//p: and @r//p/... are rejected, @r and //p are
labels), and the 0.2 claim forms — a bare label, {target}, {target, level} — are accepted but marked deprecated, as bare-target-reference
warns. schema/verification_lock.schema.json describes the
verification-set lock the same way.