Design intent

Declare what a board is supposed to contain, and have every revision checked against the architecture you agreed rather than against itself.

A netlist says what is wired. It cannot say what was meant. “The board has two regulators” is a fact you can read off the file; “the board should have two regulators” is a decision somebody made in a meeting, and nothing in the export records it.

A design-intent declaration writes that decision down as YAML beside the design, and check compares the board against it. This tier catches a rail quietly moved to the wrong domain, a module dropped during a cost reduction, or a power-up order that got reversed when the power tree was redrawn. None of those look wrong in the schematic. They look wrong against what you said you were building.

Write a declaration

# designs/gateway/design.yaml
name: gateway
extends: ../../project.yaml
entry: gateway.edn
intent:
  modules:
    - {name: regulators, class: regulator, count: 2}
    - {name: connectors, class: connector, count: 1}
    - {name: power tree, nets: [PMIC_MAIN_12V0, PMIC_CORE_3V3, PMIC_IO_1V8]}
    - {name: can, nets: [CAN1_CANH, CAN1_CANL, CAN1_TXD, CAN1_RXD]}
  nets:
    PMIC_MAIN_12V0: {nominal: 12.0, domain: main}
    PMIC_CORE_3V3:  {nominal: 3.3,  domain: io}
    PMIC_IO_1V8:    {nominal: 3.3,  domain: core}

That last rail is wrong on purpose, so the run below has something to catch. PMIC_IO_1V8 is declared at 3.3 V in the core domain, and it is an actual 1.8 V rail. Nothing about the schematic is malformed. The board simply stopped matching the architecture, and only a declaration can notice.

Where it goes

In the design’s own design.yaml, under intent:. A design that belongs to a project needs no flag, because the project reads its descriptor and finds the section:

$ agni check designs/gateway --rule intent/voltage-domain-mismatch
findings by rule:
  intent/voltage-domain-mismatch 1

first 1:
  [warning] intent/voltage-domain-mismatch: PMIC_IO_1V8 (rail "PMIC_IO_1V8" is declared in voltage domain "core" (3.3V) but its name declares 1.8V)

1 finding(s) total
3 subject(s) considered by 1 rule(s) (--verdicts for the detail)

Intent is usually per-design, where naming conventions, interface profiles and seeded parameters are usually per-project. Each board has its own intended architecture, so the declaration lives with the board rather than with the team, in the one file that already says which files the board is. A project shared by a family of boards can declare it once in its project.yaml, and a design’s own declaration replaces the project’s.

A design that belongs to no project still runs intent, and its own design.yaml declares it. --intent-path on check and review names a file in the same shape, a name and an intent: section, for a design with no design.yaml, or to try a declaration before committing it. The file travels with the request as the declaration’s value. On a design that already declares intent, it REPLACES that declaration for the run and is not added to it. A request’s naming convention replaces the project’s in the same way. A server takes no intent flag, since intent is per design and a flag would apply one board’s declaration to every design it serves (agni issue 831).

The section’s schema is the DesignIntent message in protos/agni/v1/config/intent.proto, so a declaration has the same shape in YAML, in JSON and on the wire, and a key the message does not have fails the load with the line it sits on.

Intent lives only in a descriptor’s intent: section. A key the section does not have, including the forms of the earlier nine-form vocabulary, fails the load at its line.

The seven forms

Each form answers a question the netlist cannot. Three are keyed by what they describe (a block, a net, a component), and the other four describe relationships between several nets.

Form Declares Fails when
modules a functional block the board must contain, by class or MPN with an optional count, or by the nets it must instantiate a declared module is absent, the count is short, or a declared net is missing
nets what each named net is, keyed by name (its voltage and domain, its peak draw, the protection it carries, its reset polarity, strap level, AC coupling or pull-up reach) the design contradicts any fact declared for it
sequences the power-up order of groups of rails the gating chain is absent, or runs the other way round
strap_groups several strap nets read together as one binary number, and the value it encodes the group does not encode the declared value, or two devices collide
io_map which net is on which pin of which device, and optionally the function selected there and what sits at the far end the net is on a different pin, the declared net is absent, the far end is wrong, or the datasheet lists no such function on that pin
margin_factor the headroom every supply must have over its rails’ declared peaks a supply is rated below peak times the factor
components what each named component is, keyed by ref-des (a connector’s exposure) a declared component is absent, or is not a connector

