# Project: Massing ## What this is A standalone web BIM **modeling program** + data platform for AEC firms. IFC is the source of truth. The web app is a genuine authoring tool: create a model from scratch (blank IFC → levels/grid datum), then draw walls/columns/slabs/families/MEP via server-side GUID-stable edit recipes — **not just a viewer**. (Directional change, 2026-07: in-browser authoring is now a first-class goal, reversing the earlier "web = viewer, Blender = editor" split.) Blender/Bonsai remains an optional advanced/interop editor, not the required one. RVT support is an optional, paid Autodesk bridge — never assume RVT can be read offline. ## Non-negotiables - Reference model elements by IFC GlobalId (GUID), never by transient viewer IDs. - Pre-convert IFC to Fragments on the server; never parse full IFC in the browser at runtime. - Keep geometry and metadata separate: geometry streams as .frag; data comes from the API. - Pins/RFIs/punchlist follow the BCF model so they round-trip with other BIM tools. - The viewer must run fully offline (local WASM, self-hosted tiles). ## Stack - Web: Vite + TS, web-ifc, @thatopen/{fragments,components,components-front,ui}, three (pinned pair). - Services: Python, ifcopenshell, FastAPI, SQLAlchemy/Postgres, MinIO. - Editor: Blender + Bonsai, driven via Bonsai-MCP. - Optional: Autodesk APS Model Derivative (RVT→IFC), behind a feature flag with a cost warning. ## Build order Phase 0 smoke tests → 1 conversion → 2 large-model → 3 viewer/tools → 4 API/BCF → 5 data export → 6 editor/families → 7 deploy. ## Watch out for - @thatopen/components and @thatopen/fragments version coupling — pin a compatible pair. - Bonsai-MCP execute_blender_code runs arbitrary Python: gate it, save first, chunk big ops. - Set-origin/georeferencing: preserve real coordinates for export, render near scene origin. ## Local environment notes (this machine) - **Bare `node` now resolves to v24.18.0 — the PATH workaround this line demanded is obsolete** (re-measured 2026-08-27). `which node` → `/c/Program Files/nodejs/node` → **v24.18.0**, npm **11.16.0**. v18.8.0 is still installed, at `/c/laragon/bin/nodejs/node-v18/node`, but it sits *later* on PATH and you no longer get it by default. `export PATH="/c/Program Files/nodejs:$PATH"` is therefore **belt-and-braces, not a requirement** — harmless to keep, and worth keeping in scripts, because the thing that changed is PATH *order*, which can change back the moment laragon updates. Both manifests declare `"engines": {"node": ">=24"}` and CI pins `node-version: 24`, so 24 is the supported baseline. **This is the fourth wrong value in these three lines**, and the first to be wrong in the *safe* direction: v20.3.1, then a version naming the Node you get *after* fixing PATH, then a major nobody had run in weeks — and now a workaround that outlived its cause. A stale instruction to *do something unnecessary* costs less than a stale one to skip something, but it still teaches the reader that this file is not to be trusted, which is the expensive part. **A config file that is subtly wrong is worse than one that is silent** — four drifts in, the only safe move is `which node && node -v`, never reading this line. - **Python ≥ 3.12 is now a HARD FLOOR, not a preference** (corrected 2026-08-25; this line said "guide targets ≥ 3.11" and "prefer a 3.11+ interpreter … if available"). `requirements.lock` pins `numpy==2.5.2`, and numpy dropped <3.12 at 2.5.0 — so on 3.11 the install does not degrade, it **fails outright**: `No matching distribution found for numpy==2.5.2`. CI pins `python-version: "3.12"` in `ci.yml`, `db-migrations.yml`, `desktop.yml` and `security.yml`, and the lock is compiled in `python:3.12-slim`, the prod base image. *Same failure as the Node line above, in the other language: a floor that moved under a note phrased as advice.* "Prefer if available" is what a version note says when nobody has tried the alternative — and the alternative had stopped working. **Check a floor by creating the venv, not by reading this line.** - **RESOLVED 2026-08-27: 3.12.10 installed, and `services/api/.venv` now MEETS the floor.** This entry previously said the venv could not meet it and that CI was therefore the only authority for the backend. Both halves are now out of date, and the *reason* it had drifted was not the one recorded here. | package | `requirements.lock` | venv now | venv before | |---|---|---|---| | numpy | 2.5.2 | **2.5.2** ✓ | 2.2.6 | | fastapi | 0.141.1 | **0.141.1** ✓ | 0.137.0 | | cryptography | 50.0.0 | **50.0.0** ✓ | **49.0.0 — under the CVE-2026-69247 floor** | | pyhanko | 0.35.2 | **0.35.2** ✓ | 0.35.1 | **109 lock pins, 0 version mismatches.** Verified by comparing every pin against `pip list`, not by reading the installer's "Successfully installed". Full suite: **640/641 on 3.12**, the one failure being an unrelated lane's uncommitted work-in-progress, which fails identically on the old venv. **WHY IT HAD DRIFTED — not "nobody installed 3.12".** `requirements.lock` **cannot be installed on Windows at all**, on any Python version, and this is structural rather than an oversight: - `requirements.in` asks for `uvicorn[standard]`, whose `uvloop` dependency **does not support Windows**. `pip-compile` resolved in `python:3.12-slim` and emitted an *unconditional* pin, so the `sys_platform != "win32"` marker `uvicorn` itself declares is lost. - In the other direction, `click` needs **`colorama` on Windows**, which a Linux-compiled lock has no entry or hash for — so `--require-hashes` refuses on a dependency it never knew existed. So the local install is `pip install -r ` **without `--require-hashes`**: exact versions, hashes unenforced. That trade is deliberate and worth naming — version fidelity is what a test result depends on; hash pinning is a supply-chain control, and **CI still enforces it on every push**, which is where it protects the artifact. It was never running locally, because the install never succeeded. **The old 3.10.6 venv is kept as `.venv-py310-superseded`** (and `.gitignore` now matches `.venv*/` so a rebuild's backup does not show up in every lane's `git status`). Delete it once you are satisfied; there is no reason to go back to it — it sits under a HIGH-severity CVE floor. **What is still true:** a local pass proves less than CI, because CI runs Linux with `uvloop` and hash-verified wheels. But it is no longer measuring *different code* — the gap is now one package that cannot run on this OS, rather than a FastAPI minor and three numpy minors. - Repo root: C:\Server\modelmaker (Windows / PowerShell). - Backend suite runs **from `services/api`**, never the repo root — the root exits 127 and reports "0 failures", which reads exactly like a pass. ## Directions come before the roadmap **Read [`docs/roadmap-directions.md`](docs/roadmap-directions.md) first**, then the lane table, then an item from `docs/roadmap.md`. The directions carry the non-negotiables, the shared-clone hazards, the testing and release discipline, and what "done" means. They were split out of the roadmap on 2026-07-31 so the roadmap could stay a clean list of work — if a rule seems to be missing from the roadmap, it is in the directions. ## Verify, don't recall Long sessions drift: instructions written early lose influence, and stale file contents linger in context beside current ones. The countermeasure is not a better memory, it is **checks that fail**: `services/api/test_reachable.py` (is it wired?), `apps/web/src/kernel/ties.test.ts` (do the aliases agree?), `services/api/test_no_comparative_names.py` (do the public docs name a competitor *comparatively* — as opposed to as a connector, an import format or an SSO provider, which are allowed?), and the size guard in `services/api/test_file_sizes.py`. If a rule matters, write it as a test — anything held only as prose will drift, **including the prose in this file: two of those four names were wrong until 2026-07-31.** "test_no_competitors.py" never existed at all, and the size guard is `test_file_sizes.py`, not "check_file_sizes.py". **A sixth joined them on 2026-09-03: `services/api/test_ruff_scope.py`** — is the lint aimed at the whole tree? CI ran `ruff check src/ ../data/src/` and printed "All checks passed!" while **726 of 1,338 tracked `.py` files were never linted at all**, the entire backend test suite among them. The check was real; the SCOPE was the fiction, and a green lint step is exactly what makes that invisible. The gate parses `ci.yml` for the ruff command and asserts *that* command reaches every tracked `.py` outside an explicit vendored list — so it cannot drift from what CI runs, because it reads what CI runs. **Widening it also broke something**: `--fix` stripped `# -*- coding: utf-8 -*-` from four IronPython 2.7 files under `integrations/pyrevit/`, three holding non-ASCII source, which is a SyntaxError at import on Python 2 and is executed by no CI here. *Scope and rule set are different questions, and widening one can break the other.* **A fifth joined them on 2026-08-28: `services/api/test_declared_imports.py`** — does our own `import` list agree with what we declare? It exists because `httpx` was declared in no requirements file at all and reached the lock only `# via anthropic`, so bumping that SDK to 1.x — which moved to `httpx2` — would have deleted a package two shipped modules import. Nothing would have gone red: both consumers are function-local, so the service boots, `/health` passes, the suite stays green, and only the bSDD and site-context routes 500. `pillow` sat in the same position `# via reportlab`. **A package our own source imports is a DIRECT dependency however else it happens to arrive**, and transitive availability is a fact about somebody else's metadata that can change in a release nobody here reviews. (The list above still says "four" on purpose — that sentence is about which of the ORIGINAL four names were wrong, and rewriting it to say "five" would quietly destroy the record.) **A seventh joined them on 2026-09-06: `services/api/test_seeding_sweep.py`** — is the seeding sweep real, or only written down? Five find-or-create races were fixed across 2026-08-25/27 and what bound them was recorded as prose in comments beside each fix. **A sweep held as prose is a check that can only report good news**, and there were four more sites: the ones keyed by a composite NATURAL key rather than a primary one, which `get_or_create_by_pk` could not express — so they stayed unconverted *and unmentioned*. **A sweep is bounded by the fix available to it.** They were also the worse ones: a primary key refuses the loser's INSERT (one 500, and the retry works), while a non-unique index refuses nothing, so the race does not fail — **it succeeds twice**, and `scalar_one_or_none` then raises on every later read of that element, permanently. *Severity ran opposite to the order the sites were found in.* The gate derives the population by AST instead of listing it — **and its own first draft found one of the four known races and reported the tree clean**, because it looked for the `Model(...)` assignment only inside the statement holding the `.add()`, and the doors are written as two statements. It now runs itself against the pre-fix SAML door and must find it before it may report anything. *Derive the population AND prove the derivation reaches it.* **And it was blind a SECOND time, within hours of merging.** To decide whether a conditional insert was a get-or-create it required the model's name in a lookup call *in the same function* — so `modules.add_enum_option`, which reads through the helper `list_enum_options`, was skipped silently, and behind it sat a third live instance of the class. The precondition is gone: every mapped model is a candidate and the only shape detected is the conditional insert, which is syntactic. **A predicate that decides what to LOOK at is more dangerous than one that decides what to report**, because everything it excludes is invisible to its own output — the count looked complete both times. **An eighth joined them on 2026-09-06: `services/api/test_unique_read_guard.py`** — the READ side of the seventh. `test_seeding_sweep` walks conditional inserts; this walks the queries that DEMAND one row. `scalar_one_or_none()` does not prefer a single row, it **raises** on two — so a read filtered on columns the schema does not constrain is a 500 that arms itself the first time a duplicate appears and never disarms. It would have found the `element_verifications` defect **without anyone looking at the writers**, which is the argument for having both: a future instance must now evade two unrelated derivations rather than one. Population today: 5 reads — 3 backed by a unique constraint, 1 aggregate, 1 exempt. The exemption is `cloud_identities.cloud_sub`, which is indexed and NOT unique: at most one row can carry a given sub, but the *ordering* guarantees it rather than the schema, and making it unique is a product decision (it would forbid two local accounts deliberately linked to one cloud identity) rather than a cleanup. **It fails CLOSED, and that is the whole design** — a call site the analyser cannot resolve is reported as UNKNOWN and reds the build, because the two blind spots above were both a predicate deciding what to LOOK at. **And its own first draft had the same bug one layer up**: the self-test asserted the analyser still *reported* an unresolvable read, so a mutation routing every unresolvable read to "safe" PASSED. *Reporting a site and classifying it are two different questions, and asserting one is not asserting the other.* The verdict function is now separate so it can be mutated directly. **A ninth joined them on 2026-09-12: `services/api/test_scratch_ignored.py`** — is every scratch directory the suite creates actually git-ignored? Thirteen were not, so a concurrent or crashed run left residue that **`git status` reports identically to uncommitted work** — a stop hook read `_shelf_famgeom/` exactly that way. The gate derives the population by parsing `"./"` literals and then asks **git itself** rather than reimplementing `.gitignore` matching. **Its first two drafts were each wrong in the house style: the checker did not fail, it ANSWERED.** Without a trailing slash git cannot tell an absent path is a directory, so every directory-only pattern missed and it reported **377** uncovered; asked from `services/` rather than the repo root the paths resolved nowhere, the per-directory `services/api/.gitignore` was never read, and it reported **35** — which would have had nineteen redundant patterns added to fix nothing. *A wrong question returns a confident number, and a number with a list attached reads as evidence.* Hence three self-tests, one per way of asking wrongly, run before any verdict is printed. **And the per-directory canary is DERIVED, not named:** an earlier draft hardcoded the one pattern it probed, so removing that pattern legitimately would have redded the self-test with a message blaming the wrong cause — *a check whose failure message can misdiagnose is worse than one that stays silent, because somebody acts on it.* **A tenth joined them on 2026-09-13: `services/api/test_json_null_filter.py`** — is a NULL test ever applied to a JSON column that can hold the JSON scalar `null`? SQLAlchemy stores a Python `None` in a `JSON` column as `null` unless the column declares `none_as_null=True`, and that is **not SQL NULL**, so `WHERE IS NOT NULL` matches every row ever written. The gate derives all 21 NULL tests by AST and resolves each subject against the model metadata; it fails CLOSED, and it runs itself against the one known instance before it may report anything. **The load-bearing distinction is that a JSON EXTRACTION is not the JSON column** — `data ->> 'x'` is a text scalar that yields SQL NULL on both backends for a missing key and a stored `null` alike — and conflating the two would have flagged every register query in the tree. It deliberately does **not** demand `none_as_null=True` on all 588 JSON columns (308 lack it): that is a write-behaviour change, not a gate, and the readers are two orders of magnitude fewer than the writers. **The same day, its sibling `services/api/test_pin_pgnull.py` taught the sharper lesson.** The pin sweep migration skipped exactly the rows it existed to convert, and the finding was filed as a PostgreSQL defect. **It is not a dialect defect — it is a DRIVER-DECODING defect**, and sqlite3 reproduces it exactly once a converter is registered, so the behavioural check runs on every invocation rather than only where a server happens to exist. *A defect attributed to the dialect it was found on is a defect nobody looks for anywhere else.* And the first draft of that check asserted only the emitted SQL: reinstating the exact defect on top of the corrected statement left the SQL identical and PASSED. **Asking the right question and then throwing the answer away are two different failures, and only one is visible in the statement.** **An eleventh joined them on 2026-09-13: `services/api/test_system_columns.py`** — does the allowlist of sortable row columns name columns that EXIST? `modules_query.SYSTEM_COLUMNS` short-circuits `_resolve_field`, whose own docstring is *"Never trust a caller-supplied field name"* — and the trust it was actually extending was in the three literal lines above it. Two of its six names were columns on 0 of 139 register tables, so `t.c[name]` raised `KeyError` and `?sort=updated_at` was a **500 on caller input** from a function written to return 400. **The stored form was the quieter one and therefore the worse one:** `validate_view_config` resolves through the same function with no table in scope, so a saved view carrying the phantom returned **201**, listed at **200**, and 500d only when somebody applied it — the cause two screens from the symptom. *A defect that is refused loudly is nearly fixed; one that is accepted and stored has to be found twice.* The fix is both halves — a runtime check against the table AND a corrected list — because **either alone passes every test the other would fail**, so the gate deletes each in turn: it must re-find both shipped phantoms before it may report anything, and it reinstates the pre-fix `_system_field` and requires the `KeyError` to come back. *A static check that still passes with the guard removed is not testing the guard.* `updated_at` was also the THIRD instance of that same typo on a module row — the other two returned a permanent `None` and were found in August. **The same wrong name is not the same severity in two places, and the loud one was found last.** **A twelfth joined them on 2026-09-13: `services/api/test_court_primary.py`** — is the rule that picks the ball-in-court *checkable*? `court_party` took the first outgoing transition on the stated premise that "module authors list the primary forward action first", and the web register answered the same question by unioning ALL of them: 10 states in 9 modules disagreed between the screen and every money report. **The premise was false in two places** — `pull_plan_task.made_ready` lists a rollback first, `permit.applied` lists `issue` before `start_review`. **And the obvious enforcement could not be built**: deriving "forward" from the declared state order is measurably wrong, because `action_item.states` is `['done', 'open']` — alphabetical, not a progression — leaving 45 states with no forward transition and 39 with several. *A premise that cannot be checked has already drifted; the only repair is to stop inferring it and DECLARE it.* **The load-bearing move was bounding the population**: only 17 of 267 states need a declaration, because for the other 250 the choice cannot change the answer. *A rule demanding 267 edits gets abandoned halfway and leaves the tree half-declared, which is worse than not starting* — so the gate demands a declaration exactly where one is load-bearing, and derives that set rather than listing it. Two further lessons: the value is enriched at the two ROUTES, not in `list_records`, which has 274 call sites including a BCF export built from the row's keys — *the narrowest place that can carry a fact is where it belongs*; and both routes resolve through `workflow_config.effective`, because `transition` does, and reading the shipped workflow instead would have reopened the same disagreement by a different door. **A thirteenth joined them on 2026-09-14: `services/api/test_pid_lock_pgxproc.py`** — does the advisory lock exclude two PROCESSES? `services/api/test_pid_lock_xproc.py` is *named* for cross-process serialisation and asserts a great deal about it; its one exclusion check is **two threads, in one process, on SQLite**, where `_advisory()` takes nothing and the exclusion observed is entirely the in-process `RLock`. So `pg_advisory_lock` — the whole of R35-PIDLOCK-XPROC — was exercised by no line of this tree, and could have shipped acquiring nothing. **A test named for a property can assert every neighbouring property and never the one in its name.** What it *did* prove across processes is that two workers derive the same KEY: necessary, and silent on whether taking it excludes anybody. The new gate runs two real OS processes against a real server and asserts their held intervals do not overlap — **with two mutations, because either alone passes a lock that has stopped locking**: two different projects MUST overlap (otherwise the positive check is measuring the order the harness starts things in, and a lock that excluded *everything* would look identical), and a per-process key MUST overlap (note 1's `hash()` defect, reinstated across real processes). Verified by deleting the acquisition outright — the behavioural check reds independently of the static one, and one check that *stayed* green under that mutation had its label narrowed rather than left: `cross_process_status()` reports the DIALECT, so it says the advisory path was *available*, never that a lock was *taken*. It also pins the acquisition as the BLOCKING form, because `_advisory` sets `acquired = True` without reading a result — so a `pg_try_advisory_lock` "don't block the request" change would report a lock it does not hold. That static pin is **redundant wherever a server exists** — measured, not assumed: with the swap applied the behavioural check reds too, deterministically, because the harness starts B only once A is demonstrably inside. Its value is the run with NO server — every ordinary local invocation, where the exclusion arm cannot run at all. *A check earns its place from where it is the ONLY one, not from the worst case it can be described as catching* — and the first draft of that sentence claimed the worst case. **A fourteenth joined them on 2026-09-24: `services/api/test_gap_records.py`** — does a record that calls a gap OPEN still have a gap to be about? `services/api/test_rmw_sweep.py` printed *"43 ORM sites: 43 reasoned exempt or locked, 0 named open"* while **four records in two documents** — RMW-LOCKGAP and RMW-TOKEN in `docs/roadmap.md`, G-10 and G-12 in `docs/security/threat-model.md`, plus a summary line reading "**Four** sites remain open" — described that finished work as outstanding. *A stale OPEN costs more than a stale CLOSED*: the roadmap's ranking sends the next reader at it, the threat model shows a reviewer a live exposure, and RMW-TOKEN's entry had additionally priced the fix as "a migration (add the column, backfill, route both writers through a CAS)" — work nobody ever did, because both sites closed with a lock already in the tree. **One of those entries was half wrong on the day it was FILED**: `realestate.save_appraisal` took its lock on 2026-09-13 and the entry naming it as open was written on 2026-09-14, because the gap was inherited from an earlier sweep and the split copied that sweep's reading rather than re-measuring. `services/api/test_roadmap_status.py` already asked the right question — *does the item's own gate report the work done?* — but only for items somebody REGISTERED in its map, and nothing forces a closing pull request to add itself. **A registry reports on what it contains, and its silence is indistinguishable from a clean bill.** *(That file also had a fail-open hole, found by mutation and fixed in the same change: `marked_open` was itself unasserted, and the loop reads `if not marked_open(code): PASS; continue` — so breaking its bullet regex with one literal made every registered item report PASS and the file print "every item with a measurement agrees with its marker". Its one precondition check guarded that the ROADMAP was readable, not that the PREDICATE could still say yes.)* The new gate derives both sides — subjects from the sweep ledgers by AST, records from the two documents by indentation — and **the scoping clause is the load-bearing half, learned rather than designed**: ruling on every open record reported SCALE-SEAM, an item about `apps/web/src/api/client.ts`, as a stale concurrency gap because 894 lines into its body it mentions `_restore_version`. *A rule applied outside the population it was reasoned about does not degrade gracefully — it produces confident findings about records it has no view of.* Scope is now "records that cite the gate", and the rule is "must name at least one OPEN site" rather than "must name no closed one", because a genuinely open record cites its closed siblings for contrast and RMW-LOCKGAP's did. Two spellings broke its first draft in opposite directions: without `/` in the identifier chain, `routers/proforma.share_scenario` read as `routers` and RMW-TOKEN came back clean; matching a PREFIX of a backticked span made `edit-mep` match the route `edit`. *A defect the analyser cannot spell is invisible to it and reads exactly like an absent one; a prefix match answers a question nobody asked, confidently.* It replays the four records as shipped at `53cfa71a` and must re-find **all four** and **nothing else** before it may report. **And CI caught a fourth mistake no local run could.** That replay first read the commit with `git show`, which works locally and does not in CI, whose checkout is shallow — so the gate failed closed on every build with *"fatal: invalid object name"*. Failing closed was right; depending on history was not. **A proof-of-reach that depends on the depth of somebody's clone is not a proof, it is a dependency on an environment nobody here controls** — and every neighbouring gate already embeds its pre-fix subject instead of fetching it. The records are now frozen under `services/api/tests/fixtures/gap_records/`, and a missing or truncated fixture reds the build rather than letting the replay pass by having nothing to find. **Review then found two more, and one of them found a third by itself.** The replay was reading the frozen records against TODAY's ledger — *a replay is a claim about a moment, and feeding it a moving input makes it a claim about nothing*; the statuses are frozen too now, with a mutation proving they are read. And asked what happens when a heading leaves the parsed set, the honest answer was that **twelve already had**: the marker list knew `✅ ◧ 🟡 ⭐` and not `⛔`, `❌` or a `~~struck~~` code, so the population was 78 items where it should have been 90, and *a population smaller than it looks reports a clean tree in exactly the same words as a clean tree.* The repair is not the wider list — it is the LOOSE-versus-STRICT parity check beside it, which found `❌` on its first run, after `⛔` and `~~` had been added by hand from the same measurement. **A hand-widened list is a list somebody stopped widening.** **A fifteenth joined them on 2026-09-24: `services/api/test_clash_trunc.py`** — is the guard being handed evidence it can actually rule on? `services/api/src/aec_api/soft_clash.py` exists to refuse one sentence, *"clash-free" over a partial matrix*, and keeps a third state (`untested`, never folded into `clean`) to do it. It was defeated from OUTSIDE and stayed green throughout: the clash screen built the matrix's findings from the PAGE the federated run returns — `clashes[:limit]`, default 200, beside a `count` of every clash — while declaring **every** discipline pair tested. A pair whose interferences all sat past the limit therefore arrived with no evidence and came back `clean`. Measured: 1,438 clashes, 918 of them ARC×MEP and none on the first page → 100% coverage, nothing untested, that pair reported clean. **The engine was right and its caller supplied a premise that made the refusal moot** — *a guard is only as sound as the evidence it is handed, and nothing was checking the evidence, because every test of the guard supplied its own inputs.* **The entry's first draft overstated the severity in the flattering direction** and measuring it is what caught that: it claimed `coordinated: true` was reachable, and it is not — that flag demands zero clashing cells and a truncated page always carries a finding. *A bound checked is worth more than a severity asserted; a gate that cannot reproduce the claim beside it is evidence for nothing.* It also names why the flag went unread for months: the panel re-spelled the response shape as an inline cast instead of using the declared interface, so no property access anywhere resolved to `truncated` and no type error, lint or audit over declarations could reach it. **A re-spelled shape is a field no derivation over declarations can see** — the shapes are declared once now, and `apps/web/src/api/deadFieldTyped.test.ts` pins them READ so an inline cast reds there too. **That last sentence was a fix for one panel, and the class was swept tree-wide on 2026-09-24: `apps/web/src/api/noRespelledShapes.test.ts`.** Two more wire types were declared twice — `Vital` and `LogisticsResource`, character-for-character copies of `api/types.ts`, the first under a comment reading *"as the API returns it"*. Neither was a live defect, so the sweep is a clean negative on symptoms and a real one on exposure. **The finding is the rule's NARROWNESS.** A structural-subset rule reports 20 matches of which 18 are noise or deliberate, and gating it needs five judgement-call exemptions — *an exemption list is where the next instance hides*, which is what COURT-SPLIT paid to learn when it bounded its population. Identical NAME **and** identical member set claims something else: not *these look alike* but *somebody wrote this out twice*. No exemptions, and the four same-name collisions under `apps/web/src/vendor/` stay clean because their members differ. *A check whose expected answer is zero is the easiest kind to break silently*, so it asserts its walk still finds declarations on both sides before it may report nothing. **A sixteenth joined them on 2026-09-24: `services/api/test_route_shadow.py`** — is any route registered on a `(path, method)` another route already owns? Starlette matches the FIRST such route, so a second registration is dead code that looks live; FastAPI then builds the OpenAPI document into a dict keyed by path and method, so the **LAST** one is what gets described. *Two tie-breaks over one collision, pointing opposite ways* — the server ran one handler and `/docs`, the OpenAPI JSON and `apps/web/src/api/schema.d.ts` described the other. Four had shipped, and the newest was born dead: `W11 C1`/`C3` landed at v0.3.262 onto URLs the sheet composer already held, so two features have never executed once. **FastAPI's own warning saw two of the four, and not the live one** — it keys on a duplicate *operationId*, built from the function NAME plus the path, so it fires only where two handlers are spelled the same. *A warning keyed on the name cannot see a collision that is about the path, and half a population reported confidently reads exactly like all of it.* The live defect was the pair it could not see: `api/mep.ts` declared two incompatible shapes for one URL, so the MEP systems browser read `.length` off a Record and printed "No distribution systems yet" on every model that has ever had one. Nothing went red because every MEP assertion calls the engine directly — *a test of the engine is not a test of the door*. **The per-site decisions ran in OPPOSITE directions on purpose**: the three drawing handlers went and the *serving* `/mep` handler went, because who the consumers are decides, not which registration won. The workaround was already in the tree with the cause named one layer too high — `viewer/sheetSpecs.ts` calls the rail "a caller speaking a dialect the callee does not", and that dialect is exactly the shadowed handler's published parameters: *a caller written from the documented contract is not speaking a dialect, it is the documentation describing the wrong handler.* And `apps/web/src/api/clientCallers.test.ts` **already held `mep` as callerless and froze it**, then reported the deletion as *"now HAVE a caller"* — an entry leaves that set for two reasons and the message asserted one. *A check whose failure message can misdiagnose is worse than one that stays silent, because somebody acts on it.* **The rule asks Starlette rather than `==`, and the widening was MEASURED first.** All four shipped instances were identical path strings, so equality finds all four and looks complete — but Starlette matches by regex, so a parameterised route registered before a literal one swallows it just as completely and an equality gate calls that tree clean. The non-identical form was measured at **0** before the rule changed, so this costs no exemptions; its value shows only under mutation, where one misplaced `/projects/{pid}/mep/{probe}` kills **eleven** endpoints that the narrow rule would not name. *Equality was a precondition, not the rule* — and the one-segment placeholder that makes the wider probe sound is asserted (no `:path` converter), not assumed. **And review found two MORE preconditions in that same gate, both the defect one layer up.** The probe filled `{param}` with a plain segment, so an `:int`/`:float`/`:uuid` route **rejects its own probe** and a duplicate on it was invisible — *asserting the one case you thought of is not asserting the property*, and the `:path` assertion was exactly that. And the walk read a `prefix` attribute off FastAPI's deferred-inclusion placeholder, which **this FastAPI does not have**, so it walked the un-prefixed router and a collision at a prefixed URL passed clean — *guessing at another library's internals is a precondition you did not write down*. **The second was invisible to its own tests**: every fixture used an empty prefix. Repaired at the source — the walk takes FastAPI's own `effective_route_contexts()`, whose regex carries the prefix by construction, and probe validity is **self-checking** (every probe must match its own route), so an unhandled converter reds the build. *A list of known cases is a list somebody stopped widening; a self-check is not.* "Cite a gate only after `git ls-files` confirms it" is itself a rule held as prose, so it is now `services/api/test_claude_md_gates.py`: every backticked code file named here, in `docs/roadmap-directions.md` **and in `docs/roadmap.md`** must resolve to a tracked path — including citations that carry a locator (a trailing ":line", "::symbol" or "#anchor"), which escaped the check until 2026-08-01 and hid 21 of them. *Those example forms are in plain quotes on purpose — backticking an illustrative filename makes it a citation, which is how this very sentence failed the gate once.* The roadmap contributes ~115 of the ~128 citations, so **it is the doc most likely to fail a build on this**; two wrong paths were found the day it was added, one of them naming the wrong *directory*, which matters because lanes are assigned by directory. `docs/roadmap-completed.md` is deliberately **not** gated: it is a historical record and names things that were proposed and never built. **Backticks are therefore reserved for files that exist** — a dead, historical or merely proposed name goes in plain quotes, since a backticked name reads as a live citation whether or not anything backs it. Read that test's docstring before editing this list; the lessons that cost the most live there, next to the check, not here. ## MassingViewer — the extraction, and what it means for `apps/web/src/viewer` **MassingCloud/MassingViewer is live, public, MIT**, and is where this repository's Design Room engine is being extracted to. It is not a fork and not a second viewer: massing is intended to consume it as a dependency and delete its own copy. Written here on 2026-08-15 because nothing in these instructions mentioned it, and an agent working in the viewer directory could not have known. **Ready on its side, by its own measure — and EVALUATED AND DECLINED HERE on 2026-08-23.** A facade, "@massing/embed" 0.2.0, MIT, exposing viewport, authoring session, commands, kernel, drawings, markup, ribbon and plugin host; a seam ledger whose claims are asserted against the built facade rather than ticked in a table. Packaging is validated from real tarballs, so the swap is not blocked on anything *packaging*. **Numbers measured from the published tarball, because the ones this paragraph used to carry were wrong.** It said "27 of 27 movable capabilities covered". Running its `seamCoverage()` out of the shipped "seam.js" reports **20 of 20 movable, 4 boundaries, 24 entries total**, `ready: true`, *"apps/web/src/viewer can be deleted"*. The dependency closure is **12 packages** (embed + 11), not 25. **Why it was declined — the reason is architectural, not a matter of polish.** The facade's load path is **IFC text into a browser-side tessellator**: `open(source: string | Uint8Array)` sniffs bytes, and the required `Tessellator` is `(ifcText: string) => {meshes, guids}`. Its seam entry `kernel.open` says so — *"load a model into the kernel from IFC text"*. **There is no notion of pre-converted Fragments anywhere in the package** (grepped: the single "fragment" hit is a generic use in a section-box note). That collides head-on with two non-negotiables at the top of this file — pre-convert on the server, never parse full IFC in the browser at runtime; geometry streams as `.frag`. Adopting the facade means either breaking that, or keeping our own load path outside it — and then it is no longer the whole surface, both copies live, and that is the fork their own plan calls the only risk that can end the project. **And its `ready: true` measures the wrong half.** The ledger's own test asserts every `covered` entry is reachable through the facade's type — which proves each *claim is backed*, not that the *claims cover the ground*. Its 24 entries are a dissection of this viewer as it stood around 2026-08-06. This viewer is **155 TS files / 19,159 non-test lines / 65 test files** today (re-measured 2026-09-10), and the list names none of: the plan/sheets/specs canvas modes, collaborative peer cursors, dimensional locks, viewer load timings, reference point clouds, GIS context, or the model-bounds allowlist. *Derive the population AND the reach — a completeness verdict computed over a self-authored list is confident and unfounded.* **The family is also inert.** All 12 packages were published on 2026-08-08 in two bursts and **not one has been modified in the 15 days since** (queried from the registry 2026-08-23), all still 0.1.x/0.2.0. Adopting a dependency that is not moving, to replace code that changed today, inverts the divergence risk it exists to solve. **What would change the answer:** a Fragments-shaped load path in the facade — `open()` accepting pre-converted fragment bytes, or a `KernelProvider` that streams them — plus a seam list derived from *this* tree rather than from their plan. The decision remains the user's; this records an evaluation, not a veto. **That blocker is GONE, and it was already gone when this section was written.** This said "the packages are not on npm yet. Until they are, this repository keeps its own viewer and nothing here should change on account of the extraction." Queried against the npm registry on 2026-08-21: **20 of the 25 package names are published, all MIT, all dated 2026-08-08** — including "@massing/embed" at **0.2.0**, whose eleven declared dependencies ("core", "viewport", "authoring", "commands", "drawings2d", "fileio", "kernel-api", "markup", "observability", "plugin-host", "ribbon") are every one of them published. This section is dated **2026-08-15**, a week after that. *A standing instruction can be stale on the day it is written, and this one names its blocker so confidently that no reader would think to check.* Not published: "i18n", "tessellate", "pwa", "assets", "kernel-remote" — none of which "embed" depends on. *(Plain quotes, not backticks: this file's own rule two sections up reserves backticks for files that exist, and these are package names in someone else's registry. The paragraph above already followed that rule and the first draft of this one did not.)* **What follows from that is the USER'S CALL, and nothing here changes until they make it.** The two breaking changes below are real (async viewport creation, add/remove replacing `showModel`), the divergence is now thirty-odd commits deep, and "adopt the facade" is a multi-release architectural commitment, not a dependency bump. So: the *factual* blocker is corrected here because it was false; the *decision* it was gating is untouched and still open. Keep shipping viewer work in the meantime — that guidance below is unchanged and was never contingent on the npm question. **What an agent working in `apps/web/src/viewer` should know.** **Seventy-nine** commits have touched that directory since extraction began on 2026-08-06, and `apps/web/src/viewer/app.ts` has gone from 5,064 lines to **2,442** — largely R39-DECOMP-VIEWER, which is the same decomposition the extraction plan asks for and is being done here first. That is good and it is also divergence: every one of those commits is a change the swap will have to reconcile. So: *(Both numbers were stale and are re-measured 2026-09-05 — this said "Twenty-eight commits" and "3,444 lines". Unlike the Node and Python drifts above, **this one moved in the direction that strengthens the argument**: more than twice the commits and another 936 lines out. A number that decays toward the conclusion it supports is the hardest kind to notice, because nothing it predicts ever looks wrong. Re-measure with `git log --oneline --since=2026-08-06 -- apps/web/src/viewer | wc -l` and `wc -l apps/web/src/viewer/app.ts`, never by reading this line — the same rule the two version notes above had to learn the expensive way.)* *(**And the line count above was stale again within the same pull request.** The correction first written here said **2,570**; slice ⑰ of R39-DECOMP-VIEWER landed in that same PR and made it **2,508**, so the paragraph diagnosing decay-toward-the-conclusion decayed toward its own conclusion before it was merged, and a review bot found it rather than the author. Two things follow. **A number and the change that moves it must land in the same edit, not the same commit** — "I will update the note after the slice" is a promise made inside the window where it is already wrong. And the authority is `services/api/test_file_sizes.py`, which pins `apps/web/src/viewer/app.ts` at an exact size and fails the build when it drifts; **this line is a narrative copy of a number that has a gate, and a copy is what drifts.** Read the pin, not the prose. The `wc -l` in the re-measure command above was also bare and would have waited on stdin — an instruction to verify that hangs is an instruction nobody runs twice.)* - **Keep shipping.** Blocking this roadmap for the extraction would make the extraction expensive and it would die. Landing viewer work here is the correct default. - **Prefer changes that survive the swap** — new behaviour behind the existing seams, rather than new coupling into `apps/web/src/viewer/app.ts`. - **Two breaking changes are coming together, deliberately**, so this repository absorbs one: creating a viewport becomes asynchronous (WebGPU first, WebGL2 fallback), and single-model `showModel` becomes add/remove with per-model state. Selection stays keyed by IFC GlobalId across models, which is what `planPaneSelection` and the spec pane already rely on. - **Never reference an element by a transient viewer id** across that boundary. GlobalId is the only identity that survives a reload, a re-tessellation, or a second model.