Authoring a check rule

The path from a review-checklist item to a shipped, trustworthy check rule, worked through one real rule.

A check rule asserts something about the design as it was read: “every rail carries a test point”. It does not compute the way analysis does, it does not fix anything, and it has to be checkable from the IR the readers produce today. The architecture behind the pieces lives in Rules and checks and Net solving and hierarchy. This page is the practical path, worked through one real rule, test-point-coverage.

flowchart TB
    subgraph S1["Decide"]
        direction LR
        A["is it a rule<br/>at all?"] --> B["write the sentence,<br/>then the guards"]
    end
    subgraph S2["Author"]
        direction LR
        C["spec-first"] --> D["one file,<br/>one line, one doc"] --> E["Scope, Where,<br/>Remedy"] --> F["say what you<br/>LOOKED AT"]
    end
    subgraph S3["Prove"]
        direction LR
        G["fixtures: fires,<br/>passes, guard"] --> H["verify at<br/>four levels"] --> I(["ship"])
    end
    S1 --> S2 --> S3

Is it a rule at all?

Ask what the rule reads. If the answer includes data no reader emits yet (net classes, datasheet limits before the parameters tier exists), the reader work comes first and the rule waits.

The worked example comes from a design-for-test checklist: rails and ground should be probe-able. It reads component classes, net membership, and rail-ness, all three present in the IR. So it is a rule.

Write the sentence, then the guards

A rule is one sentence plus its exceptions, and the exceptions are what keep an engineer from muting it. Write the sentence first:

A power rail or ground net must carry a test-point component.

Then interrogate it the way a real corpus will.

The question What it settled here
What if the board has no test points at all? A demo board is not wrong, it just has no test-point convention. Fire only where the design places test points somewhere. This kind of channel gate once took a rule from 1836 findings to zero.
What is “a rail” when the format carries no direction data? Name is the only rail evidence a bare EDIF netlist has, so fall back to rail facts or rail/ground name heuristics.
What about nets the read did not fully cover? Skip nets marked external.
Who decides the policy edge cases, like a feedback node named like a rail and deliberately left unprobed? The reviewer does. Use info severity and say so in the rule’s doc.
If you do read a name, is the net actually a rail? net.nominal_voltage token-scans a whole name, so a level encoded in a SIGNAL net’s name parses as a rail nominal (agni issue 194: U3_12_U7_4_3V3 yields 3.3 while classifying as neither rail nor ground). Gate on Model.IsRailNet first.

Three of the questions carry a lesson wider than this rule.

1. Is there a tier of evidence better than the name? A rail’s voltage comes from check.NominalVoltageFromName, a convention that is silent on a rail nobody named for its voltage and wrong on a name that outlived a design change. Connectivity sometimes answers outright: the pin-tracking rules bound the difference between two pins, and two pins on ONE net are one node, so that difference is exactly zero with no name read. That tier settles a “must not exceed 0V” bound as satisfied and a “must be at least 1V” bound as violated on a design whose nets carry no voltage token at all. Reach for the convention as the fallback, not the first answer.

2. What does a role gate cost when the project has not configured it? It inherits the role’s whole configuration surface. IsRailNet reads a stamp derived from the naming lexicon, and the built-in vocabulary is start-anchored (VCC, VDD, +3V3), so a project naming rails function-first matches almost none of it. On a real 1700-net board, declaring the project’s patterns moved the rail count from 13 to 91. Gate anyway, and make the gap visible rather than assuming the config is there. rail-not-classified is the tripwire that does it.

Measured end to end since (August 2026, against the catalog of the day): on a real board naming rails function-first with no lexicon declared, 18 supply nets classified as rails and 91 did not, and rail-not-classified fired 45 times. Declaring the project’s conventions moved recognition to 62 and the tripwire to zero. The tripwire works, and the remedy it points at is configuration rather than more rules. 3. Is your evidence tier actually asking your question? Preferring connectivity over a name is right, and it is still possible to pick a connectivity fact that is wider than the property you mean. decoupling-present selects “a net with a non-virtual power_in pin and no capacitor” and calls that a supply rail. On the same board every one of its 14 findings was a false positive: gate drives, buck switching nodes and sense lines all carry a power-input pin, and two of them were advising a capacitor that would short a switch node to ground. Write the sentence, then check that the fact you selected on cannot be satisfied by something the sentence would exclude.