A nets entry carries any of these facts, and each compiles to the same rule it always did.

intent:
  nets:
    VDD_3V3:   {nominal: 3.3, domain: io, peak: 0.8, protect: [ovp, discharge]}
    MCU_NRST:  {reset: low}
    BOOT0:     {strap: low, min_ohms: 4700, max_ohms: 47000}
    PCIE_TX0_P: {ac_coupled: true}
  margin_factor: 1.2

A rail with a nominal and no domain is named by its voltage (3.3V), and rails sharing a domain must share a voltage. protect takes ovp (a TVS or zener clamping the rail) and discharge (a bleeder to ground). A resistance band belongs to a strap, so min_ohms without strap is a load error. A band whose resistor has no readable resistance, or a divider’s two resistors, reports inconclusive with the band unchecked.

pull_up_reach_hops changes how far the pull-up walk searches from that net (agni issue 820). i2c-pull-up and the rules beside it look for a rail within three series resistors of a bus, because past three the series resistance is usually comparable to the pull-up and the bus no longer returns high in time. Some buses reach their pull-up through a deeper network on purpose, such as a series isolation resistor followed by a level-shifting stage, and the designer has sized it so the bus still works. Declaring the reach on that net says so.

intent:
  nets:
    SDA_MAIN: {pull_up_reach_hops: 4}
$ agni check --verdicts --rule i2c-pull-up examples/common/designs/pullup-reach.edn
i2c-pull-up  1 fail, 1 pass
    fail  SDA_MAIN  no rail is reachable from SDA_MAIN through a resistor within 3 hops
    pass  SCL_MAIN  SCL_MAIN reaches rail VCC_3V3 through R5, and rail VCC_3V3 along SCL_MAIN ->
                    [R6] -> ISO_B -> [R7] -> VCC_3V3

2 verdicts across 1 rule(s), 1 pass, 1 fail
$ agni check --verdicts --rule i2c-pull-up examples/common/designs/pullup-reach.edn --intent-path examples/common/designs/pullup-reach.intent.yaml
i2c-pull-up  2 pass
    pass  SCL_MAIN  SCL_MAIN reaches rail VCC_3V3 through R5, and rail VCC_3V3 along SCL_MAIN ->
                    [R6] -> ISO_B -> [R7] -> VCC_3V3
    pass  SDA_MAIN  SDA_MAIN reaches rail VCC_3V3 along SDA_MAIN -> [R1] -> SER_A -> [R2] ->
                    SER_B -> [R3] -> SER_C -> [R4] -> VCC_3V3

2 verdicts across 1 rule(s), 2 pass

The first run is the default, so SDA_MAIN fails within 3 hops. The second declares a reach of 4 for SDA_MAIN alone and it passes through all four resistors, while SCL_MAIN keeps the default. A failing verdict always states the limit it searched to, so a declared net that still fails says “within 4 hops”. The reach is per net because the default is an electrical judgement, and moving it for the whole design would pass buses nobody has sized. Zero or an absent value keeps the default, and a negative one fails the load. The reach is looked up by the net’s exact name. So intent/pull-up-reach-declared fails a declaration that names a net missing from the design, and a misspelled name is reported rather than ignored (agni issue 1165). SCL_MAIN also has two pull-ups, one direct and one behind a series resistor, and its passing verdict names both paths.

A components entry says whether a connector faces the outside of the product. The exposure rules (esd-protection, esd-clamp-not-tvs, input-protection and reverse-blocking-absent) treat every connector as a way into the board, which is right for a cable entry and wrong for a module socket or a mezzanine that joins two boards inside one enclosure. The product decides which one a connector is, and the part does not. The same board-to-board connector is the module socket on one carrier and the cable entry on another, and its part number and footprint rarely say either way, so the engine does not guess from them.

intent:
  components:
    J1:  {exposure: internal}   # the module connector
    J18: {exposure: internal}   # the expansion mezzanine

