Five levels, and the shape is not arbitrary: it mirrors how a substation is actually walked. You are on a job, at a lineup, in front of a device, writing down a test.
Organization created at registration; the tenant boundary └── Project a job: customer, site, job number └── Assembly a lineup, MCC, panel ← nests into itself └── Equipment a device ← nests into itself └── Record one test on one device, on one date
The two self-references#
An assembly may contain sub-assemblies via parent_assembly_id, and equipment may contain sub-equipment via parent_equipment_id. Both are optional and both exist because real gear does.
| Nesting | What it is for |
|---|---|
| Sub-assembly | A section of a lineup, or an MCC inside a switchgear room, that has its own designation on the drawings but is not a separate job. |
| Sub-equipment | A device that is tested as part of another: a trip unit inside a breaker, a CT on a bus, a relay on a cubicle. It gets its own records without becoming a peer of the parent. |
GET /db/projects/{pid}/assemblies returns assemblies whose parent_assembly_id is null, each carrying its descendants in sub_assemblies and equipment. Walk the tree from the response; do not expect a flat list. When you do want everything flat, use GET /db/projects/{pid}/all-equipment.
Roll-up counts#
Projects and assemblies are returned with pre-computed status counts, so a dashboard does not have to fetch every record to draw a progress bar. The four are disjoint and sum to the equipment count at that level.
| Field | Counts equipment whose most recent record… |
|---|---|
pass_count | has status "pass" |
fail_count | has status "fail" |
incomplete_count | has any other status |
untested_count | does not exist. The device has no records at all |
The counts are computed over all equipment beneath the node, including sub-equipment, while assembly_count on a project counts only top-level assemblies. That asymmetry is deliberate. One answers "how much work is left", the other "how many lineups am I looking at".
Tenancy#
Every query is scoped to the caller's organization by the session itself, not by a filter each endpoint remembers to apply. There is no parameter that widens it. A request for an id belonging to another organization returns 404, not 403. The resource does not exist as far as that caller is concerned, and saying otherwise would confirm the id.