If the sentence names two entities, carry both

A Finding has one Subject, and the subject is what a reader has to CHANGE. That is not always the entity the sentence is about, and when it is not, the reader gets sent somewhere the message never mentioned. crystal-load-caps is the case this exists for. Its message reads “crystal terminal net XOUT1 has no load capacitor” and its subject is the crystal, correctly, because the crystal is the part someone edits. But the sentence is about a NET, so clicking the finding highlighted a part, and both terminals sit inside that symbol, so the drawing could not say which one was at fault.

So when a message names a design entity other than its subject, carry it in Finding.Context, each entry with a Role naming the part it plays. The panel renders them as clickable chips.

// Go
Context: []check.ContextSubject{
    {Kind: check.KindNet, Subject: n.Name, NetID: n.Id, Role: "terminal"},
}

// datalog: the entity is already bound in the answer row
ContextVars: []query.ContextVar{
    {Var: "net", Kind: check.KindNet, Role: "terminal"},
}
Rule Why
Never repeat the subject as its own context the chip would navigate to where the reader already is. On a KindPin rule the subject is the ref/pin pair, so {pin} is the subject and only the net is context
Order is yours and it is significant chips render in declaration order, so declare them in the order your message names them
A role is a short lower-case noun terminal, rail, source, sense. Open vocabulary, deliberately, and roles need not be unique within a finding (“A and B both strap to address N”)
Entities only, never values thresholds, voltages and units belong in the message. A chip is something a reader can click and land on in the drawing

Set NetID on a net context entity where you have it, so a net sharing its name with another highlights the right instance rather than all of them.

Author spec-first

Proven vocabulary goes in the Spec AST. Anything multi-clause goes behind a registered SpecFunc that declares its own reads and primitives, so the derivation stays honest across that boundary. The example is one FFI (has_test_points, the channel gate) plus existing facts:

Over: nets
Where: has_test_points(design)
   and not external(N)
   and (global(N) or power_driven(N) or rail_name(N) or ground_name(N))
   and not exists T in N.connections where class(T) == test_point

Bind it with spec.Rule(check.Rule{...}). Reads and Primitives derive from the body, so they cannot drift from what the rule does. One init-order trap: register a rule’s own FFI inside the rule variable’s own initializer, because package variables initialize before init funcs run and binding validates the Call targets. Shared helpers like rail_name need no such care, since stdlib/rules/builtin imports core/check and check’s package init registers them first.

The twin discipline: proven vocabulary is spec-only, as here. New interpreter vocabulary (a new entity set, a new fact, a new traversal) ships with a Go Eval as the canonical twin until it soaks, with parity asserted between the two.

One file, one line, one doc

Path Holds
stdlib/rules/builtin/rule_<name>.go the rule
stdlib/rules/builtin/register.go one line adding it to the rules slice
stdlib/rules/builtin/docs/<name>.md the single source of the rule’s Detail, embedded at build time

The built-in catalog is its own package, not part of the core engine. It installs itself through the same public check.RegisterBuiltins seam an overlay uses, so core/check owns no rules of its own.

The harness fails CI without the doc. Write it as proper ### sections under the rule’s ## title, not bold run-ins: What it means, Why engineers want it, Impact, an ASCII sketch of fires-versus-fine, a Scope note recording every guard decision from the step above, the query structure, and a “For software readers” section mapping the EE concepts to structural analogies (a test point is a metrics endpoint, and the rule reads as “critical paths must emit telemetry”) with a diagram beside it.

Say what to do about it