A connector declared internal leaves the exposure rules, so a net reaching only it is not checked for ESD or input protection. A net that also reaches an undeclared connector still is. An undeclared connector is external, and external can be written to say so. The declarations themselves compile to intent/exposure-declared. It fails a ref-des missing from the design, and it fails a part that is not a connector. Either declaration would change nothing while reading as one that took effect.

io_map is the largest form in practice, and most boards already have one, usually as a spreadsheet. On any board carrying a big MCU or SoC, someone decides which peripheral goes on which pin long before the schematic exists, firmware is written against that decision, and the schematic is drawn from it. Usually an assignment moves late, the map is updated, and one net does not get redrawn. Nothing about the resulting board is electrically wrong, so every other rule passes, and it surfaces at bring-up as a peripheral that does not respond.

io_map:
  - net: I2C_SDA
    device: U3
    pin: '9'
  - net: MCU_NRST
    device: U3
    pin: PTC11                     # the datasheet's name works as well as the designator
    function: GPIO                 # optional, checked against the part's datasheet
    to: {device: U1, pin: '5'}     # optional far end

Write the pin either way. A map is written in the names a datasheet and a firmware header use, and a netlist answers in package designators, so both are resolved. PTE7, PTE07, pte7 and a name carrying a zero-width space pasted out of a spreadsheet all reach the same pin. When a match needed any of that, the verdict says so, so a note always means something was inferred. agni trace resolves a pin through the same lookup, so a trace can start from any pin named in a map.

It compiles to as many as five rules rather than one. Three of them ask whether the design kept the promises the map made, one inverts the question to ask which nets the map never mentioned, and the fifth compiles when a row declares a function and asks whether the part can be set to it. The fourth rule’s count of unmentioned nets is usually the important one. A design with sixteen hundred nets and a map declaring two hundred has two hundred checked and fourteen hundred unexamined, which is not the same as clean, and nothing else in a run tells those apart. Nets it does not name report as not-considered rather than as failures, because an undeclared net is a question nobody asked and not a fault in the board.

The three that check the promises are separated because a reviewer acts on them differently, and because two of them catch opposite defects (a net the map declares and the netlist does not have is usually a real disconnection, where a net the netlist has and the map does not declare is an incomplete map). The far-end columns are the sparse ones. In the one real map we measured, roughly a third of rows declared a far end, so io-map-far-end gives EVERY row a verdict and a row with no far end reads not-considered. A rule that reported only the rows carrying one would show a clean result over a third of the map and say nothing about the rest.

io-map-function checks a row’s function against the functions listed for that pin in the part’s datasheet. Those functions are read from the vendor’s pin-mux table into the corpus (see the datasheet layer). Mapping SDA onto the pin that offers SCL fails, and the failure lists what the pin can be. When a part has no datasheet entry, or its entry has no pin-mux table, the row reads not-considered with that reason. A filled-in function column therefore never reads as checked when it was not. A run with no datasheet corpus at all skips the rule, as it skips every rule that needs one.

The map and the mux table are different records. The map is this board’s allocation and lives here. The mux table lists what the part can do and lives in its datasheet spec. A mux table transcribed from a reference manual is usually a file of its own. That file goes into the corpus through agnids merge-mux rather than into the map.

Reading the map one pin at a time

The four rules each list their own verdicts. That suits a reviewer working through one kind of defect. It does not suit a reviewer signing off the map itself, who reads it a pin at a time. So a run that checks a map also lays it out that way. agni check --format html opens with a section per device (“Pins on U1”), and each declared pin is one row. A row shows the declared function, the declared net, the net the netlist puts on that pin, the far end and the route to it, and what each of the four per-row rules concluded.

outcome pin function declared net netlist net far end route
pass MDIO MDIO MDIO_NET MDIO_NET U3.MDIO MDIO_NET -> [R1] -> MDIO_OUT
fail RST RESET RST_N RST_N U3.RST

A row’s outcome is the worst any rule reached on it, so the reset row fails on its far end alone while its pin and its net both pass. A row no rule could judge reads not considered. The route column holds the route agni trace prints, net to net through each series part.

The same rows make up the pin_map table. The Tabulate rpc returns it for a check run, a workbook of the run carries it, and --table writes it as csv.

