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
- Every id starts with a letter and uses letters, digits,
_,-or.. Ids share one namespace across requirements, sections, parts, interfaces and decisions, so an interface'sbetween: [a, b]is never ambiguous. Prefixes (R4,S3,I7,D41) are a convention, not a rule. - Errors carry the file and line: a malformed id, a duplicate id, a reference to an id that does not exist or is
of the wrong kind, an empty file (reported at the line that names it), a gate exemption without a reason ("… names 'R4', which is not an interface in this dossier (it is a requirement)"), a wrong type,
a value outside its enumeration, a duplicate YAML key, invalid YAML, a missing file.
load_dossierraises oneDossierErrorlisting all of them (.issues, and.file/.lineof the first). When a file fails to parse, references are not resolved, so its ids do not cascade into unknown-id errors. - Findings are warnings, never fatal: an unknown key (reported with its line and the keys that are known), and an interface declared differently by its two sections.
- References the loader does not resolve, because they point outside the YAML: a pair exemption's party that is
not a dossier part (a fastener name or instance key, checked by the lint's
exemption-unknown-party),checks(check and gate ids, resolved by the lint againstcad-agent.yamlchecks and the gate verdicts), fastener catalogue keys (screws,fastened_by, stepfasteners), cavity names (contains[].cavity, resolved against the registry), a decision'sconfirmation. The lint (plan T2) and the registry join (below) report those.
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
}
- Hashes are content hashes from one shared function,
cadagent.evidence.shape_content_hash(shape) -> str(also exported ascadagent.dossier.shape_content_hashandshape_sha256): sha256 of the shape's BREP text, OCCT format version 1, without triangulation or normals, with the 7-digit per-shape flag lines removed. The registry and the gates use the same function, so a recorded hash is a valid gate cache key.- The same shape built the same way gives the same hash in any process and run (tested in a subprocess); a changed
dimension or placement changes it (the location is part of the text, so even
place=(0, 0, 0)changes it: the safe direction). Queries (Volume,BoundingBox,isValid,fix) and tessellation do not. - Two different constructions of the same geometry may hash differently: a cache miss, never a false match. An OCCT upgrade may change every hash, which only invalidates sign-offs.
- A shape read back from its BREP file may hash differently, because the reader recomputes frame directions
and location matrices (last-digit noise, signed zeros). Keep the recorded hash with the shape
(
assembly_shapes(with_hashes=True),content_hashes()); never re-hash a loaded shape to identify it.evidence.brep_content_hash(file_bytes)checks a stored file against its record.
- The same shape built the same way gives the same hash in any process and run (tested in a subprocess); a changed
dimension or placement changes it (the location is part of the text, so even
actor(top level) isCAD_AGENT_ACTORfrom the environment (an agent or session id, or a person),unknownwhen unset: who ran this build.parts.<name>.actoris who built that part's current geometry: the actor of the build that first produced its content hash. A rebuild that gives a part the same hash keeps the earlier actor (read from the record it replaces; a record from before per-part actors gives its top-levelactor), a changed hash takes this build's actor, and a known actor replaces an earlierunknown. The reviewer pass compares the reviewer with these (below). Neither is a gate input: a rebuild by someone else keeps the verdicts current.- Shapes are written once each to
exports/dossier-shapes/<sha256>.brep(content-addressed; files no longer referenced are removed). The directory gets its own.gitignore(*): it is build output, regenerated by every build. The JSON is the record to commit. Any gate cache belongs in a gitignored directory too. - Numbers are rounded to 1e-6 mm so the JSON diffs cleanly;
bboxis the tight OCCT box. - Instance keys are
namefor a single instance andname#0,name#1, ... for several.
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
verifiedwhen it has evidence and all of it passes (every own check passes, every derived requirement isverifiedorwaived; forverify_by: inspection, also every section that serves it is at leastreviewed);failedwhen an own check fails or a derived requirement failed;not_testedforverify_by: testordemo(cad-agent cannot run a test or a demonstration), and for a parent whose only open children arenot_tested;waived, or elseunverified.
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).