ADR-0001 — Two kernels behind one contract

Context

MassingViewer is extracted from the Design Room of ibuilder/massing, where all authoring is server-side: 96 named recipes in services/data/src/aec_data/edit.py, executed by ifcopenshell behind a FastAPI service, with Postgres and MinIO underneath. Every user gesture ends in POST /projects/{pid}/edit, then a poll for publish, then a geometry re-stream.

That architecture is good — ifcopenshell is the reference implementation for authoring IFC correctly, and the recipes carry guardrails that a client cannot enforce. But it means the Design Room cannot be handed to someone as a URL. It needs a Python service, a database, an object store, and an account. The product goal is the opposite: a standalone tool a person can open and use.

Three options were considered:

  1. Client-side only. Rebuild authoring in TypeScript. Truly standalone, simplest to host — and permanently divergent from massing's 96 recipes, with no path back.
  2. Bundle the Python service. Ship a docker-compose file. Full fidelity on day one, reusing services/data/src/aec_data/drawings.py as-is — but "standalone" now means running Docker, which is not a link you send someone.
  3. One contract, two implementations.

Decision

Option 3. Authoring goes through a single KernelProvider interface in packages/kernel-api, with two implementations:

Two design choices make this honest rather than aspirational.

Capabilities are declared, and the declaration is tested

LocalKernel will implement a fraction of what the remote service does, for a long time. The tempting move is to hide the rest of the UI when running locally. That produces a worse product: the user cannot discover what exists, and cannot distinguish "this tool doesn't exist" from "this tool needs something you don't have".

So unsupported operations stay visible and dimmed with the kernel's own explanation"add_connection_assembly runs on the Massing authoring service. Connect a project to use it." The hint is a required field on the failure type, so "dimmed for no stated reason" is not representable.

packages/kernel-conformance asserts that every operation a kernel claims actually works, and that every operation it does not claim returns unsupported rather than throwing, hanging, or silently doing nothing. A kernel that lies about its capabilities fails CI. That is what keeps partial coverage from becoming quiet breakage.

Operations are named recipes, not a geometry API

The obvious interface is move(element, matrix) / extrude(profile, depth). It cannot express what the remote service already does: add_connection_assembly, program_fit, resolve_wall_joins, derive_analytical. There is no Matrix4 that means "fit this program to this envelope".

So an operation is an OpId plus a parameter bag, keyed 1:1 with the server's recipe names — no translation table to maintain. And the operation set is discovered at runtime, which is why a recipe registered by a server-side plugin reaches the ribbon with zero client change. That is the difference between a plugin system and a plugin API.

Consequences

The deadline this ADR is really about

massing must stop holding its own copy of this engine.

Two live copies of an 11,570-line engine is not a plan, it is a fork. Concretely: during the initial extraction session, massing advanced 15 commits in about 90 minutes. None touched the extracted files — verified, not assumed — but the rate is the point.

So: until MassingViewer 0.1.0, extraction is a read of massing and massing keeps shipping normally. After 0.1.0, massing's apps/web/src/viewer is deleted and replaced with @massing/* dependencies. That is milestone M9 in the roadmap, and it is not optional and must not slip — every week both repos hold a copy is divergence debt at compound interest.

Until then, docs/PROVENANCE.tsv plus the weekly divergence report is what keeps the two honest. See docs/adr/0005-clean-history-with-provenance-ledger.md.