The docs lived on one lineage and the fleet code on another. That is why six pages described a shared fleet key as how an agent authenticates, why COLLECTOR-AUTH had to warn that the per-PC credential code was not in the repository it sat in, and why ADR-019 recorded a decision whose implementation was somewhere else. Documentation that cannot see the code it documents goes stale by construction, and no amount of editing fixes that. So the corpus moves here: 84 files, 56 pages, 20 ADRs, the proposals, INDEX.md, llms.txt and both gates. Filenames are unchanged, because a wiki page name is derived from the filename and a rename breaks every link anybody saved. THE POINT OF THE MOVE IS THE CORRECTION IT ENABLES. Every caveat that said "not in this tree" was false the moment the files landed beside the code, so each was rewritten from what is actually here: - COLLECTOR-AUTH gained the real enroll request body, the two bounds on the unauthenticated route (per-IP rate limit, pending cap with stale rows dropped before a real PC is refused), and the rejected pending reason that binds to the endpoint identity rather than the nonce. - ADR-019 now describes an implementation that is present, including its tests, and says plainly that what remains is the FLEET migration, not the code. - proposals/device-identity claimed phase 1 was unbuilt. plugins/computers declares deviceid on the collector schema and prefers it over the older fallbacks. Corrected in the page, its summary and the index. WINDOWS/IIS IS NOW DOCUMENTED AS THE LEGACY PATH, per the product decision, and carefully: it is what production runs today. Every Windows page opens by saying it is supported, not for a new site, and where a new site should go instead. No procedure was cut - an operator on an IIS box at 2am needs these pages to work exactly as well tomorrow. MIGRATE-TOPOLOGY stops being a footnote and becomes the off-ramp, which is why it nearly doubled: what was there did not match how the Docker stack really works here. The Docker material that already lived on this line was kept and given ownership rather than buried under the ported Windows-centric prose: DEPLOY now describes the four services, the migrate service that runs the upgrade chain before api starts, TLS terminated by nginx, and the plugin count corrected from thirteen to seventeen - a number both lineages had wrong. ROADMAP is rewritten from evidence rather than memory: what has shipped, read out of the changelog, the ADRs and the manifests, and ahead of it exactly two items - finalize a distributed release and polish the setup wizard. PROJECT-MAP is generated and was stale; the GENERATOR was what was wrong. --check passes now. API-REFERENCE is retired to a pointer. /api/docs serves 573 operations with derived auth, query parameters and harvested examples, and a hand-written page competing with that is a liability. The file stays so its wiki page and its inbound links survive. AND THE MOVE TURNED ON A SCRUB THAT HAD NEVER RUN HERE. test_docs_publishable keys on docs/*.md, which this line did not have, so it skipped - its own comment predicted what would surface. Porting the corpus activated it and it found 48 lines that must not reach a published wiki: branch names abbreviating the site, assistant-tooling paths, the site name in two deployment pages, and an employee ID in a Windows path that the scrub does not even look for.
7.2 KiB
ADR-017: Buildings and levels as the map model
- Status: ACCEPTED
- Date: 2026-08-17
- Deciders: ShopDB maintainers
- Relates to: ADR-001 (asset as the platform contract), ADR-004 (per-site instances), ADR-010 (frontend plugin hooks), ADR-015 (site-specific configuration)
- Supersedes: the site-wide
map_blueprint_light/map_blueprint_dark/map_width/map_heightsettings
Context
A site's floor map is one image. Four settings describe it - two blueprints
(light and dark) and the native pixel width and height - and assets.mapx /
assets.mapy are absolute pixel coordinates in that image's space.
That model has one floor in one building, which was true when it was written and is no longer. The reference site is adding a second level, and a second building is likely within a year or two. The immediate trigger is a new blueprint at 3308x4000 where the old one was 3300x2550.
The obvious cheap answer is to stack levels on one canvas: draw the second level below the first and keep one image. It works, and it was seriously considered. It was rejected because it makes the level an inference rather than a fact:
- "Which level is this asset on" becomes
mapy > 2550. Every per-level filter, count, report and export has to know that constant, and re-exporting the drawing at a different height silently changes the answer everywhere. Not broken - wrong, which is worse. - Distance between two markers becomes computable and meaningless, so anything doing nearest-asset or clustering quietly answers nonsense across levels.
- One canvas forces one scale. A mezzanine drawn at a different scale than the floor below cannot be expressed at all.
- 3308x4000 is portrait for a building whose floor is landscape. At a zoom where a marker is clickable, roughly half a level is visible, and "fit to level" is not expressible.
- A third level makes each of these worse, and adds a second threshold.
Timing decided it. Because the level layout changed and machines moved, every marker is going to be repositioned anyway. Introducing levels now costs one pass over the positions; stacking now and splitting later costs two, and in between every position placed encodes the threshold into real data - which a later migration would then have to un-guess by comparing Y against it.
Decision
Two tables, and assets reference the level.
buildings buildingid, buildingname, sortorder, isactive
maplevels levelid, buildingid, levelname, sortorder,
blueprintlight, blueprintdark, mapwidth, mapheight,
isdefault, isactive
assets levelid (nullable; the default level for existing rows)
1. An asset references the level, never the building. The building derives from the level, so the two cannot disagree. Storing both would be a fact recorded twice with no difference in granularity to justify it.
2. Blueprints and native dimensions belong to the level. This is what the site-wide settings could not express: two levels in one building may be drawn at different sizes and scales, and two buildings certainly are.
3. Name and order are separate. levelname is text and sortorder is an
integer. Levels are not reliably numbered - basement, ground, mezzanine, roof,
tunnel - and sort order gives adjacency and up/down navigation without
pretending the names are ordinal. It also lets a mezzanine be inserted between
two existing levels without renumbering anything.
4. mapx / mapy keep their meaning, scoped to the level. They stay
absolute pixels in the native coordinate space of the level's blueprint. No
normalisation to fractions: pixels are what the drawing tools produce, what an
operator can read off an image, and what the existing data already holds.
5. A level is required to render a position, and absence is not a default. Where a payload carries a position without a level, the UI renders "level unknown" rather than falling back to the default level. Falling back draws one building's ground floor with a marker positioned for another building's mezzanine: it renders perfectly and points at the wrong place, and nothing about the result looks wrong. A visible gap is worth more than a confident wrong answer.
6. The level list is readable without authentication. The printer installer map runs optional-auth, before anyone logs in, and it needs a blueprint. This follows the precedent already set for printer install-list and the slide feed. Blueprint paths and level names are not secrets; the positions of assets on them already render on public kiosk pages.
7. Levels are not Locations. Assets carry locationid already, and reusing
it is tempting. A Location answers "which operation owns this"; a level answers
"which drawing renders it, at what native size". Overloading Location with
blueprint images and pixel dimensions makes both concepts worse. They coexist:
an asset on level 2 in operation 0613.
Migration
The existing four settings become one building and one level, marked default, and every asset with a position points at it. Nothing renders differently on the day it lands. The settings keys are then retired rather than left as a second source of truth that can disagree with the rows.
Consequences
Positive:
- A second building costs a row and a blueprint. So does a third level.
- Per-level export, per-level zoom, and per-level calibration all become expressible. Calibrating one level against its own landmarks is more correct than transforming a whole site at once.
- Level becomes queryable - counts and filters per level or per building are ordinary queries rather than coordinate arithmetic.
Negative:
- Every surface that draws a marker learns there is more than one drawing. That
is two components, seven views, four asset position pickers, the printer
installer map, the PDF export, and eight API payloads that must emit
levelidbesidemapx. A payload that forgets it produces the silent-wrong-map failure described above, which is why rule 5 exists and why a build gate checks that the two fields travel together. get_map_overlaysgains level context, so this is a plugin contract change (0.20.0) and plugins rendering overlays need to declare which level they are for.- One more admin surface: levels and buildings have to be managed somewhere, and the existing floor-map settings page becomes that.
Alternatives considered
Stack levels on one canvas. Rejected above. Zero cost today, and it makes the level an inference over a magic number.
Normalise coordinates to fractions of the image. Would make a rescaled blueprint self-correcting, which is genuinely attractive. Rejected for now because it converts every existing integer position through a lossy division, and because it does not help the actual problem: the levels changed and machines moved, so the positions need human review regardless. Worth revisiting independently.
A level string on the asset, with one blueprint per level in settings.
Rejected: settings keyed by level name is a table with extra steps, and it gives
no place for per-level dimensions or ordering.