A rule carries four pieces of prose. Summary is the one-liner, Impact is what goes wrong when the rule is violated, Detail is the long-form markdown, and Remedy is what to DO about it, in the imperative, as one engineer would say it to another. Remedy is the easiest to leave off, and without it a reader who accepts that the finding matters still has to already know the fix. TestEveryRuleStatesARemedy (in tools/catalogdocs) holds the whole catalog to it.

Keeping it honest
Generic over the RULE, not the subject name the class of change (“add a bulk capacitor where the rail enters”), never a designator. A remedy templated on the bound subject is a later tier
Where the fix needs a value the engine cannot derive, say what to size it from and stop the pull-up an I2C bus wants depends on its capacitance and clock rate, neither of which the netlist states, so i2c-pull-up names no resistance. Inventing a plausible number would be the same silent-authority problem verdicts exist to remove, one layer up
Where the finding is about the ANALYSIS rather than the design, say so rail-not-classified and symbol-unresolved are remedied by giving the tool more (--conventions, --symbol-path), and their remedies say explicitly that nothing is yet known to be wrong with the board

Write it in the field, never in the rule’s docs/<name>.md. tools/catalogdocs projects Remedy onto the docsite reference page as its leading ### Remedy section, so a doc that also wrote one would print the fix twice. This is the opposite of the convention for Impact, whose field and whose ### Impact doc section may both exist and say the same thing differently.

A rule generated per-declaration (the intent and profile families) keys its remedy by KIND rather than writing it at the builder, because the fix for a missing OV clamp is the same sentence on every rail that declares one. See docRemedies in stdlib/rules/intent/docs.go and requirementCaption in stdlib/profiles/docs.go.

Scope and Where, for a spec rule

A spec has two predicates and they are not interchangeable. Both filter. The difference is where the elements they discard end up.

flowchart LR
    O(["Over: nets"]) --> S{"Scope"}
    S -->|"out"| NO["no verdict at all.<br/>The rule has no opinion about you."]
    S -->|"in"| W{"Where"}
    W -->|"false"| P["PASS<br/>looked at you and cleared you"]
    W -->|"true"| F["FAIL"]
    classDef out fill:none,stroke-dasharray: 4 3;
    class NO out;

Old-style rules put both in one And, harmless while a spec only reported violations. It stops being harmless the moment the interpreter states a considered set, because then a subject the rule was never about is reported as fine. test-point-coverage had five conjuncts over Over: "nets", of which only the last is the rule:

has_test_points(design)                      <- scope: does this board use test points at all
not net.attr.external                        <- scope: the read did not cover this net
global or power_driven or rail or ground     <- scope: is this even a rail
not feedback_name(net)                       <- scope: a sense node must not be probed
not exists(connection with class test_point) <- THE VIOLATION

On the tutorial gateway design that is 15 nets narrowed to 4 rails, so without the split the rule would assert that 11 signal nets carry a test point. The first clause is worse: it is design-wide, so a board using no test points anywhere would report every net as passing, which is a rule that declined to run claiming universal success.

Splitting cannot change what fails. The old predicate is exactly Scope AND Where, so only the meaning of the non-failing subjects changes. TestScopeAndWhereProjectToTheSameFindings pins it.

Rule of thumb
A clause belongs in Scope when a pass over it would be meaningless “this capacitor is not an LED” is not a useful thing for led-polarity to say. “This LED is the right way round” is
Scope contributes to DerivedReads so moving a clause must not change what a rule declares it reads. TestScopeContributesDerivedReads holds that
A spec stating no Scope claims every element of Over is its subject a real claim, so leave it out only when it is true

A pass states the VALUE that refuted Where, not the expression that tested it. The interpreter renders the first false conjunct and reads the subject’s actual value: claims is 1, not >= 2 rather than claims >= 2 does not hold. The threshold survives, so a reader can see what would have made the rule fire. A != that came out false is the one case with no threshold to name, so labels != "" passing reads labels is empty. This costs an author nothing but it does make a Let binding name READER-FACING, so name a binding for the fact it holds.

A predicate that measured nothing still cannot say anything. IsTrue{Call{...}} decides on a bare bool, so led-polarity passes with led_reversed does not hold and no value to offer. Closing that needs a SpecFunc that hands back what it saw rather than only what it concluded.

