A Canary device is described in seven places, each with its own generator and
its own byte-diff gate: the firmware envs (firmware/flavors.json,
firmware/build_matrix.json, firmware/envs/platformio/*.ini), the board
registry (firmware/boards/boards.json + boards/<id>/pins/pins.h), the
emulator (canary-local/emulator/build.sh + dist/*.meta.json), the fleet
figures and their confidence ladder (canary-local/devices/figures.json), the
enclosure CAD (docs/hardware/enclosure/*.scad +
canary-local/devices/enclosures.json), the flasher catalog
(canary-local/devices/flash.json) and the website (its page and glTF
model). The gates are good. What was missing is the join — nothing said
that this env, this pins header, this figure, this flasher product and
this case are the same object.
devices/<slug>/device.json is that join, one file per device. It carries the
id each of those files uses for one device: no feature lists, no prose,
and — deliberately — no status. The one thing in it that is not an id is
cad.params, the board and module dimensions its case is built around, and
it holds those as the owner, not a mirror: gen_cad_params.py writes
them into the case .scad and its --check proves the file still says
them — a board fact as a reference into the board registry that already
carries each board's evidence, a case measurement as a number. Wave 1
("Describe") of
the roadmap's §4 wrote the manifests and
the lint; wave 2 ("Consume") made the generators read
them — see "What the manifests drive" below for exactly which facts, per
generator; wave 3 ("Parametrize") put the manifest in front of the enclosure
chain — the cad.params rows below.
Validated by device.schema.json (JSON Schema 2020-12;
the linter carries its own stdlib validator because CI has no jsonschema).
| Key | What it names | Proven against |
|---|---|---|
slug |
the directory name; where a build_matrix.json or flash.json product names this hardware, the same id |
the directory; uniqueness |
name |
the product name as the flasher shows it | (copy — not linted) |
family |
the firmware product it is built from | firmware/flavors.json name |
board.mcu · psram_mb · flash_mb |
the silicon | boards.json row for board_id |
board.board_id |
the firmware/boards/<id>/ registry row |
boards.json; each env's resolved board = is that row's pio_board; each env's -I boards/<id>/pins include is this id (or a variant) |
board.variants |
SKU siblings of the same panel module whose pins headers some envs compile (the Dash 4.3 / 4.3B / 4.3C) | boards.json; same MCU |
board.envs |
every [env:NAME] that targets this hardware |
the family's platformio.ini with extra_configs resolved; claimed by exactly one device; every flavors.json build env is claimed or explained in unclaimed.json |
peripherals |
lower-cased HAS_<NAME> flags |
#define HAS_<NAME> 1 in boards/<board_id>/pins/pins.h |
figure |
the fleet figure that draws this hardware | figures.json: role device, same family, agrees with its hardware map for board_id and with every flash.json product that draws this device |
cad.scad · cad.also · cad.enclosure_sets |
the enclosure source, any further case file the same knobs own (a second form of one build — the Vision's doorbell), and the printable sets — which is also where the Lab homes each set (gen_enclosures.py inverts the lists) |
each file exists; sets exist in enclosures.json; cad.scad and every cad.also file is the source of one of them; from the other side, every set enclosures.json homes on a device is claimed by a manifest, with device / devices equal to the inversion |
cad.params |
the literal Customizer knobs of cad.scad (and of every cad.also file) this device asserts — the board and module dimensions the case is built around (board_l, vm_w, stack_sock_h), as plain numbers, strings or booleans, or as references into the board registry ({"brd": "xiao", "dim": "l"} is brd_l("xiao"); {"brd_fn": "brd_xiao_w_measured"} is that measured fact) that the generator resolves from docs/hardware/enclosure/canary_board_lib.scad before it writes. Never a selector (host, radar, preset, part — chosen per printable set at render time), never a computed value, never a design-language knob (wall_t, corner_r — case-owned, with deviates: reasons, lint_design_lang.py) |
gen_cad_params.py writes each value into the .scad's literal (the token on the knob's own line, nothing else) and its --check, which this linter also runs, proves the file still says it; the knob must be one gen_builder_manifest.parse_scad accepts; a reference must name a BRD_REGISTRY row and column or a brd_*() fact the library defines; manifests sharing one .scad may each assert a subset and must agree on shared keys |
emulator.flavor |
the browser twin | build.sh allowlist; dist/canary-display-<flavor>.meta.json exists; build.sh compiles this device's pins dir for that flavor; every dist display flavor is claimed |
flasher.product · flasher.variants |
the browser-flasher catalog entries that install onto this hardware | flash.json: exists, chip == board.mcu, tier.board_id is this board, flash_mb agrees; every catalog product is claimed exactly once |
lab.card |
the canary.local Lab card (canary-local/devices/registry.json id) that presents this hardware, only where it is not the default — the family's card when the family is itself a manifest, else the slug. Today one manifest needs it: the Nightlight's card is canary-nightlight (the id the Lab, its fleet figure and the scene builder share), its manifest canary-display-nightlight-c3 (the flasher product and the build env), so gen_enclosures.py homes the C3 case on the card and lists the manifest under devices |
registry.json has the card; a key that restates the default is refused; and, for every manifest, a claimed enclosure set lands on a card — a manifest whose home is no card fails, so a case cannot sit on a page the Lab does not have |
site.page · site.model |
the website page and AR model | shape only — those files live in the securacv_website repository and cannot be verified from here |
Plus a cross-file rule: every firmware/build_matrix.json product has a
manifest (by id, else by flavor + env), and its board, mcu and env(s)
agree with it.
The confidence ladder is never typed here. status, confidence and
tier are rejected by the schema (additionalProperties: false). The
verdict for a device is figures.json's confidence for its figure —
derived from evidence on disk (committed STLs, a firmware config, a released
catalog variant) — and scripts/lint_device_manifests.py prints it in its
table. A device with no figure yet shows —; that is the gap, honestly.
A key whose value is unknown is omitted, not guessed. No figure has been
drawn for the four canary boards; the Glance AMOLED has no enclosure yet;
the C3 Super Mini has neither. Those keys are absent. The two Sentinel
manifests (canary-sentinel, canary-sentinel-lite) carry less still. The
family is Phase 1a, compile-gated and marked unreleased in flavors.json,
so they name the board, its peripherals and the env CI compiles, and no
more: no figure, case, flasher product or emulator flavor exists for them
yet.
One key is absent by decision instead: the Nightstand C6's pocket case is
drawn (device.canary-display-nightstand-c6, a sketch off its .scad, for
its Lab card), but its manifest does not name that figure yet. Naming it
maps the C6 board in the firmware's figure table (fleet_figures.h and a
CANARY_FIGURE_HARDWARE line in its pins.h), a firmware change of its own.
A knob that is a board fact references the registry; a knob that is a case
measurement is a number. docs/hardware/enclosure/canary_board_lib.scad
already holds every board the catalog mounts, once, with its evidence rung
(BRD_REGISTRY: id, along-USB length, width, PCB thickness, status, note)
and the measured facts that refine a row, so a manifest does not retype
21.0 — it names the entry:
gen_cad_params.py resolves a reference to the registry's number before it
writes, so the .scad still receives a literal and nothing at render time
changes; what changes is that a registry correction now reaches every
manifest-owned case — --check reports it as "registry says X, file says Y"
and a write carries it into the file. A reference is declared only where the
knob's help comment already cites the registry; a knob that merely equals a
row by coincidence (the WAP's board_h 1.2, the Sense's uncommented pcb_t)
stays a number. One reference leans on less than the others: the Vision's
vm_w comment never spells brd_w("grove_v2") — it cites the [Boards]
group header (which points at the registry) and board_selfcheck(), and the
test accepts that as the citation. A design decision is which registry
entry the manifest names: the WAP and Sense clips name brd_w("xiao") (the
17.5 spec width — the clips absorb the measured board) and the Vision pins
name brd_xiao_w_measured() (17.8); the registry states the truth, the manifest
states the decision, as the library's header asks each file to. The rows and
facts no manifest references (the solar relay pod's heltec_v3 row, the
doorbell's unmeasured stack guess, the two brass-pillar facts the C3 and the
C6 cite by comment) are printed by --check as INFO, never an error — those
entries are cited by comment only (the solar pod has no manifest; the
doorbell's manifest — the Vision's, through cad.also — names the measured
stack fact, not the guess; the C3 and the C6 own their board row but not the
pillar fact — the C6's brass_h is maintainer-gated, the fact says 5.0 and
the file prints at the C3's measured 3.0). The library is read, never
written: correcting a board dimension is still an edit to the registry.
python3 scripts/lint_device_manifests.py # table, then errors; exit 1 on any
python3 docs/hardware/enclosure/gen_cad_params.py --check # the cad.params half alone; a
# failure prints the regen order to follow
python3 docs/hardware/enclosure/gen_cad_params.py --dry-run canary-wap:board_w=17.8
# the diff a manifest edit WOULD write; nothing written
python3 scripts/regen_cad.py --check # every generator downstream of a knob, in order
python3 -m unittest scripts/tests/test_device_manifests.py scripts/tests/test_gen_cad_params.py \
scripts/tests/test_regen_cad.py -vCI runs the first in .github/workflows/lint.yml (Repo Lints) beside
lint_build_matrix.py, and the second through unittest discover -s scripts/tests. The lint is grep-grade: it reads the ini files itself (no
PlatformIO) and the emulator's build.sh (no emsdk). The ini resolver and
the build-matrix→manifest resolution live once, in
scripts/_device_join.py, and both lints
import them — lint_build_matrix.py applies the matrix's side of the join
(every lane resolves to one manifest; every manifest env is a real
[env:NAME]) so the two gates cannot disagree about which device a lane is.
- One device = one piece of hardware. The three
canaryreach ports (ESP32-CAM, WROOM DevKit, Freenove S3) are separate devices, asbuild_matrix.jsonalready treats them; so are the four Vision hosts (XIAO C3, C3 DevKit, C3 Super Mini, XIAO S3) — each has its own pins header and its own flasher product.canary-visionis the XIAO C3 host because that is the boardbuild_matrix.jsondescribes under that id; the flasher product literally namedsecuracv-canary-visionis the DevKit build and belongs tocanary-vision-devkit. - Feature-variant envs stay with their hardware. The Dash's
dash-b/-rs485/-can/-vault/-sd/-rtc/-espnow/-modes/-mic/-ble5envs are one device withboard.variantsnaming the 4.3B and 4.3C pins headers they compile;peripheralsare proven against the plain 4.3 only, so the mic-bearing 4.3C's microphone is deliberately not listed on the Dash. The same 7" board carries bothcanary-display-dash7andcanary-display-nightstand7as distinct products (the OTA engine must never cross-grade them), so they are two manifests sharing aboard_id, a figure and a case. board.envs= what CI builds for this hardware plus what ships. Theflavors.jsonbuild_envsthe device claims, plus any envbuild_matrix.json/flash.jsonname as the published image that PR CI does not build (none today: the flagship'srelease_hais inbuild_envs, so the shipped image and its OTA-slot guard run on every PR). Envs noflavors.jsonlist builds are not listed: the debug envs (*-debug), the WAP'smobile, the canary'sminimal,dev_haandusb-onboardand itssecure/secure_ha(each compiled by a compile-onlyfirmware.ymlstep, not a flavor env) and the Sentinel's window / hallway / demo-head presets (door's image with other preset data).peripheralscome from the registry's pins header, even for thecanaryandcanary-waptrees, whose builds carry their pins in build flags and the sketch rather than compilingboards/<id>/pins/pins.h. The header is still the declared capability map for that board (firmware/scripts/check_board_registry.pykeeps it honest).- Website paths are declared, not verified. The
canary-vision,canary-senseandcanary-display-watchmanifests name a glTF model. The website carries more than that: the WAP's and the 4.3" Dash's models exist with nosite.modelkey yet (a one-line, shape-only manifest edit each, not yet made); the doorbell's has no manifest of its own (its case rides the Vision manifest'scad.also, andsite.modelholds one model); and the Combo's has no manifest to name it either (its catalog variant is acanary-visionbuild). No per-device page exists yet, sosite.pageis absent everywhere. - A shared case is owned as a union of subsets. Several manifests may
name one
cad.scad(the three Vision hosts; the 7" Dash and Nightstand). Each asserts the knobs about its hardware incad.params—canary-visionthe stacked-XIAO, module and camera knobs,canary-vision-devkititsdk_l/dk_w/stack_h— the union is written, and a key two manifests both assert must agree or the gate names both.canary-vision-xiao-s3asserts nothing: same case, same host, so the union already covers it. Per-case design decisions stay per case, stated as which registry entry is named:xiao_wisbrd_w("xiao")(the 17.5 spec width) in the WAP and Sense clips andbrd_xiao_w_measured()(17.8) in the Vision pins, and each manifest says exactly what its case does. - One manifest may own two case files (
cad.also). The doorbell is a second form of the stacked-XIAO Vision build — the same board, the same eleven board knobs name for name and value for value — not a second device: its env iscanary-vision's,board.envshas a floor of one, and the hardware→figure map drawsxiao-esp32c3asdevice.canary-vision, so adevices/canary-vision-doorbellmanifest is rejected by three joins and is deliberately not created.devices/canary-visionlistsdocs/hardware/enclosure/canary_vision_doorbell.scadundercad.alsoinstead;gen_cad_params.pywrites the samecad.paramsinto every file of[cad.scad, *cad.also]and--checks each, and the linter proves each file is the source of a listed enclosure set (vision-doorbell). The first write moved zero doorbell bytes — the eleven knobs already agreed. Since v0.6 the two cases also share the OV5647-62's nine camera facts (brd_ov5647_*()in the registry: the hole grid and its offset, the lens offset, the barrel and holder) — the camera both hang on posts, owned once here so its hole cannot sit in a different place in either. The blast radius of a Vision knob edit is now two released, fit-checked cases: the edit writes both files,--dry-runprints both diffs and names both, and the change owes both preview sets (26 for the Vision, 12 for the doorbell) — the doorbell also being website-carried and sha256-pinned. The doorbell's knobs are not carried intocad-dims.jsonunderdevice.canary-vision-doorbell(knobs ride on the figure a manifest draws, and the website's doorbell model consumes none); a later carry can add them. - The display cases own what is literal, and name what they cannot. Six
display manifests carry
cad.params; five joined with zero.scadbytes moved, the sixth (the C6, below) with six dead ternaries made literal. The Nightlight C3 and the Nightstand S3 name thews147family row forboard_l/board_w/pcb_t(their comments already citebrd_l("ws147")and kin, MEASURE caveat and all — the row'sdrawingrung says the same). The Watch Station namesbrd_l("round_disp")fordisc_dand copies its three measured stack numbers (disc_t,stack_t— the whole pinned stack on the calipers, glass front to USB-shell face; the socket zone the case derives from it — andxiao_t) — and its stand recline,tilt, the one non-board knob a manifest owns: it retired the Lab registry's hand-typedstand_tilt_deg, so the number the Lab shows is the literal the stand is cut to. The Touch 1.69 names thews169row forpcb_w/pcb_h/pcb_tand thebrd_ws169_glass_*()facts forglass_w/glass_h— crosswise: the registry's axes follow the board's along-USB length, which is that case's Y, so the case's X-width is the registry's glass height, exactly as the file's own comment explains;glass_t,r_glass,aa_w,aa_hand — since itsaa_dx = 0.0; aa_dy = 0.0;line was split into two lines, one knob each withMEASUREon both (the generator refuses a two-statement line by design, so the split was the.scadchange, with its eight previews) —aa_dx/aa_dyare numbers: the 0.0 the case is cut to today, owned as the placeholders they are, not blessed. The Dash copiespanel_l/panel_w/glass_t/stack_tas numbers: there is no registry row for the 4.3 panel and all four areMEASUREplaceholders — owning them documents the value the case is cut to today, it does not bless it. The Nightstand C6 joined last: itsboard_l/board_w(andaa_l/aa_w/lcm_l/lcm_w) weremodelternaries whose other branch held invented numbers and was forbidden by the file's ownassert(model == "1.47"), so the six became the 1.47 literals they always evaluated to — no geometry moved,gen_assembled_dims.py --checkandgen_figures.mjs --checksay so — and the manifest names thews147row forboard_l/board_w/pcb_t, the board trio only; the four screen numbers stay literal knobs in the file, ownable and deliberately not owned yet. The C6's manifest names no figure (its board is unmapped, no STL is committed; the Lab's sketch of its case is a figure no manifest names), so its knobs are owned, not carried: correct in the.scad, absent from the website'scad-dims.jsonuntil its manifest's figure draws it. A movedws147row now moves all three 1.47 cases, and the test suite pins it. Not owned, by construction rather than by omission: the selectors (headers,port,model,panel_variant) and the 7" Dash / Nightstand — "What a manifest cannot yet own" below says exactly why. One caution beside the Watch's and the Dash's knobs:canary_watch_station.scadandcanary_dash_display.scadareREFERENCE_SCADScarries (gen_builder_manifest.py --sitecopies them to the website, sha256-pinned — the sources its AR models trace), so the first real edit to a Watch or Dash knob moves the website's carried copy. Their fleet figures follow the CAD: each is measured off its case as seated (gen_assembled_dims.py,dims_source: "assembled-cad"), so a knob edit that moves the case turnsgen_assembled_dims.py --checkred until the ledger is regenerated.canary_combo.scadis carried and measured the same way, though no manifest owns its knobs: the Combo has no device of its own (its catalog variant is acanary-visionbuild), so its figure draws no board and carries noknobs.
Written down so the gap is a list, not a memory. Each entry names the mechanism that stops the join today and the smallest honest next step.
-
The 7" Dash / Nightstand panel record (
canary_s3_lcd7.scadreadingcanary_panel_lib.scad). Three things, any one of which is enough:- every panel number in the case is a computed read —
glass_w = pnl_glass_w(PANEL);withPANEL = panel(panel_variant);— sogen_builder_manifest.parse_scad, the eligibility set, returns none of them, and the generator has no literal token to write; - the record itself is a nested positional list literal in
panel_db()(["lcd7", …, [192.96, 110.76, 4.0, 8.2, 1.2], …]), outside the writer's token grammar (onename = <scalar literal>;per line) — owning it means a second grammar, and a second grammar is a second parser to keep honest; - both files are
gen_stamp.py-hashed: any byte moved in either is a humanSTAMP_REVbump plus the 7" fit matrix in CI.
The smallest honest follow-up is a join, not ownership: a
cad.panel: "lcd7"key checked againstpanel_db()and the case'spanel_variantdefault — proving the manifest and the case name the same panel record without writing a number. That is a separate decision, not taken here. - every panel number in the case is a computed read —
-
The selectors (
host,preset,radar,headers,port,model,panel_variant,part): chosen per printable set at render time inrender.sh,canary_case_fitcheck.scadandgen_assembled_dims.py. Making them manifest-owned is the render-plan package ("What comes next"). -
The C6's four screen numbers and its
brass_h.aa_l/aa_w/lcm_l/lcm_ware literal knobs the manifest could own and does not yet — whether a bonded module's active area is a board fact is a decision about the registry's shape, not a mechanism.brass_his maintainer-gated: the registry factbrd_ws147_brass_c6()says 5.0, the file prints at the C3's measured 3.0, and owning either number would bless it.
Wave 2 ("Consume") pointed the generators at the manifests one at a time, with each generator's byte gate proving its output did not move. Honestly, per generator — what is read from here, and what is still typed elsewhere:
| Consumer | Reads from the manifest | Still its own |
|---|---|---|
canary-local/tools/gen_flash.py (→ flash.json) |
which device each flasher product installs onto (flasher.product / variants — a product no manifest claims refuses to generate); that device's board.mcu (the chip guard), board.flash_mb, board.board_id (the support tier, re-checked against boards.json's mcu), and its family → the PlatformIO project. The build's pins header must be the manifest's board_id or a listed variant, and a resolved figure must equal the manifest's figure. Loader: canary-local/tools/_devices.py. |
the human copy, asset_stem, env, the flasher's own family grouping, the PlatformIO board (still re-derived from the firmware tree and cross-checked); one board_id override for a product that installs onto a board.variants sibling (the modes build on the 4.3B); the WAP's figure, declared because its sketch compiles no pins header (kept in the table so figure.via stays a true sentence — checked against the manifest). |
canary-local/tools/figures/gen_figures.mjs (→ figures.json, fleet_figures.h, FleetFigures.swift, …) |
the hardware→figure map (hardware.mapped, figure_for_hardware()): a manifest's figure draws its board.board_id — and only that; variants stay unmapped as different housings; two manifests drawing one board differently fail the build. |
the coarse config→device-type map (CONFIG_FIGURE, feeding device_types / configs_audit), because a config directory is not one board and the WAP's envs compile no configs/ include. The manifests validate it in both directions (every row backed by a device with that figure; every typed config a drawn device compiles has a row) and record its one dispute: canary-vision/default is compiled by the DevKit (own figure since its housing was traced) and the two XIAO hosts (the stacked-XIAO figure), all publishing device type canary-vision; the row keeps the XIAO figure pending a decision (unmapping the type moves the firmware and Swift tables). |
scripts/lint_build_matrix.py |
every build_matrix.json lane resolves to one manifest (by id, else flavor + env — the same function this linter uses); every manifest's board.envs is an [env:NAME] of its family's project |
the feature-flag cells, which are platformio.ini / canary_config.h facts, not device facts |
.github/workflows/firmware-release.yml, flasher-release.yml |
(not the manifests — firmware/flavors.json release_envs, via flavor_envs.py --release --json; roadmap item 23, landed in the same wave: neither workflow types a display env any more) |
— |
docs/hardware/enclosure/gen_cad_params.py (→ the case .scad literals) |
cad.params: the board/module knobs of cad.scad and of every cad.also file (the Vision manifest owns canary_vision_doorbell.scad beside its own case — same knobs, same values, both written) — a number, or a reference the generator resolves from the board registry (canary_board_lib.scad, read and never written). The generator writes them into the file — the literal token on the knob's own line, nothing else — so the Customizer, render.sh, the fit check, the web builder's manifest and lint_design_lang.py all keep reading the same literal knob they always did; --check proves equality, and the first run over the committed tree changed zero bytes. A knob the builder's parser does not accept (computed, module-local, [Hidden]), a selector, a type mismatch, a two-knob line or a reference the registry does not define is refused by name. |
walls, tolerances and every feature knob (the design-language canon — case-owned, explained with deviates:); the enum selectors (host, radar, preset, part — per printable set, in render.sh and the fit check); computed knobs (board_stack_h) and knobs read from a registry (canary_s3_lcd7.scad's panel record — "What a manifest cannot yet own"); and envelope_mm, which is never an input — every case derives its outer size from board dims + walls, the ledger measures it off the STL (gen_assembled_dims.py), and the manifest reaches it through figure |
canary-local/tools/gen_enclosures.py (→ enclosures.json, catalog.json, workshop.json) |
cad.enclosure_sets, inverted: each set's device is the first manifest (slug order) that lists it — homed on the Lab card its lab.card names (the C3 case on canary-nightlight), else on its family's device when the family is itself a manifest, so the DevKit's and the XIAO S3's cases stay on the canary-vision page — and devices every manifest that lists it whenever device alone does not say them (the 7" case serves the Dash 7 and the Nightstand 7); the catalog's device_compat is every claimant; the page-side twin is canary-local/assets/enclosure-sets.js |
the README variant tables (names, parts, previews) and a table of README-name hints, consulted only for a set no manifest lists — and the linter fails any set homed that way, so a hint can only ever point at a missing claim |
docs/hardware/enclosure/gen_builder_manifest.py --site (→ the website's scad/cad-dims.json) |
cad.params, resolved and merged by gen_cad_params.py and joined through figure to the fleet figure it draws, carried as that figure's knobs (the DevKit's three land on its own figure, not the stacked-XIAO's, though both name one case); the carry refuses to run while a manifest disagrees with its case, so the site never reads a knob the released STLs were not rendered from |
the envelopes and assembled seams (seams_mm, measured — figures.json, gen_assembled_dims.py); an assembled case's face features (features_mm, the Combo's lens aperture and radome window — measured off the case's own cut variables by gen_assembled_dims.py, read from its assembled_dims.json); the board registry, carried whole with its evidence rung as board_registry / board_facts (canary_board_lib.scad); and reading those keys on the website side, which is the website repository's change |
Still typed, deliberately: the confidence ladder (derived from evidence
by the figures generator, never here), the emulator twin aliases in
gen_flash.py (TWIN_ALIASES — a per-product judgment that a sibling's
build shows the same face, not a device fact), and firmware/build_matrix.json's
board / mcu cells (mirrored, and proven equal to the manifest by the
linter; making the matrix generator read them is a wave of its own).
- Wave 3 — Parametrize (landed in part). The manifest owns the board
knobs of the released cases (
cad.params, above): a dimension edit in a manifest is written into the.scadbygen_cad_params.py, and from there the chain that already exists re-renders the enclosure (render.sh), re-measures the envelope (gen_assembled_dims.py), redraws the figure (gen_figures.mjs) and re-carries the CAD ledger the website's AR models are pinned to (gen_builder_manifest.py --site). That order is one command —scripts/regen_cad.pyruns the thirteen generators and gates in the order each one's inputs dictate, stops before the emulator dist rebuild when the figure headers moved (the rebuild is upstream of the catalogs;--from gen_flashresumes), renders the owed PNG previews of every part of every changed case with--previews DIR, and--checkruns every step's check form and names the first stale one — andgen_cad_params.py --dry-run SLUG:KNOB=VALUEshows the one-line diff an edit would write before anything moves.envelope_mmis deliberately not an input — every case derives its outer size from board dims + walls and the ledger measures it — sofigurestays the join to the AR model. A knob that is a board fact is a reference into the board registry (canary_board_lib.scad), so a registry correction flows to every owned case through the same generator. The display cases joined with pure JSON (five manifests, twelve more references, zero.scadbytes — "The display cases own what is literal" above); wave 6 closed three of the four leftovers — the C6's board trio (six dead ternaries made literal), the 1.69'saa_dx/aa_dy(the line split), and the doorbell (owned through the Vision manifest'scad.also). Still open: the 7" frame (its panel record is computed fromcanary_panel_lib.scad— "What a manifest cannot yet own" below), the selectors — chosen per printable set inrender.sh, the fit check andgen_assembled_dims.py; making them manifest-owned is a render-plan package that rewrites those three and enclosure CI, deliberately not built without the maintainer's yes (its other half — re-homing the Lab cards the README name regex mis-homed — landed on its own, see the render-plan note below) — and the website's own half of the ledger —gen_builder_manifest.py --sitenow carries each owned device's resolvedknobs, its assembledseams_mmand the board registry with its evidence rung inscad/cad-dims.json(and--site <checkout> --checksays whether the carry is current), and the site's reading of the new keys — itsmake-*-glb.mjsandcad-dims.mjstakeseamsMeters,knobMetersandboardMmfrom the carry, models byte-identical — is website PR #197. A real dimension edit still owes the render previewsAGENTS.mdrequires with every.scadchange — the generator lists the changed lines, andregen_cad.py --previewsrenders them, so that obligation is a list and a directory, not a memory. - The render-plan package — its card half landed, its selector half is
not built. The Lab used to home each enclosure set by a regex over its
README name, and five display cases sat on the wrong page (the C6 pocket
case and the 1.69 puck on the Watch, the 7" case on the Dash, the S3
hallway stick and the C3 pocket case on every page as "universal"). Now
gen_enclosures.pyinvertscad.enclosure_sets(above, "What the manifests drive"), the linter refuses a set homed any other way, and each of the five sits on the device whose manifest lists it — the 7" case on both the Dash 7 and the Nightstand 7. Two of those devices had no Lab page to show it on — the Nightstand C6 had noregistry.jsonentry, and the C3 case's manifest (canary-display-nightlight-c3) and the registry'scanary-nightlightnamed one device two ways. Both do now:registry.jsoncarries cards for the Nightstand C6 and the Nightstand 7, and the C3 manifest names its card (lab.card: canary-nightlight— the registry id stays, it is the id the Lab's figure and scene builder share), so the C3 case is homed on the Nightlight's page and still listed under the manifest's slug, where the flasher looks it up; the linter refuses a claimed case whose home is no card. Two homings the regex had made by name are now stated in the Vision manifest instead, so neither moved: the combo witness (the case houses the Vision build beside a Sense one) and the Vision Pro mount — the reCamera bridge for the Vision line's Pro and Lite tiers (canary_vision_pro_recamera.md), which run no Canary firmware and so can never have a manifest of their own; left unclaimed it would turn universal and show on every device page. The selector half (option A — acad.render_planof{set, overrides: {host, preset, part}, parts}per manifest, generatingrender.sh's set list,gen_assembled_dims.py's overrides andenclosure.yml's fit families, each with a--check) is not built: each of the three hand-kept copies of the selector table already feeds an output CI gates (the rendered sets, the measured assembled envelopes, the fit matrix), and the plan would rewrite every case's render, measure and fit CI to remove a duplication whose every copy already sits behind a gate. Build it when a fourth consumer of the selector table appears, and with the maintainer's yes.
- Write
devices/<slug>/device.json— copy the closest sibling, change only the ids. Everyflavors.jsonbuild env for the new board goes inboard.envs; a CI-only env goes inunclaimed.jsonwith its reason. A flasher product needsboard.flash_mb(the flasher names the module from chip + flash size;gen_flash.pyrefuses a manifest without it). If the device has a case,cad.paramsmay assert the board knobs it is built around — copied from the.scadas they are today, because the generator writes the manifest into the file: asserting a different number is a geometry change, and owes previews. A knob whose help comment cites the registry is written as the reference it cites ({"brd": …, "dim": …}or{"brd_fn": …}), which resolves to the same literal; a case measurement is copied as the number. A second case file of the same build (a form, not a device — the doorbell) goes undercad.alsoand owns the same knobs; a different board is a different manifest. python3 scripts/lint_device_manifests.pyuntil it is green. Each error names the file that owns the fact it disagrees with.- Then
python3 scripts/regen_cad.py --previews <dir>— the regeneration orderCLAUDE.mdprescribes for the rest of the tree, as one command: the manifest-owned knobs into the.scad, the STLs, the assembled envelopes, the figures and their firmware / Swift mirrors, the two flashers' models, then — because a new board usually movesflash.json,figures.jsonand thedist/, and the rebuild is upstream of the catalogs — it regenerates the display sketch mirror (firmware/projects/canary-display/setup.sh regen) and stops: push the branch, dispatch Actions → "Rebuild emulator dist (pinned emsdk)" on it, pull, andpython3 scripts/regen_cad.py --from gen_flashfinishes the catalogs. Commit what moved; the PNGs in<dir>go to the requester, never into the tree. Before any of that,gen_cad_params.py --dry-run <slug>:<knob>=<value>shows the exact line acad.paramsedit would move.