agni check --verdicts --format csv --table pin_map design/

Suppose a team already keeps a spreadsheet of ball, function and expected net. Each of its rows maps straight onto a map row. The ball is pin, the function is function, the expected net is net, and the MCU is each row’s device. A partner pin, when the sheet names one, is to.

Drafting a map from the netlist

A board with a module connector has hundreds of pins to declare, and a map goes wrong first when someone writes each row by hand. agni pinmap drafts one for a device from the netlist:

agni pinmap designs/jetson --device J1 -o j1-pins.yaml

Each pin on a net becomes a row. The far end is filled in when a walk from the pin, crossing series parts as agni trace does, reaches exactly one pin on another IC or connector. On the Jetson AGX Thor baseboard, 170 of the module connector’s pins reach exactly one, seven reach several and say which in a comment, and the rest sit on ground or a supply and are not walked. The CAN line is one of the 170:

- {device: J1, pin: G38, net: /Expansion connector/CAN0.DOUT, to: {device: J18, pin: H08}}  # FSI_GPIO07

agni trace designs/jetson --from J1.G38 --to J18.H08 prints the same route, and the row passes when the draft is checked:

agni check designs/jetson --intent-path j1-pins.yaml --verdicts --format csv --table pin_map

The draft was read off the same netlist, so every row passes by construction, and that run proves nothing yet about the board. The draft is useful as an inventory. Review each row against the module’s pinout and the interface it implements, correct or delete what is wrong, and then the map checks this board and every later revision against something a person signed off. --intent-path replaces the design’s own intent for the run, so a design that declares other intent carries the reviewed rows into its design.yaml instead.

A peak joins two tiers. The declaration supplies the demand, which no design artifact carries, and a seeded datasheet parameter supplies the regulator’s capacity. Both halves have to be present, or the rule reports nothing rather than guessing.

One rule per declared thing

Two review items bound to one rule name share its verdict. So anything a reviewer signs off separately gets its own rule (WS3-058), and the naming follows what the sign-off is about.

Form Rules it compiles to Why that grain
modules with nets, sequences, strap_groups one per declared entry, named from a slug of it (subsystem-<name>, sequence-<name>, strap-group-<name>) each entry is its own checklist item
modules without nets fixed names (module-missing, module-count) the review item is the mechanism rather than any one entry
nets one per FACT (voltage-domain-mismatch, protection-<kind>, property-<kind>, rail-current-capacity, rail-current-margin, load-switch-trip-below-budget) a reviewer signs off “every rail has its OVP clamp”, not each rail
io_map five fixed names, whatever the map’s length the exception, since nobody signs off “net 137 is on the right pin” as its own item

strap_groups also compiles one strap-address-collision rule across all groups, because a collision is between two groups and belongs to neither. It compiles only when two groups share a bus, since over fewer it could only pass. rail-current-margin compiles only when the declaration states a margin_factor, so an item bound to it reads needs-design-intent rather than passing against a number nobody declared.

With no declaration, nothing passes

There is no built-in intent, and that absence is deliberate. A generic statement of what a board should contain says nothing, and a rule that enumerated its expectations from the design would always agree with the design. So every intent rule iterates the declaration and probes the netlist, never the reverse.

A design run with no declaration leaves its intent-bound review items reading needs-design-intent, never pass. The mechanism exists and is blocked on an input you have not supplied yet. A board that was checked and found clean is a different thing. Checks and reports covers the full outcome vocabulary.

What intent cannot do

It checks presence, property and order, over the netlist. It does not simulate. A declared sequence is verified as a gating chain in the connectivity, not as timing on a scope, so a board that wires the enables correctly and still browns out at power-up is outside what a declaration can see.

It is also only as good as the names. If the declaration calls a rail PMIC_CORE_3V3, the board has to call it that too. Naming conventions exist to make that same dependency explicit rather than accidental.

Where to go next

  • Checks and reports explains what needs-design-intent means beside the other outcomes, and how --fail-on treats them.
  • Interface profiles is the other declarative tier, per-project rather than per-design, for how a bus is wired rather than what a board contains.
  • How a rule gets written shows where an intent declaration sits among the four ways to author a rule, and why the engine ships none of its own.