Say what you looked at, not only what failed

Eval returns one check.Verdict per subject the rule was applied to, passes included. It MAPS the design onto outcomes rather than filtering it down to failures, and the findings contract is the projection of that map, taken by Run through Rule.Findings. There is one body, so a rule cannot report findings that disagree with its verdicts.

flowchart TB
    En["enumerate<br/>the subjects the rule applies to"] --> J["judge one subject"]
    J --> V["<b>Verdict</b>, one per subject<br/>Pass · Fail · NOT_CONSIDERED<br/>Subjects is a TUPLE, in the rule's own order<br/>VerdictID = rule:(kind:ref,...)"]
    V -->|"Rule.Findings<br/>projects the failures"| F["<b>Finding</b>, only for failures<br/>Subject is SINGULAR: what a reader CHANGES<br/>everything else the sentence names goes in Context"]
The discipline Why
Enumerate, then judge two functions, one yielding the subjects the rule applies to and one deciding a single subject, with Eval as map(judge, enumerate). Every rule converted so far factored this way without a fight, and it is what makes a single verdict answerable on its own
Produce the outcome and its evidence in ONE call check.CompareToBound returns both from one comparison. A rule that decided the outcome and then separately assembled a witness would fail nothing when the second step was skipped
An enumerator that drops a subject must say so a pin that will not resolve, a net with no voltage in its name, a datasheet binding no row of the kind. Those used to skip silently, reporting the same nothing as a rule that never looked, and are now NOT_CONSIDERED verdicts carrying the step that stopped them. Distinguish from OUT OF SCOPE, where a pin that is not a supply terminal yields no event at all
StatesConsideredSet is a declaration, not an inference a failures-only rule returns Fail verdicts structurally identical to a considered set whose every subject failed, so only the author can say which. Forgetting it under-reports a converted rule and can never over-report one

The two rules that still decline

Rule How it declines Why
dangling-endpoint check.FailuresOnly, conspicuous at the call site it reads a diagnostic list holding the offenders and nothing else. internal/netgraph’s dangling already decides per endpoint against an occupied map and discards it into a bool, so the obstacle is volume rather than the reader: 28 distinct endpoints for 20 wires on the showcase fixture, against 132 subjects across the whole catalog (agni issue 420)
cap-voltage StatesConsideredSet = false directly, no wrapper, since it is built from a spec capVoltageDetail returns "" both for a capacitor that clears its rating and for one with no datasheet seeded, so inside scope it cannot tell a pass from a gap

Grepping for FailuresOnly therefore misses one of them. Converting a rule means deleting the wrapper, writing the map, and setting StatesConsideredSet. bus-not-modeled is the diagnostic rule that always CAN state a set: unmodeled_buses holds every bus construct the reader saw, so the rule partitions it and a resolved bus is a countable pass.

The general fix is usually the READER

Two rules were in that position and left it by changing what the reader records.

Rule What the reader now records Why it makes a pass evidence
symbol-unresolved (agni issue 418) the references that DID resolve, with the pin count each supplied a stale library entry resolves as successfully as the real symbol, answers with no pins, and costs the netlist what a missing file does. The count moves when the library does
wire-no-junction (agni issue 420) the taps something JOINS, beside the ones nothing does splitWiresAt runs at every junction dot and mid-span label before the detection pass, so running the same detection once more before the split recovers the set

wire-no-junction was the most expensive silence in the catalog. Its own Impact calls a dotless T-tap the most dangerous silent connectivity slip in schematic capture, and a sheet whose every tap carried its dot reported exactly what a sheet with no tap reported.

The reader fix comes with a capability, not just a field. Only the KiCad reader examines wire geometry, so wire-no-junction declares RequiresCapability: CapJunctionTaps and reports not-applicable with a reason on xschem, gEDA and EDIF. Without that it would contribute a considered set of nothing on three formats, which reads as a clean sheet. symbol-unresolved needed no gate, because all three symbol-resolving readers supply the diagnostic.

