ADR 0086 — The Matter pages and their API come from go-fabric-ui¶
- Status: accepted (2026-10-25)
- Date: 2026-10-25
- Related: ADR 0084 — the Matter profiles on go-fabric's device layer, ADR 0049 — Matter one endpoint per device, ADR 0020 — External client wire contract, go-fabric concept v9 — fabric-ui (wave L1), go-fabric-ui
v0.1.1
Context¶
Until 0.93.0 this repository carried its own Matter operator surface: five Svelte pages under assets/ui/src/routes/matter/, seventeen REST routes under /api/v1/matter/* with their handlers and seventy-two handler tests, six WebSocket topics matter.*, the design tokens the pages are themed with and the Home Assistant theme bridge. None of it was about Homematic. The pages showed what every go-fabric node shows — its health, its fabrics, a commissioning window, its endpoints, its sessions and its DNS-SD record — and the handlers read it from go-fabric's node facade. go-ha2fabric, the Home Assistant bridge of the same family, needed the same pages. Keeping two copies meant fixing the same pairing-window edge twice and drifting on the second fix.
go-fabric-ui extracted the pages and the routes from this repository at commit b40b289a (its report docs/reports/U1.md cites the loom file:line each port came from) and ships twice per version: the Go module github.com/SukramJ/go-fabric-ui with the handlers (fabricui.Mount) and the npm package @sukramj/fabric-ui with the Svelte pages, the kit, the tokens and the theme bridge. What a host contributes is an ExposureProvider: which of its items are exposable, how they are shown, what a change would do, and the change itself.
Decision¶
-
The pages are the package's. The SPA's Matter view mounts the six node pages of
@sukramj/fabric-ui— Status, Pair, Fabrics, Selection, Endpoints, Diagnostics — under#/matter/…, with loom's translator, toast, confirm dialog and fetch (session cookie and CSRF header). loom's own copies of the pages, the Matter store, the Matter API types, the QR renderer, the--ha-*token block and the theme bridge are deleted; the tokens and the bridge are imported from the package. What loom keeps of the theme is the detection of the openccu-lite shell frame, which is loom's own. -
The API is the module's. The router mounts
fabricui.Mountunder/api/v1/node; the/api/v1/matter/*routes are removed without aliases, because only loom's own SPA, its chip-tool harness and its e2e tests called them (the Python client carries the generated models, no calls). The REST API moves to 14.0.0. The module's contract (contract/openapi.yaml) is copied intoassets/openapi.yamlso the request validator and the generated clients know the routes, with loom's scope block under everyx-adminoperation and the five inline enums named (NodePairingSource, …);TestNodeContractMatchesPinnedGoFabricUIholds the copy to the module versiongo.modpins. Admin rights are the host's:Deps.IsAdminanswers from the request's identity, so a viewer or an operator gets 403 on every write and on the pairing codes, as before. One loom rule sits in front of the window route (rest.NodeAPI): the node's own boot window is replaced by the operator's request, as the daemon's own opener did — go-fabric-ui answers 409 while any window is open, which would refuse the first pairing after a boot and, without a configured passcode, every pairing until the boot window times out. A window the operator opened and that is still running keeps the 409. -
loom's own half is the exposure provider.
matteradapter.ExposureProviderlists the candidate walk's rows (CollectCandidates) merged with the allowlist (matterendpoint.Store): six columns (CCU, device address, channel, data-point kind, parameter, parameter label), grouped by device, items keyed by loom's source keycentral|address|channel|kind|key, the verdict from the classifier,sourceruleoroverride. Preview returns the touched rows with the proposed state; Apply writes one allowlist row per update after the whole batch passed, and recordsmatter_exposure_bulkwith the request's principal. Verdicts are independent of each other, so a preview is never wider than the update. -
Events ride the WebSocket hub, not a second stream. go-fabric-ui's
Eventsis wired intonode.Config.Observersbeforenode.Newand handed to the handler; the daemon subscribes once and publishes every event onto the hub asnode.<type>with the stream's payload (FabricEvent,WindowEvent,{},ExposureEvent). The SPA feeds the pages from the hub (subscribeon the context) rather than opening the handler's SSE stream: one authenticated connection carries every server push, Ingress and reconnect behave as for every other topic, and the REST request timeout (30 s) would end an SSE stream after one period. Thematter.*topics are removed;wsapi.jsonmoves to 2.0. The handler's ownGET /node/streamstays mounted because it is part of the contract, with the timeout documented. -
The assistant keeps its own status seam.
get_matter_statusreadsmcp.MatterStatusReader, implemented in the composition root over the node and the allowlist, because it folds in what the node page family does not serve (the configuration flag, the enabled count); the health probe reads the same adapter.
Consequences¶
- One Matter operator surface for the family; a fix in go-fabric-ui reaches loom with a version bump, and the contract guard says what the bump changed.
- Two breaking contract moves in one release (REST 14.0.0, WebSocket 2.0). The Python client regenerates its models from the new
openapi.yamland follows thenode.*topics; itsmatter.*event types are dead until then. - A window on the configured credentials no longer echoes the configured passcode: go-fabric-ui returns the passcode only for a window on fresh credentials, and serves the QR and manual codes in both cases. The operator pairs with the codes; the passcode itself stays where it was configured.
- The device-type name in the selection list is go-fabric's (
contract.DeviceTypeName), not loom's richer table (interfaces.MatterDeviceTypeName); the list shows the standard's names. - The audit ledger loses the principal of a fabric removal: go-fabric-ui's
DELETE /node/fabrics/{index}and factory reset reach the host only as the node's observer event, the same event a controller's ownRemoveFabricproduces, so the entry is recorded with the actornode. A host hook with the request's principal is a go-fabric-ui follow-up. The reassembly after an exposure change is recorded asmatter_force_syncwith the principal, beside the exposure entry. - The guards that walk the chi router cannot see inside the mounted handler;
TestRESTRouterMatchesOpenAPISpectreats/node/as a mounted subtree and defers to the contract guard, and the authz-scope guard sees the mount's own tier (the auth chain) while the admin gate runs inside the handler —TestRouter_NodePairing_RequiresAdminmeasures that gate with the viewer and the operator as negative controls. - The Playwright baselines of the Matter screenshots are rebuilt in the CI container; the pages' own tests live in the package.