Roadmap — the settled plan after the 2026-08-09 platform review
This is the plan of record for work after M9. It exists because a proposed four-layer platform specification (Python API / Rust core / TypeScript client / WebGPU runtime) was reviewed against what actually exists, and the review changed three things: it reversed one decision, rejected two recommendations, and found that most of the proposed platform is already built in sibling repositories nobody had mapped.
What the review found
The proposed platform is ~80% already built, in eight repositories
The single most useful output of the review. MassingCloud holds:
| Repository | What it is | Maps to |
|---|---|---|
ibuilder/massing |
FastAPI + Postgres + Redis + Celery, RBAC, tenancy, audit, 96 authoring recipes | "Layer 1: Python Platform" |
massingviser |
A federated AEC platform in pure Python: plugin kernel, 15 capability tokens | "Layer 1", federation |
massingifc |
Framework-agnostic kernel and plugin architecture for a federated AEC platform | contracts, plugin host |
massing-pdf (MIT) |
PDF markup, calibrated takeoff, issue pins, revision compare, XFDF/BCF | markup, review desk |
massing-families (CC0 content) |
419 families, 2,769 types, 57 discipline packs, IFC4 | family libraries |
massingcapture |
Reality capture for AEC in pure Python, content-first format detection | "digital twin", point clouds |
massingbill, massingplan |
Billing (G702/G703), CPM scheduling | 5D, 4D |
| MassingViewer | this repository | "Layer 3: TypeScript Client" |
Settled 2026-08-09: modelmaker is the platform, and this repository integrates with it. massingviser is
analysed for what is worth borrowing server-side — its Python geometry pipeline (BVH picking, frustum culling,
clash, LOD, crease-aware tessellation), its capability-family contracts, its content-addressed versioning — and
not adopted as a second browser viewer. See ADR-0014, which
also draws the Reflex boundary: Python and Rust own the server, compiled JavaScript owns the viewport, and a Reflex
shell (if adopted) mounts @massing/embed as a React component rather than putting a WebSocket in the snapping
path.
Our own ground truth was wrong about one of these. The plan records massingviser as an "unrelated Python
project" needing a README disambiguation line. It is a federated AEC platform with a plugin kernel — the closest
thing in the org to the proposed Layer 1. That mis-characterisation is corrected here.
So what the proposal genuinely adds is three things: a Rust core, WebGPU rendering, and federation. Its Phase 1 — upload IFC, convert, load one model, tree/properties/search/isolate/measure/saved views, RBAC, signed assets — describes software that already ships.
What was rejected, and why
- A Rust core — deferred, not refused. No measurement yet shows the TypeScript parser is the ceiling, and cargo + wasm-pack would be a fourth toolchain across two repos. The plan chose npm over pnpm because "a third lockfile format in one org is a self-inflicted CI mystery"; this is a larger version of the same bet. It gets made when a scale fixture proves it, which is why benchmarks come before it below.
- IfcOpenShell ingestion — refused.
ifcopenshellis banned by name inscripts/check-licenses.mjs(LGPL), and massing's list excludes it too. If the proposal meant format-compatible rather than linking the library, that is fine and should be said explicitly. - The proposal's omission is the product. It contains no 2D drawing generation at all — no plans, sections, sheets, title blocks or DXF/PDF. That loop is the researched market gap and the reason this repository exists.
Decisions taken
- ADR-0012 — WebGPU first, WebGL2 fallback, fallback made visible. Reverses the plan's "WebGL2 baseline forever", whose premise expired when Safari 26 / iPadOS 26 shipped WebGPU.
- ADR-0013 — federation with explicit per-model state.
- ADR-0004 amendment — the measured 2D winner is licence-blocked by the consumer; our own sectioner is what ships, and the known weaknesses are recorded rather than forgotten.
The programme, in order
Ordering is by dependency and by blast radius, not by appeal.
1. Renderer seam + federation — one breaking change, not two
Batched deliberately. WebGPURenderer.init() returns Promise<this>, so createViewport becomes async;
federation replaces showModel(...) with add/remove and per-model state. Both break the same viewport API, and
massing is mid-adoption — so they ship together and massing absorbs one break.
Acceptance:
navigator.gpupresent → WebGPU; absent → WebGL2; the choice is reported, never silent (a silent fallback is a 5× cliff nobody notices —docs/deployment.md).- Memory gate still exact.
info.memorycarriesgeometries,textures,programson both renderers, and adds byte counters on WebGPU — verified in@types/three, so the gate ports and improves. - Blocker to solve first: the visual gate's determinism rests on
--use-angle=swiftshader, an ANGLE/WebGL path. WebGPU needs a Dawn/Vulkan software adapter, or the visual baselines mean nothing on the new renderer. - Multi-model: per-model transform, visibility, discipline filter, appearance, selection context. Selection stays
GlobalId-keyed across models, which is the seam massing's
planPaneSelection/specPanealready rely on.
2. Families — packages/assets and a Build-ribbon gallery
419 families, 2,769 types, 57 discipline packs, CC0-1.0, IFC4. The four draw verbs (wall, slab, column, plus door/window openings) are not a family library and were never meant to be.
Content is loaded, never vendored. The plan already specifies this: "Asset packs are code-free plugins. One
.mvpack manifest serves 3D families, 2D blocks, markup stamps, hatch patterns and sheet templates." So
MassingViewer ships the format, the loader and the gallery under MIT; massing-families ships content under CC0.
The integration point is massing's library endpoint, not the catalog repository. Confirmed with the massing
session on 2026-08-09, against their tree rather than inferred: massing has already vendored their copy of "fetch_families.py"
there, services/data/families/external/ holds 59 files including the architectural-assemblies pack,
GET /families/library is live and returns external.packs, and
POST /projects/{pid}/families/import copies every IfcTypeProduct in, deduped by class and name. So a
connected viewer reads their shelf. Fetching massing-families independently would give two copies of the
same library, free to drift — and the offline case is a cached pack, not a second fetcher.
CC0 status, stated because it was worth asking. massing's permitted list is MIT / BSD-2 / BSD-3 / 0BSD /
Apache-2.0 / ISC and does not name CC0-1.0 — while they already ship CC0-1.0 content, with
"license": "CC0-1.0" recorded in four places in their own manifest. Their written rule is narrower than their
shipped reality, which is the same shape of defect as this repository's KNOWN_GOOD tuple reading as a
seven-package pin. Treated as accepted on the basis of existing practice; the formal list change is with the
repository owner, because a permitted-licence list is not a thing either side should widen quietly.
The licence gate was corrected as part of this review: massing-families was blanket-forbidden on the strength of
GitHub reporting NOASSERTION, which is a detection artefact of its two licence files. Only "upstream/" is
genuinely off limits — it is a derivative of ibuilder/massing. Blocking 2,769 types of public-domain content for
a metadata artefact was the gate being wrong, not cautious.
Landed 2026-08-10: packages/assets — the format, parser and queries, with 18 tests. It reads the real
snake_case document shape (modelled on massing-families' own _family_index() rather than invented), maps it to
one camelCase type, groups by discipline then category preserving the library's own order, searches label / key
/ classification, and reports the families the library itself calls L200 proxies so a gallery cannot present them
as finished geometry. It performs no I/O — the host owns the transport, which makes a cached offline pack and a
live endpoint the same code path. Every entry carries its license through, defaulting from the library rather
than blank, because that string follows the object into any model that imports it.
Still to do for item 2: the Build-ribbon gallery UI, drag-to-place, and wiring to massing's endpoint.
Acceptance: DRAFT_ELEMENTS becomes galleries by discipline; the params[] form renders as the gallery flyout
and the prompt loop's keywords, one schema three renderings; drag-to-place from the gallery; every placed type
carries MF_Library.License through into the model, as the library intends.
3. Sheets, title blocks, and the review desk — adopt @massingcloud/pdf-viewer
The plan pane has no title block. That is accurate and it is a real gap — svg.ts emits a border and the
drawing, no title block, no revision, no north arrow, no scale bar. Tier-3 rasterisation found this from the other
direction: there was nothing to mask.
massing-pdf is MIT, public, and already on the plan's M0 list as a package to consume. It treats a markup as
a record — author, sheet revision, discipline, measurement, spec clause, IFC object, review status — with
rendering as one projection and XFDF/BCF/CSV/flattened-PDF as others. That is the review desk this product needs,
and rebuilding it here would be waste.
Acceptance: a sheet composes one or more viewports plus a title block from a template; R38-SHEET-MARKUP lands
(markup on a generated sheet resolving to an IFC GUID); @massingcloud/pdf-viewer is a dependency, not a fork;
its pdfjs-dist worker is bundled, never CDN-loaded, so the offline claim holds.
4. Benchmarks at scale — measured 2026-08-10, and the answer is "not yet"
fixtures/scale.ts generates buildings in memory rather than committing them, and fixtures/scale.test.ts measures
parse → tessellate → section end to end. Run it with SCALE=1; the generator's own correctness tests run every PR,
because a generator emitting subtly invalid STEP would make every number below authoritative-looking noise.
| case | IFC | products | triangles | parse ms | µs/element | cut ms | µs/mesh |
|---|---|---|---|---|---|---|---|
| small | 0.01 MB | 16 | 192 | 10 | 625 | 7 | 438 |
| medium | 0.07 MB | 144 | 1,728 | 85 | 590 | 14 | 97 |
| large | 0.34 MB | 640 | 7,680 | 130 | 203 | 11 | 17 |
| xlarge | 1.11 MB | 2,000 | 24,000 | 256 | 128 | 29 | 15 |
Cost per element falls monotonically as the model grows — 625 → 128 µs — so the pipeline is linear or better and the small cases are dominated by fixed costs and JIT warm-up. Section cost per mesh flattens at ~15 µs. Nothing here says the TypeScript parser is a ceiling, which is the evidence the Rust decision was waiting for, and the answer at these sizes is no.
Three honest limits on that conclusion, because it is the kind of result that gets over-read:
- The largest case is 1.11 MB. The plan's motivating fixture is a 240 MB tower — roughly 200× this. Linear extrapolation at 128 µs/element puts a model that size near a minute of parse, which is the regime where a Rust core would earn its toolchain. That is an extrapolation, not a measurement, and the fixture does not reach it.
- Absolute times vary run to run — xlarge parse measured 138 ms and 256 ms on consecutive runs of the same code on a loaded developer machine. The shape of the curve is the finding; the absolute numbers are not budget material, which is why this reports rather than gates.
- Nothing here measures memory, and peak worker memory is the other half of what breaks on a large model.
What would change the decision: extending the generator past ~100,000 elements and finding per-element cost rising,
or finding peak memory unacceptable. massingviser's Python geometry pipeline (BVH picking, culling, clash, LOD) is
the comparison to borrow rather than repeat — see ADR-0014.
4b. What the benchmark does not yet cover
Mostly done, and re-read on 2026-08-15 rather than trusted. The paragraph here said "two small fixtures
today, scale explicitly deferred", which stopped being true when fixtures/scale.ts landed: four generated
fixtures — small, medium, large, xlarge (20 storeys, 8 bays) — run nightly through
fixtures/scale.test.ts, with budgets measured rather than guessed, exactly the posture perf/README.md asks
for. A section that reads as wholly not-done while being three-quarters done is how the one genuinely missing
piece stays invisible.
The genuinely missing piece was the federated case, and it landed the same day. The original list named small / medium / large /
federated; what shipped substituted xlarge. So every scale number in this repository is for a single model,
while federation is a shipped feature with its own per-model visibility, per-model GUID resolution and a
selection path that has to stay correct when two files share an expressID. It is measured now — see item 5 in the list below. The costs that show up there — per-model resolver maps, a scene graph with two roots, colour-key churn
across models — are precisely the ones a single-model benchmark cannot see.
This is the gate that decides whether a Rust core is justified, so it comes before it.
5. Then, and only if the numbers say so: Rust, version diffing, capture overlays
- Rust for parse/tessellate/fragment, if the benchmarks show the ceiling.
- Version diffing between milestones — genuinely absent, composes with existing GUID identity.
- Reality-capture overlays via
massingcapture, rather than a new point-cloud pipeline here.
What this roadmap does not do
- It does not build a Python platform. Two already exist.
- It does not adopt a runtime asset format in place of IFC parsing. massing already converts server-side and
streams ".frag";
showMeshes(added 2026-08-09) is how such a host feeds this viewer with no IFC text. - It does not restore the 2D differential oracle. One engine compares against its own previous output, which
cannot catch a shared wrong assumption.
bench/still runs both on demand — seedocs/testing.md.
M9 seam: validated from tarballs, 2026-08-14
The plan's risk #1 is divergence — every week both repositories hold a copy of the engine is debt at compound
interest — and M9 closes it by having massing consume @massing/* instead of its own apps/web/src/viewer. Nothing
had exercised that path, so "the seam is ready" rested on a ledger rather than on a consumer.
It has now been exercised without publishing anything. npm pack --workspaces produced 26 tarballs; installing them
as a set into an empty project — one with no access to @massing/* on any registry — resolved cleanly, and from
there:
@massing/embedexportscreateMassingVieweras a function, plus the seam ledgerseamCoverage()reportsready: true,ratio: 1,gaps: []@massing/kernel-localexportscreateLocalKernel;@massing/drawings2dexportsgeneratePlan,toSvgandsheetFurniture
So the packaging, the dependency closure and the public surface all hold for a real outside consumer. The only remaining step on this side is publishing, which is deliberately not automated here.
What this does not prove: that massing's application code compiles against these packages, or that deleting
apps/web/src/viewer leaves its test suite green. Those need the other repository and are the actual M9 work; this
removes the packaging unknown from in front of them.
Reconciliation, 2026-08-14 — what shipped, what did not, and what is blocked
The programme above was written on 2026-08-09 and is still the plan. This section states, item by item, what is actually true of the repository now, because a roadmap whose items are never marked off becomes a wish list that reads like a status report.
The five programme items
| # | Item | State |
|---|---|---|
| 1 | Renderer seam + federation | Done, and both halves needed fixing afterwards. The WebGPU fallback was not transparent — twice. Selection silently stopped highlighting the moment a second model loaded. See ADR-0012 and ADR-0013. |
| 2 | Families — @massing/assets + a Build-ribbon gallery |
Done. Library parsing, galleryFor layout, a rendered panel with discipline tabs, search, drag-to-place, and availability dimmed with a reason. |
| 3 | Sheets, title blocks, review desk | Half done. Sheet furniture — border, title block, revision table, scale bar — ships across SVG, DXF and PDF, and 2D is now a peer surface rather than a side pane (ADR-0015). The review desk is blocked: it needs @massingcloud/pdf-viewer on npm. |
| 4 | Benchmarks at scale | Done for a single model, and extended: the drawing benchmark is joined by a main-thread measurement that found sectioning blocking for ~450 ms, and since 2026-08-15 by p95 frame time. Four generated fixtures run nightly. The federated case is not measured — see 4b, where "done" had been hiding it. |
| 5 | Rust, version diffing, capture overlays | Not started, and correctly so. Nothing measured says the TypeScript parser is the ceiling. |
What shipped since the plan was written, that the plan did not ask for
Each of these came out of a defect found while doing something else, which is the honest reason they exist:
2D as a peer of 3D — a canvas-mode reducer ported from upstream, because the plan pane was a strip.
Sectioning in a Worker — the main-thread measurement asked for it.
A long-task measurement, deliberately a report rather than a gate.
Gate repairs, and the count keeps rising because each one is found the same way — by sabotaging the check rather than reading it. As of 2026-08-15 that is eight: the bundle budget counted a second build's compiler output as shipped bytes; the message gate and the doc-path gate answered from stale or uncommitted build output;
dependency-review's deny list had never parsed and had drifted from the list it claimed to mirror; the seam ledger described a facade from before two of its features shipped;longtask.spec.tsran in no workflow; the readiness signal every E2E test waits on was published before the thing it signals; and the frame-time gate shipped a first draft whose failing branch could not be reached.The pattern worth carrying: a gate that has never failed has not been shown to work. Six of these were green, one was red and unread, and one had never executed. Three of the fixes were themselves half-right until the sabotage said so.
Not done, in the order I would take them
The two publishes.
@massing/*unblocks M9, and@massingcloud/pdf-viewerunblocks the review desk. Both are outside this repository's control. The packaging half of M9 is proven — see the tarball run above — so nothing technical stands in front of the first one.massing consumes the packages, and deletes its own viewer. This is risk #1, the only one the plan says can end the project, and it is the one item where every week of delay costs something.
Tessellator out ofDone.apps/demo.@massing/tessellateat layer 2, imported by both apps and byfixtures/;SourceMeshmoved to@massing/coreso a producer need not depend on a renderer. The divergence was worse than "two copies": the shell's had norefDirectionand noIfcRelVoidsElement, so a rotated wall drew unrotated and a wall with a door drew solid — both silent, both shipped. Each is now pinned by a test verified by removing the behaviour and watching that test fail.The p95 frame-time gate.Done 2026-08-15, in the nightly'sframesjob alongside the long-task spec — which turned out to have run in no workflow at all since it was written, so it had never reported anything. The predicted threshold problem arrived exactly as expected and was answered the same way: both fail on liveness (a loop not producing frames, a quarter-second of main-thread work) and report timings toperf/frames.jsonlrather than gating on a number a shared runner cannot hold still.The frame-time gate's first draft had an unreachable failing branch — a fixed 180-frame sample meant any stall large enough to hit the threshold also blew the test timeout, which fired first. Found by injecting a real main-thread stall rather than by lowering the threshold until it went red; the second proves the assertion is wired and nothing about whether the condition can occur. Both branches are now verified reachable with two different stall shapes.
A federated scale benchmark.Done 2026-08-15. Two buildings of the same size, so their expressIDs collide completely — the realistic federated shape and the hazardElementRefexists for — with distinct GlobalIds as IFC4 requires. Both properties are asserted rather than assumed, because a generator change could otherwise leave this quietly measuring two unrelated buildings.The assertion worth having is that both models reach the drawing. A merge keyed by expressID rather than by GlobalId keeps one model's element per colliding id and silently drops the other's; the plan still renders, still looks plausible, and is missing an entire consultant's building. Sabotage-checked by resolving every GUID from model 0, which fails with "model 1 contributed no cut geometry to the federated plan".
What the numbers say, carefully: 288 products across two models at 42 µs/element, against 111 for
medium(144) and 56 forlarge(640). No superlinear per-model cost is visible. That is a weak claim on purpose — the absolute times are 3–16 ms, small enough that JIT warm-up and ordering dominate, so this establishes the shape is not obviously wrong rather than that federation is free.Boot cost. 148–182 ms of script evaluation. The stated trap — "changing the chunk graph is what broke the offline test twice on 2026-08-13" — was wrong, and rested on the diagnosis overturned on 2026-08-14: the offline failure was
Vary: Origin, not the chunk graph. A chunk-graph change shifted which assets were fetched as CORS-mode module requests and so changed how often the race lost, which is why it looked causal. WithignoreVaryin place that coupling is gone and code-splitting is no longer blocked on it.What is measured, on a clean build: entry JS 199.2 KB br (120.2 entry + 78.7
three.core+ 0.3 registration), total 217.4.threeis already split into its own chunk — the note claiming otherwise predates the WebGPU dynamic import that caused the split. The remaining candidates are the export paths (toPdf,toDxf,toBcfZip), which are reachable only from a button and are currently in the entry. Not yet measured, so not yet claimed as a saving.
Known and unexplained
The offline-reload test, still. It failed a fourth time on 2026-08-14, on a commit that changed only markdown — which is proof on its own that no code change caused it. What the trace from that run did establish: the navigation and every subresource failed while the precache provably held all of them, so the worker was not serving. That is a materially narrower statement than the two previous diagnoses, both of which were preconditions that turned out to be proxies, and neither of which this evidence supports.
What has changed since: the worker now bounds its navigation fetch (a real defect on its own — an unbounded network-first shell hangs on a captive portal instead of opening from cache, unit-tested and sabotage-checked), the test wakes the worker before cutting the network, and — the part that matters — the test now asserts delivery and names what was not served. The next failure arrives with its cause attached instead of
element(s) not found. None of this is claimed as the fix; the local harness also dies intermittently withECONNREFUSED, which is a second unexplained thing in the same neighbourhood — see below.The local e2e server dying mid-run. Observed twice on 2026-08-14: the third test of a run failed at
page.gotowithERR_CONNECTION_REFUSED, meaning the server had gone. Two mechanisms have since been eliminated by experiment, and the cause is still unknown, which is a better state than the guess it replaces:- Aborted in-flight requests. Six rounds of cutting the network mid-reload and aborting every route,
including service-worker-initiated ones — the server survived all six. So it is not an unhandled socket
error from a client that went away, which was the obvious candidate given no
uncaughtExceptionhandler. - Long idles. Three rounds of a 45 second idle then a request — survived all three. Both real failures followed a 45 second idle, which is what made this worth testing and what makes ruling it out worth recording.
No fix has been applied, deliberately: there is nothing established to fix, and a defensive handler for a cause that has been ruled out is how a file accumulates code nobody can justify. What was added is a report —
scripts/e2e.mjsnow says so, loudly and with the server's last output, the moment the child exits while Playwright is running. Previously nothing mentioned it and every remaining test simply failed to connect, which reads as "the app is broken" rather than "the thing serving it is gone".The diagnostic paid for itself immediately. It failed again on 2026-08-14 and reported, instead of
element(s) not found:controlled: true, with the document, the stylesheet and the generated registration script all served and exactly one asset missing — the hashed entry chunk under assets/ (four on the retry). So the worker was running, controlling and serving, and missed specific assets the precondition had just confirmed were in Cache Storage. That rules out the previous reading, which was "the worker never started".Found, on 2026-08-14:
Vary: Origin. Measured in the browser rather than reasoned about — the served responses carry that header, andcache.matchhonoursVaryby comparing the stored request's headers against the incoming one's. The two sides are systematically different: the precache stores each entry undernew Request(url, { cache: "reload" }), which sends noOrigin, while the browser fetches a module script and amodulepreloadin CORS mode, which does. So the lookup missed on exactly the assets the app cannot boot without, and hit on everything no-cors — the document, the stylesheet, the classic registration script. That is the reported signature, entry by entry.(An aside with teeth: the first draft of this paragraph backticked the registration script's filename, and the doc-path gate passed locally and failed on CI. That file is generated at build time, so the citation resolved only because a build happened to be sitting in
apps/demo/dist. A gate whose answer depends on uncommitted build output is the same defect as the stale-distbundle gate fixed on 2026-08-13, one directory along, and it is still unfixed — see below.)Online the miss is invisible: it falls through to the network and re-caches under the other key. Offline it is fatal. Which key won that race is why this failed intermittently rather than always, and why it survived being "fixed" twice — both earlier fixes made the precondition stricter, and the precondition was never the problem.
Fixed with
ignoreVary: trueon both cache lookups; the URLs are content-hashed, so identity is total andVarycan only ever produce a spurious miss. The unit harness's fake cache now modelsVary— it previously could not express this failure, which is why 23 passing tests said nothing about it — and the new test was checked by removingignoreVaryand watching it fail. It had to be asserted offline: with the network up it passes either way, which is the bug's whole shape in one sentence.- Aborted in-flight requests. Six rounds of cutting the network mid-reload and aborting every route,
including service-worker-initiated ones — the server survived all six. So it is not an unhandled socket
error from a client that went away, which was the obvious candidate given no
The iPad
#dyn-huddraft test has flaked three times under load and passes in isolation every time. Attributed to contention on each occasion, never root-caused. The offline test looked exactly like this and turned out to be a real race.Still unexplained, and one real ordering defect was found looking for it (2026-08-15). The app published "ready — no network" — the signal every E2E test waits on — before
wireDraftassigned the draft controller. A tool armed in that window has no controller and silently does not arm, which presents precisely as#dyn-hudnever appearing. As written it was saved by JavaScript semantics rather than by design: nothing awaited between the two, so no remote command could interleave. That is a property of the current shape of the function, not of what it means, and one addedawaitwould have opened the window silently.Both halves corrected: the app publishes the signal after the wiring, and
arm()now waits on__massingviewer.draft— the real precondition — instead of on the panel's text, which was a proxy for it. Sabotage-checked by never wiring the controller; the wait times out naming it, where before the failure was an invisible HUD. Not claimed as the fix. The flake predates this and has not recurred to confirm anything; what has changed is that the next occurrence names which precondition was missing.German is 119/119 translated and not native-reviewed.
Stale, checked 2026-08-15 rather than assumed. It runs onAA_TOLERANCEin the raster suite has never run on Linux.ubuntu-latesttwice over: the comparator's own ten tests infixtures/raster-compare.test.tsgo throughnpm run testin the per-PRunitjob, and its real use against committed baselines runs nightly in therasterjob viafixtures/raster.test.ts. Both green, including in today's nightly.Worth noting why the entry survived: it was true when written, and nothing re-read it when the jobs that falsify it were added. An "unexplained" list is only useful if its entries are re-checked rather than inherited — the same failure as the seam ledger describing a facade from before two of its features existed.
The seam ledger measures an older shape of the facade.Fixed 2026-08-15. It reportedreadyagainst 24 capabilities while federation and sheet furniture had shipped weeks earlier and were never entered — the exact failure a ledger exists to prevent, committed by the ledger. Now 27 movable capabilities, each claim checked against the facade rather than asserted: theviatest derives its member list from a constructed viewer instead of a hand-kept copy, so a renamed member fails rather than passing against the old name. The hand-kept list was itself the same defect one layer along, and the same shape as thedependency-reviewdeny list.