A rule that seems to have no fixed arity is usually being asked the wrong question. strap-address-collision declined because its subject is the SET of devices sharing an address, two on one bus and four on the next, while SubjectShape is fixed per rule. That describes the FINDING. The question the rule answers is binary: do these two devices strap to the same number. Three devices at one address is three yes answers, so the subject is a PAIR (agni issue 391). Its message got less wrong too, since the old single finding said “U12 and U13 and U14 BOTH strap to”, and the two cases the old body dropped with a bare continue became NOT_CONSIDERED verdicts. A body that filters has nowhere to put a subject it declined.

Subjects: a tuple in the verdict, one entity in the finding

One rule about a relation, producing three different shapes from one judgement. The verdict's subject is a tuple naming every entity in the relation, because that tuple is the verdict's identity. The finding's subject is singular and names the one entity a reader has to change. Everything else the sentence mentions travels in the finding's context, as clickable chips. The same judgement, at three grains, for three different questions. one judgement about a relation a regulator over-driving a part it feeds, across a rail Verdict.Subjects a TUPLE, in the rule's own order U1 +3V3 U7 name fewer and two answers share one id Finding.Subject SINGULAR, and only for a failure U7 the one entity a reader has to CHANGE Finding.Context U1 · source +3V3 · rail clickable chips one judgement at three grains, because identity, the edit, and the sentence are three questions

A verdict’s subject is a TUPLE, and for most rules it holds one entity. A rule whose question is a RELATION names every entity in it. copper-clearance measures a distance between two nets and belongs to neither. regulator-output-exceeds-abs-max compares a regulator against a part it feeds ACROSS a rail, and all three are needed, because one source feeding one load over two supplied rails is two different answers. Naming fewer than all of them gives several answers one id, which is wrong now that the report links every row by id. Five rules used to decline for exactly this reason and no longer do.

Rule Why
Order is yours and it is significant a tracking bound reads subject-pin minus reference-pin, so swapping the two inverts the sign of the claim
A SYMMETRIC relation canonicalises INSIDE the rule copper-clearance orders its pair by name. A framework that sorted every tuple would break the directional ones
Declare the shape Rule.SubjectShape lists the kinds, so a person can construct an id without running the check and TestSubjectShapeHolds can fail a rule whose arity moves between designs. Leave it empty for the ordinary one-subject case, which the same test enforces
Do not reach for a subject that is nearly unique and hope two seeded regulators feeding one load, and a high-side FET above breakdown on both its rails, are ordinary topologies

A Finding’s subject stays SINGULAR, and that is a different question. The verdict’s tuple is identity and has to be complete. The finding’s subject is the one entity a reader has to CHANGE, and an answer with three entities in it is not one they can act on. TestFindingSubjectComesFromTheVerdictsTuple holds the two together, and consumers wanting equal standing read Context instead.

The two may differ in GRAIN, and often should. A verdict about a supply terminal carries a finding about the whole part, because “why is this pin fine” is asked of a pin while the sentence a reviewer reads names what they will change. pin-exceeds-abs-max established it. Plan for the consequence: a part with three VDD pins on one rail produces three verdicts and the one finding it always produced, so the extra terminals carry no Finding.

Converting a rule that has a spec twin has one trap. check.VerdictsToFindings returns a NON-NIL empty slice, matching check.Report, and TestSpecParity compares with reflect.DeepEqual, where a nil slice is not equal to an empty one. A converted rule would otherwise diverge from its twin on every clean design while agreeing about every finding. Twinned rules have three things that must agree, the Go body, the spec twin, and the verdicts, and that affects about a quarter of the catalog.

A rule has one severity, so a severity axis means two rules

check.Run stamps Finding.Severity from the rule’s own metadata over whatever Eval set, because the docsite catalog and --fail-on both read the declared severity as the truth about a rule’s findings. So a fact carrying its own severity axis becomes TWO rules sharing one walker. Both instances are in the datasheet tier: pin-exceeds-abs-max and pin-out-of-recommended split one comparison by LimitKind, and pin-tracking-violated and pin-tracking-advisory split one by the datasheet’s Modality, because “shall never exceed” and “should be at least 1 V higher for best operation” are different claims. The split is also what a caller wants, since separate rule names let a team gate CI on the requirement and merely report the recommendation.

