The single product roadmap — open items only. Everything shipped lives in roadmap-completed.md; per-release detail is in CHANGELOG.md. Supporting detail: gc-portal.md · ops-dr.md · mobile.md. Documentation for readers rather than for planning lives in README.md — the docs index. Closed-out audits and unbuilt plans moved to internal/ on 2026-07-29; that directory is not published to the site.
Three pillars on one IFC-keyed model: BIM authoring/viewer · GC portal · developer/finance. All three have depth — finance and CRE from R19 + R20, authoring from R18, the 5D/4D spine from R25, the interaction surface from R26. What is thin now is the drawing: the sheet is still handled as an image with text behind it rather than as data (📐 R27).
Status — reconciled 2026-07-29 at v0.3.778. CodeQL 0 open (queried from the alerts API, not
inferred from a green run) · backend 423 suites · vitest 715 (incl. vendored kernel + PDF
engine) · test_reachable 301/305 modules callable · single-source version in
apps/web/package.json · all 18 CI runs green across v0.3.776–777.
The previous status block claimed 416 / 557 as of v0.3.740 — thirty-eight releases stale. A status line nobody re-derives is just an old measurement wearing the present tense.
The seven-room shell is the ONLY shell. It renders in the Construction, Developer and Design
workspaces; opening a project lands on the persona's home and every other workspace carries a
← Project home signpost (v0.3.739). The ?shell=classic opt-out was deleted in v0.3.779 —
spine.test now asserts spineEnabled and SPINE_FLAG are absent, so a revert fails a test rather
than quietly restoring a second rail.
Read the gating honestly. A large block of what remains is genuinely blocked — see ⛔ Gated. The ▶ NOW list below is only non-gated work.
Housekeeping note, 2026-07-28. This file had accumulated three duplicate NOW sections with contradictory content — items shipped hours earlier still listed as pending, numbering running 1, 2, 1, 5, 6, and a header claiming the new shell was opt-in after it had been made default. Cause: scripted edits that inserted a NOW section instead of replacing one, repeated over several releases. Rebuilt by hand here. If you are editing this file with a script, replace between the section markers — never splice at a matched string.
- roadmap-directions.md — non-negotiables, how to verify, shared-clone hazards, lanes, testing, release discipline, what "done" means. Read before touching anything.
- What is left, prioritised — the ranked view, below.
- The lanes — who owns which paths, so two agents do not collide.
- roadmap-completed.md — what shipped, and why it was built that way.
This file is the list of work. It is not the place for working conventions — those drifted into it over several months and were moved out on 2026-07-31 so this could stay readable.
There are three organising schemes over one set of items, and an item can appear in all three. Bands rank by consequence, lanes assign by path ownership, rings group by when someone scanned. That is not redundancy to remove — the bands answer "what next", the lanes answer "can I take it", and the rings hold the evidence — but it means the ring sections are a reference, not a work list. Pick from the bands or the lane table; read the ring for why the item exists.
What the audit changed, all of it verified against the tree first:
- 19 completed items were still listed, 9 of them advertised in the lane table as available work. A second agent reading "take any item in your lane" could have picked up something already shipped. Removed here; their records are in
roadmap-completed.md.- Five rings held no open work at all — R27, R29 in part, AUTHORING-GESTURE, R24-TOOLS-SPLIT, R42 and the Reach sweep — 239 lines of research narrative whose conclusions had already shipped. Archived whole. R28 and R29 stayed: both still hold live entries, which an item-regex missed and the lane gate caught.
- The file was 3,193 lines for 42 open items. Roughly one line in fifty was a piece of available work. It is now ~2,830, and the ratio is still the thing to watch when adding a ring.
What was NOT fixed, and is a judgement call rather than a defect: most items carry no size, so "ranked by consequence-if-wrong" cannot be weighed against cost — the ranking is half a decision. And R24 is the largest open ring by a distance; an interface backlog that only accumulates is usually a sign the ring needs closing and re-cutting, not extending.
Open items: count them, do not read them here. This line said "49" while the file's own extractor
found 51 and the lane table named 48 — three numbers for one quantity, and the prose one had no way to
notice it was wrong. A hardcoded total in prose is the drift this roadmap keeps re-learning, so it
is replaced by its method: roadmapLanes.test.ts extracts open items with the ITEM regex (bullets
carrying no ✅) and asserts every one is in a lane or explicitly Parked, so the lane table and the item
list cannot disagree without failing a build. Run it for today's number.
Ranked by consequence-if-wrong, then by whether the thing is reachable rather than merely built. Sizes are the roadmap's own. ⭐ marks the highest-value item in a band.
FIN-SUITE-BLIND closed here on 2026-08-01 (G702 retainage, the 0%-read-as-unset money bug, and
the reserve/benchmarking/proforma sweep) — its full record is in
roadmap-completed.md. Two seams took its place, both found by the R35 race
sweep rather than by a failing test, which is the reason they rank first: nothing in the suite can
currently fail if either regresses.
-
◧ PARTLY SHIPPED v0.3.876 (2026-08-07) — R39-UPLOAD-CAP-APP ① and R41-UPLOAD-WARK. The front half is closed; the back half has its primitive and no adopters. The cap existed and decided from
Content-Length, so a chunked body — the ordinary HTTP/1.1 way to send a body of unknown length — short-circuited the condition and was never measured at all.bodycap.MaxBodySizeMiddlewarenow counts bytes on the ASGIreceivechannel, which bounds every route at once rather than a hand-listed set that would be stale the day it was written.storage.put_streamis the back half's missing primitive (local.part+rename, S3 multipart, both cleaning up on refusal). Corrected 2026-08-10: "no call site has been converted" was already false when written. Four were —routers/authoring.py(twice),routers/bcf_api.py,routers/bim.py— andstorage.py's own docstring says "the four converted routes", so the roadmap contradicted the code it was describing. The caller-side helpers exist too:storage.upload_chunks,storage.stream_to_path,storage.file_chunks. A fifth converted v0.3.927: the APS RVT path inrouters/convert.py, which didrvt.write_bytes(await file.read())on the largest files anything uploads here.The remaining ~34 sites are NOT all convertible, and counting them as if they were is what made this item look bigger than it is. They split three ways, and only the first is work: (a) routes that store the upload — convertible, same one-line shape as the five done; (b) routes that hand the bytes to a whole-buffer parser — CityGML XML, E57 point-cloud decode, BFAST/VIM,
bcf_io.parse_records_bcfzip, the Excel/CSV sheet readers. Streaming those means changing the parsers, which is a different item with a different risk profile, and pretending otherwise is how a "convert the call sites" ticket quietly becomes a rewrite; (c) routes whose payload is structurally small — an IDS file, a JSON config, a.bcf— where the copy is not worth a code change.So the honest remaining scope is (a), and the next step is to classify the 34 rather than convert them — a count is not a work list.
CLASSIFIED 2026-08-13. Every
await file.read()inservices/api/src/aec_api/routers/was read together with what it does with the bytes on the next two or three lines, because the call itself is identical in all three buckets — which is exactly why the count read as one work item.-
(a) stores the bytes — convertible, and this is the whole work list.
documents.py(docmanager.upload) andmodules.py×2 (mod_engine.add_attachment, single and bulk). These hand abytesobject to a function that persists it, so converting them means widening that function to take chunks —storage.put_streamalready does. Three, not thirty-four. All three converted in v0.3.941–942.Corrected 2026-08-13: this first said FIVE and named
authoring.py's content-shelf asset. That route never stores the upload — it hands the buffer tocontent.parse_meshand keeps only the resulting verts/faces, so it belongs in (b). The misclassification came from reading the two lines afterawait file.read()(a filename suffix and a category lookup, which read like the preamble to a store) instead of followingdatato its single use. Classify an upload by where its bytes are USED, not by what surrounds the read.bim.py's BCF topic attachment is a fourth store site and is absent from this list on purpose: R41-UPLOAD-WARK converted it back in v0.3.876, so it has noawait file.read()left to find. A population derived from "sites that still read the whole body" correctly excludes what is already fixed — but it also means the list is not "every store site", and reading it as one would undercount the surface. -
(b) hands the buffer to a parser —
analysis.py×4 (point cloud, sheet recover, IDS),bim.py×5 (bundle, BCF zip, clash XLSX/XML),drafting.py×2 anddrawings.py×2 (PDF),modules.py×2 (openpyxl, BCF),authoring.py(IFC open),verification.py(photo_cv),convert.py×3, plusstandards.py/research.py/review.py. Streaming these means changing the parsers, which is a different item with a different risk profile. -
(c) structurally small or already capped —
properties.pyrejects overAEC_PROPS_MAX_MBbefore parsing; the remaining config/IDS uploads are small by construction.
Two of (a)'s neighbours were converted in v0.3.940 and were not on anyone's list, because they are the one shape that hides in bucket (b):
cost.py's DXF takeoff andauthoring.py's raise-plan both read the whole upload and then wrote it straight back out to a temp file to hand a path to a parser. Starlette had already spooled that upload to disk, so each was a disk-to-memory-to-disk copy of a file that existed the whole time — the identical conversionconvert.py's RVT path already carried. Both routes have real upload tests, and mutation-checking proved those tests reach the new code rather than merely passing beside it. -
Seven of eleven engines once shipped with no route. The R32 filing-spine entries that occupied this
band are all closed and recorded in roadmap-completed.md. The current
instances:
R45 finished on 2026-08-14 with all 21 vendored modules reachable and UNREACHED empty for the
first time. Upstream shipped again the next day. a740241c brings eight new modules and 2,986
lines, plus a rewritten MSPDI writer and an injection audit across every writer — and
test_massingplan_vendor stayed green through all of it, because the copy is faithful. It is
test_vendor_reachable that failed the build.
That is the argument for the second gate, demonstrated on the very next sync rather than argued for. Faithfulness and usefulness are different claims about a vendor drop; a digest check can only ever make the first, and an engine can double in size behind a green one.
xmlsafe.py (113 lines) is the exception and is already live: the parse-bomb and XML-entity
hardening reaches us transitively through mspdi and xer, so it took effect the moment the files
landed. No adapter needed, and none of the schedule suites moved.
The seven that need one, in the order they are worth building — none of them duplicates anything we have, so unlike R45 there is no de-duplication decision hiding in this list:
| Module | Lines | What it answers that nothing here does |
|---|---|---|
windows |
300 | SHIPPED v0.3.965 — schedule_windows.py, over the captured baseline library |
modelled |
477 | SHIPPED v0.3.965 — schedule_modelled.py. Collapsed as-built refuses on our data and says why; the refusal is the finding |
p6xml |
760 | Primavera PMXML. XER cannot carry baselines; PMXML can, as additional <Project> elements, along with the global calendars a restricted XER omits — which is the gap behind our own baseline-schema work |
earned |
302 | SHIPPED v0.3.966 — schedule_earned.py. Measured 0.556 on a job SPI would call 1.0 |
compression |
439 | SHIPPED v0.3.967 — and it measured /schedule/optimize overstating: advisory 5d, finish moves 3d |
weather |
250 | SHIPPED v0.3.967 — schedule_weather.py; refuses to invent an allowance |
portfolio |
345 | SHIPPED v0.3.967 — membership checked per project, proven 403/200 |
⚠ FOUND WHILE WIRING THESE TWO: eot.py names four AACE methods and performs none of them. All
four return an identical number on the same input — the method is recorded as a label and the
arithmetic never changes, because nothing in it re-schedules a network. Three of the four are
network operations. Pinned in services/api/test_schedule_windows.py; schedule_windows and
schedule_modelled now perform three of the four for real. Whether /schedule/eot should delegate
to them, or keep its own number and cite theirs, is a domain decision — the EOT figure ends up in
arbitration, and changing what it means is not a refactor. Both still ship.
R46 IS COMPLETE (v0.3.967): 29 of 29 reachable, allowlist empty. Recorded in services/api/test_vendor_reachable.py, which fails the build until each is either wired
or argued for. The mspdi rewrite and the xer injection audit landed with no adapter change and
no test movement — checked by running the schedule suites, not assumed from the diff being large.
The vendored massingplan engine was re-synced from pin b703dca4 → d1e4bf16, which brought two
new modules and deepened seven. Measured immediately after: 9 of the 21 vendored core modules —
3,904 lines — have no path from the API at all.
The number is transitive, not a grep. A direct-import scan said 12 modules were unwired; three of
those (cpm, progress_logic, units) are reached through schedule.py, so they are load-bearing
and the grep would have had us "wire" code that already runs. The nine below are genuinely
unreachable — no direct import, and nothing reachable imports them either.
| Vendored module | Lines | Our counterpart | The real question |
|---|---|---|---|
health |
634 | ✅ aec_api/schedule_health.py |
WIRED v0.3.950, routed v0.3.951 — DCMA 14-point |
compare |
555 | schedule_baselines.compute_variance |
SHIPPED v0.3.961 — the blocker was the SNAPSHOT, and it was fixable |
levelling |
587 | ✅ aec_api/schedule_levelling.py |
SHIPPED v0.3.954 — POST /schedule/level; pulled resources in transitively |
locations |
477 | ✅ aec_api/schedule_locations.py |
SHIPPED v0.3.952 — line of balance + crew continuity, GET /schedule/flowline |
resources |
254 | ✅ (via schedule_levelling) |
reachable v0.3.954 — levelling imports it |
takt |
444 | ⚠ aec_api/takt.py 163 |
SHIPPED v0.3.953 as schedule_takt.py — and they are different methods, see the decision below |
lastplanner |
436 | ⚠ pull_plan.py + lean.py |
SHIPPED v0.3.960 — and it found THREE disagreeing PPCs; see below |
risk |
303 | ⚠ "aec_api/schedule_risk.py" 195 (DELETED v0.3.972) | SHIPPED v0.3.959 — and the two disagreed by 37–38 days, which is what settled it; see below |
progress |
214 | aec_api/progress_rollup.py 134 |
overlapping |
The split matters more than the total — and this table was wrong three times before it was right.
The count went 5 pure gain → 3 → 1. Each correction came from a different bad shortcut, and the sequence is the reusable part:
- Matched on filenames. No
aec_apifile is namedcompare, socomparewas filed as having no counterpart.schedule_baselines.compute_varianceis one. - Matched on grep patterns I guessed. Searching
levell|resource_level|resource_assignmissedresource_loading.py, which defineslevel()andloading()— 221 lines covering bothlevellingandresources. The capability was there; the words I guessed were not. - Matched on docstring keywords. Too loose in the other direction: "level" matched
bid_leveling, which is subcontractor bid levelling, an unrelated domain.
The only method that worked was opening both modules and reading them. Automated matching failed three times in three different ways, twice while explicitly trying to correct the previous failure. For a question of the form "do we already have this?", a grep can prove a string absent and can never prove a capability absent.
The verified picture:
- One is pure gain —
locations. Verified by reading it and then searching our tree for the domain's own terms (flowline, line of balance, location-based): nothing. It answers the question CPM cannot — "where is each crew, and does anyone get in anyone else's way" — and the thing it models that CPM structurally cannot is crew continuity: a forward pass gives every activity its earliest start, which is exactly what fragments a gang into work-a-floor-then-wait. On a tower that is the same twelve trades on forty floors, and a subcontractor cannot price it. This is the whole of ① now, and it is the most differentiating item in the ring. - Seven already have ours beside them —
compare,levelling,resources,takt,lastplanner,risk,progress. (healthwas the only other pure gain and shipped v0.3.950.)
All four overlaps share one shape, and naming it settles the sprint. Ours are renderers and
persistence layers that grew a bit of engine; theirs are engines. pull_plan.py is board /
metrics / pdf / signature; takt.py is plan / progress / takt_svg;
progress_rollup.py is capture_diff / rollup. takt is the sharpest case because both define
plan() — theirs also has crews_for, minimum_takt and to_network (crew sizing, minimum
feasible takt, conversion to a CPM network); ours has takt_svg, which theirs does not. Keep our
rendering, take their engine, delete ours — in all four, one at a time.
Correction, made on the same day the table was written. compare was filed as "no
counterpart" because no aec_api file is named compare. schedule_baselines.compute_variance
already computes a baseline-to-current diff, so the classification was made on a filename rather than
on a capability — the exact error this ring exists to stop, committed while writing the ring.
Having read both, they are complementary rather than duplicated, and the distinction is worth more than the correction:
- Ours matches on the internal record id. That is right for a baseline captured in the app,
where ids are stable, and it is what
compute_varianceis for. - Theirs matches on the planner's activity code, with
NAME_AND_WBSas a fallback and ambiguous pairs deliberately left unmatched. Its docstring names exactly why: a re-baseline exported from P6 carries entirely newtask_idvalues, so an id-match "finds nothing in common and reports every activity as removed-and-added — a diff that is technically correct and completely useless."
So our variance is silently useless in the one case a GC needs it most: comparing against a schedule somebody else exported. It does not error; it reports total churn. That is the finding, and it is bigger than the wiring. Theirs also carries link changes, criticality gained/lost, a driving-path delta and delay attribution — the forensic half, which is where a delay argument is actually won and which we have nothing for.
Wiring it needs one user-facing decision first — which identity key is the default — so it is not
a drop-in adapter like health was.
⚠ THREE PPC IMPLEMENTATIONS, ONE DASHBOARD, AND THEY DISAGREE. The table named two; reading all three found a third. On one week — 1 done, 1 missed, 3 still unanswered:
| denominator | reads | |
|---|---|---|
lean.ppc |
every record | 20% — the unanswered count as failures |
pull_plan.metrics |
assessed only | 50% — the unanswered are not in the denominator |
core/lastplanner |
frozen at commit | unmeasurable — null until every commitment is answered |
lean.ppc reads artificially low mid-week (on Wednesday every team looks like it is failing);
pull_plan.metrics reads artificially high (one answered of twenty, and done, reads 100%). The
portal renders the flattering one. lean.ppc additionally reports 0.0 and a rating of "needs
work" for a project with no commitments at all, and defaults a missing variance reason to
"Unspecified" — quietly filling the learning loop with a value nobody entered.
DECIDED 2026-08-16: the vendored engine's rule wins, and v0.3.974 applied it.
One rule everywhere: met or not met with no partial credit; an unanswered commitment makes the
period unmeasurable, so PPC is None rather than a number; and nothing promised is None too,
because a team that made no commitments has not broken any.
There are still three functions, because there are two registers and a route — lean.ppc scores
weekly_plan, pull_plan.metrics scores pull_plan_task, schedule_lastplanner scores the same
records through the vendored engine. Collapsing them would merge two registers holding different
work. What had to agree was the RULE. services/api/test_ppc_divergence.py now asserts the
agreement, with a closed week as the twin — three engines that always answered None would look
consolidated and be broken.
Two findings the consolidation turned up, neither of which this table knew about.
The engine could not read its own register. schedule_lastplanner grouped on a field called
week. pull_plan_task/module.json declares planned_week; week is weekly_plan's field, a
different register. So GET /schedule/reliability answered "none of the N pull-plan tasks carry a
week" on every real project, while its only test passed on a hand-written fixture supplying
week. A fixture cannot catch this by construction — it is written from the same wrong belief as the
reader, so it agrees with the reader and disagrees with the database.
services/api/test_ppc_field_conformance.py asserts the engine's field names against the register's
own module.json, a reader neither of them wrote. Mutation-checked: 4 named FAILs, and the gate
reproduces the exact pre-fix refusal.
pull_plan.metrics disagreed with itself. The trend line divided by every commitment including
unanswered ones; the headline divided by the assessed only. On a week with 1 done, 1 missed and 3
still open that is 20% in the chart and 50% in the tile, on the same panel. The existing test
could not see it: its fixture had no unanswered commitment, so both forms happened to agree.
The web chart also stopped plotting ?? 0 for a null week — drawing "not measurable yet" as a zero
bar is the confusion the null exists to remove, and worse in a chart, because a reader sees a
collapse rather than a gap.
⚠ /schedule/risk and /schedule/montecarlo disagree by 38 days on a 100-working-day chain, and
the older one is the optimistic answer. schedule_risk._network is FS-only, lag-free and
calendar-free — checked, not assumed: the module contains zero occurrences of "calendar" — and it
turns a duration into a date with start + timedelta(days=round(days)), i.e. calendar days. The CPM
beside it reports working-day dates. Measured on the test fixture: ours 2026-06-22, the vendored
engine 2026-07-30.
A P80 that counts Saturdays is not a conservative estimate, it is a different question's answer.
Retiring /schedule/risk is a user-facing removal and therefore your call, the same as the
takt.py naming decision — the replacement is shipped and carries our PPC calibration forward, so
nothing is lost by retiring it.
DECIDED 2026-08-16: keep the alias. The path stays, serving /schedule/montecarlo verbatim
with a deprecated note; scheduleRisk stays as a one-line client alias, recorded in
KNOWN_UNCALLED as deliberately callerless. This question is closed — do not re-open it as a
cleanup.
It nearly went the other way in v0.3.972. That release deleted the wrong ENGINE, which
the ring's rule authorises, and in the same stroke deleted the PATH, which this paragraph had
reserved for you. The path was put back the same day as a deprecated alias serving
/schedule/montecarlo verbatim, so nothing 404s and the wrong number is still gone. The response
SHAPE did change — dates rather than day counts — which is unavoidable, because the old shape was the
wrong answer's shape. The decision left to you is narrow: delete the path, or keep the alias.
The lesson is not "read the roadmap harder". It is that an item authorising a deletion does not authorise every deletion it touches: R45-SCHED-DEDUPE ② says "delete the loser", and the loser had two halves with different owners.
The vendored engine also reports two things ours cannot: confidence_in_deterministic (on that
fixture, the programme date had a 9% chance) and duration_sensitivity per activity — whether an
activity's duration actually moves the finish, as opposed to merely sitting on the critical path
often.
The progress row was the fifth wrong classification, and the only one caught before acting.
progress_rollup.py measures the building — percent complete from as-built element presence,
keyed by GlobalId, by class / discipline / level, by count and by value. core/progress.py measures
the schedule — BEI, variance and slippage of activities against baseline dates. A tower can be 60%
erected and four weeks late; neither number substitutes for the other, and reading one as the other is
how a report reassures somebody wrongly. They share the word and nothing else.
It also shows the compare blocker is specific rather than general: progress needs only dates,
which _snapshot has, so it shipped. compare needs predecessors, which _snapshot does not.
✅ compare SHIPPED (v0.3.961) — and the blocker turned out to be one field, not a decision.
The diagnosis above was right and the conclusion was wrong. compare() does need both networks,
and schedule_baselines._snapshot did freeze only ref, name, start, finish and budget. But
"changes what every existing stored baseline means" is exactly what a schema version exists to
avoid. _snapshot now also freezes duration, predecessors, calendar, constraint and wbs,
under schema: 2; a v1 baseline keeps meaning precisely what it always meant, and variance against it
is untouched.
The version number is the whole safety argument, and it is not bureaucracy. A v1 snapshot and a v2
snapshot of a schedule that genuinely has no relationships are indistinguishable from the data.
Rebuild a v1 one as a network and it comes back as a set of 1-day tasks with no predecessors — a
fully-parallel plan that finishes on day one — which then diffs against the real schedule into a
large, precisely-attributed delay caused by logic nobody removed. Measured, not argued: the twin in
services/api/test_schedule_compare.py forces one through and gets 53 days against a true 14,
attributed with total confidence to named activities. So a v1 baseline is refused, with a sentence
that says to capture a new one.
Deliberately not frozen into a baseline: progress (actual_start, actual_finish, percent,
remaining_duration). A baseline that already knows how the job went under-reports every later slip
by exactly the progress recorded on the day it was captured.
⚠ A finding in the vendored engine, surfaced rather than fixed. Its delay contributions are in
working days; its finish_move_days total is in calendar days. The invariant still holds —
the contributions sum to the move exactly — but only because the UNEXPLAINED bucket absorbs the
difference, and a residual labelled unexplained sends a planner looking for a cause that does not
exist. On a ten-working-day growth it is four days of weekend, every time. /schedule/compare now
returns finish_move_working_days and calendar_vs_working_gap_days so the reader can subtract it.
Not patched in massingplan/core/ — that is a re-synced vendor drop and the fix belongs upstream.
All 21 vendored modules are now reachable. test_vendor_reachable.py's UNREACHED allowlist is
empty for the first time. Its vacuity twin was rewritten in the same commit: the old one read
reach != mods or not UNREACHED, which can never fail once the list empties — a check whose failing
branch is unreachable is not a check. It now asserts the closure is derived, by recomputing it
from a strictly smaller entry-point set and requiring a strictly smaller answer.
⚠ DECISION NEEDED — takt.py is a line-of-balance engine wearing the name "takt".
The table filed takt as "two implementations, same plan()". Reading both says they are two
different methods, and the vendored module's own docstring draws the line: "Line of balance lets
every trade run at its own natural pace and then shifts the lines apart until nobody trespasses. Takt
does the opposite, and the difference is a decision, not a detail: every wagon occupies exactly one
zone for exactly one takt. The crew sizes move so the durations do not."
Our aec_api/takt.py gives each trade its own takt_days — Structure 5, Envelope 5, MEP 6, Interiors 8, Finishes 6 — and lets them chase each other up the building. Trades at different rates
is the definition of line of balance. So real takt was missing entirely, and now that
locations.py ships as /schedule/flowline and is line of balance, the product would otherwise
offer that method twice under two names and still not offer takt.
schedule_takt.py + GET /schedule/takt-train (v0.3.953) add the missing method. Nothing was
renamed or removed — takt.py backs a shipped, user-facing panel, and that is your call, not a
cleanup. The three options:
- Rename ours to "lineofbalance.py" (plain quotes: it does not exist yet) and let the Takt panel become a Line-of-Balance panel. Most honest, and it is a user-visible label change.
- Keep both names and treat our
takt.pyas the charting layer (it hasprogress()andtakt_svg(), which the vendored engine does not), pointing it at whichever engine the user picked. - Leave it. The names stay wrong; a planner asking for takt gets line of balance.
The engine work is done either way; only the naming is open.
-
✅ R45-SCHED-REACH ① (COMPLETE — all five adapters exist and are routed; marked 2026-08-16 after enumerating the routes rather than trusting the strikethroughs) — adapters for the five with no counterpart:
,health,compare,levelling,locations.resourceshealth→GET /schedule/health(v0.3.950) ·locations→GET /schedule/flowline·levelling→POST /schedule/level·resources→GET /schedule/resource-loadingand/schedule/resource-leveling·compare→GET /schedule/compare(v0.3.961, which neededschedule_baselinesSCHEMA 2 first — a v1 snapshot holds dates but no logic, and re-scheduling one produces a fully-parallel plan).resourceshas no "schedule_resources.py" and that is not a gap (plain quotes — backticking a filename in order to say it does not exist still makes it a citation, which is how this very sentence failedtest_claude_md_gateson its first run) — it is adapted insideservices/api/src/aec_api/schedule_levelling.py, which is why a file-name check would have called this item unfinished. The reach gate counts routes, not filenames, which is the reason it can see this and a naming sweep cannot.healthis done —services/api/src/aec_api/schedule_health.pyexposes the DCMA 14-point assessment (no equivalent existed anywhere outside the vendored tree; checked before building, not assumed). The adapter is thin by design and its whole content is the three states a score can be in: assessed, no activities, and a loop. Neither of the last two is a grade of F — an unplanned project is not a failing one, and a cyclic network has no computed dates for a check to read, so grading it would be grading a crash.gradeandscorecome backNone, never"F"and0. The engine's own honesty rule is asserted rather than trusted: a skipped check is excluded from the denominator, so a clean schedule scores 100 over 10 runnable checks, not 71 over 14. The four skipped ones (9, 10, 11, 14) need baselines, actuals or resources, and the adapter keeps those arguments optional — passing empty stand-ins to make them "run" would convert an honest gap into a passed check.services/api/test_schedule_health.pyincludes the twin that matters: a schedule with dangling logic must come back failing, or every other assertion is satisfied by an adapter returning a canned grade A. Each gets a thinaec_apiadapter + route, following theschedule_cpm.pypattern (adapter converts our model to theirs, never the reverse — the vendoredcoreis stdlib-only by contract and must not learn about SQLAlchemy). Ship one at a time; each is independently valuable andhealthis the highest — a schedule-quality score is the thing a GC is asked for and cannot currently produce. -
✅ R45-SCHED-DEDUPE ② (M;
taktv0.3.953,levelling+resourcesv0.3.954,progressv0.3.955,riskv0.3.972,lastplannerv0.3.974 — COMPLETE, and with it the whole R45 ring) — settle the overlaps, one decision each, in the ordertakt→progress→risk→lastplanner.riskSHIPPED v0.3.972, and the diff found a second defect the entry did not know about. "aec_api/schedule_risk.py" and its test are deleted (plain quotes: the name no longer resolves, and a backtick reads as a live citation);schedule_risk_mc.pyover the vendored engine is the only simulator left, asserted byservices/api/test_schedule_risk_single.pyrather than claimed.GET /schedule/riskstill serves, as a deprecated alias — see the ⚠ note above; the engine was this item's to delete and the path was not.- It added calendar days. Five 20-day activities in series from 2026-03-02 — a hundred working days — finished 2026-06-10 on the deleted engine and 2026-07-17 on the survivor. Thirty- seven days apart, in one portal, under two labels that both said "schedule risk".
- Its predecessor index read
ref/wbsonly, never the recordid— whichschedule_cpmresolves. Logic written with record ids therefore chained in the CPM and simulated fully parallel in the risk run: no error, and a P80 four months early.
The part worth carrying to
lastplanner: deleting the loser is not a licence to drop what it was carrying. Three things moved across rather than out — the PPC lookup (which existed twice, in the route and the MCP tool, so two callers of one forecast could calibrate differently), the measured-spreadrisk_calibrateblock, andbuffer_p80_days, re-derived in working days withrisk_board's severity thresholds re-cut to match rather than carried over.Three of the four overlaps turned out not to be overlaps at all —
taktwas line-of-balance wearing the takt name,progressmeasures the schedule whereprogress_rollupmeasures the building. Onlyriskwas two implementations of one thing. That ratio is the finding: the table was built from names. For each: diff the two behaviours, keep the deeper engine, keep our rendering/persistence, delete the loser, and write a test asserting there is exactly one implementation. Do not start this until ① proves the adapter pattern, and do not do it as a batch — four simultaneous deletions across the schedule surface is how a regression hides. -
✅ R45-VENDOR-REACH ③ (COMPLETE —
services/api/test_vendor_reachable.py, shipped with R46; marked 2026-08-16.) 29 of 29 vendored modules reachable, allowlist EMPTY. It went further than this entry asked: the allowlist is asserted in both directions — an unrecorded-and-unreached module fails, and a recorded module that becomes reachable also fails, so the list cannot rot into a graveyard. Two vacuity twins hold it up: the closure is derived (an empty seed set must reach nothing) and one entry point must reach strictly less than all of them, so a walk that returned everything regardless would report full coverage forever. Original ask below.— the gate this needs and does not have.
test_massingplan_vendor.pyproves the copy is faithful (digest, stdlib-only, no fork). Nothing proves any of it is reached. A re-sync can therefore double the vendored tree and every check stays green — which is exactly what just happened. Add a transitive-reachability assertion with an explicit, shrinking allowlist of unreached modules, so the number can only go down. -
◧ R31-CITE-HIGHLIGHT (NOT S — snippet display shipped v0.3.868; the data-model blocker was CLEARED v0.3.877; the citation became a control in v0.3.938 — all that remains is the viewer
PageWordsbridge for the in-page highlight box) — the citation could not be resolved to anything openable, so "make it a control" had nothing to click through to.SHIPPED v0.3.938 — and the delay was self-inflicted, which is the lesson. The work was one afternoon once the premise was checked. What kept it open was that both citation renderers declared their own inline
{ page, snippet }type and dropped thedoc_idandopenablethe server had been sending since v0.3.877 — so the capability was invisible from both ends, and a code comment plus this very entry both asserted a blocker that no longer existed.citationContract.test.tsnow asserts the sharedDocCitationtype againstdoc_text.pyby set equality, so a client type narrower than the response fails a build instead of quietly re-scoping a feature as blocked.CLEARED 2026-08-07. The blocker was smaller and dumber than this entry knew:
ingestranextract_pdf_textand then discarded the PDF, so even when a real document existed nothing afterwards could open it. The index entry now carriessource/source_kind, a posted PDF is stored beside its chunks,GET /projects/{pid}/doctext/{doc_id}/sourceserves it, and every citation reportsopenableas a value. A text-only ingest reportsopenable: false— there is no document behind it and there never was, so that is an answer rather than a gap. What remains is only the viewer half:citeLocate.tsis written against a structural interface and still needs something to supplyPageWords.doc_text.pyderivesdoc_idas a slug of the document's name, and the doctext index stores{doc_id, name, chunks, sections, ingested_at}— no file id, no path.ingest(pid, name, text=None, pdf_bytes=None)takes raw bytes plus a name, so a doctext document need never have been a stored file at all; for a text ingest there is no PDF anywhere.rfi_qa.py:182switched citations todoc_idin v0.3.810 describing it as "the RESOLVABLE identifier" — it is not resolvable either, so that change moved the dead end rather than closing it.Unblocking it needs a backend change first: record the source document (file id or storage key) on the doctext index entry at ingest, and accept that text-only ingests can never highlight. Then the viewer needs a
PageWordsbridge forlocatePassageto read —citeLocate.tsis written against a structural interface precisely so it does not depend on the viewer, but something must still supply the words.Shipped instead (v0.3.868): the citation now shows the snippet it is citing, which the server has been sending all along and the UI discarded, rendering
p.12alone. That is a page number the reader had to take on trust in a draft about to go to a design team. It needs no resolution path.Original: the data half and the locator are both done; nothing calls them.
doc_text.answer()carriesdoc_idinto every citation andrfi_qapasses the passage asspan(v0.3.810);drawings/citeLocate.tsfinds that passage on the page and returns its box, degrading through three match rungs and reporting ambiguity (v0.3.816). The remaining work is one seam:portal/panels/aiassist.tsstill renders"Source: p.12"as inerttextContent, and its local citation type declares only{ page, snippet? }— it drops thedoc_idandspanthe server already sends. Widen the type, make the citation a control, call the locator, draw the box. Recorded as unreachable in the module's own header so it cannot be mistaken for shipped capability — the mistake this band exists to catch. -
◧ QTO-TRADE — the four procurement methods cannot be wired at all, and this is why. (backend half DONE; the remaining work is the other three methods' screens — Lane B, not C)
BACKEND CLOSED, verified 2026-08-13.
procurement.normalize_qto_lineaccepts either dialect and is called bybuyout_packages; the trade comes fromclassification.py, not from a second mapping table beside it — which is what the entry below asked for and the right call, since a second table is how two sources of truth start disagreeing about what a wall is.buyoutPackagesnow has a screen (v0.3.939): Buyout packages on the budget panel feedsqtoByFloor'sby_disciplinelines — the exact{ifc_class, count, unit, quantity, rate, amount}shape that used to produce zero packages — straight into the engine.Two empty states are rendered separately and deliberately, because collapsing them is the specific failure this entry warned about: no priced quantities (nothing to package yet) reads differently from lines went in and nothing came out (a grouping problem). One empty table for both is what would have said "this project has no scope" about a fully-priced model.
Remaining:
procurementLevel,procurementLevelQuotes,buyoutSchedulestill have no caller. They need returned quotes to level, which is a different input than the model.Two agents reached this independently, from different directions, on 2026-08-07 — one from the input shape, one from the bid-submission side — and both refused to guess. That convergence is what promotes it from a wiring backlog item to a backend finding.
services/api/src/aec_api/procurement.pyresolves a line's identity as item-or-description-or-material and skips any line where all three are absent. Both model-derived QTO sources return ifc_class / count / unit / quantity / rate / amount — none of those three, and no grouping key. Feeding the engine the exact shape the by-floor QTO produces returns zero packages and zero cost. A screen over today's inputs would render "Buyout — 0 packages", which reads as this model has nothing to buy out rather than this input is incompatible.The generalisation matters more than the fix: the reach sweep proved 110 of 110 parseable client methods have live server routes, and reachability was allowed to follow from that. It does not. Route existence and input adequacy are different questions, and the sweep only ever measured the first. The blocking work is a trade classification for QTO lines — backend, not UI.
-
✅ RATCHET-SET — the uncalled ceiling is a scalar with no floor, and it loosened silently today. (S;
apps/web/src/api/clientCallers.test.ts)ALREADY DONE — verified 2026-08-13, and this entry was the stale thing. The conversion landed on 2026-08-07:
clientCallers.test.tsnow holds the committedUNCALLEDset and there is no survivingtoBeLessThanOrEqualassertion anywhere in the file — the only occurrences are inside the history comments explaining why the scalar was removed, which is exactly the shape that makes a grep say "still there". It went further than this entry asked, too: rather than one set-equality it asserts two directionaltoEqual([])checks, so newly appeared and newly wired report as separate diffs instead of merging into one indistinguishable blob.Left here rather than moved to
roadmap-completed.mdwith the closing argument intact, because the reasoning below is the record of why a scalar ceiling was the wrong instrument, and it is worth reading before anyone adds another one.The assertion is measured-less-than-or-equal-to-ceiling. Nothing asserts the ceiling is tight, so a higher literal always passes. On 2026-08-07 five PRs lowered that one line from four different bases; two live instances were caught only by hand: #254 carried 129 onto a main at 128, and #273 carried 123 onto a main at 117 — each would have raised the ceiling with every gate green and nobody able to see it.
Merge sequencing does not fix this. It decides which number lands, not whether it is true: two PRs measured against one base are both correct until either merges, and then the second is wrong. The second must re-measure, not rebase.
Replace the scalar with the committed set of uncalled method names, asserted equal. Tight by construction (set equality has no loose direction); merge-friendly (two PRs reaching different methods delete different lines instead of fighting over one); and strictly more informative — a method moving into the known-uncalled exclusion becomes a visible line move rather than an invisible population change.
It does not fix the deeper problem and should not be sold as doing so. A name leaving the list still only proves a call site appeared, not that the feature works — a caller wired to an incompatible input lowers the number while every gate stays green. That needs a second check: a reach PR should show its endpoint returning real data with the arguments its own caller sends.
-
✅ BOE-MAPPING-DEDUP — the estimate-to-BoE mapping has more than one implementation. (S)
DONE — verified 2026-08-13, and this entry's prescription was wrong about where the seam is. The second duplicate was resolved by extracting
apps/web/src/ui/confidenceReading.ts, which bothapps/web/src/portal/panels/budget.tsandapps/web/src/portal/register/register.tsnow import; the units are applied once there and asserted byapps/web/src/ui/confidenceReading.test.ts(7 tests). Two panels calling one shared reader is not duplication.The instruction below — "panels should call the seam rather than re-derive it", naming
services/api/src/aec_api/commercial_drift.py— could not have been followed as written. That module holdsbid_award_figure/walk/for_project: bid-to-award drift, with no confidence function in it at all. The server-side confidence scorer isservices/api/src/aec_api/est_confidence.py, and it already computes the score; what the two panels were each re-deriving was the unit handling on the response, which is a client concern and correctly lives in a client module. A roadmap item that names the wrong file sends the next reader to rewrite working code — the same failure as R31 above, on the same day.The cost-code and total mappings are re-derived client-side where the seam already exists server-side in
services/api/src/aec_api/commercial_drift.py. One duplicate was removed in v0.3.879 (the per-record BoE view inapps/web/src/portal/register/register.ts); a second remains for the confidence reading, now called from both the register andapps/web/src/portal/panels/budget.ts. Panels should call the seam rather than re-derive it.Booked deliberately rather than paid under time pressure: both copies were already merged when the duplication was diagnosed, and cutting one mid-flight would have moved the reach ceiling again on the files that batch was already fighting over.
-
◧ R43-VIEWER-CONFORMANCE (S — Lane E; MassingViewer issue #512; RUN 2026-08-13, full result in
docs/internal/viewer-conformance-2026-08-13.md) — run their conformance suite against our live API.Done: :8093 up, real project, real model.
samples/school_str.ifc(8.6 MB) uploaded, converted tofrag, 500 elements queryable./healthchecked before use, because a stale server also answers 200.1 of the 7 endpoints
RemoteKernelcalls works as-is. Two are absent (spatial-tree,elements/properties); three differ by path or scoping (export.ifcsits under/model/,jobsis project-scoped not global,geometryis a rules runner); one —/edit— exists and takesrecipewhere the kernel sends{op, params}. That last is the only item with real design content, becauserecipeis our GUID-stable edit vocabulary. Narrow and mechanical, not architectural.This entry was unfair to them and the correction is worth keeping. It said their suite "has only ever passed against a stub its own author wrote, so it is a green check with no subject." Their file says in its own header that it runs against cassettes and proves the adapter satisfies the protocol as documented, not that massing's service speaks it — and their
docs/kernels/authoring.mdrecords the live run as outstanding. It is a correctly-scoped test that names its own gap; the missing half was ours, and it is now supplied.One refusal read as a match and was not.
elements/propertiesanswered404 "element not found"— a domain message, which normally means a route matched and rejected the input. There is no such route:/elements/{guid}matched withguid="properties". Resolved against the OpenAPI table rather than the status line, because probing alone gets this one wrong.
All three checked 2026-08-07. Two were real, one closed for free — the band's thesis held for the
eighth time running. The record is below; the previous five are in
roadmap-completed.md.
Re-checked 2026-08-13: both "REAL" findings are now CLOSED, wired by PULSE-FINDINGS into the home pulse's deal card. Neither entry was updated when the work landed, so the band read as carrying two open gaps that no longer existed. The band's own thesis is why this matters — these entries exist to say "a value is computed and nobody can see it", and an entry that keeps saying so after the screen ships is the same defect pointed the other way.
-
✅ CLOSED by PULSE-FINDINGS —
suggestion_clears_horizonreaches a screen. (verified 2026-08-13)apps/web/src/portal/portal.tsfansreserveStudyinto the home pulse and maps the field toreserveSuggestionFailson the deal card. The finding below — "no panel callsreserveStudyat all" — was true when written and is not now.Read strictly, and that is the part worth keeping:
=== false, never!value. A missing field means the engine did not answer, and letting that read as the suggestion fails invents a risk line from an absent value — which costs trust in every other line on the card.apps/web/src/portal/panels/pulse.test.tsgates the strictness againstportal.tssource and carries a vacuity guard, because the mutation that swapped=== falsefor!valuepassed all 77 portal tests with nothing guarding it. -
✅ CLOSED by PULSE-FINDINGS —
nothing_renovatedrenders. (verified 2026-08-13) Same fan-out:portal.tscallsproformaRenovationand shows the finding on the deal card, preferring the engine's ownnothing_renovated_whyand falling back to "no unit completed a start". The claim below that "the only caller ofproformaRenovationis a test" is stale.Chasing that one turned up something larger, and it is now gated.
apps/web/src/api/surface.test.tsintroduced a list with "the methods the rest of the app actually calls … each has real call sites in the shell, the viewer or the portal", and three names under it had no caller but that list. Adding a client method makes the endpoint reachable and leaves the screen as absent as before.apps/web/src/api/clientCallers.test.tsnow separates the two questions and ratchets the second: 132 of 703 client methods have no caller outsidesrc/apiand tests. Wiring these two fields to a screen is the remaining work, and it lowers that number.
How to work here lives in roadmap-directions.md, not in this file. Claim a lane rather than an item, premise-check before building, announce before a full suite, and land what you finish. Those rules and the reasons behind them are in the directions; this section is only the lane assignment.
Organised by LANE rather than by priority, because several sessions work concurrently and a single ranked list serialises work with no reason to be serial. For a ranked view of the same items, see What is left, prioritised above.
Nine lanes, and every open item is assigned to exactly one. shell/roadmapLanes.test.ts asserts
that: it extracts the item codes from this file and fails if any is missing from the table below, or if
the table names a code that no longer exists. Pick a lane, read its row, take any item in it — no
two rows share a path, so two agents in different rows cannot collide.
| Lane | Owns these paths — disjoint | Open items in this lane |
|---|---|---|
| A · Shell & IA | apps/web/src/shell/, apps/web/src/portal/portal.ts, main.ts |
R24-CMDK-VERBS · R24-RUNS-INBOX · UX-READINESS-EVERYWHERE · UX-DUP-DESTINATIONS · REL-4 · R40-RIBBON ② · R43-CRUD-FRAGMENTS |
| B · UI & panels | apps/web/src/ui/, portal/panels/, portal/register/, field/, reportCenter.ts |
R24-ELEMENT-CARD ② (moved from E 2026-08-06 — the cell said E, the item's own text says the remaining work is "purely call sites: RFI, estimate line, pay app, COBie row", which live in apps/web/src/ui/ and apps/web/src/portal/panels/. A lane's paths and a lane's items are two claims and only the first is tested, so the cell drifted from the item under it) · R24-CHARTS-GRAMMAR · R24-REPORTS-BY-MOMENT · R24-DENSITY ② · R24-MONO-DATA · R24-TERMS · R24-FIELD-MODE · UX-GANTT · R22-REPORT-BUILDER · R23-SYMBOL-COUNT · R31-CITE-HIGHLIGHT · R36-ROOM-BRIEFS · R38-SHEET-MARKUP ③ · R39-A11Y-JOURNEYS ② · BOE-MAPPING-DEDUP (the second copy of the estimate-to-BoE mapping; call the seam) |
| C · Backend engines | services/api/src/aec_api/, !services/api/src/aec_api/routers/, !services/api/src/aec_api/main.py |
R22-ENTITLEMENT · R22-AGENT-PACKS · R22-PROVENANCE · R22-PIPELINE · R24-PERF-BUDGET · SEC-PLUGIN-LOADER · PERF-WORKERS ① · PERF-THREADS ③ · R35-DEAL-MEMORY · R37-TRIAGE · R39-UPLOAD-CAP-APP ①◧ · R41-UPLOAD-WARK · QTO-TRADE (blocks the four procurement methods; a trade classification for QTO lines, not UI) · R43-MASSINGBILL-CORE · R43-PLAN-DRIFT |
| D · Geometry & drawings | services/data/src/aec_data/ |
R38-ARRAY-LIVE ③ · R21-4D-CLASH · R28-BUNDLE ② — the three that landed in PRs #176/#178/#179 on 2026-08-02 (R28-ICDD, R23-STOREY-LOD, R28-UNIFY) are shipped and pending archive. Corrected 2026-08-06: this read "all SHIPPED and MERGED", which was false for 8 of the 11 codes beside it — SEC-PLUGIN-SANDBOX is ◧ with its setrlimit half explicitly REFUSED, R38-SYNC-VIEW and R21-4D-CLASH are ◧, and five carry no marker at all. A row-level word like "all" has no defined scope, so it drifts the moment the row grows; the item markers are the authority and this sentence is not. Three carried defects a post-merge review then found, all fixed v0.3.843: the array editor repositioned nothing on a pitch change, the ICDD writer left a truncated container when it refused, and the guided cut dropped linework silently. Merged is not verified — that is the argument for the review pass, not against it. |
| E · Authoring feel & viewer | apps/web/src/viewer/, inference.ts |
A29-GUIDE-UNDERLAY ③ (in flight, PR #199) · R28-VIEWER ④ · R36-VIEWER-SUBAPP (the remaining half of the rail arc — the canvas must switch 2D/3D in place, including PRINT) · R38-SYNC-VIEW ③ (mostly built; only cursor sync left) · R38-SOLVER-LOCKS ③ · R23-BATCH-OVERLAYS · R39-VIEWER-OBS ② · R39-DECOMP-VIEWER ③ (ratchet pinned; seams measured — see entry) · R38-SYNC-SELECT ③ (SHIPPED v0.3.829, pending archive) · R41-MODEL-ALIGN · R43-VIEWER-CONFORMANCE |
| F · Docs & demo | README.md, docs/, apps/web/src/demo/ |
keep the shipped surface honest (below) — no coded items. demoData.test.ts now gates the shell's startup endpoints; re-run build_demo_data.py and that test after adding one |
| G · API surface | services/api/src/aec_api/routers/, main.py |
no standalone items: every lane routes its own work, which is why this is a lane rather than a shared file |
| H · Registers | services/api/modules/*/module.json |
— |
| I · API client | apps/web/src/api/ |
SCALE-SEAM ⑧ |
| J · Build & tooling | apps/web/scripts/, apps/web/vite.config.ts, apps/web/src/style.css, services/api/test_file_sizes.py, services/api/run_tests.py |
BUILD-WORKTREE-CHUNKS (lane added 2026-08-06 — three sessions in one day flagged a path belonging to no lane: services/api/test_file_sizes.py, apps/web/src/style.css, and the build scripts. Each flagged it correctly and then had to edit it anyway. An unowned shared path is not neutral ground; it is a collision nobody is watching for) · R41-BUNDLER-SPLIT |
Parked — not available to pick up. These are decisions or multi-release commitments, listed so nobody starts one thinking it is a sprint item: QUALITY-ROOM · R26-V-TIMING · R24-PERSONA-SHAPE · R24-IDENTITY · R32-TAXONOMY-LIFECYCLE (all five need the user's call) · PHOTO-PIN · CMMS-OPS (BIG-TICKET: open one, slice it) · REL-7 (gated on RT-KNIP) · R35-SANDBOX-ISOLATION (process/container isolation for snippet execution — a genuine design change, needs the user's call on deployment shape) · R35-PREFLIGHT-CI (run the prod-config validator against the actual deploy overlay in CI — still needs a decision on where the deploy env template lives. Split 2026-08-02: the half that needs NO decision — smoke the validator against a synthetic safe posture → exit 0 and an unsafe one → exit 1, catching a validator crash, a check regressed to a no-op, or a FAIL demoted — is unparked as a ~10-line CI step; the security session has claimed it).
A fourth was wrong until 2026-08-07, and it is wrong in the way the table could not see.
roadmapLanes.test.ts asserts the rows are disjoint — no two lanes claim the same path — and it
is right to. But disjointness and coverage are two different claims, and only the first was
tested. A table that is perfectly disjoint and owns 62% of the tree passes every assertion, and the
rest is invisible: not contested, simply unclaimed.
Measured: 152 of 390 tracked files under apps/web/src belonged to no lane — 53 once vendored code
and ambient type declarations are set aside. The unowned set includes proforma/, drawings/,
kernel/, pins/, studio/, tools/, tree/, connections/, account/, deploy/, and the loose
files in portal/ that are neither portal.ts (A) nor panels//register/ (B) — offlineQueue.ts,
prefs.ts, panelContext.ts, sitting between the two lanes least likely to be watching each other.
That is the same shape as the 2026-07-30 nested overlap, one level up.
This is not theoretical: Lane J's own note records three sessions in one day hitting a path belonging
to no lane, and on 2026-08-07 a real defect in apps/web/src/proforma/proforma.ts — a reserve
contribution presented as a recommendation without reading the flag that says whether it verified —
had to be fixed under a one-change lane assignment because there was no row to point at.
It is now a ratchet rather than a proposal. roadmapLanes.test.ts counts unowned files against a
ceiling that only ever goes down, and the way down is adding a row here. Rows still to agree:
proforma/ · drawings/ · kernel/ · pins/ · studio/ · tools/ · tree/ · the loose portal/
files · tooling/ + dev/ (probably J) · account/ · connections/ · deploy/.
CORRECTED 2026-08-07, and the correction found a real overlap the original claim would not have.
This said the disjointness check "compares the strings raw", so field/ and apps/web/src/field/ in
two lanes would read as disjoint. That was false — the check already stripped apps/web/src/, and
planting exactly that duplicate proved it caught it, naming both lanes.
The real gap was the asymmetry: it normalised one prefix, apps/web/src/, so it was blind on the
services side — and that is where a live overlap was sitting. Lane G claims bare main.py, which is
services/api/src/aec_api/main.py; Lane C claims services/api/src/aec_api/ and carved out only
routers/. The FastAPI entry point belonged to two lanes at once, and those two strings never
compared. main.py is now carved out of C, and the check resolves every claim to a repo-relative path
against git ls-files instead of stripping one hard-coded prefix.
Two lane boundaries were wrong until 2026-07-30 and are worth naming. Lane A used to own
apps/web/src/portal/ wholesale while B owned portal/panels/ — a nested overlap, so the two lanes
least likely to notice each other shared a directory. And routers/ sat inside C's path with no owner
of its own, which is how a route can be added twice. A lane table whose paths overlap is not a lane
table; the new roadmapLanes.test.ts asserts disjointness so this cannot come back.
A third was wrong until 2026-08-03, and it is a different KIND of wrong — worth more than the other
two. Lane A owned portal/portal.ts; Lane B owned the register-shaped items. Those paths are
perfectly disjoint and roadmapLanes.test.ts was perfectly green, because the generic register
renderer — the config-driven engine behind every register's list, filters, table, form, record and
board — lived inside portal.ts. So every Lane B item aimed at a register had to edit a Lane A file
to do its work, and the table said nothing was wrong.
Three items hit it before anyone named it. R36-EMPTY-STATE shipped that way (v0.3.849) and avoided a
collision only because the Lane A session happened to be elsewhere that hour. R24-MONO-DATA gave up
on its portal.ts hunk and left the reason in ui/monoData.test.ts — "held by another session's
in-flight work". R24-DENSITY ②, whose whole point is "applied to registers, not just the
dashboards", was pointed at the same wall.
The lane check cannot see this class of defect and no version of it can. It asserts a property of paths; this is a mismatch between a path and the work. Two fixes were available and only one was honest:
- Move the register items to Lane A. Rejected — it relabels the collision instead of removing
it.
R36-EMPTY-STATEgenuinely editedui/empty.tsandportal.ts; under Lane A it would have straddled in the other direction. While one file holds both jobs there is no assignment of the items that makes them stop straddling. - Make the boundary statable. A carve-out is written
!pathand a path is a file, so "the register methods ofportal.ts" cannot be expressed at all — and §4 of the directions is explicit that a prose exclusion is not a boundary. So the renderer became a file:apps/web/src/portal/register/belongs to Lane B, andportal.tsis the shell (nav rail, room spine, dashboards, destination dispatch) and stays Lane A's. Shipped v0.3.850; a behaviour-preserving move, 1149/1149 web green.
Two checks hold it. roadmapLanes.test.ts now asserts the two paths are owned by different lanes —
the failure that matters is someone dropping portal/register/ and leaving it unowned. And
portal/register/registerOwnership.test.ts asserts the code side: no register internal may reappear
in portal.ts, and the six members the shell reaches through are enumerated, because a seam holds
only while crossing it is inconvenient and this.reg. is not inconvenient at all.
The general lesson, and it is not about registers. A lane's paths and a lane's items are two claims, and only the first one is tested. Before starting an item, check that the file you will actually edit is inside your lane — the table's own greenness is not evidence of that.
Unowned paths — found while fixing the above, 2026-08-03. NOT decided here. The lane check asserts
that lanes do not overlap; nothing asserts they cover, and they do not. apps/web/src/drawings/,
proforma/, studio/, tools/, tree/, pins/, kernel/, account/, connections/ and the
portal/ root files (prefs.ts, offlineQueue.ts, panelContext.ts) belong to no lane, which the
carve-out check in roadmapLanes.test.ts correctly calls "editable by everyone" when it happens
deliberately. The live case: R23-SYMBOL-COUNT and R38-SHEET-MARKUP ③ are Lane B and land in
apps/web/src/drawings/, while R36-DRAWINGS-RETURN is Lane A and lands there too — so drawings/
is contested by two lanes right now, the same shape as the register with the extra twist that nobody
owns it. It needs its own premise-check and possibly the same answer. It is deliberately left open:
guessing an owner for a directory two lanes are already aimed at is how the register problem was made.
Shared files that need a heads-up before editing. Every multi-session conflict so far has been one
of these: services/api/run_tests.py · services/api/src/aec_api/main.py · docs/roadmap.md ·
CHANGELOG.md · the three version files (apps/web/package.json, src-tauri/tauri.conf.json, and
package-lock.json — which is regenerated, never hand-edited).
apps/web/src/api/ is a lane now rather than a shared file. Until v0.3.800 it was one 4,956-line
client.ts that every lane had to open, which made it the single worst collision point in the repo.
SCALE-SEAM split it by domain (schedule.ts, model.ts, modules.ts, estimate.ts, authoring.ts,
library.ts over httpCore.ts), so adding an endpoint now touches the one domain file that matches
it. client.ts itself is composition only — if your change adds a line there, say so.
Unglamorous and the most consistently wrong thing in the repo: the docs describe an older app.
Three staleness bugs were fixed on 2026-07-29 alone — the README claiming ?shell=spine turned the
shell on (default for 50 releases by then), the roadmap status block quoting suite counts 38 releases
old, and both README and roadmap still offering a ?shell=classic opt-out that had been deleted. Each
was caught by accident rather than by a gate.
Re-measured 2026-07-29 after v0.3.786/796 — two of the three items below were fixed while this section still described them as broken. That is the same defect the section is about, one level up: a list of stale claims that had itself gone stale. Measured, not assumed:
-
The Pages demo snapshot is CURRENT. ✅ Fixed in
90783da7(v0.3.786), which addedgrab(c, "/rooms")tobuild_demo_data.pyand re-captured.demoData.jsonnow carries 133 modules across the populated rooms —design32 ·schedule38 ·planning25 ·cost18 ·operate12 ·deal8 — andGET /roomsis in the capture for the first time.workis absent because it legitimately holds 0 modules, not because it is missing. The previous entry's "132 modules across exactly four rooms, 38 pointing at a room id the shell cannot render" is no longer true of any file in the repo. The root cause is worth keeping even though the symptom is gone:/roomswas never in the crawl at all, so the demo rail always rendered fromFALLBACK_ROOMS— and the fallback drew something plausible, which is why a taxonomy change rotted it silently. See [[the-dangerous-default-is-the-plausible-one]]. -
docs/walkthrough.mdandREADME.mdcarry no stale shell flag. ✅ Neither mentions?shell=spineor?shell=classicat all (grep: 0 occurrences each). The walkthrough names rooms (10×), the vitals strip (2×),.mass(3×) and samples (4×); the README names rooms (11×), received-sheet regions and firm standards. The R26-era gap this entry described has been closed by the doc gates plus ordinary release notes. -
✅ CLOSED 2026-08-01 (v0.3.815). The walkthrough was read end-to-end against the seven-room spine, and the gap was worse than "unread": the existing membership gate is satisfied by a single headline enumeration, and that is exactly what the doc had. Measured,
planning,workandoperateappeared once each — in the room list — and nowhere else, whilecostappeared eleven times. Three of seven rooms were named and never explained: the doc-level form of a tab that highlights but does not navigate. The click-through now visits Planning, Work and Operate with their shipped job statements.Both missing gates are now in
docsCurrent.test.ts, mutation-checked: room ORDER (a doc that lists the rooms must list them inROOM_IDSorder) and room SUBSTANCE (every room must appear in the walkthrough beyond the enumeration). The order gate's first draft used a proximity window and failed on correct prose — it read the click-through's route ("Schedule → Budget … back in Design … the Cost room") as a claim about tab order. It now matches only verb-free lists, because a gate answered by rewriting good writing is worse than no gate.
docsCurrent.test.ts gates 10 assertions across README.md and docs/walkthrough.md — shell
flags in both directions, every room appearing in the README, the retired three-tab nav, the vitals
strip, what a sample is, deleted samples, and rooms the product does not have. That is more than
"a handful", and it is why two of the three items above closed themselves. It should still gate
more: every doc sentence naming a flag, a count or a room is a claim that can rot, and the ones
that rotted were all sentences no test read. Note for whoever extends it — the file lives in
apps/web/src/shell/, which is Lane A, not Lane F.
- CC0-1.0 on the permitted licence list. We already ship 59 CC0 files under
services/data/families/external/, andmanifest.jsonrecords the licence in four places — so the written rule is narrower than the shipped reality. CC0 is a public-domain dedication, strictly more permissive than MIT. The recommendation is to add it; widening an allowlist is not ours to do. massingviservs modelmaker — which is the platform? Its own description is a federated AEC platform in pure Python with a plugin kernel. MassingViewer raised this because both are about to grow a federation manager, and two federation managers is the expensive version of this question.ROOM-NAMING— settled 2026-07-29 (v0.3.779): professional terms. Design · Planning · Cost · Schedule · Deal · Work, not the prototype's Building · Budget · Timeline · Money · My to-do. These are the words the work already has — an architect issues a design, a contractor runs a schedule, a developer works a deal. A plain label reads friendlier until someone must decide whether "Money" is the budget, the commitment, the pay app or the equity draw, at which point it is a second vocabulary on top of the real one. The reasoning lives inrooms.py, androomNames.test.tsasserts the Python and TypeScript tables agree label-for-label — they had been duplicated across the language boundary with nothing checking them, and the TS copy is what renders when/roomsfails.Delete— done v0.3.779. Default since v0.3.715, deleted sixty-four releases later. Two shells is two rails to keep in step, two places a defect can hide, and a render audit whose verdict depends on which one it measured — a mistake this repo actually made. What the two-shell period bought is kept:?shell=classicparity.teststill asserts the room rail reaches every destination the lifecycle-stage catalog lists, so nothing became unreachable outlives the shell that motivated it.- QUALITY-ROOM — inspections/ITP sit in Design because an inspection checks the built thing
against the design. Answered 2026-07-28: the task already reaches Work via the queue
(
INS-001,DEF-001,NCR-001are in it now), so the register stays with its discipline. Revisit only if that stops feeling right in use. - Branch protection —
mainis unprotected and public. Blocking force-push and deletion costs nothing and is not reversible after an accident. - R26-V-TIMING — needs real users. Plugin pricing — needs customers. Both correctly parked.
Two third-party analyses were reviewed in full. Neither produced work worth doing, and the reason is worth recording so the next person does not re-run the exercise or, worse, act on the numbers.
CodeFlow — REMOVED by the user, 2026-07-30. Decision made; nothing here is actionable any more.
Kept as a record of why, because the failure mode is generic to bolt-on analysers and the next
one will look the same. The measured history: 0 successes across 30 main commits and all 10
PRs, every one reporting "CodeFlow was not able to perform analysis" — the analyser never ran.
- Its own report says "Processing of this commit timed out" and "Tool timed out: pylint". The commit it was asked to analyse was never fully analysed.
- Its 2,350 issues are dominated by TSLint-era rules:
max-line-length960 ·CodeDuplication526 ·jsdoc-format402 ·one-variable-per-declaration200 ·no-shadowed-variable78 ·no-bitwise18 ·variable-name7 ·max-classes-per-file5. TSLint was deprecated in 2019. This repo lints with ESLint 9.39.5 and ruff, both configured, both clean. So CodeFlow is not measuring our standards — it is measuring its defaults, and disagreeing with the linters we actually chose. - Its one structural finding is wrong: "Avoid storing generated files in GIT
(apps/web/src-tauri/Cargo.lock)". Rust's own guidance is to commit
Cargo.lockfor binaries, and a Tauri desktop app is a binary. CI already guards it withcargo metadata --locked. - It failed on every PR, and that is the durable lesson. A permanently-red check is worse than no
check: it teaches everyone to scroll past red, which is how a real failure gets waved through. It
nearly did here — five PRs read as failing when only #98 carried a genuine CodeQL HIGH. It also
saturated the aggregate signal: GitHub's legacy commit-status
stateis the OR of its contexts, so while CodeFlow sat there red,statewas pinned atfailureand a new status context going red would have been invisible. A monitor was changed to report contexts individually rather than trust the aggregate — worth keeping if another external checker is ever added.
Repowise — reading a snapshot 426 releases old.
It has indexed f3b171f = v0.3.363; main is v0.3.789. Every count it reports (881 files, 6,587
symbols, 2,771 findings, 45 dead exports) describes a codebase that no longer exists. Same failure as
the demo snapshot: a capture rots and nothing fails when it does. Re-index before quoting any figure.
UPDATE 2026-07-30 — re-indexed and now CURRENT, and the verdict changes shape. Connected as an MCP
connector and re-indexed: 1,488 files / 12,064 symbols (was 881 / 6,587). Freshness confirmed the only
way that can't be fooled — create_stepup_token, a symbol ~24 h old, resolves. So the staleness
objection above is closed.
The findings still do not survive verification, but for a different and more useful reason: three systematic confounds, each checked against this repo rather than argued:
| layer | what it reports | why it is wrong here |
|---|---|---|
untested_hotspot (64) |
has_test_file: false on main.py, models.py, db.py — and on run_tests.py |
looks for a paired test file; our Python tests live at services/api/test_*.py, not beside the source. proforma.ts correctly reports true, so the heuristic works for TS only |
dead_code (8, all safe_to_delete: true) |
incl. plugins/example-wall-brand/plugin.py::register |
that IS the plugin contract — plugin_registry.py:143 does mod.register(PluginApi(...)) after a hasattr check. The editor-bridge recipes are dispatched by name via f"recipes.{recipe}(...)" at bridge.py:70. Every one sits at a dynamic-dispatch boundary the analyser cannot see |
import cycles (3) |
20 files in aec_api, 15 in aec_data, 1 TS pair |
test_import_cycles.py passes: no top-level cycles across 493 modules. It counts deferred/function-local imports — which are the fix for cycles. The TS pair is import type on both sides, erased at compile time |
Do not action any of those three layers. Acting on the dead-code list would delete the plugin API.
What IS worth reading: get_risk in PR-review mode. Its missing_cochanges correctly caught that a
change touching main.py had not updated CHANGELOG.md or apps/web/package.json — the version bump
this very release then made. Its defect_profile also flags routers/drawings.py as a bug_magnet
(4 fixes / 6 months, naming pdf_seal), which matches where the v0.3.807 findings actually were. The
history-derived layers are sound; the static-analysis layers are not.
get_security (CVEs, secrets, SBOM) is Pro-gated and returns upgrade_required — unavailable, not
empty. Do not read a missing security section as a clean one.
Its "45 dead exports" does not survive verification, and that is the useful part of this review. Re-derived against current code: 1,097 exported symbols, 231 referenced nowhere outside their own file — but split by kind that is 153 interfaces + 45 types + 17 consts + 16 functions. Dead types are not dead code; they are a client's published shape. And of the 16 functions, the alarming-looking ones were checked by hand and are all called inside their own module:
| candidate | actually called at |
|---|---|
startTour |
ui/onboarding.ts:25 and :125 |
showUpdateBanner |
ui/update.ts:74 |
renderCostSpine |
portal/panels/margin.ts:115 |
readEntry |
api/recordCache.ts:105 |
So there is no unreachable feature — the finding is an unnecessary export keyword, which is a
tidy, not a defect. Roughly seven more sit in vendor/massingifc and vendor/massingpdf, where a
library's public API is supposed to look unused from inside this repo.
Left undone deliberately: dropping ~20 stray export keywords is churn in the highest-churn files in
the repo, and would collide with three active lanes for no behavioural gain.
What Repowise did contribute — independent confirmation of the hotspots, mined from git history rather than from the code:
| file | percentile | prior fixes | commits 90d |
|---|---|---|---|
portal/portal.ts |
99.9th | 4 | 143 |
viewer/app.ts |
99.7th | 7 | 161 |
main.ts |
99.6th | 12 | 136 |
api/client.ts |
99.4th | 8 | 325 |
services/data/src/aec_data/edit.py |
99.3th | 5 | 59 |
That is exactly the god-file list already tracked in [[web-godfile-decomposition]] and [[god-module-decomposition]], and exactly the collision set named in the NOW lanes. An outside tool reaching the same five files by a different method is worth more than the finding itself — it is evidence the decomposition plan is aimed at the right place. Its "bus factor 1" flag is an artifact, not a risk: every commit here is authored by one identity whether a human or an agent wrote it.
Three sections in this file were wrong about their own state on 2026-07-28: A2-CONSTRAINTS,
A2-SHEET-REGIONS and A2-ICON-RENDER were all listed as built-but-unrouted, and all three are
routed and consumed (analysis.py:542, analysis.py:572 + a live 200, toolbarView.ts:15). The
roadmap is a claim like any other. Check the premise before spending a release on it — see
[[check-the-blocker-premise]].
2026-07-29 — the shape those errors take, now that there are enough of them to name. Six R22/R23
items were implemented in one day and two of the six had premises that did not survive contact,
both of the same form: "it already is X, just formalise it." R23-RECIPE-ARTIFACT said the edit
log already was a CAD operation timeline (the parameters were nowhere). R22-ENTITLE-RISK read as
"add two inputs" (Timing had no pre-construction period and monte_carlo could not express a
binary event — there was nowhere to put either). The same day, three more entries were open in prose
and closed in code: A2 above, R22-GOLDEN-THREAD, and the IfcClass half of classify assist.
So: "it already is X, just formalise it" is the most expensive sentence in this file, because it sets the estimate before anyone opens the file. It reads as a small item, is written by whoever last skimmed the area, and is never re-checked precisely because it sounds like the check already happened. Two rules follow — open the file before accepting an estimate that rests on an "already", and when a premise turns out wrong, correct the entry rather than only the code, or the next reader inherits the same wrong estimate.
A second, sharper pattern from the same day: a roadmap entry that describes a visible failure
often conceals a silent one, and the silent one is worse. R22-CLASSIFY-AI said an unclassified
import "gets nothing" — it actually prices everything as 01 00 00 General Requirements while
reporting a complete takeoff. Same shape as [[qto-measured-area]] and the entitlement draw that must
not be solved: a fabricated value survives review that a missing one would not, because nothing
downstream can tell. When an entry claims a feature is absent, check whether it is instead wrong and
confident.
🧩 R43 — SIBLING INTEGRATION & THE UI-STRATEGY RING (measured 2026-08-09/10; see plan-2026-08-10.md)
The framework question that opened this ring is closed, and the answer was neither candidate —
not React (the facade we would consume, @massing/embed, has zero React dependency and an external
runtime closure of exactly three), and not Reflex (it compiles to React + Next.js, needs Redis for
per-session state in production, and round-trips every interaction, which the 60fps canvas cannot do
and the offline non-negotiable forbids). What survived the analysis is the measured part, and it is
the only item here with real scope.
-
⛔️ R43-CRUD-FRAGMENTS — RESCOPED 2026-08-11 before any code was written; as originally written it was not executable. The entry said "do ONE register first with a before/after and let that number decide whether the others follow". There is no one register.
apps/web/src/portal/register/ register.tsis a single generic 2,546-lineRegisterUI, schema-driven, with zero per-module branches, serving 206module.jsonmodules. Converting "a register" converts all 206 at once.That is a different risk profile from the one the ring was approved on: not an incremental trial that a measurement can halt, but a single swap across every register in the product, with no intermediate state to compare against. The measurement the entry asks for cannot be taken, and the plan's own logic — let the number decide — has nothing to decide with.
The LOC split it rested on is confirmed, re-measured recursively:
portal11,123 +proforma1,891 = 13,014 of 46,693 hand-written lines (27%), againstviewer+drawings16,904 (36%). So the thesis stands — the register third is thin over FastAPI JSON and is where server-side composition would pay. What does not stand is the delivery plan.Before this restarts, someone must answer: what is the smallest reversible slice? Candidates, none costed: one field TYPE rather than one module; the read-only table only, leaving inline edit on the client; or one module behind a per-module flag the generic renderer honours — which is itself a change to the generic renderer. Until one of those is scoped, this is a plan, not a task.
Method note, because it nearly went the other way: a re-check of the 13k figure using
portal/*.tsreturned 1,673 and I almost published a correction saying the ring rested on a 3.6× error. The glob is non-recursive and caught 10 of 43 files —portal/panels/andportal/register/are subdirectories. The re-check was sloppier than the thing it checked, and a wrong correction is worse than a wrong original because it arrives wearing a verification badge. -
◧ R43-MASSINGBILL-CORE (M — Lane C; money half CLOSED v0.3.969, and not the way this entry said) —
The premise below was stale by the time it was acted on, and checking it found a live defect. This entry recommends taking "core/money.py" to fix a float path in
payapp.py. v0.3.927 had already replaced that path with exactDecimalquantized HALF-UP. But it fixed one file: the other G702 sites this entry itself names keptround(amount * pct / 100, 2)on floats —cost.pytwice,routers/cost.pyonce — andround()is ROUND_HALF_EVEN where an invoice rounds half away from zero. Four of six sampled cases differed by a penny (2.50 @ 5% → 0.12 vs 0.13). Two conventions inside one G702; a pay app out by a penny is rejected.Closed by pointing the three float sites at
services/api/src/aec_api/money.py, which has carried the half-up quantize since v0.3.191 — additive,payapp.pyuntouched, the five existing G702 suites as the parity gate, andservices/api/test_money_spine.pypinning both the convergence and the divergence it replaced.Neither "massingbill/core/money.py" nor a new module was adopted (plain quotes: it is upstream's file, and a backtick reads as a citation into this tree). The correct implementation was already here; three sites had simply never been pointed at it. Integer cents is not more correct than quantized
Decimal, it is a different correct answer, and swapping one for the other on a shipped billing path is risk without reward.The requisition half is untouched and still needs the per-site decision described below.
-
◧ R43-MASSINGBILL-CORE — the original review (kit reviewed 2026-08-10 at their pin
3af9124c) — the core now exists and is MIT; their "pure addition" claim does NOT survive checking. They shippedmassingbill/core/— four stdlib-only modules (money,retainage,requisition,enums) with a CI job that pip-installs nothing at all, so "zero deps" is measured rather than asserted. Licence read from theirLICENSEfile: MIT.Verified on our side, and this is the part that changes the plan. Their message said there is "no G702 header math, retainage engine or change-order handling on your side to supersede". Both halves of that are wrong:
payapp.py:49computesamt * retainage_pct / 100.0— retainage arithmetic, in floats, on billable amounts — andG702appears in six of our files (cost.py,report.py,rooms.py,routers/closeout.py,routers/cost.py,routers/proforma.py). So the requisition half is not an addition, it is a potential duality, which is the shape that produced "two objects both called an RFI". It needs a per-site decision, not a drop-in.What they got right, confirmed:
payapp.py:23is a singlefloat(str(v).replace(",", "") .replace("$", ""))that every billed amount flows through, and summing a 200-line schedule that way drifts.sov_build.pyderives an SOV from a model estimate and theirs never does, so that one is genuinely ours and they compose.Recommended split (the user's call, see the decisions section): take "core/money.py" first and fix the float path alone, with the existing G702 suite as the parity gate — the new code must reproduce the old numbers before it replaces them. Treat the requisition half as its own ring once the six G702 sites are mapped.
Their environment claim was also wrong in our favour to catch: they assumed we run 3.12. CI does; the api venv is 3.10.6, where
from enum import StrEnumraises. Their guard commit is the difference between a clear message and a baffling one on the first local run. -
✅ R43-PLAN-DRIFT (S — Lane C; local half v0.3.943, cadence DECIDED + shipped v0.3.944) — pin cadence for the vendored massingplan engine. What does not exist is a way to notice when the pin stops being correct. The decision outstanding is cadence, not correctness.
The pin named here was stale, which is the item's own failure mode. This read
155640a7;VENDOR.mdrecords the tree moved tob703dca4on 2026-08-11 and says so explicitly — "was155640a7, 2026-08-10". A pin copied into prose drifted from the pin in the tree, and nothing noticed. Corrected 2026-08-13.CADENCE DECIDED v0.3.944: weekly, non-blocking, one reused issue.
.github/workflows/vendor-drift.ymlrunsservices/api/vendor_drift.pyon a Monday schedule and opens/updates a single issue; it never fails a build, because upstream moving is not a defect in this tree and a red nobody can fix by editing our code teaches people to ignore red. Weekly rather than daily because the pin moves a few times a month and noise is how a notification stops being read. The issue is closed again when the pin catches up, so it does not become furniture.UNKNOWN is a first-class verdict. A failed upstream query reports "could not tell", never "no drift" — otherwise a job that has silently stopped working posts good news every Monday forever.
services/api/test_vendor_drift.pyproves all three verdicts are reachable and that the exit code stays 0 when drifted.read_pinis single-sourced invendor_drift.pyand imported by the local gate, so the two checks cannot disagree about which commit VENDOR.md names.The half that was never blocked is a test. Has upstream moved? needs a cadence, a network and a decision. Has anyone edited the copy HERE? needs none of those — only the recipe VENDOR.md already writes down.
services/api/test_massingplan_vendor.pyexecutes that recipe, comparing against the digest parsed from VENDOR.md rather than a second copy in the test, so a re-sync updates one file and the gate follows. It also holds the stdlib-only contract the whole adoption rests on. Mutation-checked three ways: a local edit, a digest not updated after a re-sync, and a third-party import intocore— the last caught independently by two checks.VENDOR.md earned it. Its own words about the previous, unreproducible digest: "a recorded verification value nobody can recompute is not a verification, it is a decoration." A recipe stated in prose and never executed is one revision from being decoration again.
Measured against an actual issued wall-section + detail package (13 sheets, 1:100 → 1:10) rather than against a description of one. The mission is acquisition → turnover at LOD 500, and LOD 500 is field-verified as-built — but a project only reaches verification through an issuable LOD 400 set. These are the gaps between what the platform draws today and what that package contains.
Tier 1 — the set cannot be issued without these
-
◧ R21-4D-CLASH (phase 1 shipped v0.3.682; install-before-support still open) — sequence clash: two trades occupying one space in the same schedule window, or an install ordered before its support. The 4D timeline and CPM both exist; this reads them together.
Blocker retired (2026-08-02): the "no task→element binding" prerequisite was FIXED by R25-TASK-BIND (element_guids reaches analyze(); bound_activities counts it in
services/api/src/aec_api/sequence_clash.py) — this prose outlived the code. Phase 2 SHIPPED same day:services/data/src/aec_data/support_graph.pyreports what the IFC states, graded by what it licenses (connected/assembly/structural; direction only from an analysis model), and deliberately refuses to infer support from geometry — a column and beam that touch but are unrelated produce zero edges. No relations returns stated:false — absent data, not absent conflicts. -
R21-MULTISCALE(S) — several viewports at different scales on one sheet (1:100 overall + 1:50 parts), each with its own title/scale block.sheet_layout.pycomposes viewports; per-viewport scale is the missing parameter.
The finding that orders this ring: across every platform scanned — agent bureaus, procurement AI, document intelligence, field capture, ERP, and the category leader's twenty-agent library — not one competitor's AI touches geometry. They all read documents about the building. Massing's agents can read the building. Items marked ⭐ are the ones that convert that into product; the rest are table stakes we are missing.
Read every entry in this ring against the code before sizing it. Four in a row on 2026-07-29 turned out to be wrong in the same direction — the machinery existed and only its reach was missing (R22-MEMORY: cross-project cost-code distributions existed, per-unit did not. R22-CARBON-OPTION: three carbon paths existed, the option card had none), or nothing was missing at all (R22-ACCT-SEAM: the whole seam shipped, including an exact double-entry assertion; the correct deliverable was reporting that, not adding a redundant gate). The entries over-estimate because they were written from a competitive scan rather than from the files, and they are never re-read precisely because they sound like the check already happened. An entry that says "build X" and means "extend X by 20%" costs more than an entry that says nothing — a named prerequisite is not evidence of a missing one.
Tier 1 — closes the mission's own gaps
-
◧ R22-ENTITLEMENT (M/L — ①② shipped:
approval_conditions.py,condition_checks.py) — permit & entitlement workflow: jurisdiction submittal packages, review cycles, comment responses, and conditions of approval carried into the model as constraints. Today there is a hole between "acquisition" and "construction" in our own mission statement — we underwrite the deal and we build it, and nothing spans approval.⚠️ Two name collisions sit on this item; gap-check on SEMANTICS before touching it.tiers.pyis subscription tiers (free/pro/enterprise), nothing to do with land use — it was "entitlements.py" until v0.3.847 renamed the two squatters so only the land-use register keeps the word. The warning below is kept because it is what made the collision findable, not because it is still live.proforma/approval_risk.pyis genuinely adjacent — but it scores risk, it does not run a submittal workflow, so it neither closes this nor is irrelevant to it. A name-based sweep gets this item wrong in both directions:tiers.pymakes it look shipped, and stopping there means never noticingapproval_risk.py, which the eventual build should probably feed. Third collision found on 2026-07-31, afterreport_builders/(five hardcoded builders, not the no-code builder R22-REPORT-BUILDER describes). -
◧ R22-AGENT-PACKS (M —
agent_packs.pyshipped; audit half CLOSED 2026-08-06; console is Lane A/E) — named agent packs + org "Skills" + a governance console over the MCP layer we already ship. We expose raw capability; the market ships "Submittal Review Agent", which a superintendent understands. Pure packaging of existing tools, plus per-run audit logging — the gating factor for enterprise adoption. Our version reads the IFC, so a submittal check can test the submitted product against the element's specified properties rather than against a PDF.The audit half is done, and the last gap was in the caller, not the callee.
dispatchaudits every run, success and failure — but it can only record the identity it is given, and the stdio transport (services/api/mcp_server.py, the one path every real agent run takes) passed none. Every row read actormcp, no user, no pack: the trail answered which tools ran and not whose agent ran them, which is the half an enterprise actually asks for, both before granting access and after an incident.test_agent_packs.pyasserted attribution "to the effective user, not the transport" and passed the whole time, because the test supplied a user the transport never did — a test that provides a caller's arguments cannot notice the caller omitting them. The transport now readsAEC_MCP_USER/AEC_MCP_ACTOR/AEC_MCP_PACK, warns at startup when runs will be unattributable rather than recording them silently, and refuses to invent a person-shaped default (an unverified name in an audit trail is worse than an honest "the transport ran this").services/api/test_mcp_attribution.pyasserts the call site forwards all three, mutation-checked against the original defect. What remains is the governance console itself — Lane A/E, not C.
Tier 2 — evidence, provenance and procurement
-
◧ R22-PROVENANCE (L — assumptions + estimate legs done; ANSWERS leg is the remainder) — cite to file, page and revision. Every proforma assumption, estimate line and agent answer traceable to a source page. Three of thirteen platforms lead with this; it is what makes AI output admissible in an IC memo or a claim.
Estimate leg closed 2026-08-06.
provenance_reportalready said exactly what was missing, in code rather than prose: theestimateregister stored line items as code/description/qty/unit/ unit_cost/amount and captured nosource,quote_reforbasis_date, soboe_ledgercould only run on lines posted in a request body. Those three columns now exist andfrom_projectgathers the leg from stored records. The bug that mattered was the seam, not the columns.boe_ledgerreadscost_codeandtotal; the register writescodeandamount. Passing the rows over unmapped does not raise —_key()falls through todescription, so every line still gets a key,cost_codereturns None on every row, andtotalsilently drops to None for any line priced as a lump sum rather than qty x unit cost. That is a full, plausible, quietly-wrong ledger. The mapping is now stated in one place andservices/api/test_provenance_estimate_leg.pyasserts it againstboe_ledger's real output, not a fixture written to agree with it; mutation-checked by emptying the map (4 named FAILs). The ANSWERS leg staysnot_capturedand the verdict still cannot readadmissible— agent answers are not persisted at all (cited_answeris an in-flight contract with no store behind it), and a leg readingno_databecause nobody filled it in is a different problem from having nowhere to put it. A store of answered claims is the remaining schema change. -
R22-REPORT-BUILDER (M) — RESCOPED 2026-07-31; the original premise was false. The entry read "132 modules of structured data with no end-user query surface". There is one, and it is good: per-field filtering with operators (
?f.discipline=Structural&f.amount.gte=1000, capped atMAX_FILTERS = 12), field names validated against the module's declared fields in one place so the two cannot drift, calculated columns (qty * unit_cost), generic Excel/CSV import with preview, and saved views persisted server-side with saved-search alerts. A user can already filter, compute a column, save it and be alerted on it without an engineering ticket.The real remainder is the four things that separate a saved list from a report:
-
No aggregation over user-chosen fields. The only
group_byin the whole module path is internal and hardcoded toworkflow_state(modules_query.py:230,241). No count/sum/avg by discipline, trade or month. This is the substantive one. -
Single-module only.
SavedView.moduleis one string and nothing spans modules, so "RFIs against change orders by trade" is not expressible — and that is most of what a report is. -
SavedView.configis an unvalidated JSON blob, "filter/sort/column config" by docstring only. A saved view is whatever a client happened to write, so a schema change breaks views silently with no migration path. Same family asmodule.jsonhaving no capability key. -
Per-user, never shared —
useris part of the identity key, so a view cannot be a firm or project report. A builder whose output only its author can see is a personal filter. -
reports.REPORTSis a separate registry the saved-view layer knows nothing about. Reports already exist, with their own categories, rendered in their own panel — so a "report builder" that only grows the module query surface would ship a second way to make a report, sitting beside the one users already have. Unifying them, or deciding deliberately that they stay separate, is part of this item.This fifth line was missing from the four-item list above for a day, and how it was missed is the point: the gap-check read the module and saved-view layer thoroughly and never opened the report registry. The list was accurate about what it examined and incomplete about the item — the same failure this entry exists to correct, committed while correcting it.
reports.REPORTSis invisible to a module-layer sweep because it renders in a different panel, which is also how three of its categories came to name sections the product had retired (fixed separately). A completeness check has to ask what it did not look at, not only what it found.
Still (M), but a different (M): add aggregation and cross-module scope to the surface that exists, give
SavedView.configa schema, and let a view be shared. Building the entry as written would have rebuilt working filtering. Items 3 and 4 land inmodels.py/routers/modules.py— check the lane table before starting, that is not lane C's to take unilaterally. -
-
◧ R22-PIPELINE (M → S — premise-checked 2026-08-07; the backend is built, the remainder is mostly viz) — multi-site pipeline dashboard above the project workspace. Acquisition is a funnel, not a project.
PREMISE-CHECKED (no build). Both halves the entry describes already exist. The acquisition funnel is
deal_funnel.py— stage conversion, weighted value, cycle times, and adata_qualityguard that refuses to report a conversion rate off too few closed samples. The roll-up above the project workspace isGET /portfolio/executive: cross-project SPI, % complete, lookahead, late milestones, GMP / EAC / variance-at-completion, an overall status per project, portfolio totals and a status tally, plus the latest solved scenario's IRR/EM per project./portfolio/construction,/portfolio/prioritization,/wip/portfolioand/fca/portfoliosit beside it. Against the spec reference this entry cites, that already covers multi-project KPI strip, EVM SPI/CPI, milestone tracking and cost-by-project. Genuinely missing is three items, and two of them are not Lane C: a cross-project Gantt (schedule_viz.pyis per-project), a portfolio risk heat map, and resource allocation by department. The near-misses were checked rather than counted: the "heat" matches inelement_5d.pyandscan_deviation.pyare different domains (5D element heat, scan deviation), and thedepartmentmatches inrooms.pyandscope_clauses.pyare incidental rather than resourcing. So this is not an M of backend work. Size the resourcing engine on its own and route the two visualisation items to the lane that owns them.
A THIRD false blocker, and the biggest one. W10-9 dimensional constraints has sat gated for
months behind "planegcs, LGPL — sidecar-solved, baked to IFC". The licence survey confirms the hard
blocks (CAD_Sketcher and py-slvs/SolveSpace are GPL-3, correctly excluded) — but it also found
that we never needed a full geometric constraint solver. kiwisolver (Cassowary) is
Modified BSD-3, a ~60–100 KB prebuilt wheel, already a transitive dependency of matplotlib, and
its inequalities-plus-strengths model covers what BIM dimensional locks actually are: axis distance
locks, alignment, offsets, equal spacing of grids/columns/mullions, level-height chains, sill/head
heights — and clearance minimums as ≥ constraints, which wires straight into code-check.
Over-constrained models degrade gracefully and unsatisfiable constraints are named, which is the DOF
feedback the UX needs. The nonlinear tail (angles, tangency, arcs) is residuals through
scipy.optimize.least_squares (BSD-3, already present) — structurally what planegcs does inside.
W10-9 moves out of Gated and becomes ordinary work with a BSD-3 dependency.
Three gates disproved in one day — dev API, geometry stall, and now this. The lesson is now a rule: a gate is a hypothesis until someone tests it. See [[check-the-blocker-premise]].
Tier 1 — measure, then take the cheap wins. Every item below is unverifiable until R23-PERF-TEST exists: the repo has a 220 KB bundle budget and zero runtime perf assertions.
-
R23-CONSTRAINTS(L) — W10-9 via scipy'sleast_squares, which is already a dependency (services/api/requirements.in:27andservices/data/requirements.txt:8, bothscipy>=1.11). This entry said "via kiwisolver + least_squares" until 2026-07-29.kiwisolveris NOT a dependency of this repo — so the entry pointed at a package someone would have had to add, in a repo where a new dependency needs explicit sign-off, to get a solver scipy already provides. Checked because a named prerequisite is exactly the kind of claim that turns out to be a past reading rather than a property of the code. Dependency taken 2026-07-25.kiwisolveris Modified BSD-3 — squarely on the approved licence list — a ~60–100 KB prebuilt wheel, and already a transitive dependency of matplotlib, so it adds a declaration rather than new surface area. Trivially reversible. Proceeding on the standing delegation; flagged here so it can be objected to in one line. -
⛔️ R23-PICKING (M) — CLOSED UNBUILT 2026-07-31, on a measurement. Do not reopen without a new one. Raycast latency was measured directly on
loader.fragments.raycast()against a generated 35,030-element fixture (19× the densest sample), 300 samples after a discarded warm-up:n min p50 p90 p95 p99 max hits 143 0.7 1.6 3.0 3.8 4.8 5.4 ms misses 157 0.0 0.3 0.8 1.2 2.0 6.0 ms Single-digit ms across the entire distribution, p99 and max included. The 1500 ms fallback the prose justifies has ~250× headroom over the worst observed sample. Hits and misses are reported separately because they are different code paths and a mean would have hidden that misses are ~5× cheaper. The comment at
app.ts:389— "Normal raycasts answer in ms" — is correct, and now has a number behind it instead of a claim. GPU ID-buffer picking would optimise something that is not slow.Fixture is generated and LOCAL;
samples/*.ifcare gitignored, so the reproducible artefact is the recipe, not the file:generate_blank_ifc(storeys=20, storey_height=3.5), then per storey 250×edit_struct.add_wall+ 250×add_column→ 10,000 products / 35,030 local ids / 10.9 MB IFC → 1.1 MB frag. Generation is superlinear: 10,000 elements took 407 s where a linear extrapolation from 2,400-in-21.6 s predicted ~90 s. Budget from that, not from the linear estimate.Original premise correction, kept because it is why the item survived to be measured: The scan read the 1500 ms
Promise.raceatviewer/app.ts:337as "an admission that picking latency already hurts". The source says the opposite, in its own comment: the race guards against a stalled Fragments worker (hidden tab / heavy load) silently eating clicks, and states plainly that normal raycasts answer in ms. It is a resilience guard, not a latency workaround, and there is currently no measurement showing picking is slow at all. GPU ID-buffer picking (scissored 1×1 target, O(1) in polygon count) remains a real technique and three-mesh-bvh is present transitively (MIT) — but this is now gated on measuring raycast latency on a genuinely large model first. If the measurement does not justify it, the correct outcome is to close this item unbuilt. Fourth false premise found this session; see [[check-the-blocker-premise]]. Blocked on a fixture, checked 2026-07-31 — the measurement cannot currently be taken. Picking goes throughloader.fragments.raycast()(viewer/app.ts:391, not:337— the file moved), which is the Fragments runtime's own call, so only a live measurement answers this; a bareTHREE.Raycasterbenchmark would be a different reader answering a different question. Two things are missing: the dev API is down (curl :8093/health→000) and there is no genuinely large model anywhere in the repo — the biggest fragment set inpreview_storage/is 3.6 MB, against a gate reading "a genuinely large model".⚠️ CORRECTION 2026-07-31 — my own prescription here was wrong, and inverted. I first wrote that the fix was to convert one of the 50 MBsamples/*.ifc. Measured, that produces the second-smallest model in the repo. File size is anti-correlated with element count in these samples, because the 50 MB files are large as text, not as geometry:fixture elements IFC MB frag MB basichouse(the 50 MB one)154 50.3 3.6 school_str1,536 8.2 0.6 vertical_farm(densest)1,840 1.5 — generated, 10 storeys 2,401 2.5 0.3 basichouse.ifcwas converted rather than argued about: 52.7 MB → 3.6 MB frag in 7.0 s, i.e. exactly the size of the set we already had. Following the instruction lands you back where you started.So this is not blocked on converting a fixture; it is blocked on a fixture that does not exist. The repo maximum is 1,840 elements and picking needs 10k–100k+ — not within an order of magnitude. The fixture step is therefore generate, not convert, and must be specified in elements, never megabytes:
generate_blank_ifc+edit_structproduced 2,401 elements in 21.6 s, extrapolating to ~20k in roughly 3 minutes. Unlike the samples this is reproducible from a clean clone, sincesamples/*.ifcare gitignored.Worth measuring rather than closing, for one specific reason:
app.ts:389already asserts in a comment that "Normal raycasts answer in ms", and that prose is load-bearing — it justifies the 1500 ms timeout fallback. Replacing a prose performance claim with a number is precisely what this ring exists to do. If the number comes back single-digit ms, close the item unbuilt and keep the measurement. -
◧ R23-BATCH-OVERLAYS (S) — the instancing clause is CLOSED UNBUILT, with the measurement, and the sweep found a different defect in the same place. Following the precedent set by the item directly above: replace the prose claim with a number, and if the number does not justify the work, keep the number.
The premise was "app-authored overlays use zero instancing", which is true and turns out not to matter, because there is almost no population to instance. Enumerated every scene-object construction in app code — 31 sites across 11 files, all forms, not a sample — and of the five families named above:
named family what it actually is instanceable? pins DOM <div>s projected per frame (apps/web/src/pins/pins.ts)no — BatchedMeshcannot batch DOMgrid 1 Line+ 1Spriteper axis (apps/web/src/viewer/draft/gridOverlay.ts)the only real population snap markers · dimensions · clash markers no mesh overlay exists nothing to batch (unnamed) GIS context already hand-merged into 3 objects ( apps/web/src/viewer/gis.ts)already optimal Everything else — peer cursors, reference models, the guide underlay, the gizmos — is O(1) or O(peers). And where the population is real,
BatchedMeshis still the wrong tool: each grid bubble carries its own 64×64CanvasTextureand material, andBatchedMeshrequires a shared one. The correct fix there is a texture atlas or a cache, not instancing.What the measurement did find, in
gridOverlay.ts:set()rebuilds on every work-plane move, andclearMeshesdisposed geometry and material but not the texture — in three.jsMaterial.dispose()does not releasematerial.map. MeasuredAXES=8 → 8 textures made → 8 leaked after ONE rebuild, i.e. a GPU leak proportional to axes × elevation changes during ordinary authoring. Fixed by caching bubbles by tag (which also removes the re-rasterisation churn) and moving ownership todispose(). A third bug fell out:getContext("2d")!asserted a context that is null under happy-dom, which is why the file had no tests at all — the untestability and the defect had one cause.The FOV/FAR clause is now BUILT (
apps/web/src/viewer/cameraProfile.ts), and measuring it found a second defect the clause did not describe. The library constructs the camera asPerspectiveCamera(60, aspect, 1, 1e3)and nothing had revisited it:- A fixed VERTICAL fov gives a phone a third of the view a desktop gets — horizontal fov is derived from vertical and aspect, so portrait collapses it: 30° on a phone against 85° on desktop. Now derived from a target horizontal angle and clamped, so a phone gets ~46°. The clamp floor is 60° — today's value — deliberately: this can only ever widen, never narrow. A profile that computed a "better" 51° for desktop would be a regression shipped as an improvement, and nobody reports seeing less as a bug.
near = 1 mis wrong for the first-person walkthrough.apps/web/src/viewer/walkMode.tsputs the eye at 1.65 m and exists for close inspection — but everything within a metre of the eye was clipped, so walking up to a wall or a column made it vanish before you reached it.
Why the near plane moves only in walk mode, with the number that decides it. Depth precision at distance scales with
near; for a 24-bit buffer the resolvable gap is aboutz²/(near·2²⁴):near=1gives 2.4 mm at 200 m,near=0.1gives 6.0 mm at 100 m.apps/web/src/viewer/guideUnderlay.tslifts its plane 5 mm to avoid z-fighting, so a global change would make that underlay z-fight on any model of real size — trading a walk-mode defect for a rendering one.The
MeshStandardMaterialclause needs no work — every remaining use is GIS context, not the BIM pass. -
R23-SYMBOL-COUNT (M) — deterministic template-match symbol counting in the existing pdf.js takeoff worker: mark one instance, normalised cross-correlation, non-maximum suppression. Zero new dependencies, offline, auditable — which matters for quantities that feed a bid.
Watch, not work: WebGPU (WebGPURenderer exists in the pinned three, but Fragments targets WebGL
— 2–3 year horizon) · browser-side IFC parsing (a streaming WASM parser now exists; server-side
pre-conversion still buys caching, GUID-stable recipes and offline tiles, so the non-negotiable holds).
DECLINED 2026-07-25 — do not revisit without a new reason. ifclite-geom is MPL-2.0, which
is off the stated MIT/BSD/Apache list, and its 99.9% agreement is not bit-identical. It could only
ever have accelerated world_bounds and the clash AABB pre-pass — a narrow win — while adding a
file-level-copyleft Rust binary wheel and a second geometry answer that must be reconciled against the
first. A determinism guarantee is worth more than a bounds speed-up. Original note kept below for the
record: ifclite-geom as an accelerator only for
world_bounds and the clash AABB pre-pass. It is MPL-2.0 (file-level copyleft, not on our
MIT/BSD/Apache list), a new Rust binary wheel, and 99.9% agreement is not bit-identical — so it
must never touch drawing generation, which has to stay deterministic. Would ship behind a flag with a
per-GlobalId AABB cross-check against the ifcopenshell path.
🎛 R24 — INTERFACE RING (external design audit 2026-07-25; see design-audit.md)
The thesis, and it is not "add features". Adoption is the binding constraint, not capability. 47% of contractors name getting people to use new technology their biggest challenge (AGC 2024); 12% of features carry 80% of daily use (Pendo, 615 subscriptions). With ~130 modules shipped, about ten matter to any one person on a given day — and which ten depends entirely on who they are. A catalog with favourites and a filter treats that as a browsing problem. It is a routing problem.
The payoff is specific to us: every record, geometry and cost line shares one IFC GlobalId, so the platform can answer "where did this number come from" in one hop. The interface does not cash that in. R24 is about making the engine's one real advantage visible.
The ring was transferred from the audit in one sitting and never re-checked against the code. It has been now, item by item, and three things came out of it that change what to work on.
① The audit's own evidence base was stale on arrival. Its header reads v0.3.4 · 566 commits and
"~80 modules"; we were near v0.3.67x with ~130. internal/archive/design-audit.md corrected the module count without
recording the provenance problem. Consequence for anyone using it: its diagnoses are sound, its
"today the app does X" claims are not evidence — verify against the file before acting. That is
also why two findings had to be retro-marked "already true before the audit landed".
② Six items in the audit never became roadmap items at all — they are reinstated below as
R24-CHARTS-GRAMMAR, R24-REPORTS-BY-MOMENT, R24-TOOLS-SPLIT, R24-BASELINE, R24-KEYS and
R24-PERF-BUDGET. The largest of those is R24-BASELINE: the audit's phase 0 said instrument
before you redesign, listed six metrics with targets, and none of it was carried. R26 replaced the
entire shell and nothing in the stack can say whether it worked.
③ One finding was deliberately reversed and never recorded as one. The audit prescribed a
persona-scoped rail; spine.ts ships rooms "identical for every role" on purpose. That may well be
the better call — but it is an unrecorded reversal of the source document, unlike ROOM-NAMING which
is recorded. Filed under Decisions below.
Verified status of all 18 findings — ✅ closed · 🟡 partial · ❌ open · ⚠️ reversed:
| # | finding | item | status, with the evidence |
|---|---|---|---|
| 01 | catalog is the wrong front door | R24-SPINE | ✅ rooms are primary nav (shell/roomTabs.ts), default since v0.3.715 |
| 02 | pillars are a mode switch | R24-SPINE | ✅ workspaces demoted to weighting, shell/spine.ts WORKSPACE_ROOM |
| 03 | roles gate the UI invisibly | R24-ROLE-EXPLAIN | ✅ v0.3.685 |
| 04 | long jobs, foreground UI | R24-JOB-TRAY | ✅ shipped; this row was stale — apps/web/src/ui/jobTray.ts is 373 lines, mounted at apps/web/src/main.ts:2052, 28 tests. The ❌ survived its own implementation |
| 05 | analyses are modals → no history | R24-RUNS-INBOX | ◧ v0.3.947 — history + run-over-run diff in apps/web/src/ui/runs.ts / apps/web/src/ui/runsInbox.ts. The premise "no runs concept" was half wrong: Job already stores params, actor, timestamps and result. Routing clash/IDS/cost/energy through the queue is the open half |
| 06 | the single-GUID advantage is invisible | R24-ELEMENT-CARD | 🟡 apps/web/src/ui/lifecycleStrip.ts + inspectorTabs.ts built; now two call sites — the viewer inspector and apps/web/src/ui/elementCard.ts, mounted from apps/web/src/portal/panels/traceability.ts:75. Four surfaces still unwired |
| 07 | onboarding teaches the chrome | FIRST-RUN | 🟡 improved v0.3.777; still not the lot → building → deal chain |
| 08 | persona picker only relabels | (none) | |
| 09 | tools panel mixes verbs with analyses | (none) | ✅ v0.3.848 — R24-TOOLS-SPLIT cut the 1087-line qa section in two; Analyse is its own rail item |
| 10 | finance numbers have no provenance | R24-TRACE-UI | 🟡 v0.3.775 shipped trace for cost coverage; the proforma chain (IRR ← NOI ← rent roll ← area ← GUID) — the audit's actual demo — is not built |
| 11 | density | R24-DENSITY | 🟡 two steps not three (portal/prefs.ts:71), dashboards only, not registers — which is where the 8-hour user lives |
| 12 | mobile is a bottom sheet in a desktop IA | R24-FIELD-MODE | 🟡 field/field.ts is a real offline queue with GPS, still inside the desktop IA |
| 13 | search is scoped to modules | R24-CMDK-VERBS | ✅ v0.3.946 — verbs, elements, reports and an assistant fallback; apps/web/src/ui/paletteProviders.ts. Fixed a second defect on the way: async hits were concated onto an already-grouped list, so a record landed under a second RECORDS heading below Modules |
| 14 | empty states | R24-EMPTY-GUIDE | ✅ verified done 2026-08-14 — the "24 lines, 'no project' only" reading is stale by a wide margin. apps/web/src/ui/empty.ts is 156 lines and R36-EMPTY-STATE shipped the hard part: a register with no rows distinguishes none / filtered / failed, because those send a reader to three different places and rendering them identically was the defect. Plus acronym-safe nouns ("No rfis yet" was the bug), textContent throughout since the name and the error body are untrusted, and data-empty so a test can assert WHICH kind was decided. Curated hints in apps/web/src/ui/emptyGuide.ts (157 lines), wired at two register.ts call sites, covered by apps/web/src/ui/empty.test.ts and apps/web/src/ui/emptyGuide.test.ts |
| 15 | charts have no grammar | (none) | ❌ dropped → R24-CHARTS-GRAMMAR |
| 16 | Report Center is a list of nouns | (none) | ❌ dropped → R24-REPORTS-BY-MOMENT |
| 17 | three vocabularies collide | R24-TERMS | 🟡 storey/floor settled v0.3.945 — storey was already canonical in the API, QUERY-DSL and both table headers; three chrome strings disagreed, one of them with its own tooltip. Gated by apps/web/src/shell/storeyVocabulary.test.ts, which permits gross floor area and floor plan. The element/component and estimate/budget/cost pairs are NOT settled and are a user decision, not a cleanup |
| 18 | site promises a lifecycle, app opens on a shell | (none) | 🟡 R26-VITALS (v0.3.773) is arguably a better answer than the audit's lifecycle strip — treat as closed |
External corroboration (13-platform UI scan, 2026-07-29). One major construction-management
platform's design system evaluates office and field as separate UX, not one responsive layout —
independent support for #12. Among model-checking tools, the ones that lead on IFC do so by making
rulesets and checks durable first-class objects, and the leading common-data-environment answer
to breadth is saved, re-runnable, shareable searches — both are the same shape as
R24-RUNS-INBOX, and it is the most externally validated item in the ring. One 2026 drawing-layout
tray redesign drew open backlash ("bulky, less legible and inefficient", broken shortcuts) — density
is a regression risk, not a taste call, which is why R24-DENSITY ships as a user switch and why
R24-KEYS is not optional. A major PDF review tool's most recent release deliberately did not
restyle and invested in customizable profiles instead. And two conclusions worth keeping: no
incumbent ships a command palette as a primary entry point — a real opening, and a warning that ⌘K
must be taught, not just bound — and none of them can trace a number to a GlobalId. That is the
moat and it is still uncashed.
Everything after this sprint is a claim about adoption. Nothing in the stack can currently confirm or refute one, so this goes first even though it is the least visible.
- ◧ R24-PERF-BUDGET (S —
perf_budget.pyshipped) — premise re-checked 2026-08-06: the work this entry describes as remaining is done. It reads "the remaining work is the asserted budget itself … as atest_*". That test exists —services/api/test_perf_budget.py— and it is the strong form: the server budget is asserted against real traffic driven through the app, with the p95 read from the live histogram rather than a synthetic number, andquantilereturning None is treated as a failure on thebeyond_histogrambranch, because reading None as "no problem" would make the budget pass hardest exactly when latency is worst. The true remainder is the client beacon, and it is Lane A/E, not Lane C.BUDGETSlists all three and marksclick_echoandpanel_loadmeasurable: Falsewith a stated reason — nothing on the server can observe a click-to-paint interval. That is the honest shape, not a gap: a budget file listing three and quietly checking one is how a green suite implies more than it tested. Whoever takes this should build the beacon and flip those two, and should not expect to write a backend test.
-
◧ ⭐ R24-ELEMENT-CARD ② (S — was M, was L; ◧ added 2026-08-06 —
apps/web/src/ui/elementCard.tsdeclares this item and one surface already mounts it, so "nothing exists" was never true; what is open is REACH, not capability) — the strip exists and works, and the extraction it was blocked on is DONE. The card's frame + loader live inapps/web/src/ui/elementCard.tsand one non-viewer surface already mounts it (apps/web/src/portal/panels/traceability.ts:75).The extraction cost two import lines:
lifecycleStrip.tsimported one type and nothing else — it was already viewer-independent and merely filed underviewer/. Another estimate that came from where a file sat rather than what it contained, which is why this dropped L → M → S.Remaining is purely call sites: RFI, estimate line, pay app, COBie row. No component work, no dependency risk —
elementCard.tstakes a GlobalId and an API client. -
R24-TRACE-UI ②(L, and BACKEND — re-scoped 2026-07-29 after a premise check) — make the proforma emit its own derivation: each headline figure carrying its inputs and a model-derived / overridden / market-assumption tag, terminating in a GlobalId where one exists.proforma/solve.py·returns.py·operations.py. The UI half is genuinely small once the data exists, and the v0.3.791 element card is already the right terminus.This entry read as a ready-to-build M and was not. It said "
traceability.pyalready walks model→cost→GL by GlobalId; this is the surface for it" — true of cost, false of the proforma. Measured one653473b:traceability.pyis cost-only (its docstring scopes it toelement_costs/summary; 0 mentions of proforma/IRR/NOI/rent), andproforma/*.pycontains zeroGlobalId/guidreferences, so no figure links to a model element at any layer. The lone"basis"string inapproval_risk.pynames which population was averaged — not a chain.Building the UI first would have meant inventing provenance in the client: a trace that looks like it walks to a GlobalId while actually asserting one. That is the fabrication shape this repo has spent a day naming, and it would have been shipped as the on-stage demo. Whoever picked up Sprint 2 would have started in the client and found nothing to render.
-
◧ R24-RUNS-INBOX (M; history half v0.3.947) — clash, IDS, cost and energy become durable Runs (inputs, timestamp, author, artifact, diff against the previous run) with a per-project inbox. Most externally validated item in the ring — see the corroboration note above.
The recorded premise, "no runs concept in the web app", was half wrong — and the wrong half is the expensive one to assume.
models.py:Jobalready stores every field a run needs:params(inputs),actor(who),created_at/finished_at(when),result(the artifact), androuters/jobs.pyhas served them for a long time. There was never a missing table, so the item splits into a cheap half and a risky one:- ✅ History and comparison —
apps/web/src/ui/runs.ts(pure) andapps/web/src/ui/runsInbox.ts(render). Runs group by kind, newest first, each diffed against the previous comparable run. Reachable from the job tray's footer and from ⌘K. The decision the module exists to get right: a metric present in one run and absent in the other has anulldelta, never zero. Absence-as-zero turns a detector that stopped reportingcountinto a confident, precise, invented −412 — worse than no number, because it reads as a finding. Same reasoning refuses a failed run as a baseline: a run with no result is not a run whose every metric fell to zero. Both are mutation-checked. - ❌ Routing the analyses through the queue — clash, IDS, cost and energy still run in the request thread behind a modal, so they never become rows. Four call sites and their handlers; the larger and riskier half. Until it lands the inbox is genuinely empty on most projects, and its empty state says which half is missing rather than implying the feature is broken.
- ✅ History and comparison —
-
✅ R24-CMDK-VERBS (M; grouping half v0.3.780 as R24-CMDK-GROUPS, providers v0.3.946) — results render in sections (Do · Records · Elements · Reports · Modules · Go to), a group is inferred from the
hinta caller already sets, recency ranks your last twenty commands, and the row cap is per section — a flat cap removed every workspace from the list once 130 modules outranked them. The four missing providers shipped inapps/web/src/ui/paletteProviders.ts, all pure and all injected, so none of them imports the viewer, the API client or the DOM:- Authoring verbs do not reimplement Move/Copy/Rotate — they find the toolbar button by its
titleand click it.apps/web/src/viewer/toolbarLayout.tsalready keys the whole tool table on that exact string and already fails when an installed button is missing from it, so the palette inherits that gate: a retitled button cannot silently lose its palette row. A verb whose button is not installed is omitted, not disabled — before a model loads, every authoring row would otherwise look available and do nothing. - Elements is a GlobalId lookup plus an IFC-class list, and deliberately not a name search: there is no server-side element text search, and pulling every element down to filter in the browser would violate "never parse the model in the browser" to answer a question badly.
- Reports are static rows from the server catalog, so they go through ranking and recency —
the two reports you run each month rise on their own. Choosing one opens the Report Center
scrolled to and highlighting that row (
focusReportRow), rather than dropping the reader at the top of a 56-row modal to re-find what they just named. - Ask is the fallback, passed as
fallbackrather than mixed into the results, so it is appended after the per-section cap. A fallback the cap can delete is not a fallback — it would vanish exactly when a query matched twelve things badly, which is when it is most useful.
And a defect found while reading, not while testing:
refresh()diditems = items.concat(extra), so async hits were appended to an already-grouped list. A record — group Records, which sorts second — landed below Modules and Go to under a secondRECORDSheading. The grouping was a property of the first paint only, and the section the async provider exists to fill was the one it could never reach; adding Elements and Reports would have made it three duplicate headings. Fixed bymergeResults, which re-sorts on group rank alone —Array#sortis stable, so each side keeps its own ranking inside its section, and re-scoring would have thrown away the server's relevance order. - Authoring verbs do not reimplement Move/Copy/Rotate — they find the toolbar button by its
-
R24-DENSITY ② (M) — three steps (Field 56 px / Default 36 px / Compact 28 px) applied to registers, not just the dashboards
prefs.tscovers today. Tabular figures wherever a number appears. The register half isportal/register/register.tsas of v0.3.850 — inside Lane B, where this item is. It was inside Lane A'sportal.tsuntil then, which is whyR24-MONO-DATAskipped its hunk.
-
R24-FIELD-MODE (L) — capture-first home, 56 px targets, 7:1 outdoor contrast, permanently visible sync queue, dictation on notes. A mode, not a breakpoint.
-
🟡 R24-CHARTS-GRAMMAR — no-data rule SHIPPED v0.3.783, the rest open. Only
histogramhandled empty input; the other twelve drew their axes, gridlines and legend with nothing in them — noNaN, nothing broken, and therefore indistinguishable from a chart whose data failed to load. All nine framed charts now sharenoData(), andCHART_KINDS+charts.test.tsfail the build if a new chart skips it. Tick / legend / currency SHIPPED v0.3.948, and none of the three was cosmetic once measured:- Ticks. Three charts hand-rolled the same gridline loop and the copies had already diverged —
lineChartlabelledmax − (k/4)(max − min),groupedBarandwaterfalllabelledmax − (k/4)·max, a different axis whenever the minimum is not zero. AndstackedBardrew no gridlines at all, so a cash-flow chart beside a budget chart was read against different furniture. OneyGrid, and a source scan that fails on a local gridline loop. - Legend. Four hand-rolled copies of the same magic coordinates → one
legendRow. The donut keeps the one legitimate position difference (under the ring, which has no plot to sit above) and still goes through the helper, so swatch, size and spacing cannot drift. - Currency. The real number was 22 declarations in six behaviours, not the 18 a first grep
found — the gate enumerated the population and my grep had not. Ten wrote
`$${Math.round(n).toLocaleString()}`, which renders a loss as$-1,000with the currency mark on the wrong side of the minus; three had already fixed it locally, so the panels disagreed with themselves.inspectorTabs.tsusedIntlcurrency and mapped a non-finite value to$0— an absent number rendering as a plausible zero, the failure the vitals strip exists to prevent.proforma/format.tsexported a competing one. All 22 now importusdfromapps/web/src/ui/charts.ts;chartsGrammarbans re-declaring it. And one of the 22 was not money at all: the stormwater card'susdemitted no$because it formats cubic feet. Converting it would have put a currency mark on a detention volume — soqtyexists, and the rule bans declaring a formatter rather than banning the name. unit: "money" | "percent" | "count"now says what a chart's numbers are rather than making every caller remember a formatter; an explicitfmtstill wins.
Still open: the series-colour split below. And one correction to the audit, made deliberately: it says colour should be "restricted to the four semantic hues". That is right for status and wrong for series identity — a seven-series S-curve needs seven distinguishable colours, and collapsing them to four makes the chart unreadable in service of a rule about badges. The split to enforce is semantic hues for status, a categorical ramp for series, which is a different contract from
ui/colorContract.ts(that one governs CSS selectors; SVG fills are outside it entirely). - Ticks. Three charts hand-rolled the same gridline loop and the copies had already diverged —
-
🟡 R24-REPORTS-BY-MOMENT — grouping SHIPPED v0.3.785; scheduling still open. The catalog was 56 reports under 18 group headings, six holding a single report. Seven packages now sit above them — owner monthly · lender draw · IC · precon/GMP · design issue · closeout · ownership quarter — each stating who asks and when, collapsed by default, with every report still under its noun heading below.
reportMoments.test.tsreadsreports.pyand fails the build if a package names an id the server no longer defines; without that, a renamed report shortens a package silently on the Friday it is due. Still open: "scheduled and shared, not just downloaded." A package is currently something you open and click through. Making it a scheduled deliverable — assembled on a date, sent to a recipient, with a record that it went — is the larger half and wantsrouters/jobs.py(now wired to the UI by R24-JOB-TRAY) plus a delivery surface. That is a real feature, not a grouping change. -
R24-TERMS (S) · R24-MONO-DATA (S) · R24-DENSITY ② (M) — the remaining long tail.
Explicitly NOT in scope: the audit's visual identity (ink canvas #080C12, IBM Plex Sans/Mono,
the 24 px/192 px brand grid at 5–7%). It is the most seductive item in the document and the least
defensible right now: a full restyle with no measurement behind it, immediately after a shell
replacement that also has none. Colour discipline shipped and is test-enforced; colour identity
did not, and style.css:20 is still a generic dark-app grey. Revisit once R24-BASELINE has numbers,
or settle it as a decision — do not let it ride in on the back of another item.
- R24-PERSONA-SHAPE — the audit prescribed a persona-scoped rail;
spine.tsships rooms identical for every role. Which is right for a superintendent who needs four rooms and an underwriter who needs one? The sibling question, ROOM-NAMING, was settled on professional terms at v0.3.779 — this one is shape rather than vocabulary and is still open. Settle it with a real user. - R24-IDENTITY — is the visual identity in scope at all, or does the current grey stay?
The 18 diagnoses hold. But the three surfaces the audit judged hardest — the spine, the room tabs and the vitals bar — have all been replaced since it was written, so its verdict on the front door describes a door that no longer exists. Questions to hand a re-auditor verbatim: (1) rooms vs personas, per the decision above; (2) does the vitals bar prove the one-model claim or is it six numbers nobody can act on — it holds the most valuable strip of the window on the strength of a prototype, unmeasured; (3) what is the register experience at 500 rows — every density finding in the original was about dashboards and it never opened a table; (4) where does ⌘K get taught, given no competitor ships one; (5) what are the six metrics' actual baselines; (6) is the offline/field path trustworthy on one bar of signal, judged from a phone rather than a desktop.
Four sessions are live in this repo. R24 is apps/web outside src/shell/. Specifically:
-
Owned elsewhere, do not edit:
apps/web/src/shell/*(Massing Core session).services/api+services/datacarried six backend PRs; #94 #95 #96 #97 #99 merged on 2026-07-29 (by the user, not by an agent), #98 R23-RECIPE-ARTIFACT open, plus #100 fixing the alert below. Status stated with its method rather than its conclusion, which is the habit worth copying: #94/#96/#99 API gates PASS; 15/15 pairwise clean bygit merge-tree, which is not a merged-and-tested result and must not be read as one ([[tests-that-cannot-reach-the-failure]]). -
How to count CodeQL alerts, because a wrong zero was reported here twice on one day.
gh api "repos/{owner}/{repo}/code-scanning/alerts?state=open&per_page=100" --paginate -q '.[].number' | wc -l
--paginateapplies a-qfilter PER PAGE and prints one result per page, so--paginate -q 'length'emits1\n0\n…and anything reading the first line reports the first page's count as the total. Count lines of ids, never a per-pagelength. A bare--jq 'length'without--paginateis correct only while there are ≤100 alerts — it degrades silently past that, which is the worst moment for it to. And a green CodeQL run is not zero alerts — that was already in memory ([[codeql-monitoring]], [[ci-green-means-the-ci-job]]); the new half is that a badly counted alerts query is not zero alerts either. -
Read the alert's own
start_linebefore applying a remembered fix. Alert #108 (py/stack-trace-exposure) namedrouters/prefab.py:64-67— thereturn pk.assess(...)in the GET detail route. It was diagnosed here as line 87'sraise HTTPException(422, str(e))because that matched the pattern in memory, and the memory won over the data the alert supplied. The real source wasprefab_kit.resolve()returning{"error": f"bad selector: {e}"}— a dict field, not a raise — feeding three response paths (register,assess,freeze). Fixing the raise would have left two tainted and the alert open. Recalling a known fix is what stopped the reading. Merge protocol, agreed between sessions and binding: whoever merges pings the release lane first and waits for "not mid-release" before the first merge. The window that bites is bump → tag — a merge landing inside it yields a tag and apackage.jsonthat disagree, and nothing fails until somebody reads the update banner. Every push here is race-guarded onorigin/main == HEAD~1, so the guard catches it, but do not rely on the guard alone.main.tsandportal/portal.tswere held through the classic-shell removal and released at v0.3.779 — checkgit statusbefore assuming either is free, since both are large enough that two sessions in one is a guaranteed conflict. -
docs/roadmap.md,CHANGELOG.mdand the three version files are a single lane, held by whoever is shipping. This was agreed rather than assumed: the backend-PR session deliberately touches none of them on any branch, precisely because they are the high-conflict files. If you take a lane, say so — a session message costs nothing and a merge conflict in this file costs an hour. -
The version lives in THREE files, not the two the
ship-releaseskill names:apps/web/package.json,apps/web/src-tauri/tauri.conf.json, andpackage-lock.jsonunder thepackages["apps/web"].versionkey. Located by key, not by line: it sat at line 23 until the R41-BUNDLER-SPLIT dedupe added a root devDependency and pushed it to 24.apps/web/src/shell/versionConsistency.test.tsalready reads it structurally, which is why the move broke nothing — a line number in prose is the part that rots.shell/versionConsistency.test.tsfails the web suite if the lockfile disagrees. -
The web app's three escaping layers have DISTINCT scopes. Stated once, because assuming one covers another is how the gap between them gets used:
layer watches baseline ui/innerHtmlGuard.test.tsunescaped ${…}interpolated into.innerHTMLratchet at 88 ui/hrefGuard.test.tsan external field ( info.url,d.html_url,att.fileUrl) assigned straight to a URL attributezero safeUrl/safeHref(ui/feedback)the helpers both gates point you at — None of them sees
textContent, and that is correct — nothing needs to.innerHtmlGuardred = you added an unescaped interpolation. Wrap it inesc()(ui/charts) orescapeHtml(). Only on lines assigning to.innerHTML. Never escape intotoast(),notify(),setStatus()or.textContent—toastsetstextContent, so escaping there makes users read&lt;literally. Worst offenders if you are in them anyway:proforma/proforma.ts16 ·portal/portal.ts14 ·portal/panels/standards.ts8 ·portal/panels/analytics.ts6 ·viewer/app.ts6.safeUrlHTML-escapes (for interpolation into a string);safeHrefdoes not (forel.href = …). UsingsafeUrlon a DOM property rewrites?a=1&b=2into&— a broken link that still looks defended.- Known blind spot, deliberately:
hrefGuarddoes not coverimg.src/audio.srcfed a local variable, because a line-local regex cannot do dataflow and a zero-baseline version of that flagged eleven safe sites. Eleven false alarms is how a check gets switched off, and then the real twelfth goes through. SosafeMediaUrlin the vendored attachment plugin is doing real work that no gate backs up — keep it if that file is ever refactored.
-
Free for R24:
apps/web/src/ui/*,apps/web/src/api/client.ts,apps/web/src/field/*,apps/web/src/reportCenter.ts, and any new file. -
Sequencing rule: prefer a new self-contained module plus one small mount point over an edit inside a large shared file.
ui/jobTray.tsis the template — the whole feature lands in a new file and the mount is three lines. -
Run the backend suite from
services/api(the repo root exits 127 and reports "0 failures", which reads exactly like a pass). Never runnpm run buildwhile the suite is running — it rewritesapps/web/dist, whichtest_desktopreads.
The thesis, from research and our own R29 finding: the room is not behind on features — it is behind on the first ten minutes. The measured inventory is deep (14 draw tools, families with sized variants, groups/arrays, constraints, phasing, curtain walls, MEP, server-generated plans/sections/sheets, a full markup engine, node canvas + NL authoring). What the beginner-friendly tools prove is that a new user must see a shape respond in the same frame and must be able to learn the whole app from the cursor. What the parametric tools prove is that parameters must stay alive after creation. What the document-first tools prove is that the sheet is a working surface, not an export. Three waves, one per lesson. The server recipe stays writer of record throughout; no second renderer, no new dependency without sign-off (Manifold, Apache-2.0, is the one pre-cleared candidate if face-CSG is ever pursued — see the R29 licence table).
Three of Wave 1's four items shipped 2026-08-02 (v0.3.819–820; details in
roadmap-completed.md): A29-LOCAL-PREVIEW (a pending edit looks pending — the
amber marker stays over the incremental preview; failure turns it red and keeps its location),
R38-DIM-INPUT (the typed-constraint box is visible as a hint the moment a run is in progress, and
the grammar accepts imperial — 12'6 echoes back 3.81 m before the click commits), and R38-STAIR
(server recipes add_stair/add_ramp in services/data/src/aec_data/edit_enclosure.py — placed
exactly where drawn, riser/tread/slope compliance REPORTED by stair_geometry/ramp_geometry, never
enforced by moving the run — plus the stair and ramp draw tools, SR/RP shortcuts). R38-PUSHPULL
shipped v0.3.821 — a single top handle on the selected element, base-anchored ghost, committed
through the pre-existing set_extrusion_depth recipe (never mesh; non-extrusions refused
server-side). Wave 1 is complete. Carried forward:
R38-STAIR-LIVE shipped v0.3.822 — live riser/tread (and ramp slope) readout while dragging the
run, constants pinned by test to the server's (apps/web/src/viewer/draft/stairLive.ts); the
server's report stays authoritative on the authored element. Wave 1 and its follow-on are done.
- A29-PLACE-VALID ② · A29-UNDO-LOCAL ③ · A29-GUIDE-UNDERLAY ③ — as already coded in Lane E.
-
R38-ARRAY-LIVE ③ (M, prerequisite in Lane C/D) — "arrays whose count/spacing stay editable after placement". Premise-checked:
groups.array_element(guid, nx, ny, dx, dy, dz)produces independent GUID-stable copies and stores nothing — no group, no pset, no definition. There is therefore nothing to re-edit; changing a count today means deleting copies by hand. Prerequisite: persist the array definition (an IfcGroup or pset carrying nx/ny/dx/dy/dz plus its member GUIDs) and aset_array_paramsrecipe that adds/removes members to match. Viewer half is then small. -
R38-SOLVER-LOCKS ③ (M — DECIDED 2026-08-07: both, within-element first) — the R23 dimensional locks as UI.
The user's call: ship within-element locks (hold depth, drive width, keep area) against the existing solver now, and treat across-elements (align these walls, hold this offset) as a separate later item once multi-element parameter edits exist. That gets a usable feature out without committing to the larger build up front.
Premise-checked 2026-08-07, and the entry's framing was wrong in a way that makes this cheaper than it reads. The question was posed as though the solver constrained the answer. It does not:
services/data/src/aec_data/dim_constraints.pyexposessolve(variables, constraints)over a flat dict of named scalars. It has no concept of an element, so within-element and across-element are the same call with different variable names. Its own docstring's example — "keep a wall 3000 from a grid" — is an across-element relationship, so the general case is what shipped.- The route already exists:
POST /projects/{pid}/constraints/solveinservices/api/src/aec_api/routers/analysis.py. It has no client caller, so the whole feature is server-only today — the same shape the reachability gate exists to catch, and it is not inKNOWN_UNCALLED, so it slipped past on the last-static-segment rule.
So the solving is done; the work is UI plus a write-back. The route says so itself: "pure computation over caller-supplied values — it neither reads nor writes the model." A UI must gather variables from the selection, call solve, and apply the result back through an edit recipe.
The instance-level write path DOES exist —
services/data/src/aec_data/edit.py'sRECIPEStable has 14 entries keyed byp["guid"], a single element rather than a type. The relevant ones:set_extrusion_depth(guid, depth),set_wall_thickness(guid, thickness),set_wall_slope(guid, start_height, end_height),move_element(guid, dx, dy, dz)andset_element_pset(guid, pset, prop, value, dtype).This entry claimed the opposite for one revision, and the way it went wrong is the reusable part. The functions that ARE the wrong granularity are easy to find by name —
edit_type_params(type),set_pset_on_class(class),set_storey_elevation(storey),instance_props(read + reset only) — and a grep for guessed setter names finds exactly those and stops. The guid-keyed writes live in a dispatch table, not under names anyone would guess. If a claim is load-bearing, enumerate the whole table rather than the functions you can name.So the remaining work is a MAPPING, not a capability.
solve()returns named scalars, and something must decide thatthicknesson a wall writes throughset_wall_thickness(guid, …)whiledepthon a column writes throughset_extrusion_depth(guid, …). That mapping is the substance of Apply; it is Lane E/C work and needs no new recipe.Where a variable has no recipe, refuse that one BY NAME and apply the rest. A partial apply that says which locks it could not write is honest; one that silently drops them is the
suggestion_clears_horizonfailure in a new place — a result that looks complete and is not.
- ◧ split by premise-check 2026-08-02 — un-archived 2026-08-10, the ✅ was wrong. Only
R38-SYNC-SELECT of the four children shipped; the other three are open, and archiving the
parent took them with it — caught by
roadmapLanes.test.ts, which names lane items with no entry left in the file. A ✅ on a parent is not a claim about its children. Original heading — split by premise-check 2026-08-02** — R38-SYNC-2D3D. The plans are server-generated, but the pipeline discards element identity at bake time:drawings._bake_uncachedhasshape.guidin hand and keeps only(cls, mesh), socut_bakedemits anonymous polylines andcut_baked_classedadds back the class but never the GUID. Nothing in a plan can name what it draws. Hence:-
◧ R38-SYNC-VIEW ③ (M, Lane E — checked 2026-08-06: MOSTLY BUILT, one of the three named syncs is missing) —
apps/web/src/viewer/planPane.tsopens with "R38-SYNC-VIEW + R38-SYNC-SELECT" andapps/web/src/viewer/app.tsmounts it beside the model. Storey sync ships (planParams(storey), and the pane refetches only when the cut changes, so a selection change costs no round-trip). Pan and zoom ship (overflow:autobody; a client-sidezoomPctthat deliberately does not refetch, because zoom is presentation and a refetching zoom would cost a bake per click). 14 tests. The residue is live CURSOR sync, and it is BLOCKED — checked 2026-08-06 and it is not client work at all.plan_drawing_svgcomputesT(x, y) = (ox + (x - mnx) * scale, oy + draw_h - (y - mny) * scale)and then discards every term of it: the root carries onlywidth,heightandviewBox="0 0 W H", and the onlydata-attributes anywhere aredata-guid/data-classon polylines. A client holding a world position cannot find its pixel. Blocked on R38-PLAN-TRANSFORM ③ below.The tempting workaround is the one to refuse: the transform could be back-solved from a polyline whose element geometry is known, which would work in a demo and drift silently the first time a cut differs from what the client thinks it is. That is the same shape as R24-TRACE-UI's rejected plan — inventing in the client a fact the server threw away. Original text: the second viewport with cursor, pan/zoom and storey sync. Buildable today; needs no identity.
-
✅ R38-PLAN-TRANSFORM — SHIPPED v0.3.928. The plan SVG root now carries the six terms of its own transform:
data-plan-scale,-ox,-oy,-minx,-miny,-drawh.plan_drawing_svgderived all six to place every polyline through a localT(x, y)and serialised none of them, so the drawing knew where everything was and the client could not ask. Full float precision, not formatted: a rounded scale puts a cursor visibly off at the far end of a large plan, which reads as a sync bug rather than as rounding.services/api/test_plan_transform.pyasserts the round trip, not the attributes — it reads the terms off the root, rebuilds the inverse independently, takes a real pixel off a real polyline the server placed, and requires pixel → world → pixel agreement to 1e-3. Plus an extent check (catches a self-consistent but wrongly-scaled transform) and a two-storey comparison (catches a hardcoded one;scale,minx,miny,drawhall move). Asserting the attribute exists would pass on a value that is wrong, stale, or from a different cut — the presence of an attribute says nothing about it being the transform the drawing actually used.This unblocks R38-SYNC-VIEW's cursor sync, the last third of that item, and with it anything that needs to point at a place rather than at an element.
-
✅ R38-PLAN-IDENTITY — ALREADY DONE when this entry was written; confirmed 2026-08-10.
cut_baked_guidedexists inservices/data/src/aec_data/drawings.py, returns(guid, ifc_class, polyline), and its own docstring opens with this item's id. The SVG has emitteddata-guidper cut polyline since R38-SYNC-SELECT shipped v0.3.829. The entry stayed open anyway and kept being cited as a prerequisite blocking three other items. Third stale premise found the same day — after massingbill's "pure addition" claim and R41-UPLOAD-WARK's "no call site has been converted" — and all three overstated the work remaining, which is the direction that makes a roadmap quietly wrong rather than loudly wrong. -
✅ R38-SYNC-SELECT ③ (S, Lane E) — SHIPPED v0.3.829 — selection sync in both directions. The SVG emits
data-guidper cut polyline (both rendering modes), the pane adds invisible fat hit-twins to ~1px linework, and one element lights as MANY loops. Shipping it also surfaced that the pane had been unreachable (toggle button never appended), its fetch had failed cross-origin since v0.3.826 (credentials:"include"without a credentials CORS grant), and the live route dropped thestoreyparam entirely — three defects only a live drive could see.
-
- R38-SHEET-MARKUP ③ (M, Lane B) — the vendored markup toolset (clouds, callouts, stamps, tool sets) opened on the room's OWN generated sheets, markups tied to GUIDs through the existing pin-to-drawing spine.
- Consumes: R24-ELEMENT-CARD ② and R31-CITE-HIGHLIGHT (both already coded) as the "everything about this thing" surface.
Sequence: Wave 1 before all; within a wave, listed order. Quick wins to fold in when adjacent: a material paint tool, orbit-around-selection.
🎀 R40 — OPERATOR RESEARCH RING (7-image requirements survey, 2026-08-02; premise-checked, split across lanes)
Source images used as a requirements survey only — nothing copied, no vendor names in repo docs (standing directive). Two proposals from the same drop were recommended against and declined, recorded so nobody re-proposes them: an ML training-data pipeline built from bid history (cuts against deterministic/offline/no-silent-LLM — a strategy decision, not a task), and a BIM-interop expansion (IFC already covers the named authoring tools; the image is a landscape, not a gap).
-
R40-RIBBON ② (M, Lane A/E) — the flat glyph bar presented as tabs. Measured before accepting:
apps/web/src/viewer/toolbarLayout.tsdefines 27 tools in 5 groups (look · measure · author · analyse · collaborate) withMAX_PRIMARY = 8, so 19 live in overflow. The five groups already map nearly one-to-one onto the surveyed tabs (look→View, author→Author, analyse→Analyze, collaborate→Share, measure folding into Analyze) — this is a presentation of a taxonomy we already have, not a re-taxonomy, which is what makes it cheap. The constraint that outranks the arrangement:toolbarLayout.test.tsexists because R26-TOOLBAR's audit found 25 unlabeled glyphs, and its tests are mostly about nothing disappearing and only then about the bar being short. A ribbon inherits that gate —unlaidTitles()staying empty matters more than any tab layout. Design question before build. -
✅ R40-EOT (Lane C —
eot.pyshipped; the SOURCED path shipped 2026-08-07; the method became arithmetic rather than a label in v0.3.971 — COMPLETE)The last half was a defect this entry could not see, because the entry's own premise-check found the refusals correct and stopped there.
eot.analyse()validatedmethodagainst the closed set, echoed it back inmethod,method_meaningandnote— and never read it again. Every branch computed the same additive sum, soas_planned_vs_as_builtandwindowsreturned identical numbers on identical facts, under an engine whose stated reason for existing is that they do not.A required field that changes nothing is worse than no field: it tells a reader the figure came from a windows analysis when it came from adding up event durations.
test_eot.pywas the evidence rather than the guard — it namedtime_impactandwindowsat random for arithmetic checks, which is only possible while the choice cannot matter.Fixed by computing two and refusing two.
impacted_as_plannedsums impact beyond float and reports its ownover_claimed_days;as_planned_vs_as_builtcaps at the actual movement of completion, requiresactual_finish, and namesunattributed_slip_daysrather than granting them.windowsandtime_impactreturnmethod_needs_schedule_updates— windows namingGET /projects/{pid}/schedule/windows, time-impact naming nothing, because nothing performs it and pointing at a route that does something else is how this started. Measured 10 vs 4 on a mitigated job; the twin asserts they agree exactly when the cap does not bind, so the difference is the cap and not different arithmetic.services/api/test_eot_methods.py.The general lesson: a premise-check that reads the refusals is not a premise-check of the computation. Every refusal this entry asked for was genuinely there, and the number they guarded was still one number wearing four names.
Original entry follows. Extension-of-time entitlement, with its method stated. Every input exists (
schedule_cpm.computegives ES/EF/LS/LF and free float, with total float derivable as LS−ES;schedule_baselinesgives named baselines and per-activity variance;notice_clockalready types weather/constructive-change/suspension delay events). What is missing is the step from baseline + as-built + events to a defensible entitlement: EOT days, excusable / non-excusable / compensable, per-event time impact. The refusal IS the feature: forensic delay Premise-checked: every refusal the entry asks for is already ineot.py— the four methods as a closed set,method_required, concurrency named rather than apportioned, absorbed float reported as absorbed. What had no provenance were its inputs.POST /schedule/eottookbaseline_finish,actual_finishand the whole event list from the request body, whileschedule_baselines(named captured baselines + per-activity variance) andnotice_clock(typed weather / constructive-change / suspension events, each carrying its source record) sat wired to nothing. On the number the entry itself says ends up in arbitration, that is the wrong place to leave provenance: two people can produce different EOTs from one project by typing different dates, and every careful refusal sits downstream of an input nobody can audit.services/api/src/aec_api/eot_sourced.py+POST /projects/{pid}/schedule/eot/sourcedjoins them. The design follows from what the two sources actually give, which is not the same thing:variance()gives quantum (finish_varper activity vs a named baseline);notice_clockgives cause and carries nodaysfield at all — detection establishes that an event occurred, never what it cost. So the gap is reported rather than filled, twice: an event with no stated duration isneeds_duration, listed and excluded from the figure rather than handed the slip it sits near; and slip with no matching cause isunattributed, nevernon_excusable— defaulting unexplained slip to contractor risk hands one party an entitlement finding nobody demonstrated. Matching is by explicitactivity_idonly: proximity is not causation, and causation is the contested half of every claim. All three mutation-checked (5 / 2 / 6 named FAILs); no baseline returnsbaseline_requiredwith the available baselines rather than falling back to a typed date. Two of my own assumptions were wrong and caught by reading the engine rather than trusting the name:compute_variancerows key onref(there is noid), and itssummarycarries slip counts andmax_slip_daysbut no project finish dates — sobaseline_finish/actual_finishare passed through from the caller and NOT derived here, because inventing a completion date would fabricate the very input this exists to make auditable.analysis has a published method taxonomy (AACE 29R-03, SCL Protocol 2nd ed) and the same facts give different answers under different methods — as-planned-vs-as-built, windows and time-impact are not interchangeable, and concurrent-delay apportionment is openly contested. The engine states its method and refuses to emit an EOT number without one, reporting concurrency as concurrency rather than silently apportioning it. An unmethodded EOT figure is the confident-wrong shape at its most expensive: this number ends up in arbitration.
-
◧ R22-PIPELINE — no rewrite needed; a spec reference now exists from the same drop (portfolio dashboard: multi-project KPI strip, cross-project Gantt, EVM PV/EV/AC + SPI/CPI, risk heat map, milestone tracking, resource allocation by department, cost-by-project).
Why this ring reads differently from the others. Three of the six code repositories examined carry a licence that differs from what their README or badge claims, and two of those are hard exclusions for us specifically. The scan's most reusable output is therefore a process change rather than a feature: a machine-enforced licence allowlist in CI would have caught them mechanically instead of requiring a manual read — filed below as R41-LICENCE-GATE.
- Two Claude-skill repositories (a BIM health scorecard, a spec indexer). Their READMEs say "free, self-hosted" and never mention licensing; the LICENSE file is a custom source-available licence whose second clause forbids distribution "as part of a paid service" — which is exactly what this product is. Internal use on our own models would be permitted; shipping any part is not, and the rulebook cannot be copied. Two of their ideas are free to adopt, since ideas are not copyrightable: an evidence-tier ledger grading each claim verified / sourced / chosen with a dated primary-source read log recording what was opened and what it did not contain; and stage-aware rule severity, where a missing field is informational at schematic design and a failure at fabrication.
- A hosted AutoLISP script library — no LICENSE, no repository, no named author, so all rights are reserved by default. An exclusion by absence, which is easier to misread as permissive than a bad licence is. Its twelve command names remain a field-validated requirements checklist for the R27 drawing-is-data ring: delete duplicates, fillet-to-zero to close corners, straighten near-axis linework, split lines at intersections, and select by length, orientation, overlap or layer. Behaviour from a published description is fine; implementation is not.
- A browser-local IFC viewer and clash tool licensed SSPL — source-available, not open source, and aggressively copyleft for hosted services. Do not read its source with intent to copy. Commercially it is worth knowing that it is free and needs no signup, which makes it real pressure on a viewer wedge and none at all on authoring, cost, scheduling or finance; its own comparison page concedes it has no coordination workflow, which we lead on.
- A closed-access Automation in Construction paper on a joint-embedding predictive architecture for 3D BIM geometry (DOI 10.1016/j.autcon.2026.107169, vol 191, art 107169). Title, authors and DOI confirmed; the abstract is deposited nowhere public and was deliberately not inferred. A technique to watch rather than act on: no released weights or code were found, and we have no ML training pipeline. Re-check whether the authors later publish code.
-
R41-MODEL-ALIGN (M — Lane E) — align a federated model that arrived with wrong, missing or unit-mismatched georeferencing, without touching the source file. This is the daily reality of GC federation and is not the same problem as our standing set-origin note. Two techniques, both reimplement-from-description — the reference source carries an object-code-only header despite an MIT root, so do not paste it: (a) a yaw-only oriented bounding box fitted to drawn geometry [PREMISE-CHECKED 2026-08-06: HOLDS — nothing aligns anything today — but (a) is substantially cheaper than written.
georef.pyexposes one function,georeferencing(model), which reads and returns; there is no yaw fitting, no unit-mismatch handling and no alignment anywhere inservices/. What lowers the cost: shapely is already a declared dependency (services/data/requirements.txt,>=2.1.2, pulled in for trimesh planar paths) anddrawings.pyalready usesMultiPoint(...) .convex_hull. Shapely shipsminimum_rotated_rectangle, verified in the pinned version — so the hull-and-fit half is a call, not an implementation, and needs no new dependency and no pasted reference code. What must still be written by hand is the part the entry says is valuable: the ≥20% area-saving acceptance threshold that buys a wall-parallel rectangle rather than a smallest one. The geometry is small; the acceptance rule and the "never touch the source file" guarantee are the work.] (2D convex hull of the footprint, minimum-area rectangle), accepted only when it saves at least 20% area. The reasoning is the valuable part: a true minimum-area rectangle sat 37° off a building's own walls to buy 14%, which reads as broken. The threshold buys wall-parallel rather than smallest. Their measured gap was a 54 × 78 m true extent against a 126 × 127 m axis-aligned box. (b) pick-based move, rotate and scale from two point pairs. Check first whether the AABB-versus-OBB gap already affects our section box, zoom-to-model and any bounding-box UI — if it does, that is a defect rather than a feature.CHECKED 2026-08-06, and the answer is BOTH — but the live defect is not the one this entry hypothesised. Two sites build a scene-wide
Box3and include the 2000 × 2000 presentation ground planeworld.tsadds (aec-shadow-ground):apps/web/src/viewer/measureSection.tsandapps/web/src/viewer/envTools.ts. Neither excludes it;world.tsdoes, with the comment "the shadow-catching ground is 1 km across and would swallow the fit". These are the fourth and fifth instances of the defectapps/web/src/viewer/modelBounds.tswas written to fix, whose docstring already records that the fact was "encoded correctly twice and missed once".Measured against a 54 × 78 m building rotated 37° plus the real plane:
AABB of the model alone 90.1 × 94.8 m — the genuine AABB-vs-OBB gap, ~2× the true area AABB including the ground plane 2000 × 2000 m — what those two sites actually measure section-box clip half-extent 700 × 700 m about the origin → the whole building is inside it, so the section box clips nothing storey grid size 2200 m where it should be 104 m — 21× too large With presentation mode off both are correct, which is exactly why it hides and why no test caught it: it misbehaves in one render mode only.
So this entry splits. The two ground-plane sites are a defect for Lane E to fix now, and the fix already exists —
planBoundsFromModelsinmodelBounds.tsis an allowlist precisely so every future non-model mesh is excluded by construction rather than by name. Both sites should call it instead of hand-rolling a traverse. The OBB work then remains a real feature at its stated size: fitting an oriented box on top of these two sites would compute a beautiful oriented box over a 2 km ground plane. -
✅ R41-CLASH-TRIAGE (Lane C — COMPLETE; marked 2026-08-16 after re-verifying rather than re-reading.
clash_intel.analyzeis reached fromservices/api/src/aec_api/routers/analysis.py,services/api/test_clash_reduction_scale.pyis in therun_tests.pymanifest and passes, and the 18:1 measurement below still holds. The entry's own text already said all four asks were built; what it never got was the ✅, which is how a done item keeps costing the next reader a premise-check) — a reduction stage between detection and workflow. A competitor's headline is not detection quality but 22,843 raw clashes reduced to 103 groups: group by geometric and semantic similarity, drop duplicates, filter grazing false positives, then rank survivors by construction consequence. We have detection including soft and sequence clash, and BCF round-trip. Detection without reduction produces a number nobody reads, and the same shape applies to every engine that emits many findings — code compliance, scope gap, QTO variance.All four asks were already built, which is the finding.
clash_intel.analyzegroups by greedy set-cover on the dominant element (a duct crossing 12 joists is ONE issue), scores by discipline pair x penetration volume x group size, andaec_data.clash.detecttakesmin_volume=1e-3plus atolerancethat shrinks the boxes so merely-touching elements never register — that is grouping, de-duplication, grazing-filter and consequence ranking. It also carries a stablegroup_hashacross re-runs, which this entry never asked for. What was NOT verified is the only number the entry is about.test_clash_intel.pyassertedreduction == 2.0on a four-clash fixture, and four rows cannot demonstrate an order of magnitude — greedy set-cover is precisely the algorithm whose ratio is a function of topology, fine on a toy and ~1:1 on a federation where every pair is distinct.services/api/test_clash_reduction_scale.pymeasures it on realistic shapes: 5,760 raw → 320 groups (18:1), sparse 2:1 versus dense 30:1, so the ratio provably tracks density rather than sitting at a constant. It also closes the two ways a grouper can cheat — reducing by losing clashes (membership is summed, not just groups counted) and merging problems a coordinator would have to split again (60 unrelated clashes must stay 60 issues). Mutation-checked by keying the group on both elements: 4 named FAILs, ratio collapses to 1.0:1. The first mutation attempt was applied and changed nothing — it hit_group_hash, which labels groups rather than forming them, and reported a clean pass. An applied mutation that alters no behaviour reads exactly like a gate that cannot fail; the measured numbers coming back identical is what caught it. -
✅ R41-COMMERCIAL-DRIFT (Lane C; walker SHIPPED 2026-08-07, PO hop SHIPPED v0.3.970 — COMPLETE) — diff the money across documents, not across our own estimates. R25-ESTIMATE-DIFF compares two of our numbers. The gap is the chain bid → executed contract → purchase order → invoice, each hop diffed against the one before it with findings ranked by dollar impact: scope added between bid and contract, invoice lines drifting from a locked buyout price. This is where subcontractors actually lose money, and it sits on top of cost and document control we already have.
Walker SHIPPED —
commercial_drift.py,GET /projects/{pid}/commercial-drift. Premise-checked first and the item was smaller than written: three of the four hops already existed and were already referentially wired (subcontract.awarded_from → bid_submission,sub_invoice.subcontract → subcontract), so bid → contract → invoiced needed no schema change. Why the existing engines could not do it.margin.pyandcost_spine.pyboth measure per cost code, and a roll-up adds before it compares — two subcontracts can net to the right code total while one award drifted +15% and another −15%. There is a test for exactly that: both are reported here and the per-code view sees nothing. Two agreed numbers are deliberately NOT called drift, which is the whole design: a change order is money somebody signed for —subcontract.change_orderssumscor.amount, so it belongs to the contract→invoiced hop as part of the agreed sum and never to bid→contract; counting it as drift would flag every project that has a CO, which is every project. And an unaccepted alternate was never bought —bid_submissioncarriesamount,base_bidand analternatestable with anacceptedflag, so the comparable award figure is base bid + accepted alternates, with the row stating its basis. Comparingamountblindly buys the rejected ones; comparingbase_bidalone makes the accepted ones look like scope from nowhere. A hop missing a figure on either side isincomparableand counted separately — not a zero-dollar difference. All three refusals mutation-checked (4 / 6 / 2 named FAILs). PO HOP SHIPPED v0.3.970 — and the blocker was a NAME. This read "purchase_orderstill does not exist".commitmentis that register: titled "Commitments (POs)", carryingpo_date,amount,vendor,retainage_pct,cost_code— and already referencingsubcontract. No schema change, exactly like the three hops before it.procurement_packageremains an island and is not needed for this walk.Third time in this ring that a capability was filed as missing because it was called something else —
takt.py,bid_leveling.py, and now this. A grep for a name proves a string absent, never a capability.Change orders are excluded from
contract_to_po(a CO is normally followed by its own PO, so a CO-inclusive comparison reports a timing artefact as drift) and every PO against a subcontract is summed rather than the largest taken. The entry is closed.PREMISE-CHECKED 2026-08-06 (no build): three of the four hops exist AND are already linked, and the cost-code axis of this diff is done. The item is much smaller than written. Registers present:
bid_submission(amount,unit_prices),bid_package,prime_contract(value,sov_value),subcontract(value),owner_invoice(amount,retainage_total,architect_certified_amount),sub_invoice(amount). The chain is already referentially wired:subcontract.awarded_from → bid_submissionandsub_invoice.subcontract → subcontract, so bid → contract → invoice can be walked today without a schema change. And the diff partly exists, along a different axis than the entry assumes.margin.py(MARGIN-CBS) already totals budget / committed / actual / billed per cost code, andcost_spine.pyalready asks the harder question — whether one cost code carries the same scope from estimate through budget, commitment and invoice, because a code appearing at one stage and not the next produces a row that looks fine. That is document drift measured per cost code. What is actually missing is two things, not four:- the PO hop has no register.
purchase_orderdoes not exist;procurement_packagecarriesest_costandaward_amountbut has zero reference fields — it is an island, so nothing can walk into or out of it. This is the one genuine schema gap; - nothing diffs amounts document-to-document along the references that already exist — bid
amountvs thesubcontract.valueit was awarded into, contract value vs the sum of itssub_invoice.amount. The per-code roll-up cannot see this: two documents can net to the right code total while the individual award drifted. Sized on evidence rather than the entry's wording, this is S/M — one register plus one walker over existing references — not the M implied by "build a four-hop diff".
PREMISE-CHECKED 2026-08-06 (no build): three of the four hops exist AND are already linked, and the cost-code axis of this diff is done. The item is much smaller than written. Registers present:
bid_submission(amount,unit_prices),bid_package,prime_contract(value,sov_value),subcontract(value),owner_invoice(amount,retainage_total,architect_certified_amount),sub_invoice(amount). The chain is already referentially wired:subcontract.awarded_from → bid_submissionandsub_invoice.subcontract → subcontract, so bid → contract → invoice can be walked today without a schema change. And the diff partly exists, along a different axis than the entry assumes.margin.py(MARGIN-CBS) already totals budget / committed / actual / billed per cost code, andcost_spine.pyalready asks the harder question — whether one cost code carries the same scope from estimate through budget, commitment and invoice, because a code appearing at one stage and not the next produces a row that looks fine. That is document drift measured per cost code. What is actually missing is two things, not four:- the PO hop has no register.
purchase_orderdoes not exist;procurement_packagecarriesest_costandaward_amountbut has zero reference fields — it is an island, so nothing can walk into or out of it. This is the one genuine schema gap; - nothing diffs amounts document-to-document along the references that already exist — bid
amountvs thesubcontract.valueit was awarded into, contract value vs the sum of itssub_invoice.amount. The per-code roll-up cannot see this: two documents can net to the right code total while the individual award drifted. Sized on evidence rather than the entry's wording, this is S/M — one register plus one walker over existing references — not the M implied by "build a four-hop diff".
PREMISE-CHECKED 2026-08-06 (no build): three of the four hops exist AND are already linked, and the cost-code axis of this diff is done. The item is much smaller than written. Registers present:
bid_submission(amount,unit_prices),bid_package,prime_contract(value,sov_value),subcontract(value),owner_invoice(amount,retainage_total,architect_certified_amount),sub_invoice(amount). The chain is already referentially wired:subcontract.awarded_from → bid_submissionandsub_invoice.subcontract → subcontract, so bid → contract → invoice can be walked today without a schema change. And the diff partly exists, along a different axis than the entry assumes.margin.py(MARGIN-CBS) already totals budget / committed / actual / billed per cost code, andcost_spine.pyalready asks the harder question — whether one cost code carries the same scope from estimate through budget, commitment and invoice, because a code appearing at one stage and not the next produces a row that looks fine. That is document drift measured per cost code. What is actually missing is two things, not four:- the PO hop has no register.
purchase_orderdoes not exist;procurement_packagecarriesest_costandaward_amountbut has zero reference fields — it is an island, so nothing can walk into or out of it. This is the one genuine schema gap; - nothing diffs amounts document-to-document along the references that already exist — bid
amountvs thesubcontract.valueit was awarded into, contract value vs the sum of itssub_invoice.amount. The per-code roll-up cannot see this: two documents can net to the right code total while the individual award drifted. Sized on evidence rather than the entry's wording, this is S/M — one register plus one walker over existing references — not the M implied by "build a four-hop diff".
PREMISE-CHECKED 2026-08-06 (no build): three of the four hops exist AND are already linked, and the cost-code axis of this diff is done. The item is much smaller than written. Registers present:
bid_submission(amount,unit_prices),bid_package,prime_contract(value,sov_value),subcontract(value),owner_invoice(amount,retainage_total,architect_certified_amount),sub_invoice(amount). The chain is already referentially wired:subcontract.awarded_from → bid_submissionandsub_invoice.subcontract → subcontract, so bid → contract → invoice can be walked today without a schema change. And the diff partly exists, along a different axis than the entry assumes.margin.py(MARGIN-CBS) already totals budget / committed / actual / billed per cost code, andcost_spine.pyalready asks the harder question — whether one cost code carries the same scope from estimate through budget, commitment and invoice, because a code appearing at one stage and not the next produces a row that looks fine. That is document drift measured per cost code. What is actually missing is two things, not four:- the PO hop has no register.
purchase_orderdoes not exist;procurement_packagecarriesest_costandaward_amountbut has zero reference fields — it is an island, so nothing can walk into or out of it. This is the one genuine schema gap; - nothing diffs amounts document-to-document along the references that already exist — bid
amountvs thesubcontract.valueit was awarded into, contract value vs the sum of itssub_invoice.amount. The per-code roll-up cannot see this: two documents can net to the right code total while the individual award drifted. Sized on evidence rather than the entry's wording, this is S/M — one register plus one walker over existing references — not the M implied by "build a four-hop diff".
PREMISE-CHECKED 2026-08-06 (no build): three of the four hops exist AND are already linked, and the cost-code axis of this diff is done. The item is much smaller than written. Registers present:
bid_submission(amount,unit_prices),bid_package,prime_contract(value,sov_value),subcontract(value),owner_invoice(amount,retainage_total,architect_certified_amount),sub_invoice(amount). The chain is already referentially wired:subcontract.awarded_from → bid_submissionandsub_invoice.subcontract → subcontract, so bid → contract → invoice can be walked today without a schema change. And the diff partly exists, along a different axis than the entry assumes.margin.py(MARGIN-CBS) already totals budget / committed / actual / billed per cost code, andcost_spine.pyalready asks the harder question — whether one cost code carries the same scope from estimate through budget, commitment and invoice, because a code appearing at one stage and not the next produces a row that looks fine. That is document drift measured per cost code. What is actually missing is two things, not four:- the PO hop has no register.
purchase_orderdoes not exist;procurement_packagecarriesest_costandaward_amountbut has zero reference fields — it is an island, so nothing can walk into or out of it. This is the one genuine schema gap; - nothing diffs amounts document-to-document along the references that already exist — bid
amountvs thesubcontract.valueit was awarded into, contract value vs the sum of itssub_invoice.amount. The per-code roll-up cannot see this: two documents can net to the right code total while the individual award drifted. Sized on evidence rather than the entry's wording, this is S/M — one register plus one walker over existing references — not the M implied by "build a four-hop diff".
- the PO hop has no register.
-
◧ R41-UPLOAD-WARK (M — Lane C; the byte-bound half SHIPPED v0.3.876 —
services/api/src/aec_api/bodycap.pymeasures the request body instead of trustingContent-Length, andstorage.put_streamgives callers a way to write without holding the object. The resumable handshake below is untouched, and no upload route has been converted to the streaming write yet — that is what is left) — content-addressed resumable upload in front of object storage. Technique from an MIT-licensed file server (verified from its LICENSE); reimplement the handshake rather than adopt the server. Three parts: chunk size chosen so the chunk count stays bounded, keeping the handshake manifest roughly constant regardless of file size — a fixed part size gives a manifest that grows linearly with a large IFC; an upload identity derived fromhash(salt + filesize + chunk hashes)so resumption is not a special code path (re-handshake, receive the still-needed list) and deduplication falls out for free; and per-chunk hashes catching corruption before IFC-to-Fragments conversion runs. IFC revisions are large and mostly identical between uploads, so an unchanged re-upload currently costs a full transfer.PREMISE-CHECKED 2026-08-06; the hazard it surfaced is now FIXED and half the mechanism landed. The premise holds — nothing is chunked, resumable or content-addressed — but two things changed the shape of it. The hazard, and it was worse than the version I first wrote. I flagged that a chunked upload would defeat
AEC_MAX_UPLOAD_MBbecause the guard readscontent-lengthon a single request and N small chunks each pass. The real mechanism was simpler and already live: with nocontent-lengthheader at all the condition short-circuited and the body was never measured — so the cap was defeatable without chunking anything. Fixed in v0.3.876 bybodycap.MaxBodySizeMiddleware, which counts bytes on the ASGIreceivechannel. Recorded because the correction matters: I reasoned to the right conclusion from the wrong mechanism, and a guard that fails open on a missing header is a different class of bug from one that is out-scoped by chunking. The other half stands and has moved.storage.put(key, data: bytes)was whole-bytes on both backends;put_streamnow exists (local.part+rename, S3 multipart) — but no call site is converted, verified in the tree. So the remaining work is the conversion plus the content-addressed handshake itself, and whatever lands must still cap the assembled size at the handshake rather than trusting a declared filesize. Their sparse-file capability check is this codebase's own house style expressed in a network protocol: on a mismatch it refuses loudly, naming file, chunk index and offset, rather than silently writing a corrupt file.CHECKED 2026-08-06 — the premise HOLDS, and the tree is one step worse than the entry says. Nothing in
services/mentions resumable, chunked, multipart or part-number uploads. Every upload is a single FastAPIUploadFilemultipart POST, andservices/api/src/aec_api/storage.py's interface isput(key, data: bytes)— there is no streaming put at all. Six call sites doawait file.read(), so the whole file is materialised in memory before it reaches storage.So the entry's framing — "an unchanged re-upload currently costs a full transfer" — is a bandwidth argument, and it is right. But the same fact is also a memory argument for a 50 MB IFC, and that half is not in the entry. Note the asymmetry that makes this cheap to miss:
storage.pyalready hasget_range(key, start, end), so ranged reads are supported and only writes are all-or-nothing — the capability looks half-present when the half that matters is absent.Nothing is content-addressed either: storage keys are caller-supplied paths sanitised by
safe_seg/validate_key, and there is no hashing in the storage layer, so deduplication has nothing to key on. The entry'shash(salt + filesize + chunk hashes)identity would be the first content address in the system rather than a change to an existing one.
-
✅ R41-BUNDLER-SPLIT (S — Lane J; DONE v0.3.941) — the suite never exercises the bundler that ships. The app is built with Vite 8 / rolldown (pinned in
apps/web/package.json, installed nested atapps/web/node_modules/vite) and tested under Vite 6 / rollup, from the copy hoisted to the repo root.The stated cause was wrong, and it made this look like a dependency decision it is not. This read "because
vitest@4.1.10depends on vite ^6". It does not: vitest declares^6.0.0 || ^7.0.0 || ^8.0.0in bothdependenciesandpeerDependencies, and 4.1.10 is the current release. Checked against the lockfile, no consumer requires ^6 exclusively —@vitest/mockeris^6 || ^7 || ^8andvite-plugin-pwais^3 … ^8. The root copy is 6.4.3 purely because that resolution satisfies every range and nothing has forced npm to move it.So the remedy is a lockfile dedupe, not a version bump and not a new dependency.
CLOSED v0.3.941 — and the dedupe alone was not enough, which is worth recording. An
overridesentry was the obvious fix and npm registered it (npm lsprintedvite@8.1.5 overridden) while leaving the hoisted copy at 6.4.3 and reporting the treeinvalid. Neithernpm installnornpm install --package-lock-onlyre-resolved it, and deletingnode_modules/vitedid not either — the lockfile still pinned 6.4.3 andnpm installhonours the lock. A registered override with an unchanged lock is the shape to watch for: npm tells you it applied and the tree says otherwise, so trusting the config overnpm lswould have left the split in place while reporting it fixed.What worked was declaring
viteas a root devDependency at the same exact pinapps/webuses. That is one added line, not a new package — the root then dedupes to a singlevite@8.1.5andapps/web/node_modules/vitedisappears entirely.The suite now runs on the bundler that ships: 1,576 tests pass under Vite 8 / rolldown, and faster (26.5 s vs 32.6 s). Typecheck, lint and the production build are unchanged, and the precache output is byte-identical at 902.20 KiB — so this bought the coverage without moving what ships. Consistent between the clone and a worktree, so test results are not unstable — but the test environment is not merely narrower than production, it is a different implementation, and it can agree with you about code the shipping bundler treats differently.
Chunking, CommonJS interop and tree-shaking are exactly where rollup and rolldown diverge, and we assert about all three:
bundle-budget.mjsasserts the vendor split, and the 19.7× shell found in BUILD-WORKTREE-CHUNKS was a rollup-vs-rolldown difference. Same family astest-environment-more-permissive-than-browser: happy-dom vouched for a drop the real browser refuses. Ask not what the test environment cannot see, but where it is a different thing wearing the same name.Not resolvable inside BUILD-WORKTREE-CHUNKS — that item is about which Vite resolves, this one is about the two of them being legitimately different tools. Options are to wait for a vitest that tracks vite 8, to run a smoke suite against the built rolldown output, or to accept it explicitly and write down why.
Two sources are not competitors: a construction-operations consultancy, and a BIM services firm delivering LOD 400/500 models and 5D quantity take-off on data-centre and hospital projects. The latter is a customer and channel profile rather than a threat — a services firm delivering by hand exactly what this platform automates, with a data-centre concentration matching the hotel and data-centre gap already recorded in the proforma asset-class scope. One further source is live-events design and is off-mission entirely. One hardware repository is a bench-top protocol tool with no telemetry, device management or building-automation path, and is off-mission despite a superficially plausible IoT reading — the connection was checked and deliberately not manufactured.
Vendor names and commercial detail are deliberately absent from this file — they live in
docs/internal/, because services/api/test_no_comparative_names.py gates the public docs.
Why this ring exists. An external audit of the deployment surface found that several controls are
weaker than they read: a throttle that counts per process behind four workers, an upload cap that only
exists if requests happen to arrive through the bundled proxy. The shape is familiar — R35's theme of
"a lock the backend ignores" applied to the ops layer. Already landed from the same audit (do not
re-open): the converter build stage moved to the supported Node LTS with a pinned digest
(services/api/Dockerfile), a Content-Security-Policy with a no-inline-script gate
(apps/web/nginx.conf + apps/web/src/deploy/nginx.test.ts), the multi-worker sidecar-lock boot
refusal (services/api/src/aec_api/main.py), and full-history checkout for the secret-scan job.
-
BUILD-WORKTREE-CHUNKS (M — Lane J) — a
vite buildfrom a git worktree succeeds and emits the wrong bundle. One commit, two checkouts: thethree-*.jsandthatopen-*.jschunks vanish and the eager shell goes from 334 KB to 6,581 KB — 19.7× — at exit 0.searchForWorkspaceRootreturns the worktree root rather than the repo root, deps fall back to CommonJS interop, and theadvancedChunksrules never match. Resolution is NOT the cause —threeand@thatopen/componentsresolve to identical absolute paths in both checkouts.Three things had to line up for it to stay silent, which is the part worth keeping:
apps/web/scripts/bundle-budget.mjscomputed and printed the lazy-chunk count without asserting it; the only objection came from the PWA precache limit, an accident rather than a check anyone wrote; andVitePWAis excluded whenVITE_PAGES=1, so on the public-facing path even the accident is absent.apps/web/vite.config.tshad already written the warning in its own comment — "verify by grepping the OUTPUT, never by reading the config and believing it" — and nobody was doing it.Half closed in v0.3.874:
apps/web/scripts/copy-wasm.mjsresolves the package instead of guessing directory depth (the fix already existed one file away inapps/web/vitest.config.ts, andcopy-wasm.mjsimportedcreateRequireand left it unused), andbundle-budget.mjsnow asserts the vendor chunks exist. What remains is workspace-root scoping inapps/web/vite.config.ts, deliberately not taken inside a tooling PR: shared config, blast radius across every lane, and the fix is scoping rather than resolution. Interim policy, now in the gate's own failure message: builds happen in the main clone or in CI; a worktree build is for typecheck and tests, never a shippable bundle.Still open beside it:
npm run budgetis absent from.github/workflows/pages.ymlentirely, so the public build has neither guard. Being fixed as a follow-up. -
◧ R39-UPLOAD-CAP-APP ① (S, Lane C — FRONT HALF SHIPPED v0.3.876; the conversion of the 36+
await file.read()call sites ontostorage.put_streamremains, and is the rest of R41-UPLOAD-WARK. premise corrected 2026-08-06: an app-level cap DOES exist*, so the item is not "add one" but "make the existing one measure rather than trust")* — the entry said the cap lives only in nginx (client_max_body_size) and that a deployment exposing the API directly has no cap at all. That is wrong:services/api/src/aec_api/main.pydefines_MAX_UPLOAD_BYTESfromAEC_MAX_UPLOAD_MB(1 GB default) and thesecuritymiddleware rejects oversized bodies with a 413.The residue is exactly the sentence the entry already wrote as its prescription, and it is worth keeping for that reason: "count as chunks arrive and cut off at the limit, never buffer-then-measure". The present guard does neither — it is header-derived, and its own comment says so ("cheap Content-Length check — avoids reading them into memory"). A request that does not carry that header is not measured. So the work is to make the limit a property of the bytes received rather than of what the request declared about itself.
Deliberately not spelled out further here: the repo is public and this is a request-handling boundary. The precise reachability note went to the release holder for
docs/internal/, per the non-negotiable that security detail stays out of published docs.How to size it, so the next reader neither panics nor dismisses it. An unbounded body read is DoS-shaped, and the standing security-review policy explicitly excludes DoS and memory-exhaustion from vulnerability reporting. That does not make it a non-issue — it makes it tracked engineering work rather than an incident. No drop-everything fix; a real entry with a real fix.
It composes with
R41-UPLOAD-WARK, and neither entry could see this alone.services/api/src/aec_api/storage.pyhas no streaming put: the protocol isput(key, data: bytes), and 36 call sites across 15 router modules doawait file.read()before handing the bytes over — counted 2026-08-06. So the same request is both unmeasured and fully materialised: this entry bounds it from the front,R41-UPLOAD-WARK's chunked handshake bounds it from the back, and "count as chunks arrive" is the identical instruction from either end. Two entries, one mechanism — fix either in isolation and the other still holds the memory open. -
R39-A11Y-JOURNEYS ② (M, Lane B) — keyboard-only acceptance journeys for the seven rooms, encoded as tests rather than an audit doc: for each room, tab-reach the primary action, operate it, and land focus somewhere sane. The a11y sweeps so far checked attributes; nothing yet checks a journey, and a journey is what a keyboard user actually has.
-
R39-VIEWER-OBS ② (M, Lane E) — the viewer has no timing record: "loads slowly" arrives as a feeling, not a number. Instrument the load journey (fetch → parse → first frame, keyed by model size) and POST the timings to the platform's own API — no third-party telemetry, nothing new to approve — so p50/p95 by model-size bucket is a queryable fact before any perf work is prioritised.
-
🟡 R39-DECOMP-VIEWER ③ (L, Lane E — started 2026-08-06: the ratchet is pinned and the seams are measured; the extraction is NOT begun, and the reason is below) —
apps/web/src/viewer/app.tsis the last of the three god-files still standing (client.ts was split by SCALE-SEAM, portal.ts is REL-4).⚠️ The recipe as written does not apply, and this is the finding that matters more than the split. It says "the suite as the parity gate". Nothing in the suite importsapps/web/src/viewer/app.ts— it has zero test coverage. A full suite run after moving 871 lines of it would be green whether or not the move broke the viewer, because the suite never touches the file. SCALE-SEAM was safe for a different reason than "we ran the tests":client.tshadapps/web/src/api/surface.test.ts— "the API client's public surface, pinned" — asserting every method still existed after each cut. There is no viewer equivalent and there cannot easily be one, becausecreateViewerAppneeds a WebGL context and a Fragments worker, which is exactly why the file has no tests in the first place. Do not read a green suite as parity here.What can serve as a gate instead, and it is not nothing: make every extracted dependency an explicit typed parameter, so a capture you fail to thread is a compile error.
tscthen gates the move. The one failure class it cannot see is a stale closure — and that is removed by construction if mutable state is passed as an accessor rather than a value. Which matters here:projectIdisconst(safe by value) butselectedGuidandlastPointarelet, so passing them by value would compile cleanly and silently freeze whatever they held at panel-build time.The seams, measured 2026-08-06 (
app.ts= 5,160 lines; pinned inservices/api/test_file_sizes.pyat that number before any cut, so the extraction has to beat the unimproved figure):block lines size closure captures buildToolsPanel()1769–4840 3,071 (60% of the file) — ├ buildersmap3542–4798 1,257 ~16 │ ├ qa3594–4464 871 ~12 │ ├ analyse4465–4704 240 ~14 │ ├ authoring4705–4798 94 ~5 │ └ exports3543–3593 51 ~5 buildPanels()1385–1536 152 — The seam is cleaner than the capture counts suggest, and that is the useful part: the builders compose two helpers —
section(key, title, opts)andtoolBtn2(…)— that are themselves closures declared insidebuildToolsPanel. They can be handed over as functions, carrying their own state, instead of their state being re-plumbed. So a builder extracts asbuildQaSection({ section, toolBtn2, api, … }).The absence is self-reinforcing, and it decides the order. This file has no tests because it needs a WebGL context and a Fragments worker — and it is risky to split for the same reason. The thing that makes coverage hard is the thing that makes change dangerous, and every year of that makes the next split harder. So the order is not "smallest first" for its own sake: extract the parts that do NOT need a renderer first, precisely so they become testable. A builder that only composes
section()andtoolBtn2()and calls the API is pure DOM assembly — once it is a module taking explicit parameters, it can be unit-tested for the first time, and the next extraction has something to lean on that this one did not.Start with
exports(51 lines, 5 captures) — the cheapest place to prove the recipe, and no renderer in it. Thenqa: 871 lines, one of the four independent builders, and R24-TOOLS-SPLIT already gave it internal structure.
Parked from the same audit: brotli — the build already emits .br siblings and the stock
nginx image cannot serve them; switching base images is a dependency decision for the user.
Web-vitals telemetry via a third-party package — same reason, new dependency.
Audit 2026-07-25 (live, against the running stack). Measured rather than opined: 170 visible
controls, 0 unlabelled; 0 console errors; 20/20 first-class Design destinations render real
content (the "Design tab is blank" report was a measurement error on my side, not a defect — innerText
returns empty for anything not laid out, and clicking rebuilds the nav, detaching the node being
measured). Two density defects were real and are fixed in v0.3.677: Build held 13 entries of
which 7 were project accounting, and Model & standards held 14 mixing project rules with model
findings. Split into Build/Money and Model & standards/Analyse & check — max group 14 → 7, nothing
removed. Remaining, in priority order:
- ⭐ UX-READINESS-EVERYWHERE (M; superseded by R24-READINESS-HOME, kept for its evidence) — the app already contains its own "simple stupid" front door
and hides it. The Master Builder panel is a live 8-step readiness synthesis: each step reads
ready / partial / gap against real project data, names exactly what is missing ("needs: Jurisdiction
so code editions + loads resolve"), and offers → Close this gap straight to the tool that fixes
it — plus an honest disclaimer that labels reflect what is present, not what is correct. That is
precisely the "tell me what to do next" surface a builder/developer/architect/engineer wants on
opening a project, and it is reachable from exactly ONE destination inside ONE workspace (Design).
Promote the readiness strip to every workspace dashboard, scoped per persona.
Model Health(8 references),Model Analysis(5) andBIM KPIs(5) are three destinations whose names do not tell a user which answers their question; all three now sit together underAnalyse & check, which makes the overlap visible and worth resolving rather than hiding it. - UX-GANTT (M) — weekly Gantt/calendar hybrid with inline % + crew coloring + a metric strip.
- UX-DUP-DESTINATIONS (S — checked 2026-08-06 and still genuinely OPEN; recorded so the next reader does not re-check) — all three destinations are still present and distinct in the tree:
- UX-3 library depth — thumbnails · drag-to-place · pick-host→auto-build · appendable IFC libraries · CC0 seed/H1. UX-4 one-shell layout (a11y/mobile pass).
- SPRINT C — FIELD-PWA (L, frontend) — offline-first mobile PWA: service-worker sheet sync, auto slip-sheeting, hyperlinked callouts. Ships build/typecheck-verified under the preview-stall caveat.
- PHOTO-PIN (L) — photo/360 pinning to plan locations + timeline compare.
- CMMS-OPS (L) — preventive-maintenance plans + work orders on the COBie assets (ASSET-REG shipped the first slice).
- A2 RAG index (M) — an offline index over the ifcopenshell / IFC docs for the authoring assistant.
- SITE-1 remaining (S–M) — parcel overlays (terrain DEM auto-fetch is network-dependent → flagged, offline-degrading).
An industry essay on making AEC formats legible to software. Reviewed for gaps; it names almost nothing we lack, which is worth recording so it is not re-read as a source of work.
Its central line is one we arrived at independently three hours earlier: visual plausibility is not
physical validity. That is precisely R31-SCHEMA-DIAG's finding — a model that renders correctly,
passes IDS rule-compliance, and is structurally invalid IFC. Its other architectural claims map onto
things already built or already scoped: route deterministic questions to specialist tools rather than a
model (query_dsl is the deterministic selector; the AI command bar goes through validators and
recipes), externalise implicit structure as a graph (docgraph.py, graph.py, W9-4), preserve
provenance for verification (R24-TRACE-UI ②, COST-DB vintage, the derived / declared / unlinked / unavailable tagging), constraint systems around generation (R23-CONSTRAINTS), and accumulate
ground truth across completed projects (COST-DB, vendor memory).
Its observation that there is no canonical way to organise a building model is the problem the room
spine, the discipline tree and classification.py exist to answer — and, per the user, the document
taxonomy should be derived from those same rooms rather than invented again.
One external measurement worth keeping, because it puts a number on an item we already hold: current models score 40–55% on object-counting from drawing sets, with symbols and linework the weakest part. That is direct corroboration of R23-SYMBOL-COUNT (Lane B) and a reason to treat it as higher-value than its size suggests — it is the measurable floor under every takeoff claim.
The framing worth adopting even though it is not a feature: reduce verification cost, not just production cost. Several items already do this without saying so; it is the sharper way to argue for them.
Fifteen sources reviewed: five open-source repos, five commercial products, two engineering-practice articles, one capital-allocation essay, one curated finance list, one profile. Most describe things we already have — that is the honest headline, and the rejected list below is the more useful half of this scan, because it stops the exercise being re-run.
One genuinely new build item, one strong corroboration, three gap-checks.
-
R31-K1-PACK(was S/M) — the one genuine remainder of R31-SYNDICATION-TAIL.capital.py:90already states the boundary in the statement PDF itself: "…is informational and not a tax document; K-1s are issued separately." That sentence is the spec. Everything a K-1 pack needs upstream — per investor contributions, distributions, unreturned capital, class rollup — already exists and is reached; what is missing is the allocation and the document. Well-bounded precisely because the boundary was written down rather than left implied. -
◧ R31-CITE-HIGHLIGHT (re-headed 2026-08-05 — this heading contradicted its own body, and the live entry is the Band 2 one) — it read "S — premise HOLDS, and it is far cheaper than written" while the
⚠️ CORRECTION further down this same entry establishes that the viewer half is not available and needs a decision about the vendored kernel repo. A reader taking the ⭐ and the "S" at face value — which is what a starred size is for — would never reach the paragraph that withdraws them.The two entries found different, independent blockers, and both are real: this one found the highlight function is module-private inside vendored code, and Band 2 found the
doc_idresolves to no openable document. Neither alone is the whole story, which is why this is folded in rather than deleted. — checked 2026-07-31. Confirmed: we cite document and page and do not highlight the passage.aiassist.ts:334renders"Source: p.12"as inert text — not a link, and nothing calls the viewer. Citing a 40-page PDF and citing a paragraph are different products.✅ HALF SHIPPED 2026-07-31 — the data half is done.
doc_text.answer()now carriesdoc_idinto every citation, andrfi_qa.pyprefers it over the display name and passes the snippet asspan. Pinned end-to-end:test_doc_textasserts every citation carries adoc_idand that the id resolves against the catalog — an id matching nothing is as dead as a name. Mutation-checked.The dropped field was the real blocker and is worth remembering as a shape:
search()always produceddoc_id,answer()rebuilt the citation list without it, and both functions read correctly on their own. The defect lived in the seam.⚠️ CORRECTION to this entry's own cost estimate — the viewer side is NOT as available as recorded. It was written here (from the gap-check) thatfind(page, query, limit)andflash(v, page, box)were callable. Checked against the file:vendor/massingpdf/plugins/search.tsexports onlyfindInWordsandsearchPlugin.flashexists — at line 279, drawing the rect and scrolling to centre exactly as described — but it is module-private, invoked only from a click handler on a search-result row (line 247). And nothing outside the vendor tree imports the plugin at all.So the remaining work is not "call the existing function". It is: expose a highlight entry point from the plugin, then have
portal/panels/aiassist.ts:334— which today renders"Source: p.12"as inerttextContent— open the document and drive it. The catch worth pausing on:vendor/massingpdf/is vendored from the separateMassingCloud/massingifckernel repo, so an edit there is either lost on the next re-vendor or has to go upstream first. That is a real decision, not a line of code, and it is why the frontend half is not claimed as trivial.Still true and still the reason this is cheap overall: no stored bbox is needed (
extract_pdf_textis pypdf and discards positions, so storing one would have forced a new extractor and possible AGPL exposure), because the passage text now travels in the citation and the client can re-find it.
An external essay on construction capital allocation argues the industry's real gap is that the "what should this cost and where should the money go" decision sits outside the software, fragmented across estimates, value-engineering exercises and tribal knowledge. Its named requirements are structured data instead of PDFs, historical cost patterns across similar projects, current pricing — and "decision context linked to estimates, not isolated numbers."
We have the first three (COST-DB with vintage + source, benchmarking, ESTIMATE-DIFF). The fourth is
exactly R24-TRACE-UI ② as re-scoped on 2026-07-29: make the proforma emit its own derivation, each
figure carrying its inputs and a model-derived / overridden / market-assumption tag. Independent
external confirmation that the re-scope picked the right work — and a reminder that its value is the
basis, not the UI.
Reviewed at the user's request. It is a policy document: a strategic framework for public-sector bodies introducing BIM at national or programme level, organised as four action areas — establishing public leadership · communicating vision and fostering communities · developing a collaborative framework · growing client and industry capability and capacity. Three of the four are governance and procurement, with no software implication at all.
The fourth, developing a collaborative framework, is the one that could have mapped to product work — in EU BIM terms it means standardised information requirements, open data formats and a common data environment, i.e. ISO 19650. Premise-checked against the repo, case-sensitively and word-bounded:
| concept | where it already lives |
|---|---|
| CDE state machine | modules/information_container/module.json — real states wip → shared → published → archived |
| EIR, as an authored artefact | modules/info_requirement/ — a register, not just a referenced term |
| BEP · MIDP · TIDP · LOIN · suitability | present across 13–33 code files each |
| open format as source of truth | IFC is the non-negotiable in CLAUDE.md |
So the handbook's technical content is implemented, and its strategic content is not ours to implement. Recorded rather than deleted so the next agent handed this PDF does not re-derive it.
Two process notes. The supplied PDF has no text layer — 57 chars extracted from 57 pages by both
pypdf and poppler's pdftotext, no embedded page rasters, text drawn as vector outlines — and there
is no rasteriser on this machine, so it was read from the published edition and web sources instead.
And the first coverage measurement was wrong in the reassuring direction: a case-insensitive EIR
matches "their", MIDP matches "midpoint", so the initial grep reported 317 files and would have
supported "already covered" without evidence. The conclusion survived a correct measurement; the point
is that it was reached first by a broken one. See [[confident-wrong-beats-missing]].
| source | why not |
|---|---|
| A PolyForm-Noncommercial multi-agent orchestrator | Licence excluded. MIT/BSD/Apache only; noncommercial forbids our use regardless of merit. |
| DuckDB-WASM spatial GIS platform (MIT) | In-browser spatial SQL is genuinely interesting, but it is a heavy new dependency and parcels / GIS already serve site analysis. Revisit only if offline spatial SQL becomes a requirement, not a curiosity. |
| Curated systematic-trading library list | Domain mismatch — market microstructure and HFT do not transfer to property cash flows. The one transferable idea (portfolio optimisation) is R31-PIPELINE-ALLOCATE above, without the dependency. |
| A Revit QA/QC add-in (MIT) | C#/.NET against the Revit API — unusable here. Its check list (missing/duplicate mark, wrong level, element count, health score) is already ~covered by model_qa + model_warnings. |
| Reference-closure element extraction | Already covered. SUBSET-EXPORT prunes to a QUERY-DSL slice via remove_deep2 with the spatial skeleton preserved, and editPreview returns a single-element fragment. |
| "Keep the agent instruction file under 200 lines" | Checked: CLAUDE.md is 55 lines. No action — and worth recording that the check was run, since the alternative is assuming. |
| An Apache-2.0 agent-evaluation harness (harbor) | Not imported. Licence is fine and it runs locally on Docker, but it is an evaluation harness with no training path — running it changes no behaviour here — and it needs an API key plus a container per task, against an offline/$0 constraint. Its unit of work (a task plus a verifiable check) is what run_tests.py and vitest already are, for free and already wired to CI. The transferable idea, offered to the user and not yet adopted: a regression corpus of the defects that passed a green suite here — the fake-link fallback, the 100%-wrong GP promote, the surface gate with 13 methods of slack, the lane gate that was red and untracked. Each is a case where a check existed and could not see the failure. Deliberately left uncoded until the user picks it up, so nobody claims an item nobody agreed to. |
| Commercial construction PM / AI-workspace / syndication products (five reviewed) | Capabilities reviewed and already covered: document Q&A with citations, takeoff on drawings, registries, schedule editors, 2D→BIM extraction (plan_to_bim), waterfalls. Named generically per the standing directive that competitor names stay out of repo docs; the one real gap they surfaced is R31-SYNDICATION-TAIL. |
Two engineering-practice sources describe what this session already does, which is worth recording as
confirmation rather than as work: a second agent context finds bugs the first introduced — that is
precisely how the glTF uint32 gap, the GP-promote error and the surface.test.ts slack were all caught
today, each by someone other than the author. And "prove to me this works" beats accepting an
implementation — the mutation-check habit. The failure modes the other article names (cascading
instability, security blindness, unmeasured debt) are what CodeQL-after-every-push, the ratchets and the
full-suite-on-merged-tree runs exist to prevent.
An external security audit and a directed race-condition sweep ran the same day; three defects were
fixed and gated immediately (v0.3.817), and the remainders below are coded items. The sweep's method
is the reusable part: list every read-then-write seam, then ask what holds the world still between
the read and the write — and on WHICH backend. A lock the backend ignores is worse than no lock,
because the code reads as protected. with_for_update() is a no-op on SQLite, which is a supported
deployment backend — that single fact produced duplicate human refs under four concurrent creates,
measured by the new test_race_conditions.py the first time it ran.
Shipped 2026-08-01:
- ✅ job claim is now compare-and-swap — two workers sharing one database could both mark the
oldest queued job
runningand execute it concurrently ("handlers are idempotent" covers a crash-recovery re-run, not two copies interleaving live).UPDATE … WHERE state = 'queued'has exactly one winner across all workers; losers advance to the next job rather than sleeping. - ✅ ref allocation is a single atomic increment —
UPDATE … SET n = n + 1 … RETURNING nreplaces read-modify-write under a row lock that only Postgres honoured. Counter seeding survives losing the first-create race via a savepoint (theconsume_stepuppattern) instead of surfacing the PK refusal as a 500. - ✅ secret scanning is a suite gate (
test_no_secrets.py), not a paths-filtered workflow — a paths filter is how the lockfile gate sat red and unseen for three releases. Seven credential patterns at zero tolerance over every tracked file; the sanctioned dev constants (the guard's own subject matter) pinned to an allowlist whose every entry is asserted live. - ✅ the audit's Critical #1 was already closed —
_production_guard()refuses to boot on any non-SQLite DSN orAEC_ENV=productionwith a default secret / RBAC off / trusted X-User / default object-store creds, tested intest_prod_hardening.py. Recorded because the audit read only theAEC_REQUIRE_SECRETbranch and called the fallback unguarded — an audit that misses an existing control still tells you the control is hard to find.
Open:
- ◧ R35-DEAL-MEMORY (M —
deal_memory.pyshipped) — the platform's own closed deals as a comp database: when underwriting a new deal, surface this portfolio's realised outcomes (exit cap achieved vs assumed, actual lease-up months, cost/SF by vintage) beside the assumption being entered. External research (2026-08) puts this "institutional knowledge" layer as the least-commoditised part of the AI-underwriting stack — and it is the one layer that cannot be bought, because it is made of the operator's own history. Builds onbenchmarking.py's cross-project aggregation and the provenance spine; no new dependency.
Also settled, no code change: the Fragments converter stays Node, by constraint — the Fragments
serializer exists only in the JS kernel libraries, so services/converter/ is the one deliberate
non-Python server component (an isolated, subprocess-shaped CLI; everything else server-side is
Python). Revisit only when a Python Fragments writer exists upstream.
A full task list from a repowise health scan: 2 import cycles, 11 oversized files, 139 dead-code findings (~1,075 lines), 349 single-owner hotspot files, 636 small local refactors. It is real work — and its index is dated 2026-07-17 (commit f3b171f0 = v0.3.363), which is 455 releases behind main at v0.3.818. The first draft of this sentence said "~150", an unverified guess that understated the staleness threefold — in the one section whose whole thesis is that unverified numbers get people hurt. Counted from the tag list, not estimated. Every claim must be premise-checked against TODAY's tree before acting; several are already known-wrong:
- Its §4 ("9 high security findings, detail paywalled") is already covered and mostly closed —
CodeQL runs on every push with 0 open alerts,
pip_audit/npm auditran 2026-08-01 (pypdf floor raised; diskcache advisory has no fix and is monitored), andtest_no_secrets.pyscans every tracked file in the suite. Do not re-open this as if unknown. - Its dead-code list predates the reachability sweeps (R31/R32/Band 3) that deliberately WIRED
several of the named symbols. Example class:
validate.pyanddocgraph.pysymbols were "unused" in mid-July and have callers now. The check per symbol is the usual one —git grepthe name including string/registry references, then delete or wire, never assume. - Its hotspot list (§3) is corroborated independently:
main.ts,portal.ts,client.tsare the repo's own known god-files, and SCALE-SEAM already splitclient.tsby domain after this index was taken. Credit what shipped; keep the rest.
-
◧ R37-TRIAGE (M — Lane C; do FIRST, before any deletion or split)
STEP 3 RE-DERIVED v0.3.973, and the derivation is the finding. The entry said the dead-code list "should be re-derived, not triaged". Done, and the useful output is not the list but how far a wrong population misses by:
population rule candidates public functions in aec_apinever referenced by name877 of 1,993 …excluding decorated functions (FastAPI handlers are reached by decorator, never by name) 35 …and counting string literals as references, over services/apiandservices/dataand the test tree13 877 → 13 without changing a single threshold. Every reduction was a correction to what counts as a caller, and the first number would have been shipped as "44% of this package is dead" by anyone who ran the obvious query. This is [[derive-the-population-and-the-reach]] with a 67-to-1 error bar on it. The 13 are candidates, not corpses — string dispatch through a registry and
__all__re-exports can still hide a caller, so each needs reading before deletion.The list:
discipline_names·excluded_import_names·input_fields·map_procore_change_event·map_procore_rfi·map_procore_submittal·project_with_source·quadrant·register_recipe·scorecard_inputs·search_filter·sync_property·(deleted v0.3.973 — see below).verify_stepup_tokenOne of the 13 was worth the whole exercise.
auth.verify_stepup_tokenran identical signature, expiry, action and password-fingerprint checks toverify_stepup_claimsand returned only the subject — so a caller reaching for it could not spend thejti, and the step-up assertion it verified stayed replayable. A step-up exists to attest "a human confirmed THIS act", andrbac.consume_stepupspends the jti againststepup_spentprecisely so a captured token cannot seal a stack of documents. Nothing called the weak one, which is the reason to delete it rather than a reason to leave it: two verifiers where one silently drops replay protection is a footgun whether or not anyone has picked it up.services/api/test_stepup_single_verifier.pyasserts the property — every step-up verifier returns something a caller can spend — rather than the absence of a name, because a grep for a deleted name passes forever and reads as coverage.Steps 1–3 triaged in v0.3.865–867 on measurements rather than recollection: cycles ALREADY-CLOSED and gated on both sides; the oversized-files list names the wrong files (
app.tssits at 97% of ceiling, the named candidates at 13–19%); the dead-code list should be re-derived, not triaged. Step 4 is explicitly Lane A and not routed here; step 5 is opportunistic. What remains needs a dependency decision. Original: re-run the backlog's claims against main: for each §2 symbol, grep for callers today and mark delete/wire/keep with the evidence; for §1's cycles, confirm the edges still exist; for §1b's split candidates, compare against the REL-3/REL-4 decompositions already landed. Output: this section rewritten with each item marked VERIFIED-OPEN or ALREADY-CLOSED, so the execution order below runs on facts.
Execution order after triage (the backlog's own, amended). Paths are directory-qualified because
both basenames the backlog cites are ambiguous — modules.py and codecheck.py each match two
tracked files, and the wrong-directory misroute is exactly how lanes collide:
-
Break the two cycles— ALREADY-CLOSED, verified 2026-08-04, and both are now gated.services/api/test_import_cycles.pyreports zero top-level cycles across 516 first-party modules and 968 import edges, andapps/web/src/no-import-cycles.test.tspasses on the web side. Thedb.pyring is gone in the direction that matters:models.pydoesfrom .db import Baseanddb.pyimports nothing back.panelContext.tsstill exists — the file was never the problem, the edge was, and the edge is gone.Recorded rather than deleted because the backlog's index is 455 releases old: the useful output of triage is "this was true and is not any more", not a quietly shortened list. Nobody needs to re-derive it, and if a cycle returns, the two gates fail before anyone reads this.
-
MEASURED 2026-08-04 — the backlog names the wrong files, and the real one is nearly out of room. Against this repo's own ratchet (
services/api/test_file_sizes.py, CEILING 5200):apps/web/src/viewer/app.ts 5064 97% of ceiling <- 136 lines of headroom apps/web/src/api/client.ts 3967 76% .../portal/register/register.ts 2162 42% services/api/src/aec_api/modules.py 988 19% services/api/src/aec_api/main.py 697 13%The backlog's candidates are 13–19% of the ceiling; they are not the problem.
app.tsis, and the next feature that touches it reds the build — v0.3.861 put 42 of those lines there for the verification photo button, so this is a live constraint, not a projection. Splittingapp.tsis Lane E and a real piece of work; it is named here so nobody spends the effort onmodules.pyfirst and reports the file-size item as addressed.Retained below because "982 lines" is now 988 — the file grew while sitting on a to-split list, which is the other reason a stale backlog costs: it makes work look done that is quietly getting worse.
Original: split
services/api/src/aec_api/modules.py(982 lines — Lane C) andservices/api/src/aec_api/main.py(Lane G convention applies: announce first, it is a shared file). The backlog's "codecheck.py" split almost certainly meansservices/api/src/aec_api/routers/codecheck.py(614 lines) — that is the routers carve-out Lane C does not own; whoever takes it claims it as a routers change, not under this item. The non-routerservices/api/src/aec_api/codecheck.pyis 184 lines and needs no split. -
Delete only VERIFIED dead exports — and TRIAGING THIS LIST IS THE WRONG MOVE, verified 2026-08-04. Three cheap measurements, together decisive:
- The two examples this section already suspected are confirmed stale:
validate.pyhas 3 importers today anddocgraph.pyhas 2. Called "unused" in mid-July, wired since. - The cheap dead-code classes are already gated —
ruff --select F401,F841(unused imports, unused locals) passes clean acrossservices/api/srcandservices/data/srcon every push. Whatever remains in the 139 findings is symbol-level: exports defined and never called, which ruff does not look for. - Finding those needs a tool this repo does not have (
vultureis not installed), and adding one is a new dependency — the user's call, not a triage step.
So the real choice is not "triage 139 findings vs skip them", it is re-derive vs triage a 455-release-old list. Each stale entry costs a
git grepincluding string and registry references — a symbol reached only through a registry looks dead to every naive check — and the two spot-checks suggest a high false-positive rate. Triage would spend that cost and still produce a list bounded by July's tree.Recommended: leave this step closed as specified; open a fresh scan as its own item once the dependency question is answered.
- The two examples this section already suspected are confirmed stale:
-
The web-side refactors (
apps/web/vite.config.ts,apps/web/src/main.ts) are Lane A work and belong to R36-RAIL-SCOPE's owner — they are listed here for sequence only, not routed by this Lane C item; a C session must not pick them up off this list. -
Hotspot tests, then small-effort batch work when already in a file.
The user's asks, verbatim in spirit: the left rail must show only the current room's tools; drawings and specs have no way back to Design and should integrate with the model viewer as one subapp; the Author menu needs its "More" tools promoted and its groups split; and every room must be analysed for what its role actually needs first. Audited before planning — the facts:
- No tool is unassigned —
spine.tsrefuses to file a destination without a room and surfaces the unrouted list in the rail (destinations.test.tsgates it). The defect is different: the rail renders every room's group with the current one merely opened, so every room shows all tools. Filtering exists as disclosure, not as scope. - The drawings/specs dead end is real —
drawings.tsrenders into its own workspace with no back affordance and no route into the viewer; a user's only way out is knowing the room tabs are the navigation. Specs behaves the same. destinations.test.ts:19still says "the five that exist" over a seven-room assertion — label drift only, the assertion readsROOM_IDS.
External reference points (2026-08 scan): browser CAD/BIM tools that feel coherent share three choices — one canvas, many modes (2D sheets and 3D model are views of one subapp, switched in-place, never separate pages); tool scope follows context (the palette shows the active mode's verbs, with a command bar as the escape hatch to everything); and role-shaped landing content (the first screen of a work area answers that role's first question, not a generic dashboard).
-
◧ R36-VIEWER-SUBAPP (L — Lane E; SLICE 4 SHIPPED v0.3.918 —
apps/web/src/viewer/canvasMode.ts) — the mode switch is in. The canvas is now one surface at a time (Model ▸ Sheets) rather than 3D with a strip attached: the plan wasposition:absolute; right:0; width:38%, so 2D was a slice of the model rather than a peer of it. Visibility is DERIVED from the mode, so "both visible" and "neither visible" are unrepresentable rather than merely discouraged, and the old "◫ Plan beside model" toggle now routes through the switch so one thing owns the pane. A refusal carries a reason — Sheets before a project is open says so, because a tab that swallows the click reads as broken.Slices 1–3 were the print path this depended on, and the roadmap's own sequencing note was right that it had to come first:
axonreaching the shipping dispatcher (it had been drawing a plan titled ISO VIEW), theviews=grammar, and "place this view on a sheet". Without them the switch would have exposed 2D and 3D as non-peers immediately.SPECS SHIPPED v0.3.920 — all three modes are real.
apps/web/src/viewer/specPane.tsrenders the 3-part MasterFormat manual as a canvas surface, and selecting an element reveals its section. It needed no backend work:specmanual.pyhas servedelements: [{guid, name, ifc_class}]per section since it shipped, and the client's return type simply never declared the field — so the section↔element link looked like unbuilt work while sitting in every response. Exact mirror of the sheet-params defect: there the client SENT keys the route ignores, here it IGNORED keys the route sends. A contract believed rather than read, in both directions.The
elementslist is capped at 50 per section whileelement_countis the true total, so the pane distinguishes "no spec section" from "not in the first 50, so I cannot tell" — the second reported as the first would be a statement about the payload posing as one about the model.The v0.3.918 guard that forbade registering
specswithout a surface now guards the general rule, and a mutation showed it had been too weak: it matched the stringspecPaneanywhere, which the mode's ownenter/leavesatisfy, so a mode wired to a pane that is never built would have passed. It now requiresnew SpecPane(AND theappendChild— "built but never appended" being this repo's most repeated defect.Not verified live.
createViewerAppneeds a WebGL context and a Fragments worker and the dev-preview geometry loader stalls, so the tab strip has not been seen in a browser. 11 unit tests cover the switch's behaviour andtsccovers the wiring; the DOM is unverified and said so.Slice 5, measured v0.3.919 — the model↔sheet half ALREADY WORKED, by accident. Picking in 3D and switching to Sheets does carry the GlobalId, because three unrelated mechanisms happen to line up:
onSelectionChangedfires whether or not the pane is visible and storesselbefore touching the DOM;dock("full")forces a refresh;refreshends by re-applyingsyncPlanHighlight. Remove any one and the feature vanishes silently — the plan renders, nothing is lit, and it reads as "that element isn't on this level".PlanPanehad no instance-level tests at all, so nothing would have noticed.apps/web/src/viewer/planPaneSelection.test.tsis now the thing that fails first.Slice 6 scoped by measurement, 2026-08-09 — it is an INTEGRATION, not a build. The markup and takeoff layer is already complete and wired: all five client methods (
drawingMarkup,addDrawingMarkup,saveDrawingMarkups,deleteDrawingMarkup,promoteDrawingMarkup) have callers, the read side has four, and every one of them is inapps/web/src/drawings/drawings.ts. Nothing needs writing; it needs to be reachable from the Sheets canvas so a drawing is marked up where it is being looked at, rather than in a different room.The one real design question, and the non-negotiables answer it. Markups key on a sheet id — a persisted document record — and the viewer's Sheets mode renders
plan.svg?storey=…&scale=…, a live cut with no record and no id. So a generated plan needs an identity to attach markups to. Keying on the storey NAME would look natural and is wrong: levels can be renamed here, and every markup on that level would orphan silently. Per the non-negotiable — reference by IFC GlobalId, never transient ids — the key is the storey's GlobalId. That is a rule the project already holds, not a new decision, and it is the reason this is specified rather than open.Cost that follows:
PlanPaneasks for a storey by name (activeStorey()), so slice 6 also has to carry the storey's GUID down to the cut request. The existing${sheet.id}#pdfconvention shows the codebase already namespaces markup stores by suffix, soplan:<storeyGuid>fits the pattern that is there.Remaining: the keynote → spec section link (the spec surface now exists; keynotes do not yet carry their section code), and slice 6 as specified above.
-
R36-ROOM-BRIEFS (M — Lane B; one room per release) — per-room, per-role landing priority: each room opens with the three answers its primary role needs (superintendent in Schedule: today's lookahead, blockers, yesterday's variance; developer in Deal: returns vs guardrails, open diligence, next decision gate). Write each brief as a short spec in the room's panel file header, then make the panel match it. The Work room already does this by construction; it is the template.
-
◧ ⭐ SCALE-SEAM ⑧ —
client.tsis no longer a god-file, but the split is not finished. (◧ added 2026-08-06: the bullet's own text says ②–⑧ have shipped andapps/web/src/api/proforma.tsdeclares ⑧ — the SLICE is done and the SERIES is not, which is exactly what ◧ means) ②–⑧ have shipped:schedule.ts(v0.3.800, 26 methods / 207 lines) ·model.ts(v0.3.802, 29) ·modules.ts(v0.3.803, 34) ·estimate.ts(v0.3.804, 12) ·procurement.ts(9) ·auth.ts(20) ·proforma.ts(⑧).client.tswent 4,956 → 3,796 lines (wc -l; ⑦ left it at 3,871). ⑨ is the next route-group by size; pick it by re-running the classification below, not by reading the section comments.This entry read
③+and named/model,/modulesand/estimateas the next groups until 2026-07-30, by which point all three had shipped. Caught byroadmapLanes.test.ts, and not for the reason anyone would have predicted: the lane table assignedSCALE-SEAM ⑥while this bullet still said③+, so the two codes did not match and the item read as unassigned. A consistency check between two lists found staleness in one of them — which is the argument for asserting cross-list agreement even when neither list is the thing you are trying to protect.The original defect is still worth keeping:
roadmap-completed.mdrecorded SCALE-SEAM as complete while measuring ① — a 112-line reduction, 2% — with the file still at 4.8k. That is the dangerous direction of drift. Stale estimates that understate what exists get tripped over eventually; one that overstates it means nobody looks again.There is no big cut left, and this is the number that should set the estimate. Classify all 669 methods by the route each calls — the only honest basis, since the
// --- section ---comments label the start of a run and the file then continues with other domains, so they no longer delimit anything. That gives 219 route-groups; the largest is/modelat 221 lines (4.5% of the file) and the top six together are 20%. So this is roughly 25 releases of one group each, not a big-bang split. Anyone scoping it as an L-sized refactor is reading the section comments.⑥ shipped —
/procurementout (9 methods / 86 lines;client.ts4,026 → 3,940). The nine sat in six separate regions of the file, which is the concrete form of "the section comments no longer delimit anything": they label where a run starts, and the file then carries on into other domains. Groups are located by the route each method calls, and each body by brace matching.⑦ shipped —
/authout toapps/web/src/api/auth.ts(20 methods / 96 lines;client.ts3,967 → 3,871), across four regions. Three things are worth carrying forward.The difficulty this entry predicted did not exist. ⑥ said
/auth"needs care, because it is the one group that owns token state rather than just calling routes", and sized it at 19 methods / 90 lines. It does mutate token state —changePasswordandlogoutAllboth adopt the fresh token the server returns — but that state has lived onHttpCorebehind a publicsetTokensince the T2 transport extraction. A mixin cannot seeApiClient's privates; its own base's public members are fine. The real blocker in ③ wasliveStreambeing private onApiClient, and nothing here is shaped like that. The caution was recorded before the fix that removed it, and then outlived it — the same drift as the③+staleness above, in the one direction that costs work rather than causing a defect: an item scoped defensively for a reason that has already been discharged.Four methods that read as
/authdeliberately stayed.auditLog,errorLog,clearErrorLogandreportClientErrorsit inside the// --- admin: user management ---run but route to/audit,/admin/errorsand/client-errors. Grouping by section comment would have moved three unrelated domains. The orphaned// --- auth ---banner was deleted rather than left labellingintegrations().The surface ratchet had gone slack again — the second consecutive increment to find this. Floor 698, live count 699, raised to 699. What makes the claim usable is that the count was measured on the unmodified tree first: 699 before and 699 after, from the gate's own reader, so the move provably dropped nothing and the slack was inherited rather than caused. Mutation-checked both ways (drop one method → 698 red; leave the mixin uncomposed → 679 red), with each mutation confirmed to have applied before its result was read — the first attempt's regex silently matched nothing under CRLF and returned a green that meant nothing.
⑥reported this same drift; two in a row is the pattern the test predicted, not bad luck — every merge that adds an endpoint converts the ratchet back into a floor, so re-reading it is part of taking a SCALE-SEAM increment, not a discretionary extra.The remaining-groups list here was wrong, and re-measuring is the only way to keep it honest. It named
/procurement,/authand/elementsand omitted several larger ones. Measured onmainby counting route literals per first path segment:/auth20 ·/proforma15 ·/connections11 ·/drawing-set11 ·/drawings11 ·/elements11 ·/models9 ·/documents9 ·/contracts8 ·/sync7 ·/pdf7 ·/mep7⑦ is
/auth(20) — the one group that owns token state rather than just calling routes, so it needs care;/proforma(15) is the larger easy one and is the better next cut if ⑦ stalls.The method is safe and worth reusing verbatim.
api/surface.test.tscaptures the runtime method surface, so "I moved code" is distinguishable from "I changed behaviour" — which a typecheck cannot do, since deleting a method and deleting its last caller both compile clean. Both gates are needed and they catch different things: droppingscheduleCpmfails the surface test by name and fails tsc at the two real call sites. Mutation-verified on ②, and it took three attempts to mutate correctly — the first broke the file syntactically (vitest reported "no tests", which is not a passing gate) and the second deleted a docstring line naming the method. A mutation you have not confirmed landed tells you nothing. -
SEC-PLUGIN-LOADER (L) — renamed from SEC-PLUGIN-SANDBOX on 2026-08-05: it was a different item wearing the same ID. The other SEC-PLUGIN-SANDBOX (Band 1) is
sandbox.py'sexecute_ifc_codeAST allowlist, shipped v0.3.864. This one isplugin_registry.pyimporting third-party Python into the API process. Different file, different lane, different status — and while they shared an ID, "SEC-PLUGIN-SANDBOX is partially shipped" was simultaneously true and false. An ID collision is worse than a stale line: a stale line is merely out of date, whereas this made a correct status report misleading. — plugin Python executes inside the API process.plugin_registry.py:136-141doesspec_from_file_location→module_from_spec→spec.loader.exec_module(mod), then callsmod.register(PluginApi(...)). Whatever the entry module does at import time runs with the API's full privileges: its DB session, its filesystem, its network, its environment.What is already right, so nobody re-does it: discovery is opt-in and off by default (
AEC_PLUGINS_ENABLED=1), the manifest is validated before the entry is touched,api_versionmajor must match, and a plugin that raises is refused non-fatally with its recipes rolled back out ofedit.RECIPES. That is a careful loader. It is not a boundary — every one of those checks happens beforeexec_module, and none of them constrains what the code then does.The work is a real boundary, not more validation: run registration in a separate process with a narrow IPC contract (register-only, returning recipe names), a wall-clock and memory cap, and no ambient DB or storage handle. Signing is the weaker alternative — it answers who wrote this, not what it may do, and this repo already learned from [[sandbox-object-api-surface]] that a denylist cannot see methods reached through an injected object. Gate the design on that lesson.
Threat model, checked 2026-07-31 — this is a PRODUCT gap, not a live vulnerability, so it does not belong in the exploitable band. What was checked, not concluded:
_plugins_dir()defaults to<repo-root>/pluginsand is overridden only byAEC_PLUGINS_DIR; user uploads land underSTORAGE_DIR(./storage) — the two do not overlap; no route anywhere underrouters/writes into the plugin directory (/pluginsis a GET,/plugins/reloadis platform-admin gated); and discovery is off unlessAEC_PLUGINS_ENABLED=1. So the only way a.pyreachesexec_moduletoday is an operator putting it on the disk — which is the same privilege aspip install, and an env var is a trusted input. There is no path from an unprivileged caller to plugin execution.That changes the moment plugins are distributed — a marketplace, a shared pack, anything a user installs rather than the operator. Build the boundary when that ships, and as its prerequisite, not before; an L-sized process boundary for a dormant operator-only path is cost with no risk retired.
Do not confuse this with the A1
execute_ifc_codesandbox — a different surface that IS bounded. Probed 2026-07-31 by execution rather than by reading its docstring; all 12 escapes refused and the benign case ran: dunder ladder (__subclasses__),import,__import__,open,eval,getattr,lambda,def,while True,model.write→SandboxError;for i in range(10**12)hit the 5s wall-clock deadline at 5.02 s;10**10**9hit the chained-**integer-blowup guard. No file was written. This retires the [[sandbox-object-api-surface]] note's "banningwhiledoes not bound execution" — a deadline now exists and was observed firing. -
❌ SRI for the offline WASM/fragment assets — considered 2026-07-29 and REJECTED. An external audit recommended Subresource Integrity for the WASM and fragment assets. It does not apply here, and the reason is worth recording because the next outside reading will recommend it again: SRI exists so a document can pin the bytes it expects from a different origin. Checked — there are no remote asset URLs anywhere in
apps/web/src/orindex.html; everything is bundled and self-hosted, which CLAUDE.md requires (the viewer must run fully offline, local WASM, self-hosted tiles). Same-origin, content-hashed bundles get nothing from it: anyone who can alter the bundle can alter theintegrity=attribute in the HTML that names it, in the same write. Adding it would be ceremony that reads as a control. A hardening measure that does not narrow an attacker's options is worse than none — it spends review attention and returns a false sense of coverage.
External corroboration — Repowise re-index, 2026-07-29. Worth recording because it was reviewed once before and judged mostly not actionable; re-indexed, it independently names the same five files this section and [[web-godfile-decomposition]] already target, ranked by churn × prior bug fixes:
| file | churn | prior bug fixes |
|---|---|---|
apps/web/src/portal/portal.ts |
99.9th pct | 10 |
apps/web/src/viewer/app.ts |
99.8th | 16 |
apps/web/src/api/client.ts |
99.8th | 15 |
apps/web/src/main.ts |
99.7th | 18 |
services/data/src/aec_data/edit.py |
99.6th | 6 |
Also: 168 of 1,426 files are hotspots, and 17 of the 20 lowest-health files were bug-fixed in the
last 6 months at 5.01× the baseline rate. That is the useful number — it says the decomposition
backlog is not hygiene, it is where the defects actually come from. The list is the same one
multi-agent-lanes names as the concurrent-edit collision points, arrived at from git history rather
than from our experience, which is what makes it worth more than a restatement.
Two of its findings are NOT actionable and should not be picked up. "Bus factor 1 on every hotspot" is an artifact — every commit here carries one git identity regardless of which session wrote it, so the metric cannot say anything about this repo. And "4,591 open findings" is unscoped; treat it as a lint-level count, not a defect count, unless someone bounds it by severity first. Scores for the record: defect risk 7.3/10, maintainability 8.5/10, static performance 9.9/10.
- REL-4 leaves (M) —
portal.tsnext leaf +viewer/app.tsleaves. - REL-7 — evidence-gated dead-code removal (needs RT-KNIP first — see Gated).
The premise check first, because it changes the whole question. The ask was "should we invent a
.mass file — a zip with everything inside, like a markup tool's bundle?" We already ship one.
aec_api/bundle.py defines .mmproj (FORMAT = "aec.mmproj"): one zip carrying the published
Fragments tile, the source IFC, every project-scoped database row — which is all 130 CRUD modules
and the proforma, since those are project_id tables — plus attachment blobs. Import mints a fresh
project id and remaps foreign keys, so a bundle clones or moves machines cleanly. The app's Open menu
already lists it. So the container is not the gap; nobody could tell it existed is the gap.
And a standard already defines this shape: ISO 21597 (ICDD). Information Container for linked
Document Delivery is a zip with /Payload documents/ (the heterogeneous files), /Payload triples/
(RDF linksets joining them, at document or object level), /Ontology resources/, and an
index.rdf. Part 2 standardises the link types. That is precisely "model + construction data +
proforma in one file, with the relationships preserved" — and adopting it is the same bet this
platform already made with BCF: a bespoke container round-trips with nothing; a standard one
round-trips with everyone. (ISO 21597-1 ·
Part 2: link types)
The real defect is conceptual, and it produced two shipped bugs. Opening a model does not open a project, and opening a project does not guarantee a model. In v0.3.703 that split surfaced twice: the portal rendered "No project open" while a model sat visibly loaded, and the Developer tab latched itself permanently empty. Both were repaired at the symptom. The cause is that "project" and "model" are two states the app keeps having to re-marry, and every feature built on top inherits the seam.
-
R28-BUNDLE ② — make
.mmprojlegible. It already carries the data; nothing says so. Name it in the UI, show what a bundle contains before import, and state on export what was included and what was left out (_SKIP_TABLESdrops users, audit log, settings and connections — correct, and currently silent). The same unknown ≠ none rule the engines follow. -
R28-VIEWER ④ — the future viewer opens a container, not a file. This is now live and external: the kernel rebuild is
MassingCloud/massingifc(private), first commit 2026-07-26 — a framework-agnostic kernel + plugin host with all fourteen capability families still contracts only. That is the cheap moment to settle this, and the window closes as families get implemented.Its persistence is per-document (
{schema, version, savedAt, data}); there is no container concept, so "open a project package" would land as a plugin concern and be retrofitted. A container is a mechanism, which by that repo's own first design rule puts it in the kernel, with.mmprojand ICDD as adapters. Three further contract-level notes: project↔model unity wants to be a kernel contract or the v0.3.703 bug class returns; selection and markup anchors must key on GlobalId, never fragment-local ids, or every capability above inherits transient identity; and the 5D/4D contracts should carry provenance (quantity_source/rate_source, and the 4D link being the task's output) rather than bare values. Also: that repo has no LICENSE, which needs settling before this public repo can depend on it.
Sequencing. ① first — it is the bug class, needs no dependency and no standard. Then ② which is mostly surfacing what exists. ③ is now unblocked and is the interop play, not a prerequisite. ④ falls out of ① and ③ and should gate the viewer rebuild.
An external analyser produced eight performance findings. Most of its facts check out; two of its headline recommendations are backwards, and one finding examined nothing. Recorded so the wrong fix does not get made later from the same report.
- PERF-WORKERS ① — the cache story is duplication, not size. Verified:
ifc_loadercaches 8 models (@lru_cache(maxsize=8)),drawings._BAKE_CACHE_MAXis 4, andUVICORN_WORKERSdefaults to 4. The analyser's advice was to raise both caches. That makes the problem it identifies worse: the caches are per-worker, so raising them multiplies resident memory by the worker count — its own finding ③ contradicts its finding ①. The real options are a bounded shared cache (a loader process or Redis-backed handle), or worker affinity so one model lives in one worker. Sizing is the last lever, not the first. - PERF-THREADS ③ — cap the pool, but the stated mechanism is wrong. The claim was "unbounded threads → resource exhaustion". Starlette/anyio's default pool is 40 threads, not unbounded. The genuine risk is different and worth fixing: 40 concurrent IFC operations at hundreds of MB each is an OOM, so the cap wants to be small and explicit for model work specifically — not because threads are unbounded but because each one is expensive.
Not adopted. "No evidence of query batching" is an absence of evidence offered as a finding — the
report did not examine the ORM layer and does not name an endpoint. It is the inverse of this repo's
own rule: a check that examined nothing must not report anything, clean or dirty. Lazy imports
are also deliberate — they keep cold start cheap for the paths a deployment never touches — and the
bake cache's id() key is already sound: it holds a strong model reference and re-checks identity.
Scanned at the user's request. The headline is a licence trap worth recording, because the repository's own README states the wrong licence for its most valuable asset.
The CWICR cost database — 55,719 work items, 27k resources, 30 regions, 21–31 languages — is
CC BY-NC 4.0, NOT CC BY 4.0. The skills repo's README advertises it as CC BY 4.0; the actual
LICENSE-DATA.txt in the data repo is NonCommercial. That is the same exclusion class as the
PolyForm-NC library already refused above, and taking the README at its word would have put a
non-commercial dataset inside a commercial product. The repo's code is Apache-2.0 and fine; only the
data is encumbered. Check the LICENSE file, never the README's summary of it.
⛔ Not usable: the CWICR data (CC BY-NC 4.0) · the cad2data RVT/DWG/DGN converters
(proprietary — an explicit commercial licence, despite the repo reading as open) · an
open-source construction ERP (AGPL-3.0) · and roughly ten GPL-3.0 notebooks covering embodied
carbon, ML price prediction, quantity takeoff and estimation.
✅ Permissive and worth reading: a 221-file skills corpus for construction AI agents (MIT), a 4D–5D pipeline (Apache-2.0), Revit/IFC project quality checking (MIT), a CAD/BIM-to-code pipeline (MIT), and an IFC/Revit ETL collector (MIT).
What is actually worth taking, and it is not code. We already have .claude/skills/ and the
master-builder skill, and we already have model_qa, qto, fived and the 4D/5D spine — so none of
those repos closes a capability gap. What the 221-skill corpus is good for is a map of what
construction teams actually automate, at a granularity nobody publishes otherwise. Read as a
coverage checklist against our 130 modules it is a gap-analysis input, not an import.
- ⛔ R22-PHOTO-CV defect detection — REFUSED IN PLACE, the way semantic search was. Not a scheduling problem: a defect classifier needs labelled construction photos and this project has none. One carve-out, recorded so the refusal is not read wider than it is: concrete cracks specifically do have public datasets (SDNET2018 and several Mendeley sets, mostly CC-BY), so that one class is reachable by fine-tuning if it is ever wanted. Broader defect detection is not. Fine-tuning needs a few hundred labelled images per class, not the thousands a from-scratch model would — the earlier "months of labelling" estimate was costed against training from scratch, which nobody should do, and overstated the barrier.
⛔ Licence exclusions recorded from this scan (evaluated, refused, do not re-litigate). Two otherwise-relevant OSS projects are unusable under the standing MIT/BSD/Apache-only rule: a GPU map- rendering library under PolyForm Noncommercial 1.0.0 (non-commercial only — incompatible with a commercial product regardless of technical fit) and a physics/simulation library under AGPL-3.0 (the same class of exclusion that already keeps PyMuPDF out of the PDF stack). Two are permissive and remain open as options: a MIT OpenCascade-based geometry kernel (C#/.NET — a process boundary, so a real cost) and a MIT TypeScript canvas UI toolkit. Nothing in this ring depends on any of them; the deterministic path above needs no new dependency at all.
Sequencing — HISTORICAL, all of it shipped. Kept for the reasoning, not as work. R27-SOV-LOOP, R27-CLAIM-TYPE, R27-RISK-CALIBRATE, R27-FIRM-MEMORY and R27-LAYOUT ①→③ are all in roadmap-completed.md. The original plan: SOV-LOOP first as the smallest change with the largest reach; LAYOUT as one track because ② and ③ are meaningless without ①; CLAIM-TYPE and RISK-CALIBRATE independent and interleavable; FIRM-MEMORY last, being a data-scoping change that wanted the org tier settled.
This paragraph is why the lane table went on advertising four shipped items after their entries were archived: a planning note mentions a code, and a mention is indistinguishable from an entry to any check that searches text. Marking it historical is the fix; the lesson is that prose naming an item keeps that item looking alive.
Why this ring exists. The prompt was "pascalorg/editor has interesting authoring abilities — import what is beneficial." The audit's finding is that almost none of what is beneficial is code we can import, and the reason is worth stating up front because it decides the whole ring: our authoring is not behind on features, it is behind on feel. Every edit round-trips to a server recipe. Pascal's edits do not.
| Source | Licence | Verdict |
|---|---|---|
| pascalorg/editor — 19.3k★, pushed 2026-07-29 | MIT ✅ | Ideas only. Stack is React 19 + Next 16 + R3F + WebGPU + Zustand + Bun. Adopting its packages means adding React, Next and a second renderer beside our pinned three 0.184 / @thatopen 3.4.x pair. Not a dependency decision — a rewrite. |
| louistrue/ifc5cad (IFChili) | AGPL-3.0 + LGPL-3.0 WASM ❌ | Excluded by the licence rule. 66+ CAD commands on OpenCascade WASM, IFC5/IFCX serialisation at "Phase 0". May be read for ideas; no code may be copied. |
| ThatOpen/engine_clay | MIT ✅ | Reference, not dependency. The obvious candidate — IFC-native modelling, same family as our stack. But: not published on npm (@thatopen/clay → 404) and last commit 2024-10-09, stopping mid-feature on "first working version of exportable wall corners". Roughly two years dormant. |
| three-bvh-csg vs Manifold | MIT / Apache-2.0 ✅ | If client-side booleans are ever needed, Manifold, not bvh-csg. bvh-csg is far faster than BSP but its own docs concede the result "may not be correctly completely two-manifold" and point at Manifold for CAD. A non-manifold solid is an unquantifiable solid. |
Two corrections to the first pass, recorded because both would have led somewhere wrong. Pascal's
README describes no file formats, and the first read concluded "no IFC". It has an IFC importer
(v0.9.0, packages/ifc-converter) — but import only: IFC → its own node schema, exports as
GLB/STL/OBJ, and the export path was contributed from outside the core team. And a search result
described @thatopen/clay as having "active npm distribution"; the registry returns 404 and the
commit log is two years cold. Both claims were plausible, both were wrong, and both were only caught
by fetching the artifact instead of the description of it.
Not a gap list until the premise is verified ([[check-the-blocker-premise]]). Already shipped:
SketchUp-style inference snapping (inference.ts — axis / parallel / perpendicular, pure and
unit-tested), a transform gizmo, grid overlay, draftPanel/draftProxy, eight server-side
edit engines (edit_core, edit_enclosure, edit_struct, edit_mep, edit_annotate,
edit_asbuilt, …) plus edit_history, a family/type system, and IFC-as-source-of-truth with
GUID-stable recipes. Pascal has none of that last part: its data model is bespoke JSON.
So the honest framing is not "Pascal is ahead". On what a building is, we are far ahead. On what editing one feels like, it is ahead, and the difference is one architectural choice: it keeps a local scene graph with dirty-node tracking and regenerates only the changed geometry in the render loop, so an edit is visible in the same frame. We commit through a server recipe and republish.
Each item is independently shippable and none blocks the NOW list. The commit path does not change: the server recipe stays the writer of record and IFC stays the source of truth. What changes is that the screen stops waiting for it.
- ✅ shipped v0.3.819 — A29-LOCAL-PREVIEW — the edit shows before the server agrees. The rule that kept it honest is the one this codebase already lives by: a pending edit must look pending. As built: the amber draft marker stays over the incremental one-element preview until publish completes, and a failed recipe turns it red in place instead of erasing the evidence. See roadmap-completed.md.
⚠️ These three carried "SHIPPED" in their own text and no ✅ marker until 2026-08-06, androadmapStale.test.tscould not have caught it. That gate scansservices/api/srcandservices/data/srcfor a module declaring itself an item's implementation — Python only. All three of these are TypeScript inapps/web/src/viewer/, so they were structurally outside the population, and the gate would have stayed green forever. Verified before marking: each has a module, a test, and a live import inapps/web/src/viewer/app.ts—placeValid.ts,spatialSelect.ts, anddraftHistory.ts(note the last does not match its item name, which is why a filename-based check would also have missed it). Widening that gate to the web tree is filed as ROADMAP-GATE-TS.
-
🟡 A29-GUIDE-UNDERLAY ③ (in flight, PR #199) — trace over a plan. A 2D reference image pinned to a level and scaled, for redrawing an existing building from a scan or a PDF. Small, self-contained, and the one place their
Guidenode maps onto something we do not have.apps/web/src/viewer/guideUnderlay.tsholds the calibration, the refusals and the plane;app.tsgets six lines.Scale is the whole feature, and the entry undersold it. An underlay that is merely placed is decoration — the reason to build it is that walls traced over it come out at real dimensions. So every path reduces to metres-per-pixel and every derivation refuses rather than guesses: a silently-wrong scale is the one failure that poisons everything traced afterwards, and unlike a mis-placed image it looks completely fine on screen. The coincident two-point pick is the one that matters, because it is what a real user produces by double-clicking, and dividing by a zero pixel distance yields
Infinity.This item is what found the
modelPlanBoundsdefect (PR #198). Premise-checking "can I add a plane to the scene?" led to "what already reads the scene?", and the answer was that a 2000 × 2000 shadow-catcher had been defeating the mis-click guard. Before adding an object to a shared structure, check what already reads that structure — a new object is the moment you finally have a reason to enumerate the readers.A mutation pass caught a decorative test of my own. "the guide is not a raycast target" passed whether or not the guard existed: calling
mesh.raycast()by hand on a mesh whose world matrix was never updated intersects nothing either way, so it asserted an empty array that was empty for the wrong reason. It now runs a control first — an identical unguarded plane must be hit by the same ray — so empty means "the guard worked" rather than "the ray missed". Same shape as the picking benchmark that reported a confident p50 withhits: 0.
Explicitly NOT in this ring: adopting React/R3F, adopting a bespoke node schema beside IFC,
vendoring engine_clay (dormant), and anything from IFChili (AGPL). If client-side booleans become
necessary later, that is a separate decision with Manifold as the candidate and a dependency
conversation attached.
Verification-gated — THE GATE WAS FALSE (corrected 2026-07-25). This block was held for
months on the claim that a "dev-preview geometry stall" stopped buildPanels from ever running. Two
things were actually true: .claude/launch.json had no API entry, so the dev backend was never
started; and buildPanels fetched elements before rendering, so a project with no model threw a
swallowed 404 and left the panel blank. With the api launch target and a published 52 MB IFC, the
Project Browser builds completely (Floor 0: 151 elements, Floor 1: 3). Nobody had tested the
blocker. Each item below is now to be re-verified against the live stack and moved to ▶ NOW as it
passes — not assumed blocked: 🧭 R17 viewer tail — CITE-JUMP (S; click-to-expand claims jump the
viewer to the cited GUID) · 4D5D-VIEWER (M/L; schedule + cost bound to GUIDs → a 4D scrubber with a
running earned-value readout) · WALK-MODE WebXR pass (M; the renderer.xr headset half of the
shipped desktop walk) · BCF-VIEWPOINT restore depth (S; section planes + visibility exceptions +
the toDataURL thumbnail) · CLASH step-through UI (S) · FILL-MATRIX frontend (S) · NODE-CANVAS
(L; the reusable connector/node substrate) · COLLAB selection halos.
Binary/toolchain-gated: SPRINT A — ENERGY phase 2 — ship the EnergyPlus (BSD) / Radiance (LBNL) binaries through the durable job queue and parse results back onto the model (phase 1 — gbXML + IDF export — shipped v0.3.655) · RT-NODE-LANE — CI is on Node 22; the local Node is still 20.3.1 (user action), then unpin eslint off 9.39.5, then Vite 6→7 behind a build benchmark (defer Vite 8/rolldown).
New-dependency-gated (needs an explicit OK): RT-BVH — three-mesh-bvh for the raw-three
raycast paths (already present transitively, MIT — instrument before adding) · RT-KNIP —
unused-export / dead-dep scan (feeds REL-7) · ifclite-geom (MPL-2.0, Rust wheel — see R23).
W10-9 dimensional constraints — UNGATED 2026-07-25. It was held on planegcs, LGPL; the
licence survey found we never needed a full geometric constraint solver. kiwisolver (Cassowary,
BSD-3) covers the linear/alignment/equal-spacing/clearance set and scipy.optimize.least_squares
(BSD-3) the nonlinear tail. Now ordinary work — see R23-CONSTRAINTS.
Spec-gated: SPRINT E — FAB-DELIVER phase 2 — byte-exact BVBS BF2D / DSTV-NC, held behind the authoritative spec + a real importer/validator. A wrong file mis-bends real steel.
Flow-gated: JOB-QUEUE PAdES (S) — PAdES sealing on the queue needs a queued signing flow first. · REL-6 tail — cargo-audit / gitleaks in CI when available.
P3 — externally gated:
- Upstream: IFC5/IFCX geometry write (web-ifc/Fragments write path) · bSI Validation Service in CI (service account).
- Paid / flagged (never core): VIZ-U1/VIZ-3/VIZ-4 presentation/VR builds · W9-7 AI PDF auto-takeoff · CODE-6 licensed code prose · COST-DB cloud ingest (the offline importers ship) · DWG (ODA) / USD (pxr) export.
- Platform/pipeline: native mobile Capacitor shell (needs macOS/Xcode; the PWA ships) · SOC 2 cloud-infra feature set (KMS/retention/residency — the readiness matrix itself shipped as R19 COMPLY-SOC2) · BMS/IoT telemetry (Brick/Haystack source required) · reality-capture progress quantification (capture data required).
- Large optional builds (prerequisites complete): coupled-frame FEM solve · viewer tile-streaming upgrade · AR field overlay · per-county location-factor/PPI DB tables.
- Counsel-gated: regulated syndication depth. ⚖️ Not legal advice.
- Environment note: headless/hidden panes stall the Fragments raycast + web-ifc import workers (vendor-level; the app-side timeout fallback ships). Verify those two paths in a visible tab.
- Copyright deposit upload — case 1-15213313031, still pending on the government account.
- Local Node 20.3.1 → 22 — unblocks RT-NODE-LANE's local half.
.mpp parsing (XML/CSV import is the path) · custom Revit plugin (certified revit-ifc covers it) ·
live ENERGY-STAR/BAS integrations (flagged stubs only) · CAFM/1031 tooling · scraping code prose ·
GPL/AGPL vendor code (reimplement techniques) · LLM/OCR reconstruction of unstructured docs
(offering memoranda, scanned T-12s, rent-roll PDFs, leases — we ingest structured exports, and the
deterministic engines are what make the numbers defensible) · prompt-library / workflow-count framing
(a way of using an assistant, not product surface) · owning capture hardware / photogrammetry
pipelines / hosted digital-twin cloud · native VR-headset app + cloud co-presence sync · payment
execution + financing rails · consumer marketplaces · learned risk forecasting (Monte Carlo covers
it) · voice agents. Deliberate 501 bridges (money movement / KYC / paid APS) are a compliance
pattern, not gaps. Integrate-not-build: Cesium ion imagery · Speckle Automate · iTwin REST ·
Autodesk APS · Pollination.
INTEGRATE (optional, feature-flagged, offline-degrading — never a runtime dependency):
higher-coverage permit backend · contractor license/history feed · permit-density market-activity ·
new-home starts/pricing feed · named BCF-hub connectors · national e-ID/e-sign · ERP connectors ·
paid comp / market-data feeds and county-recorder pulls behind the existing opendata.py
indirection.
License guardrails: ifcopenshell/geom = LGPL (safe dep) · no AGPL (no PyMuPDF) · planegcs (LGPL, extractable) over GPL solvers · CC0/CC-BY assets vetted per-asset · OSM = ODbL attribution as a separate layer.