Docs · docs/dossier.md · Markdown

The design dossier

The dossier records a design the way a design review needs it: requirements, the sections the design is broken into, a spec card for every part, the interfaces between parts and sections, the decisions with the alternatives rejected, and the assembly sequence. It is written beside the CAD, in YAML, and joined by id to the geometry the build script registers (cadagent.dossier.registry, below). The gates and the review site check it; the plan behind it is design-process-plan.md §3.

What it does not do: a dossier that loads and lints proves the design is declared, not that it is right. The gates check the geometry against the declarations; a reviewer checks the declarations against the requirements. Nothing in it proves physical fit or strength.

Layout

cad-agent.yaml                 cad: dossier: dossier/project.yaml
dossier/project.yaml           title, requirements, sections, interfaces, decisions, assembly, gates
dossier/sections/<id>.yaml     one section and its part cards
dossier/parts/<name>.yaml      a part card split out of its section (optional)
dossier/decisions.yaml         project decisions (the site's decisions format, extended)
dossier/reviews/<section>-<date>.json   reviewer records (written by `cad-agent review-section --submit` only)
exports/review-bundle/<section>/  the reviewer's prompt bundle (written by `cad-agent review-section`)
exports/dossier-geometry.json  what the build script registered (written by the registry's build())
exports/dossier-status.json    every item's computed status and the lint findings (written by `cad-agent dossier`)
exports/gates/dossier-lint.json  the lint as a gate verdict

Paths inside the dossier are relative to the directory of project.yaml. sections: entries may be globs ("sections/*.yaml", loaded in sorted order) or inline mappings; a section's parts: entries may be paths to card files or inline cards.

YAML, not JSON: requirements are user quotes and decisions need comments.

from cadagent.dossier import load_dossier, load_project_dossier
d = load_dossier("dossier/project.yaml")             # raises DossierError on any error
d = load_project_dossier(load_project("."))           # via cad-agent.yaml; None when it has no dossier: key
d = load_dossier(path, strict=False)                  # errors in d.errors instead of raising (for a lint)
d.parts["hopper"].section, d.decisions["D41"].scope, d.findings   # findings: unknown keys, interface conflicts

Ids, errors and findings

Schema

project.yaml:

title: Turntable feeder                    # optional
requirements:
  - id: R4
    text: Holds 40 balls
    source: {kind: user, quote: "it has to hold forty"}   # kind: user | standard | derived | research | agent
    verify_by: inspection                  # analysis | inspection | test | demo
    parent: R1                             # optional: the requirement this is derived from
    rationale: ...                         # optional
    checks: [gate:containment]             # optional: checks that verify it directly
    status: waived                         # optional; computed by the lint, so only `waived` belongs in YAML
sections: [sections/base.yaml, "sections/*.yaml"]
interfaces: [...]                          # project-level interfaces (usually between sections)
decisions: decisions.yaml                  # a file (list, or {decisions: [...]}) or an inline list
assembly:
  steps:
    - order: 1                             # unique integer; steps are sorted by it
      fit: pan_base                        # the part fitted at this step
      onto: bench_base                     # a part id or a list of them
      direction: -Z                        # +X..-Z or [x, y, z]: the way the part moves to its seat
      tool: hex_2.5
      fasteners: [screw_m4x12, screw_m4x12, screw_m4x12]
      access_check: gate:assembly          # optional check id
      note: ...
gates:                                     # the integrity-gate configuration (plan §4.1)
  touch_tol_mm: 0.01
  grounded: [bench_base]                   # parts the connectivity gate treats as ground
  free_parts: [{part: ball, reason: loose in the hopper}]     # a reason is required (a bare id is an error)
  intended_overlaps: [{parts: [hopper, insert_m3], reason: heat-set insert}]   # reasons required here too
  ignore_pairs: [{parts: [lid_a, lid_b], reason: variants, never fitted together}]
  # a pair's parties may be parts, instance keys (ball#3) or fasteners by name or instance (screw_0, m3:lid-base#1);
  # [ball, ball] is the instances of one part among themselves:
  # intended_overlaps: [{parts: [screw_0, insert_0], reason: thread envelope inside the insert}]
  strict_interfaces: false                 # true: an undeclared contact fails instead of warning
  motion_samples: 12
  require_separate_reviewer: true          # the reviewer pass: refuse a sign-off by a part's builder or the section's
                                           # author (default true; not a gate input, so it never stales a verdict)
  # any other key is kept in GateConfig.extra (and reported) so a new gate option works before the schema knows it

A section (sections/<id>.yaml), with its part card exactly as in plan §3.2:

id: S3
title: Hopper and feed                     # required
purpose: Hold 40 foam balls and present one at a time to the feed wheel
requirements: [R4, R7, R12]
author: agent-session-12                   # optional; a reviewer named here (or on a card or decision) is refused
interfaces:
  - id: I9
    between: [hopper_flap, hopper]         # two different parts or sections
    kind: revolute                         # see "Interface kinds"
    datum: flap pivot axis, hopper frame
    loads: {torque: "0.1 N·m at the stop"}
    fastened_by: pin_3x20                  # optional catalogue key
    clearance: 0.2                         # mm: the gap the connectivity gate accepts along this interface
    owner: S3                              # the section (or part) that may change the datum
    description: ...
parts:
  - id: hopper
    function: Contain the ball store and funnel balls to the feed throat under gravity
    interfaces: [I7, I8, I9]
    loads: {static: "40 balls, 0.26 kg, on the floor cone", shock: "1 m drop on a corner (R12)"}
    material: {name: PETG, process: FDM, print_axis: "+Z", wall_mm: 2.4}   # or a bare name
    fasteners: [{to: pan_base, screws: screw_m3x8, qty: 4, via: I7}]
    contains: [{what: ball, count: 40, cavity: hopper_cavity, free: true}]
    decisions:
      - id: D41
        decision: Single-wall cone with a 55° floor
        why: Balls roll at 35°; 55° clears the feed throat without a stirrer
        alternatives:
          - {option: Stirrer paddle, rejected: adds a motor and a jam mode (DFMEA F3)}
          - {option: Vibrating floor, rejected: no evidence it clears 40 mm foam balls}
    checks: [gate:containment, gate:connectivity, fea:hopper-drop, print:hopper]
    status: reviewed                       # draft | built | gated | reviewed | signed_off
    author: ...
  - parts/hopper_flap.yaml                 # a card in its own file, loaded in place
decisions: [...]                           # section-scope decisions
review: {self: {...}}                      # the author's self-critique (plan §5.1); free-form here

The minimum card is three fields: function, interfaces, and one decision with why and one rejected alternative. Everything else is added when it is known (loads when the FEA is set up, contains when the part holds something, fasteners when the joint is chosen). Keep it that small at first; the lint asks for what is missing.

Decisions (decisions.yaml, or nested in a section or card):

- id: D1
  title: Print in PETG
  decision: PETG for every printed part    # required
  why: tougher than PLA
  status: decided                          # decided | open | superseded
  date: 2026-10-27
  source: {kind: user, quote: "use PETG", ref: chat 2026-10-27}
  alternatives:                            # {option, rejected}; a bare string (the site's older format) loads
    - {option: PLA, rejected: creeps above 50 °C}   # with no reason, and the lint reports it
  consequences: ...
  confirmation: print:hopper               # MADR's confirmation: the check that shows the decision still holds
  superseded_by: D7
  author: agent-session-12
  scope: section                           # only in decisions.yaml: project (default) | section | part,
  section: S3                              #   with the section or part it applies to

A nested decision takes its scope from where it is written (section in a section, part in a card); to_dict() leaves the implied scope out so the card round-trips. The site's site: decisions: key keeps working: point it at the same file.

Interface kinds

Kind Meaning What the connectivity gate expects
fastened, bonded, press_fit, snap_fit, contact rigid, 0 DOF touching or overlapping (an overlap needs gates.intended_overlaps)
gap rigid with a designed gap apart by at most clearance
revolute, slider, cylindrical, planar, ball a mechanism a registered @joint with this interface id; contact is not required
electrical, thermal, fluid, other what crosses is not (only) mechanical nothing geometric unless also registered

Round trip

Every dataclass has to_dict(), which writes the YAML shape back with fields at their default left out. A card split into its own file is written back as its path. dump_yaml(section.to_dict()) gives YAML that loads to the same section. Comments are not preserved: the dossier is edited by hand, to_dict() is for tools and tests.

The dossier: key

cad-agent.yaml gains one key in its cad: section, dossier: dossier/project.yaml. CadProject.dossier is the resolved path (None when absent), and load_project_dossier(project) loads it with file names shown relative to the project root.

Declaring geometry in the build script

Only the build code knows which solid is the cavity, where a joint axis is and where each screw sits, so those facts are declared in code, next to the geometry (cadagent.dossier.registry). The YAML cards and the code are joined by id.

from cadagent.dossier import Axis, part, cavity, joint, fastener, assembly, build

@part("bench_base", section="S1", grounded=True)       # ground for the connectivity gate
def bench_base(p): ...

@part("hopper", section="S3")                          # registers the shape; joined to card "hopper"
def hopper(p): ...                                     # returns a shape in assembly coordinates, as today

@cavity("hopper", "hopper_cavity")                     # the void the containment gate tests against
def hopper_cavity(p): return hopper_inner(p)           # always a declared solid: the gates do not derive one

@part("ball", section="S3", free=True, reason="loose in the hopper")   # allowed to float; a reason is required
def balls(p): return [sphere_at(c) for c in p["ball_centres"]]         # a list = one instance per item

@joint("I9", kind="revolute", axis=lambda p: Axis(p["flap_pivot"], (0, 1, 0)), range_deg=(0, 70),
       parts=("hopper_flap", "hopper"), intended_contacts=[("hopper_flap", "hopper_stop")])
def flap_pivot(p): ...                                 # optional body, never called; joint() also works bare

fastener("screw_m3x8", clamps="hopper", into="pan_base", via="I7", tool="hex_2.5",
         at=lambda p: [Axis((x, y, p["flange_z"]), (0, 0, -1)) for x, y in p["flange_holes"]],
         length_mm=8, thread_start_mm=lambda p: p["flange"], min_engagement_mm=4,
         shape=lambda p: mcmaster_screw("91292A111"))  # optional solid, modelled in the McMaster screw frame

assembly.step(1, fit="pan_base", onto="bench_base", direction="-Z", tool="hex_2.5", fasteners=["screw_m4x12"] * 3)

if __name__ == "__main__":
    build()            # parameters from cad/parameters.json; writes exports/dossier-geometry.json
Declaration Arguments Notes
@part(name, section="", *, place=None, grounded=False, free=False, reason="") function (p) or () returning a CadQuery shape, Workplane, Assembly, build123d object or TopoDS_Shape; a list for several instances place: a cq.Location, an (x, y, z) translation, or a list of them (instances); then the function returns the shape in its own frame
@cavity(part, name) function returning one solid in the part's frame: it follows the part's place
joint(interface, kind="revolute", *, parts=(moving, fixed), axis, range_deg=None, range_mm=None, intended_contacts=(), samples=None) kind: fixed, revolute (needs range_deg), slider (range_mm), cylindrical (both), planar, ball the axis is in assembly coordinates; axis may be a function of the parameters
fastener(key, *, clamps, into, at, name="", via="", tool="", length_mm, diameter_mm, thread_start_mm, thread_depth_mm, min_engagement_mm, shape=None) at: one Axis per screw, from the head seat in the direction the screw advances thread_start_mm: seat to first engaged thread; numbers may be functions; shape is placed at every axis (McMaster frame: origin on the seat, +Z out of the hole)
assembly.step(order, fit, onto=None, *, direction="", tool="", fasteners=(), access_check="", note="") direction: +X..-Z or (x, y, z) steps may also be written in project.yaml; when both exist with the same order they must agree

Declarations are checked when they are made (a free part without a reason, a part both grounded and free, a revolute joint without a range, an unknown kind, a bad id raise RegistryError). The same declaration run twice (a re-import) replaces itself; the same name declared at two places raises. Parts, fasteners and cavities are separate kinds: a fastener or cavity named like a part is refused, and fastener names (default key:clamps-into) never contain #. A cavity belongs to a single-instance part (the containment gate must know which container it is).

Which registry. The module-level part, cavity, joint, fastener, assembly.step and build write to the current registry: the innermost with use_registry(reg): block, else cadagent.dossier.REGISTRY. build_module() builds every script in a fresh registry of its own, so nothing leaks between two builds in one process (a test session, a watch loop, cad-agent gates); modules under the project root that the script imports are re-imported, so their declarations land in that build too. A tool or test can also use its own Registry() and its methods (reg.part, reg.assembly.step, ...).

build(params=None, *, out_dir="exports", root=None, write=True) calls every declaration with the parameters (a mapping, a JSON path, or cad/parameters.json under root), and returns a BuildResult. A declaration that fails raises RegistryError naming it and its file:line. build_module("cad/build.py") executes a build script and builds it, adding print_parts()/dossier_items() (called again on every build, so new parameters give new shapes). root defaults to the project root, project_root(script): the nearest directory above the script with cad-agent.yaml or vptest.yaml (else the parent of a cad/ directory), never the current directory. out_dir must be inside it. Every path in the record is relative to the root; a declaration made in a file outside the root is recorded by its file name only, so the committed record carries no machine paths.

One out_dir holds one geometry record: a project with several build scripts gives each its own out_dir (a second build into the same directory replaces the record and removes the first build's shape files).

Migrating an existing build: dossier_items()

A build that keeps print_parts() -> {name: shape} adds one function, and moves parts to decorators one at a time (a decorated part wins over a print_parts() entry of the same name):

def dossier_items():
    return {
        "parts": {"hopper": {"section": "S3"}, "ball": {"section": "S3", "free": True, "reason": "loose"}},
        "cavities": {"hopper_cavity": {"part": "hopper", "shape": hopper_inner(P)}},
        "joints": [{"interface": "I9", "kind": "revolute", "parts": ("hopper_flap", "hopper"),
                    "axis": Axis(P["flap_pivot"], (0, 1, 0)), "range_deg": (0, 70)}],
        "fasteners": [{"key": "screw_m3x8", "clamps": "hopper", "into": "pan_base", "at": [...]}],
        "assembly": [{"order": 1, "fit": "pan_base", "onto": "bench_base", "direction": "-Z"}],
    }

Every print_parts() shape is registered; a part not yet given a section here is still a shape, and the join reports it as a shape without a card until its card exists. That list is the migration's to-do list. The same checks apply as for decorators (a free part needs a reason; grounded and free together are refused; a joint needs interface).

exports/dossier-geometry.json

{
 "schema": "cad-agent.dossier-geometry/1", "units": "mm",
 "hash": "cadagent.evidence.shape_content_hash: sha256 of the OCCT BREP text ...",
 "actor": "agent-session-12",              # who ran this build
 "parameters": {"file": "cad/parameters.json", "sha256": "..."},
 "out_dir": "exports", "shapes_dir": "exports/dossier-shapes",
 "parts": {"ball": {"section": "S3", "grounded": false, "free": true, "free_reason": "loose in the hopper",
                    "sha256": "<hash of the instance hashes>", "count": 8, "declared_at": "cad/build.py:24",
                    "actor": "agent-session-12",          # who built this part's current hash
                    "instances": [{"key": "ball#0", "sha256": "...", "file": "dossier-shapes/<sha256>.brep",
                                   "bbox": {"min": [x, y, z], "max": [x, y, z]}, "volume_mm3": 523.6,
                                   "solids": 1, "valid": true}]}},
 "cavities": {"hopper_cavity": {"part": "hopper", "sha256": "...", "file": "...", "bbox": {}, "volume_mm3": 0,
                                "solids": 1, "valid": true, "declared_at": "cad/build.py:20"}},
 "joints": {"I9": {"kind": "revolute", "moving": "hopper_flap", "fixed": "hopper",
                   "axis": {"origin": [31, 0, 70], "direction": [0, 1, 0]}, "range": [0, 70], "unit": "deg",
                   "intended_contacts": [["hopper_flap", "hopper"]], "samples": null, "declared_at": "..."}},
 "fasteners": {"screw_m3x8:hopper-pan_base": {"key": "screw_m3x8", "clamps": "hopper", "into": "pan_base",
               "via": "I7", "tool": "hex_2.5", "length_mm": 8, "thread_start_mm": 2.4, "min_engagement_mm": 4,
               "count": 4, "instances": [{"key": "screw_m3x8:hopper-pan_base#0", "origin": [-25, -25, 12.4],
               "direction": [0, 0, -1], "sha256": "...", "file": "...", "bbox": {}}], "declared_at": "..."}},
 "assembly": {"steps": [{"order": 1, "fit": "pan_base", "onto": ["bench_base"], "direction": "-Z",
                         "tool": "hex_2.5", "fasteners": ["screw_m4x12", "screw_m4x12", "screw_m4x12"],
                         "declared_at": "cad/build.py:40"}]},
 "physical_fit_verified": false
}

Hand-over to the gates

The gates (cadagent.gates, plan §4) consume the registry through two calls and never import the build script:

from cadagent.dossier import build_module, load_geometry

result = build_module("cad/build.py")                    # in the build process, or
result = load_geometry("exports/dossier-geometry.json")  # in another process (cad-agent gates): reads the BREPs,
                                                         # checks each file against its recorded hash; the root
                                                         # comes from the record's out_dir
shapes = result.assembly_shapes()          # {key: cq.Shape in assembly coordinates}: "hopper", "ball#3",
                                           # "screw_m3x8:hopper-pan_base#0" (placed fasteners; fasteners=False omits)
pairs = result.assembly_shapes(with_hashes=True)   # {key: (cq.Shape, content hash recorded at build time)}:
                                           # what the gates' Assembly.from_build() takes, nothing re-hashed
result.content_hashes()                    # {key: hash} for every instance, placed fastener and cavity
result.owner("ball#3")                     # "ball": the part an instance key belongs to
result.kind("ball#3")                      # "part" | "fastener" (None when unknown)
result.parts["ball"]                       # [cq.Shape, ...] instances in order
result.cavities["hopper_cavity"]           # cq.Shape, in assembly coordinates
result.joints["I9"]                        # {"kind", "moving", "fixed", "axis": Axis, "range", "unit", ...}
result.fastener_axes[name]                 # [Axis(origin=seat, direction=advance), ...]
result.geometry                            # the JSON above: grounded / free flags, hashes for cache keys

What each gate reads: connectivity uses assembly_shapes(), grounded and free (union of the code flags, gates.grounded/gates.free_parts and contains[].free in the YAML), the joints and the dossier interfaces; containment uses cavities and the instances of contains[].what; the fastener gate uses fasteners (axes, stack numbers, placed solids) and the steps; the motion gate uses joints (axis, range, intended_contacts, samples, else gates.motion_samples); the assembly gate uses the steps. Pair caches key on the recorded hashes.

What moves with a joint (settled, final): a joint names one moving part, and its body is the transitive closure from it over fixed joints and fastener declarations (the fastener, what it clamps and what it threads into), stopped at grounded parts and at any joint's fixed part (cadagent.gates.relations.moving_bodies, used by Assembly.from_build, the connectivity gate and the motion gate). In a chain, a child joint's fixed part is therefore its parent's moving part itself; see gates.md.

Joining cards and geometry

join(dossier, geometry) (geometry: a BuildResult or the JSON mapping, so the lint needs no CAD kernel) returns Issues with file:line (the card's YAML line, or the build script's declaration line):

Code Severity When
card-without-shape error a part card no @part or print_parts() registers
shape-without-card error a registered part with no card
section-mismatch error @part(section=...) differs from the section the card is in
unknown-cavity, cavity-wrong-part error contains[].cavity missing, unregistered, or on another part
contains-count error contains[].count differs from the instances the build made
joint-without-interface, joint-parts-mismatch, joint-kind-mismatch error a joint with no interface of its id, other parties, or another kind (fixed matches any rigid kind)
interface-without-joint error a mechanism interface (revolute, slider, ...) with no @joint
unknown-part, unknown-interface error a joint, fastener, cavity or step names something that does not exist
fastener-count error a card's fasteners[].qty differs from the axes fastener() places
fastener-not-registered, step-fastener-unregistered warning declared in the card or a step, not yet in code
step-conflict error a step in project.yaml and in code with the same order disagree

The dossier lint runs this with its own checks; every code in this table is an error or warning of the lint under the same name.

Lint and status

cad-agent dossier lints the dossier and computes the status of every part card and section. It writes exports/dossier-status.json (the site and the report read this, never the YAML) and the lint verdict exports/gates/dossier-lint.json (cadagent.gate/1, one check per finding plus one passing check per rule). cad-agent check runs the same lint as its cad-dossier feature, and cad-agent all runs it after the build (as the last step of cad-agent gates, gates.md). It needs no CAD kernel: it reads files only.

from cadagent.dossier.lint import lint, lint_project, project_evidence, write_outputs, Evidence, RULES

res = lint_project(load_project("."))            # None without a dossier: key
res = lint(load_dossier(path, strict=False), Evidence(geometry=..., gates={...}, checks={...}, reviews=[...]))
res.findings          # Finding(code, message, where=file:line, severity, item, parts): sorted by file and line
res.parts["hopper"]   # {"status": "gated", "blocked_by": ["no current reviewer record"], "checks": [...], ...}
res.sections["S3"], res.requirements["R4"], res.decisions["D41"]
res.passed            # no error
write_outputs(res, "exports")

What it reads. The loaded YAML (loaded with strict=False, so loader errors such as unknown-id become findings with their file and line), exports/dossier-geometry.json, every verdict in exports/gates/, the cad.checks of cad-agent.yaml (a check passes when every pass_key is true, as in the report) and the reviewer records in dossier/reviews/*.json.

Check ids. A checks: entry (on a card or a requirement, a decision's confirmation, a step's access_check) is gate:<name> (the verdict exports/gates/<name>.json; unrun until it exists, and an error when <name> is neither a known gate nor a written verdict) or the id of a cad.checks entry. Any other id is unknown-check.

Rules

cadagent.dossier.lint.RULES is the list; every finding gives its file, line and code. Errors fail cad-agent check; warnings are reported.

Code Severity What must hold
card-no-function error a part card states its function
card-no-interfaces error a part card lists its interfaces (a free part, or a one-part dossier, may not)
card-no-decision warning the card, or another card or the section it is in, records a decision
interface-not-party error a card lists only interfaces whose between names the part or its section
interface-one-sided error an interface between two parts is listed on both parts' cards
interface-datum-conflict error two sections that declare one interface agree on its datum (plan §5.4)
interface-conflict warning they agree on its other fields
fastener-via-mismatch error a card's fastener runs via an interface between this part and to
exemption-unknown-party error every party of a gates.intended_overlaps or gates.ignore_pairs entry is a part, one of its instance keys, a fastener or one of a fastener's instance keys in the geometry record and a [part, part] pair names a part with at least two instances (unchecked without a record; a name that is another kind of dossier id is the loader's unknown-id, and one instance key twice is a loader error)
wall-undeclared error / warning a card checked by gate:wall (error), or made by an additive process (warning), declares material.wall_mm
decision-incomplete error a decided decision has a why and at least one alternative
alternative-no-reason error every alternative says why it was rejected
decision-open warning an open decision; it also blocks its section's sign-off
unknown-check error every check id resolves (above)
requirement-untraced error every requirement is served by a section (or derived into one that is)
requirement-unchecked error every requirement has a check: its own, or one on a card of a section that serves it
waiver-no-rationale error a waived requirement says why
requirement-status-ignored warning a requirement's status is computed; only waived belongs in YAML
section-no-requirements error a section names the requirements it serves
section-no-parts warning a section has part cards
section-below-gated error every section has reached the required status (default gated, plan §5.4)
status-not-earned error a card says reviewed or signed_off only when a current reviewer record gives it
status-overclaimed warning a card's YAML status is not above the computed one
review-invalid error a reviewer record passes validate_record (the reviewer pass): every field, each answer with its evidence, a sign-off consistent with its findings and answers
review-unknown-section error a reviewer record names a section of the dossier
review-same-actor error the reviewer did not build any part of the section (parts.<name>.actor) and is not named as an author of the section, its cards or decisions; neither the reviewer nor a part's builder is unknown (while gates: require_separate_reviewer, plan §5.3)
review-builder-unknown warning a current review is of a section whose parts were built by a known actor; otherwise nobody can be shown to be someone else, and the review does not count (a fresh checkout built without CAD_AGENT_ACTOR: the section stays gated, check still passes)
review-incomplete warning a current review passes check_record against the dossier and the packet on disk (for a sign-off: every decision judged, every requirement traced, every question answered; for any record: no answer to a question the packet does not ask, nothing outside the section); otherwise a sign-off does not count, and a change request still counts (it voids an earlier sign-off of the same geometry)
review-stale warning the latest review still matches the geometry; otherwise its sign-off no longer counts
signoff-open-decision error a section with an open decision is not signed off
geometry-missing warning the geometry record exists (without it every card stays draft)
geometry-invalid, verdict-invalid error the geometry record and the gate verdicts are readable
verdict-stale error a gate verdict's inputs match the current geometry record (gates.md); a stale or unbound verdict is not a pass

The loader's errors and warnings (above) and the join's codes (Joining cards and geometry) are findings under their own codes.

The status machine

Each part card climbs one step at a time, and stops at the first step its evidence does not reach. blocked_by says why it is not one step higher.

Status Reached when
draft the card exists; it stays here while it has a lint error, or the build registered no shape for it
built lint-clean, and the geometry record has its shape and content hash
gated every check that applies passes: the card's checks plus every gate verdict in exports/gates/. A gate counts against the part when a failing finding names it, an instance name#i, a fastener that clamps it or threads into it, or a cavity of it, and against every part when a failing finding names nothing known; at least one check must apply
reviewed a reviewer record for its section, by a separate actor, whose hashes match the current hash of every part in the section
signed_off that record says signed_off: true and the section has no open decision

A section's status is the lowest of its parts' (draft when it has no cards or a lint error of its own). Open decisions count against the section they are in, or the section of the part they are scoped to (a part: decision in decisions.yaml included). A lint error on any line inside a card is that card's (with Evidence.root set, as lint_project does).

A requirement's evidence is its own checks and its derived requirements (parent:). The checks on the cards of the sections that serve it satisfy the trace rule and are listed as covered_by, but they verify nothing on their own: a wall gate passing says nothing about "the lid stays on". A requirement is

Geometry changes void sign-off. A reviewer record binds the content hash of each part it reviewed (the sha256 of parts.<name> in dossier-geometry.json, the hash recorded at build time). When a part is rebuilt with different geometry, no record matches any more: the section falls back to gated if its gates still pass on the new build (built if not), the status file shows the voided record under invalidated with the parts that changed, and the lint warns review-stale. A card that still says status: signed_off in its YAML is then status-not-earned: statuses are computed, so leave status: out of the cards.

The status uses the newest record whose hashes match, so a revert to reviewed geometry restores its sign-off.

Reviewer records (dossier/reviews/*.json, the reviewer pass) are validated whole; an invalid one is review-invalid and does not count. A record by a part's builder or the section's author, or by unknown, is refused (review-same-actor; a section whose builder is unknown gives the warning review-builder-unknown and the record does not count either) while the dossier's gates: require_separate_reviewer is true (the default; lint(..., require_separate_reviewer=) overrides it). Only a record whose hashes match the current build is checked, since only such a record can count: an old record does not become an error because its reviewer later changed a part. A current record read from a file also goes through check_record against the dossier and the packet on disk (the checks --submit runs, without those the lint cannot make or reports itself: the packet's staleness, open decisions, the gates, and, unless the packet on disk is the one the record names in packet.sha256, which views and gates the packet had when the record was written); a sign-off that fails them does not count, and a change request that fails them still counts (review-incomplete). With no geometry record nothing is built, so no record is refused or counted. A requirement verified by inspection also needs the current record of each section serving it to trace it yes and not to request changes. Records are read in time order (timestamp, else date, then the -2, -3 suffix of a second record the same day); the newest whose hashes match decides.

Stale verdicts. A gate verdict counts only when it was computed from the current geometry record: its inputs.geometry.digest must equal the record's (cadagent.gates.verdict.geometry_digest). Otherwise, or when it has no inputs, the lint reports verdict-stale with the shapes that changed, the gate's checks read stale, and the parts stay built until cad-agent gates runs again. Rebuilding the same geometry (another actor, a declaration moved to another line) keeps verdicts current. For a project (lint_project, cad-agent dossier) the lint also compares the gate's declarations and options, as cad-agent check does (gates.md).

Limits. A clean lint means the design is declared and agrees with the build. It does not mean the geometry is right (the gates check that) or that it meets its requirements (the reviewer checks that).

The reviewer pass

A section is reviewed by an agent or person that did not build it (plan §5.3, task T11; cadagent.dossier.review). The author hands it over (skill design-process §7) once the section is gated and its review packet is rendered (review-packet.md). Then:

cad-agent review-section S3                      # writes the bundle; exit 1 while something blocks a sign-off
cad-agent review-section S3 --bundle-dir /tmp/s3 --since v0.4     # elsewhere; diff against a given revision
# the reviewer reads PROMPT.md, copies record-template.json, fills it in, and:
CAD_AGENT_ACTOR=review-agent-7 cad-agent review-section S3 --check my-review.json    # validate, write nothing
CAD_AGENT_ACTOR=review-agent-7 cad-agent review-section S3 --submit my-review.json   # validate and write the record
cad-agent dossier                                # the section's status: reviewed or signed_off
from cadagent.dossier.review import write_bundle, submit_record, validate_record, check_record
manifest = write_bundle(project, "S3", bundle_dir=None, since=None)    # -> bundle.json as a dict
path, record, problems = submit_record(project, "S3", "my-review.json", reviewer=None, write=True)
problems = validate_record(record)       # [Problem(code, message)]: the record on its own (what the lint runs)
problems = check_record(record, dossier=d, geometry=geo, packet=manifest, packet_stale=None, section_status="gated")

The bundle

exports/review-bundle/<section>/ (or --bundle-dir; a directory that is not empty and holds no bundle.json is refused, never wiped). It needs no CAD kernel: the packet must have been rendered before.

File What it holds
PROMPT.md the reviewer's instructions: who they are not, what to read first, how to answer, how to submit, what blocks a sign-off now
bundle.json the manifest (cad-agent.review-bundle/1): the section and its computed status, the part hashes and who built each, require_separate_reviewer, the authors named in the dossier, the packet's state, open decisions, the diff's base and paths, earlier records, blockers, ready, and the sha256 of every file
section/<path> the section's YAML, its split part cards and project.yaml, at their project paths
gates.json per gate: status, stale reason and the checks that name a part of the section (from the packet's measurements); the gate findings and the lint findings for the section
status.json the lint's rows for the section and its parts, each part with built_by
packet/ the review packet's manifest.json and every image it names
checklist.json the packet's checklist (questions, measured_only, instructions), the decisions to judge and the requirements to trace
diff.patch the section's sources (the dossier directory without reviews/, the build scripts that declared its geometry, the parameters file, cad-agent.yaml) in the working tree against the base: the commit the last sign-off names (commit), else the commit that added that record, or --since; untracked files under those paths are listed in bundle.json. No earlier sign-off: no diff, review all of it
record-template.json the record to fill in: the current hashes, one entry per question, decision and requirement, every answer null

blockers (and exit 1) name what would refuse a sign-off today: a missing, unrendered or stale packet, a section below gated, an open decision, a part whose builder is unknown. The bundle is written anyway: a reviewer can still request changes.

The record (cad-agent.review/1)

dossier/reviews/<section>-<date>.json (-2, -3, ... for another record the same day). Written by --submit, which fills in actor (from CAD_AGENT_ACTOR), date, timestamp, signed_off (from verdict), commit, build_actors, packet and toolkit; the reviewer writes the rest.

{
 "schema": "cad-agent.review/1", "section": "S3",
 "date": "2026-10-28", "timestamp": "2026-10-28T14:03:12Z", "actor": "review-agent-7",
 "verdict": "sign_off",                       // sign_off | changes_requested
 "signed_off": true,                          // verdict == sign_off
 "hashes": {"hopper": "<sha256>", "hopper_flap": "<sha256>"},     // every part of the section, as built now
 "build_actors": {"hopper": "agent-12", "hopper_flap": "agent-12"},
 "commit": "<git HEAD when submitted: the next bundle diffs from here>",
 "packet": {"manifest": "exports/review/S3/manifest.json", "sha256": "...", "section_hash": "...", "stale": null},
 "checklist": [
  {"id": "contained-inside:hopper_cavity:ball", "question": "In the cut through hopper_cavity, ...",
   "bad_answer": "no", "answer": "yes", "evidence": "every ball is inside the wall in both cuts; containment passes",
   "views": ["cut-cavity-hopper_cavity", "cut-cavity-hopper_cavity-plan"],
   "image_only": false, "measurements": ["gate:containment"]},
  {"id": "plain-box", "question": "Does any part appear as a plain box ...", "bad_answer": "yes",
   "answer": "no", "evidence": "the flap has its hinge knuckles and stop", "views": ["solid-iso"], "image_only": true}
 ],
 "decisions": [{"id": "D41", "verdict": "justified", "evidence": "both alternatives rejected with a reason"}],
 "requirements": [{"id": "R4", "traced": "yes", "evidence": "gate:containment checks 40 balls in hopper_cavity"}],
 "findings": [{"id": "F1", "severity": "minor", "text": "the flap stop has no fillet", "view": "pose-I9-max",
               "parts": ["hopper_flap"]}],
 "summary": "...", "toolkit": "0.1.0", "physical_fit_verified": false
}
Field Rule (validate_record)
top level schema, section, date (YYYY-MM-DD), timestamp (ISO 8601 with a zone), actor, verdict, signed_off, hashes (part → sha256 hex), checklist, decisions, requirements, findings are required; build_actors, commit, packet, bundle, summary, toolkit, physical_fit_verified (false only) may be given; any other key is refused (a typo such as signed-off must not pass silently)
checklist[] id; answer yes, no or unclear; evidence (non-empty); views (view ids); image_only: true when the answer rests on the images alone (then views is not empty and measurements is), false when a measurement backs it (then measurements lists it: gate:<name>, a check id or a packet finding id)
decisions[] id; verdict justified, weak or missing_alternative; evidence
requirements[] id; traced yes, no or unclear; evidence
findings[] id; severity critical, major, minor or note; text; optional parts, view (a packet view id: the site shows the finding beside that image), checklist (a question id), decision
a sign-off answers at least one question; no critical or major finding; no answer unclear or equal to its bad_answer; no decision missing_alternative; every requirement traced: yes
changes requested at least one finding

check_record (run by --submit and --check) then compares the record with the project now:

Code Refused when
stale-hashes hashes is not exactly the current hash of every part of the section: stale hashes never sign off (nor request changes on geometry that is gone); make a new bundle
same-actor while gates: require_separate_reviewer: the reviewer is unknown, a part's builder is unknown, the reviewer built a part of the section (parts.<name>.actor) or is the author of the section, a card or a decision
actor the record names an actor other than the submitting CAD_AGENT_ACTOR
unanswered, unknown-question, question-changed, unknown-view, unknown-measurement the checklist answers a question the packet does not ask, changes a question's text or bad answer, cites a view without an image or a gate the packet does not quote; a sign-off also leaves no question unanswered (unanswered), while a change request may stop at the first blocking flaw
unjudged, unknown-decision, untraced, unknown-requirement a decision or requirement outside the section is judged; a sign-off also judges and traces every one of the section's
packet, packet-stale a sign-off with no rendered packet, or one that is not current (project_packet_stale)
open-decision a sign-off while a decision of the section is open
below-gated a sign-off of a section that is draft or built
section, geometry, unbuilt the section is not in the dossier; nothing is built; a part has no shape

The lint runs validate_record on every record, and check_record on a record that matches the build, against the dossier and the packet on disk (review-incomplete; without a packet on disk the questions cannot be checked), with its own rules on top (review-same-actor, review-builder-unknown, review-stale, signoff-open-decision). What it cannot check is whether the packet was current when the record was written: only --submit checks that.

A record changes the dossier lint that the packet quotes, so after any record the packet reads stale: render it again (cad-agent review-packet) before the next sign-off.

On the site. The review pages (below) read the status file's sections.<id>.review (record, date, timestamp, actor, verdict, signed_off, findings: the record's finding count, blocking: how many are critical or major) and list the record's findings beside the packet image each one's view names, with the reviewer's checklist answers.

Limits. The separate-reviewer rule is only as strong as CAD_AGENT_ACTOR: a harness that gives two agents the same value, or one agent two values, defeats it. The lint cannot tell who wrote a record file: a file in dossier/reviews/ that names another actor counts if it passes the checks (--submit at least stamps the actor from the environment; who commits the file is git's record, not the lint's). Someone who only edited the section's YAML (a gate exemption, a card) without building a part and without an author: line is not known as its author and is not refused. The per-part builder is carried from the geometry record a build replaces: a build into a directory without that record (a fresh checkout where exports/ is not committed) records its own actor for every part, so a reviewer who builds there becomes the builder and is refused (the safe direction; commit the record, or review without rebuilding). A sign-off binds part hashes, not the dossier text: editing a card's function or a decision after a sign-off does not void it (the packet goes stale, so the next sign-off needs a new one). The reviewer judged renders and measurements of the model; a signed-off section is not shown to fit, hold or work.

On the review site

cad-agent site builds review pages for a project with a dossier: or gates: key (cadagent.site_review), in site/review/, linked from the overview's "Design review" block and, when the site configures no nav, from three top-bar tabs (Review, Requirements, Gates). A project without either key sees no change.

Page Shows
index.html one row per section: status, requirements, part count, gate state (pass / fail / stale / unrun over its parts' gate checks), review state (unreviewed / findings open / reviewed / signed off / void), the reviewer record; the lint findings with their file and line
section-<id>.html the section's requirements (status, verifying checks), its interfaces and those of its parts, section decisions, and each part card: function, material, loads, fasteners, contains, decisions with the alternatives rejected and why, checks as pills linked to their evidence, the status ladder draft → built → gated → reviewed → signed_off, what keeps it from the next step, and a stale badge when the geometry changed since the last review (invalidated). Then every gate finding that names one of its parts (an instance, a fastener that clamps or threads into it, a cavity of it), with the witness, and the lint findings on it; then the review packet
requirements.html id, text and source quote, status, the sections it traces to, its own checks (and the card checks that only satisfy the trace), derived requirements, waivers and their rationale
gates.html one row per gate: state (pass, warn, fail, stale, missing, invalid, not applicable, skipped, from cadagent.gates.run.gate_rows), counts and links to the verdict's .md, .json and .dot; the connectivity graph as SVG; each gate's findings with witnesses, exclusions, what it did not check, assumptions and limits

What it reads. Statuses only from what the pipeline computed: exports/dossier-status.json, the verdicts in exports/gates/ and the reviewer records the status file names. The YAML is loaded (strict=False) for the words only (functions, purposes, decisions, interfaces, requirement sources and waivers). When a dossier file is newer than the status file, every page says so ("run cad-agent dossier"); with no status file the pages still list the sections from the YAML, marked "not computed". cad-agent site --serve rebuilds the pages when any of these files changes, and serves .md, .dot and .yaml files as plain text so the linked verdicts open in the browser.

The connectivity graph is drawn from exports/gates/connectivity.dot by cadagent.dotsvg: Graphviz's dot when it is installed, else a built-in layered layout (each cluster laid out on its own, layers by distance from the grounded part, left to right). The page says which drew it, and writes site/review/connectivity.svg too. The built-in drawing takes its colours from the page's theme (light or dark); Graphviz's keeps its own black on clear.

The review packet (plan T10, cad-agent.review-packet/1) is read from exports/review/<section>/manifest.json when it exists: each of its views (id, caption, note, file relative to the manifest, null when not rendered) is shown with the checklist questions that name it, and the manifest's parts.*.sha256 that differ from the current build show a "rendered from older geometry" banner. The reader also takes a plain images list (paths or {file, caption, view, hash}) and a hashes: {part: sha256} map. The current reviewer record's findings ({severity, text|message, render|image|view}) are listed beside the view they name (by id or file name), the rest below. Without a manifest the page says how to make one.

Limits: the pages show what the files say. They do not recompute a status, and a page built before the gates ran again shows the old verdicts (the gates page marks them stale against the current geometry record).