Where a value is legal but leaves the severity unknown, send it to the louder rule and mark the finding Inconclusive rather than dropping it. param.Validate requires a pin relation’s kind, bound and provenance but not its modality, so an unstated modality is a legal spec: reporting it as an error would invent a requirement, and dropping it would pass a breach in silence.

Comparing is safe, subtracting is not

A rule that SUBTRACTS two name-derived voltages meets binary floating point head-on. 3.3 - 1.8 is 1.4999999999999998, which prints as noise in a finding and misjudges a bound of exactly 1.5. The older datasheet rules only ever compared a rail against a limit, so this first appeared with pin tracking. Round the result (microvolt resolution is finer than any bound a datasheet states) and assert the PRINTED number in a test, which is how it got caught.

Two independent channels, when one channel is ambiguous by construction

Some questions cannot be answered from one signal no matter how carefully you read it. Is a net named ..._3V3 a 3.3 V rail, or a signal that swings at 3.3 V? Both are named that way, legitimately, by the same teams. No naming grammar separates them, and a rule firing on the name alone is noise dressed as a finding.

flowchart LR
    N["the NET's name<br/>carries a voltage"] --> AND{"both agree"}
    P["the PART declares<br/>the pin a power input"] --> AND
    AND -->|"yes"| C["candidate: 101 nets down to 45"]
    AND -->|"no"| Q["silent"]

rail-not-classified works this way. The channels come from unrelated sources, one off the net and one off the part’s own pin declaration, so their agreement is evidence in a way either alone is not, and it separated a board with a genuine configuration gap (45) from one without (5).

Two things keep it honest. Name the channels in the rule’s doc, because a reviewer needs to know the finding rests on a coincidence of two weak signals. And check what happens when a channel is structurally absent: on a format that cannot type power pins the second channel is always missing, so the rule is a no-op there rather than a partial answer, and the doc should say so.

Fixtures are the rule’s contract

Conformance fixtures are executable expectations, and the sidecar drives both the harness and the viewer’s expectations panel. Author three shapes:

  • fires: the defect, plus every incidental firing listed, because the harness is exhaustive.
  • passes: the same topology done right. fires: {} is the strongest assertion the harness holds.
  • the guard case: the channel fixture proving the rule stays silent where the convention is absent (rails, zero test points, nothing fires).

KiCad authoring details that bite everyone once: pins bind at wire endpoints while labels and junction dots bind mid-span, the pin connect point is origin + (local_x, −local_y), and power-symbol fixtures need the {} .kicad_pro stub or their nets stay external.

Expect the showcase boards to react. They are the load-bearing anti-false-positive gates, so a new rule firing there is a deliberate decision. Cover the passes board, which must stay silent, and list the fires board’s incidental firings in its sidecar.

Verify at four levels

Level What it buys
Unit guard matrix one test exercising every guard both ways
Conformance the fixtures above, in CI
Corpus plus the real exports sweep old-versus-new binaries and attribute every delta per-file, per-rule. The corpus alone is not enough: the real exports carry the scale and the tool dialects that expose whole classes of false positive (the 1836-finding lesson)
A reference implementation, when one exists agreement with kicad-cli sch erc or kicad-cli pcb drc is the strongest evidence a rule’s semantics are right, and it pinned the connection-point rules and the naming priorities

Hand-trace what fires at level 3. The example’s five real-board findings decomposed into three genuine gaps and two policy-edge feedback nodes, and that decomposition set the severity. Design-for-test has no open reference implementation, so say that in the PR.

Ship it

The PR carries the reviewer’s guide, the hardware-context primer, the before/after transcript, and a record of every deferred edge. The rule’s doc is already written, so the catalog, the viewer, and the next reader all get the explanation the day it merges.