Files
houseplan-card/docs/CONFIG-COMPATIBILITY.md

1097 lines
64 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Config compatibility registry
House Plan accepts some fields that are no longer written by the current UI.
They are not all equivalent: some preserve an old visual exactly, some are
losslessly migrated on an explicit edit, some are immediately discarded by
backend validation, and a small number still need a product decision.
The machine-readable source of truth is
`scripts/config-field-registry.mjs`. Every entry records:
- persisted path and type;
- inheritance level and default;
- whether the current UI exposes it;
- the runtime consumer (or the fact that no supported consumer was found);
- migration behaviour;
- the read-compatibility decision.
The registry started from the compatibility and internal-field debt identified
by HP-DATA-01 and has grown with every model change since. The schema itself is
machine-checked by `test/config-schema-parity.test.mjs` (#33): backend and
frontend enums agree except through an explicit allow-list, and every entry of
this registry resolves to a path of the generated schema manifest or to an
explicit passport. A field missing here is therefore a documentation gap, not
an unknown schema.
## Schema manifest and parity (#33)
The Voluptuous schema in `custom_components/houseplan/validation.py` is the only
owner of the persisted config/layout shape. `scripts/dump-config-schema.py`
walks it into the deterministic `scripts/config-schema.json`;
`tests_backend/test_config_schema_manifest.py` regenerates the manifest and fails
on uncommitted drift. `test/config-schema-parity.test.mjs` compares manifest
enums with the exported frontend lists (`DISPLAY_MODES`, `TAP_ACTIONS`,
`SPACE_FILL_MODES`/`ROOM_FILL_MODES`, `OPENING_TYPES`, `VACUUM_TRAIL_MODES`,
`ZERO_WALL_STYLES`, `BG_MODES`, `SUN_RAY_ORIGINS`). Every divergence is blessed
in `scripts/schema-compat-allowlist.mjs` with a reason and an owning issue; an
entry that no longer matches a real divergence fails the test, so the list
cannot rot. Registry entries resolve to a manifest path or carry an explicit
`schema: 'allow-extra' | 'lovelace-card'` passport; implemented mechanisms cite
their code point in `enforcedBy`. `test/fixtures/config-lifecycle/`
(`oldest-supported`, `current`, `future-fields`) pins the load contract: each
passes the schema losslessly and future fields round-trip exactly.
## Offline inventory
Exported JSON can be inspected without uploading it or changing it:
```bash
npm run audit:config -- path/to/houseplan-config.json
npm run audit:config -- --json path/to/houseplan-config.json > findings.json
```
The auditor accepts either the config object itself or an export wrapper with a
top-level `config` object. It reports only fields known to the registry, shows at
most three example paths in human-readable mode and always exits read-only. With
no file it prints the current registry.
## Status meanings
| Status | Meaning |
|---|---|
| `decision-required` | Preserve the field until its supported UI/runtime fate is explicitly decided |
| `deprecated-read` | Current writes use another representation; reads preserve old data/visuals |
| `migrate-on-write` | A lossless current representation is materialised during the documented write path |
| `migrate-on-settings-save` | Removed from current settings semantics and dropped only when those settings are explicitly saved |
| `drop-on-validation` | A stale client may submit it, but backend validation removes it safely |
Unknown future fields remain outside this report and continue to follow the
backend's forward-compatibility policy. Absence from the report is therefore
not permission to delete a field.
## Revision-less config writers (#340)
The current frontend has sent `expected_rev` with every `config/set` since
v1.4.4. A client without that field may initialise an empty configuration store
at revision zero, where no newer work exists to overwrite. Once a document has
been saved, omission is a `conflict` and leaves the config, revision, backup and
events unchanged — including when the submitted document would otherwise be a
semantic no-op.
There is no version-based compatibility window for writes over a non-zero
revision. Without a server-issued token, an old client and a stale concurrent
writer produce the same request; accepting either would reopen last-writer-wins
data loss. This changes only the WebSocket write contract. Stored config,
model/store versions, exports and read compatibility are unchanged.
The same rule applies to `layout/set` (#356).
## Crash-resumable config/layout pairs (#491)
`optimize_pending` is the durable authority for every paired config/layout
write: Optimize, Optimize Undo, full import and space deletion. Current intents
carry canonical target documents, their exact target revisions and exact final
layout-store metadata. The intent is written before either visible half and is
removed only by the final layout write after config and layout both match it.
On a persistent target failure, an explicit rollback intent restores the exact
before-pair, including its revisions and unknown metadata.
Before every runtime config/layout writer reads revisions or validates its own
candidate, the common write fence resolves a valid pending intent and reloads
both stores. Old CAS revisions conflict normally; point layout writes then
change only their named entry on the recovered layout. If recovery cannot yet
finish, the new write is rejected without deleting the intent or backup.
Startup invokes the same resolver before storage migrations. Legacy valid
intents without `final_metadata` retain their previous compatibility fallback;
there are no new persisted fields, store/model version bumps, export fields or
read-side repair semantics.
## Room hover information preference (#426)
`settings.show_room_tooltip` is an optional global boolean. Absence and any
invalid legacy/future value read as the historical enabled default; only exact
`false` hides the floating room information window. Saving the enabled value
removes the key. The field does not change room highlighting or device
tooltips, and does not require a model/store version migration.
An older frontend ignores the field and temporarily shows the room window. An
older backend preserves it through the existing unknown-settings policy, so a
new frontend restores the disabled behavior after upgrade. Full backup/import
preserves the setting and the privacy-safe support projection includes only a
validated boolean.
## 2.5D View setting (#649)
`settings.volumetric_view` is an optional global boolean for the whole
installation — every space, user, device and kiosk. Only exact `true` turns the
View into 2.5D; absence, `false` or anything else read as Flat. Saving `false`
removes the key. The backend accepts only a boolean
(`vol.Optional("volumetric_view"): bool`), and the privacy-safe support
projection copies only a validated boolean. There is no migration: the former
per-device, per-space alpha choice (`houseplan_card_view_v1` in the browser)
was never part of the config and is no longer read.
An older frontend ignores the field and shows Flat. An older backend preserves
it through the unknown-settings policy. Full backup/import carries `settings`
whole, so the value survives.
## Stairs (#663)
`spaces[].stairs[]` is an optional bounded (250 records per space)
discriminated collection. `kind: "straight"` stores positive `length` and
`width` plus `direction: "forward" | "backward"`; `kind: "spiral"` stores a
positive `radius` plus `direction: "clockwise" | "counterclockwise"`. Both
variants carry stable `id`, continuous normalized centre `x/y`, scalar
`angle`, and an optional nullable `target_space_id`. Optional `color`,
`opacity`, `fill_color` and `fill_opacity` store the stair-owned screen style.
Their absence is the legacy form: renderers/dialogs resolve the current decor
default without rewriting the config, and the first successful Properties save
materialises all four fields. Changing the global decor default later does not
recolour a stair that already owns the fields.
Stair transforms follow furniture's continuous contract. Config writes apply
only the nine-decimal scalar cleanup to position, size and angle; they do not
use near-lattice snapping, and Optimize does not move them. Full backup,
support data and diagnostics retain the bounded records. Full import remaps a
known target space id and clears a target that was not imported; single-space
transfer cannot invent a link to an external floor. Deleting a target space
clears incoming links without deleting the source stair.
The collection is additive and requires no model/store version migration. A
current frontend renders and edits it; a legacy frontend ignores the objects.
The current backend preserves valid records through unrelated writes, while a
backend predating the field must not be used to edit a newer configuration.
## Sun-ray window face (#577)
`settings.sun_ray_origin` is an optional global enum: `inner` or `outer`.
Absence and an unknown read-side value resolve to `inner`, preserving the exact
pre-#577 source geometry without rewriting an existing configuration. Saving
General settings materialises a valid value; new installations start with
`inner`. Backend writes reject every other value. There is deliberately no
per-space override and no model/store version bump.
Full backup/import preserves the enum. The privacy-safe support projection
includes only a validated `inner`/`outer` scalar. Older frontends ignore a
preserved value and temporarily render the historical inner mode; a current
frontend restores the selected mode after upgrade.
## Summary panel namespace (#437)
`settings.summary_panel` is an optional shared versioned object. Absence means
that the frontend derives one localized default block without writing the
server; an explicit `blocks: []` remains intentionally empty. Version 1 bounds
the panel to 10 blocks and 20 values per block, uses stable block/value ids,
and accepts only entity state sources or the three built-in read-only values.
New entity and space references are checked identically at `config/set` and
`plan/optimize`; an unchanged old broken reference remains editable and is
shown as a warning.
An old ordinary client that omits an existing namespace cannot erase it. A
bounded future version is preserved losslessly but stays inert in an older
frontend. Full backup/restore remains authoritative. Per-user/per-card show and
View scale preferences, HA states, registry snapshots, computed totals and
clock values are browser runtime data and never enter server config, exports or
support packages. `config/get.summary_panel_api === 1` is a runtime capability,
not persisted user configuration and not a store/model version bump.
Browser-local summary preferences are keyed by HA user, route, host and logical
card slot; the resolved summary-local key is the scale authority and the legacy
kiosk key only seeds its first load. A native Masonry slot is the top-level
card's index in `hui-masonry-view.cards` (dashboard config order, stable while
HA moves cards between responsive columns); a nested stack/conditional appends
only its composed descendant path. The `masonry-v2` marker separates these keys
from the older ambiguous DOM-path keys (#561), so earlier per-card Masonry
choices may need to be set again. While the canonical array or matching ancestor
is not available yet, preferences stay session-only and no guessed persistent
key is read or written (`summary-panel-identity.ts`).
The writer authority is explicit:
| Writer | Summary-panel authority |
|---|---|
| `config/set`, Optimize | Candidate; omission preserves the exact stored namespace, while explicit `blocks: []` remains empty |
| Full import | Archive is authoritative, including an absent namespace |
| Space/plan-only import | Exact global settings of the target installation |
| Optimize/Import Undo | Exact configuration from the corresponding backup |
| Create/copy/delete space | Ordinary candidate built over current global settings; no scope is silently remapped or removed |
Every row also preserves unrelated known and future settings namespaces. Only
the two ordinary WebSocket writers apply the caller's current entity-read
permissions to newly selected version-1 references; an authoritative restore
may legitimately carry a broken transfer reference for later replacement.
## Contextual Zigbee topology (#54)
`settings.zigbee_topology` is an optional global object. Only exact
`enabled: true` activates the admin-only full-card View layer; absence,
malformed values and older configs are off. `z2m_base_topics` is a bounded list
of exact MQTT base topics: empty entries, wildcards, duplicates and control
characters are dropped. Disabling the feature retains valid topics; resetting
General settings removes the object.
Provider snapshots, IEEE addresses, links, errors and timestamps are runtime
memory only. They are not config fields, do not enter backup/export or support
diagnostics, and disappear with the HA connection/page. Older frontends ignore
the settings object; the backend's unknown-settings policy preserves it. No
model or store version migration is required.
## Vacuum map routes (#162)
`marker.vacuum.map_routes` is an optional array of
`{ id, source, map_id, space, calibration? }`, at most 32 per marker, with `id`
and the pair `(source, map_id)` unique inside the marker. It requires no model
or store version bump: absence reads as the historical behaviour.
Reading is lossless in both directions. When `map_routes` is absent or `null`,
every valid `calibration[map_id]` is an effective route into the dock's space,
so nothing is migrated on load and an ordinary save of other marker fields
leaves the vacuum block untouched. Any array is explicit authority: an empty
`map_routes: []` means that no route is configured and suppresses every
retained legacy matrix. The first explicit routing edit converts the whole
legacy dictionary at once and needs an exact source to do it; partial
conversion is refused, and `calibration` is removed only after the config write
succeeds.
Semantic validation is change-aware: an untouched legacy or future-shaped block
never blocks an unrelated save, while an edited one must be valid or the write
is refused atomically with `invalid_vacuum_map_route`. A full export/import
round-trips routes verbatim. A single-space export drops routes that point at
other spaces and counts them in `dropped_marker_links`, because their target
would not exist in the imported document. If that removes the last explicit
route, the export keeps `map_routes: []` rather than writing `null`, so a later
import cannot make legacy calibration authoritative again.
Downgrade: an older frontend ignores `map_routes` and falls back to the legacy
`calibration` dictionary, which the conversion removed — such a plan shows the
robot only where a matrix still exists, and re-upgrading restores routing
without data loss because the routes themselves are preserved by the
unknown-fields policy. Server trails written by a newer backend carry
`route_id`/`source`; an older frontend ignores both and matches runs by
`map_id` exactly as before.
## Stable wall identity — model v8 (#282)
Model v8 adds `space.wall_segments[]`, ordered `rooms[].wall_ids[]`, IDs on
`room_drafts[].segments[]`, and tagged wall hosts on room-wall openings. These
fields are authoritative for wall identity, thickness and opening ownership.
The historical `rooms[].poly`, `space.walls[]` and opening coordinates remain
materialised compatibility projections.
Reading a v7 store is side-effect free. A v8 wall catalog is materialised only
before a structural write, through **Optimize plans**, or when a v7 candidate is
imported into a v8 target. A v7-to-v7 import remains v7. Full and space-only
export/import preserve IDs; copy/merge deterministically remaps IDs together
with all references. There is no automatic downgrade from v8 to v7.
An older frontend may read the compatibility projection of a v8 config. Its
byte-equivalent legacy round-trip is accepted and the backend restores the v8
fields. If the legacy-visible structural projection changed, the backend
rejects the write instead of allowing thickness, draft identity or an opening
host to detach. A migration or transition conflict is fail-closed: the previous
config and revision remain unchanged.
The stale-client comparison is deliberately limited to contour-coupled legacy
geometry: rooms, compatibility `walls` and `open_spans`. Self-identifying
`room_drafts`, `partitions`, `wall_columns` and explicitly hosted `openings` may
change without rewriting the room-wall catalog, but still pass the complete v8
schema. Write-time sanitation and Undo preserve every surviving draft-segment
ID. If a physical `config/set` is rejected, the frontend restores the earliest
snapshot in that pending batch before attempting a best-effort authoritative
reload; rejected draft geometry cannot be promoted by a later gesture (#314).
## Canonical zero-thickness walls — model v9 (#306)
Model v9 removed the separate virtual-boundary representation. A contour atom,
independent partition or then-current draft segment with `cm:0` became the only form of
a wall axis without masonry. `space.zero_wall_style` is optional and accepts
`dashed | solid`; missing or unknown values read as `dashed`. Dashed zero walls
transmit Glow and sunlight, while solid zero walls are exact line barriers.
`space.open_spans[]` and `rooms[].open_to[]` remain compatibility reads for v8
documents only. Explicit valid spans win; `open_to` expands to the full proven
shared boundary only when spans are absent. The first structural write,
confirmed **Optimize plans**, or import into a current target atomizes that
geometry into stable `wall_segments[].cm=0`, preserves surviving IDs, and then
removes both legacy fields in one transaction. An opening never blocks that
migration (#316): the atom carrying an existing opening keeps its positive
thickness (the zero run continues on both sides), an ambiguous carrier is
resolved deterministically (current host, then distance, thicker cm, smaller
id), and an opening with no usable carrier at all persists **unhosted** — a
valid degraded v9 state that is inert in the physics (no body, tunnel or cut),
renders by its own `x/y`, survives later writes untouched and may be re-placed
in the editor. Post-migration structural writes keep the fail-closed refusal
for an opening that LOST its carrier. Presentation,
marker and ordinary space-settings writes do not trigger the migration.
There is deliberately no provenance flag. Existing v8 `cm:0` and atoms derived
from legacy virtual spans are identical. Consequently some old plans may change
line style or light transmission after upgrade; this is the accepted migration
trade-off. Canonical v9 export/import never recreates `open_spans/open_to`, and
downgrade after the first v9 structural write is unsupported.
## Ordinary wall chains — model v10 (#478)
Model v10 removes persisted `space.room_drafts`. Each accepted segment of the
Walls tool is written immediately as one ordinary `space.partitions[]` record;
the active chain id, ordered partition ids, path and per-edge thickness list are
session-only. Switching tool/editor/space, leaving the card or reloading only
ends that session. Accepted segments remain independently selectable walls and
are not resumed as a chain.
The v9→v10 migration converts every valid draft edge one-for-one into a
partition. A unique existing segment id is retained; a missing, empty or
colliding id is replaced deterministically from the space, draft and edge
index, with a stable numeric suffix when needed. Draft order and each edge's
`cm` are preserved, existing partitions are not merged, and the carrier record
is removed atomically. Malformed geometry or invalid thickness fails closed.
A current v10 document that still carries `room_drafts` is **healed, not
refused** (#529). The key is removed the same way the first migration removes
it: an empty carrier silently, drafts converted one for one into partitions.
Refusing it locked the plan completely — the card runs the same migration, so
structural edits were rejected before the request left the browser, and export
calls it too, so no backup could be taken to repair the file by hand.
A stale writer is identified where it can actually be identified — in
`validate_wall_model_transition`, which sees both the submission and the stored
plan: drafts appearing over a stored plan that does not have them are refused
with the outdated-client error, regardless of the `model_version` the client
submitted (a stale card echoes back the number it was given). The schema
invariant still refuses a non-empty carrier as the last line.
Import preview, full/space backup restore and Optimize materialize this legacy
shape before current processing, report converted draft/segment counts, and
never export `room_drafts` from a current configuration. A room accepted from a
closed active chain consumes only exactly coincident chain partitions in the
same room transaction. Cancel/Keep-as-walls leaves all accepted partitions
unchanged. Downgrade after the first v10 structural write cannot restore the
old resumable-chain behaviour.
## Canonical geometry on write (#224, #291)
Config and layout schemas canonicalize only named persisted numbers. Lattice
coordinate/size components use the exact nearest `k / 240` double only when
their deviation is below `1e-4` of one grid step; authored values farther from
a node keep the nine-decimal scalar storage contract. Backdrop transforms,
angles, lengths and normalized ratios always use that scalar contract. Common
storage helpers repeat the same idempotent operation for internal writers,
while the frontend adopts the exact candidate it sends. This removes ULP noise
without changing the schema, JSON number type, model/store version or visible
placement.
Decor uses an explicit box-geometry catalog shared by the frontend contract and
mirrored by the integration: `rect`, `ellipse`, `furniture` and `image`.
Their `x/y/w/h` fields follow the lattice rule above and `angle` follows the
scalar rule; image asset, opacity, mirror flags and unknown fields are not
geometry and remain unchanged.
The operation is lossless at the product scale and intentionally narrow.
`view_box`, `cell_cm`, `plan_aspect`, physical centimetre values,
presentation settings, colours, opacity/brightness/temperature, vacuum
calibration and unknown/future numeric fields retain their exact input values.
No recursive “round every number” migration is allowed.
Existing stores remain byte-for-byte untouched on read. Their geometry becomes
canonical on the next config/layout write; Optimize Plans is the immediate bulk
path. Optimize/Import/repair Undo restores the previous semantic geometry and
unknown fields in canonical representation, not the invisible noisy IEEE-754
tail. A repeated canonical Save with the current revision is a no-op and does
not invalidate the one-deep maintenance backup.
Optimize itself removes measured near-node tails before visible grid alignment,
then returns and compares the same storage-canonical config/layout pair as the
writers. Consequently the normal commit, durable pending recovery, update-event
reload and a cold read all converge on one JSON value set; feeding any of them
back to Optimize is a no-op (#248). Its separate lattice report counts cleaned
coordinate components and untouched authored off-grid values without mixing
their sub-pixel maximum with visible grid movement.
Wall-thickness compatibility keys use the same boundary without depending on
which side of it produced the key (#258). A `wallKey` endpoint already within
`max(pitch · 10⁻⁶, 10⁻⁹)` of a grid node is treated as that exact node before
midpoint quantisation, so `83/240` and persisted `0.345833333` identify one
stretch. Existing entries whose old midpoint key landed one grid step away are
read immediately by strict equality of their lossless `a/b` endpoint pair;
read does not rewrite config and does not broaden parent/child matching.
Explicit Optimize rewrites the compatibility key, retains `cm`, endpoints and
unknown siblings, and its next in-memory or backend storage round-trip is a
no-op. Legacy key-only records continue through the previous midpoint fallback.
Explicit Optimize also has one deliberately lossy wall-thickness repair. A
positive interval shorter than half a grid step may inherit its two equal
positive neighbours only when all three belong to one original straight room
edge and exact owners are unambiguous. One endpoint may be a room T-node: only
the interval `cm` changes, so that node and its perpendicular incident geometry
remain intact. An opening/open-span endpoint, two room topology endpoints,
unequal neighbours, a half-step-or-longer interval or conflicting owners always
block the repair. Normal read, render, Save and editor paths remain lossless;
only confirmed Optimize applies it, with the ordinary preview and server Undo
(#198, #273).
Explicit Optimize may also straighten a stored wall whose slope is non-zero
but no more than `0.25°` from an axis (#290). This is a confirmed lossy repair,
not a read migration or schema change. All coincident room-owner endpoints move
together, wall/opening identities are reprojected by the canonical pipeline,
and true diagonals survive byte-equivalent. Older clients continue reading the
result as ordinary polygon geometry; reverting code does not require a storage
migration.
## Open-passage opening type (#157)
`space.openings[].type` additionally accepts the literal `passage`. Its
canonical record contains only `id`, `type`, `x`, `y`, `angle`, `length` and
unknown extension siblings. The door-only keys `contact`, `lock`, `invert`,
`flip_h` and `flip_v` are inapplicable; their presence is invalid even when the
value is `null` or `false`.
New/changed records and every full/space import are validated fail-closed with
`invalid_passage_fields`. An already stored broken passage may survive an
unrelated write unchanged so legacy data cannot lock the whole plan; removing
bad keys is allowed, while adding or changing one is rejected. An explicit UI
save of a passage deletes all five known keys and preserves unknown siblings.
Stale binding values remain inert at runtime and create no entity subscription.
Older v1.64.x frontends do not know the literal. Downgrade is read-only
best-effort: do not edit an open passage with an old frontend, because its
fallback may show or rewrite it as a door. Before a permanent rollback, convert
saved passages deliberately in a current version; automatic conversion is not
performed because it would invent a leaf and binding semantics.
## Furniture mirror flags (#383)
`space.decor[]` furniture records may contain optional boolean `flip_h` and
`flip_v`. Their `w` and `h` remain strictly positive; absent flags mean the
historical unmirrored orientation, so no migration is required. Full, space
and plan-only transfers preserve both flags, while coordinate canonicalization
and Optimize leave them untouched.
The same field names already exist on door/window/gate opening records, where
they describe leaf direction. This is not a shared semantic field: openings
and furniture are validated, canonicalized and exported through separate
object schemas and allowlists. Older renderers ignore furniture flags and show
the original orientation; older plan-only exporters may omit them.
## Custom decor images and export v2 (#51)
`space.decor[]` accepts an additive `kind:'image'` variant with a lowercase
64-character SHA-256 `asset_id`, positive `x/y/w/h`, optional `angle`,
`flip_h`, `flip_v` and `opacity`. File bytes are not config fields and live in
`config/houseplan/assets`. New backend + old frontend is read-only for configs
that already contain this unknown kind: a current frontend exposes the tool
only after `houseplan/config/get` advertises `decor_assets_api:1`.
Portable export format v2 adds extension-neutral `decor_asset` manifest rows.
It records content hash and source availability but never embeds file bytes or
signed URLs. The importer continues to accept v1. A matching verified local
hash is reused; otherwise import requires confirmation and preserves the image
record as an editor repair placeholder instead of removing its geometry.
When the source blob and metadata are already absent, the canonical row has
`exists_at_export:false` and may have `mime:null`; that exact missing state is
importable in full, single-space and plan-only documents. Missing MIME is not a
general validation bypass: the availability flag must be a literal boolean,
identity/hash remain exact, and every supplied non-null MIME must be supported.
Before a permanent downgrade, remove all image objects with a current card and
then explicitly delete their now-unused files from the palette.
The #432 backend hardening does not change that schema, URL shape, export format
or `decor_assets_api:1` capability. A read-only user still resolves images used
by the saved config; only arbitrary unreferenced ids are now returned as
`missing`. Writers keep the full catalog contract. Authenticated and signed
exact content URLs remain valid, while integrity results are shared in a bounded
memory-only cache. Old and new cards therefore remain rolling-compatible with
the hardened integration; the cache is discarded on restart and needs no data
migration or downgrade step.
#434 keeps the same capability version and wire formats while tightening the
rolling boundary. Embedded `houseplan-space-card` instances call
`houseplan/assets/resolve` only after a fresh `config/get` returns exact
`decor_assets_api:1`; cached config from localStorage starts unverified, and a
later missing or malformed capability revokes a previously learned value even
when config content is identical. Positive and missing resolve results are
cached only for the same connection, config revision and id set. No persisted
field, schema migration, export-version change or downgrade action is added.
Decor quota now follows physical allow-listed hash blobs rather than trusted
catalog metadata. An exact re-upload may restore a missing or broken sidecar at
full quota because it adds no blob; the response uses `reused:false` to state
that the catalog entry was created by this request. Older cards can continue to
list and resolve valid rows and ignore this response distinction.
## Independent-wall opening host (#132)
`space.openings[].host` is an optional discriminated object
`{kind:'partition', id:string, t:number}`. Its absence preserves the historical
room-wall association. When present, the referenced partition in the same
space and normalized `t` are authoritative; the legacy `x/y/angle` siblings
remain a materialized compatibility projection for older readers. Full export,
plan-only export, merge and optimization preserve the host object.
Current writes validate the reference, fit and non-overlap. They also reject a
stale writer that keeps an existing opening but silently drops its host; this
prevents a downgrade from converting it into a nearby room-wall opening. Old
frontends may display only the materialized projection, so opening or editing a
hosted opening with an old bundle is unsupported. A missing/invalid host is not
re-associated automatically: current renderers fail dark and Plan offers an
explicit rebind.
The sole host-removal exception is the explicit Optimize reconciliation from
#276/#280. The server does not trust a client counter: it independently proves
that the old partition was removed, its complete segment is either one solid
outer boundary owned by exactly one room or one solid shared boundary owned by
exactly two rooms, the replacement wall envelope is not narrower, the
materialized centre/angle and every unrelated opening field are unchanged, and
no new slot overlaps. This capability is enabled only by
`houseplan/plan/optimize`; ordinary config writes and crafted candidates keep
the fail-closed `invalid_partition_opening_host` result.
New hosted openings and direct changes to `host.id`, `host.t`, `length`, host
span or host thickness reserve a jamb at both endpoints equal to half the
actual partition thickness. This is semantic delta validation, not a schema or
migration: an unchanged legacy near-end opening, an unrelated edit and a rigid
translation of its host remain valid and are never silently clamped. Full
backup restore intentionally uses the structural zero-margin fit boundary even
without a trusted previous config, so older backups remain restorable; the next
direct geometry edit must satisfy the current jamb rule.
## Four-phase background default and transfer (#146)
The schema remains `settings.bg_mode: static | daynight` globally and per
space; absence is still accepted for older files and the runtime's final
fallback remains `static`. New semantics are materialized instead of changing
that fallback:
- new integration config uses explicit global `daynight`;
- manual and Floors/Areas space creation writes explicit per-space `daynight`;
- storage minor v1.2 migrates a missing or invalid legacy global token to
`static` once, while preserving valid global/per-space values, unknown
siblings, revisions, and the other stores;
- full export/import always carries an explicit global mode, with legacy
missing mode becoming `static` before preview/apply;
- a space export copies its effective mode into the exported space; a legacy
space import without a mode becomes `static` before merge, regardless of the
target installation's global setting.
Import preview and apply therefore operate on the same normalized candidate.
Explicit `static` and `daynight` survive same-instance and foreign transfer.
## Additive plan-only space transfer (#167)
`houseplan/export/create` accepts `plan_only: true` only for a one-space
export. The resulting version-1 envelope adds `transfer.plan_only: true`,
contains no markers and retains only canonical `rl_<room_id>` room-label
layout. Normal full/space exports never write `plan_only: false`, so their
existing document shape and lossless compatibility remain unchanged; an
absent field still means an ordinary export.
Plan-only data is a fail-closed allowlist projection of supported geometry,
presentation and content references. Known Area, temperature/humidity,
opening and decor bindings are removed and recognized live-text references are
frozen as `—`. Import rejects a true flag on a full export, non-boolean values,
or any document whose projected config, layout, placement or content owner no
longer satisfies that privacy contract. There is no persisted config/layout
migration: the new field exists only in the portable envelope.
## Legacy device tap action
The canonical marker token `tap_action: none` is an explicit saved no-op. It is
different from an absent, `null` or empty action: those values retain the
domain default (Toggle for a primary `light.*`, Device card otherwise). The
current frontend consumes short click/tap and keyboard activation before any
capability, UI or HA dispatch, while hold and context-menu paths are unchanged;
the current backend accepts the literal. Generic full/space transfer preserves
it without migration, while virtual duplication continues to omit tap action
with the other device-specific behaviour. On downgrade, an older frontend
safely projects the unknown token to Device card, but an older backend rejects
a subsequent config write containing it. Therefore rollback must keep backend
read/write acceptance until stored `none` values have been migrated to `info`.
The historical marker token `tap_action: cover` remains accepted indefinitely.
It is projected in the current UI as the universal **Toggle state** action and
keeps cover-first target priority at runtime. Merely opening and saving an
unrelated marker field preserves the literal `cover` token; once the user edits
the action selector, the current canonical `toggle` token is written. The UI
never creates new `cover` values. Unknown or unavailable cover capabilities
remain a safe no-op and are never replaced by a guessed service call.
An absent action on a primary `light.*` likewise stays absent on an untouched
Open → Save.
The universal `toggle` resolver uses the current HA registry as its capability
boundary. A disabled, orphaned or not-yet-verified device target is therefore
a visible/explained safe no-op; it is not silently retargeted to a sibling
entity and does not fall back to opening the info card. Entity bindings that
still have a live, enabled service target may continue to work while registry
metadata is refreshing. Persisted actions are preserved in both cases so a
temporarily unavailable binding recovers without a config rewrite.
## Independent Glow compatibility
The historical space and room token `fill_mode: glow` remains accepted on read
indefinitely. Runtime projects it into an ordinary inherited data fill plus an
enabled Glow overlay; explicit `glow_enabled` / room `glow` booleans always win.
A normal edit that replaces the legacy token writes the resolved boolean in the
same operation. Optimize Plans applies the same lossless, idempotent model-v7
migration while preserving unknown sibling settings.
## Space reference repair (model v7)
Missing `marker.space`, `marker.room_id`, `vacuum.segment_map` and layout
ownership remain readable by the permissive persisted schemas. They are never
rewritten during load or an unrelated Save. Explicit Optimize may map an
untruncated exact `space_<old>_<8 hex>` / `room_<old>_<8 hex>` import signature,
or use the production HA Area placement for an active real marker. Without a
valid target it removes the marker's missing placement but preserves its old
position for the owner-aware cleanup decision.
The same explicit Optimize candidate automatically removes layout entries only
for owners proven absent: missing room labels, removed marker tombstones, and
known devices or `lg_` entities absent from an authoritative HA registry/state
roster. A live owner in a deleted space is preserved unless the administrator
explicitly opts into removing its old position. A limited or unavailable
registry and an unknown/future layout namespace always preserve the entry;
nested vacuum mappings likewise remain stored and reported. This is a runtime
read-model decision, not a persisted migration: schemas, store/model versions
and the layout shape are unchanged. The pass is data-driven, undoable,
idempotent and runs even when `model_version` is already 7.
A one-space import uses its known id map (not a heuristic) to repair matching
orphan target references when the original space id is absent. Full restore is
unchanged. Space deletion now uses a revision-guarded config/layout transaction
and refuses active marker dependencies while another space remains; removed
tombstones keep their metadata and lose only placement fields owned by the
deleted space. Deleting the sole remaining space is the intentional exception:
all affected active and removed marker records survive with only `space` and
`room_id` cleared, preserving the empty-state contract. Older clients can read
every repaired candidate because the schemas and field shapes did not change.
Current `fill_mode` additionally accepts `custom`. Its optional color is stored
as `{c:'#RRGGBB',a:0..1}` in `space.settings.custom_fill` and, for an explicit
room override, `room.settings.custom_fill`. Missing or invalid historical data
is projected at render time through room → space → `#607d8b`/`0.18`; merely
reading it never rewrites the config.
Since #581 the room override is read only together with the room's **own**
`fill_mode: 'custom'`. A `room.settings.custom_fill` stored without that mode —
older editors wrote it when a room was switched back to "as the space" — is
projected as the space colour on every surface (card, space-card, PDF) without
being rewritten; the next save of that room through its dialog removes the
field. New saves never write `custom_fill` without `fill_mode: 'custom'`. The
field shapes and the backend schema are unchanged, so older clients read
configs saved by newer ones as "a room without its own colour" — the same
picture the newer client shows. Backend writes keep the strict shared
hex/finite-alpha contract. An explicit `null` is accepted at either level and
has the same projection semantics as a missing override.
The current space editor presents `custom` as the ordinary/default room fill
instead of offering a separate `none` choice. A historical space-level `none`
is still rendered losslessly until edited, then the dialog projects it to
`custom` with the existing/default color at zero opacity and materializes that
visually equivalent choice on Save. Newly created spaces use the same
zero-opacity custom value, so the UI change does not introduce a visible floor
or remove the Glow base by default. A zero-opacity resolved fill still receives
the Glow base. `none` remains accepted by the model and exposed at room level,
where it is still required to suppress an inherited
LQI/light/temperature/custom fill for one room.
Rooms may also store optional `settings.temp_min` and `settings.temp_max`
finite numbers. Each absent or explicit `null` side independently inherits
`space.settings.temp_min` / `temp_max` (and then the product default). The
effective pair is sorted only at the read boundary, so a partial override is
not materialised merely because its inherited counterpart crosses it. Current
writers sort two explicitly entered values and omit cleared keys. The fields
affect only temperature room/tunnel fills. Older cards ignore them and may
erase them if they rebuild that room's settings after a downgrade.
## Per-marker light role and Glow appearance
`marker.is_light` is tri-state. Missing/null means automatic device-role
discovery, `true` forces the marker's own controllable entity to be a spatial
source, and `false` suppresses that own source. External `controls` are not
suppressed: they continue to contribute to room light state and counts without
placing a Glow pool at the controller. This intentionally changes the read
semantics of hand-written legacy `is_light: false`: older frontends treated it
like Auto, while current frontends treat it as Never. The historical writer
only emitted `true` or `null`, so ordinary UI-authored configs are unaffected.
`marker.glow_color` is optional and strict: `{c:'#RRGGBB'}` fixes colour while
keeping live brightness; `{c:'#RRGGBB',bri:0.01..1}` fixes both. Missing/null
uses the live source. Invalid objects fall back atomically to live values and
never partially reach SVG. `{c,bri:null}` is accepted for compatibility,
projects like `{c}`, and is canonicalised to `{c}` by the next marker save.
Older frontends ignore this field at render time and may erase it when they
rebuild the same marker after a downgrade; this limitation cannot be repaired
retroactively.
`marker.light_entity` optionally stores the leading `light.*`/`switch.*` for an
Always source with several controllable entities. It is copied literally by
full and space transfer: entity ids are instance-specific and are never
remapped. Missing/invalid selections remain stored, produce a dialog warning
and temporarily fall back to the normal deterministic selection; merely
opening or saving another field does not erase them. Older frontends ignore the
unknown field and may erase it only if they reconstruct that marker.
`marker.toggle_entity` optionally stores the exact own `light.*`/`switch.*`
operated by Toggle. Absence/null keeps the historical single-target resolver
and external-only controls groups bit-for-bit. A present active choice is exact:
temporary missing/unavailable/secure state never retargets it to a sibling. A
choice no longer belonging to the marker remains stored, warns in the dialog
and temporarily uses the historical fallback. New/changed values are
domain-bounded by lossless delta validation; an untouched future literal can
round-trip. Full and space transfer copy the entity id literally, while a
duplicate marker virtualised during space import drops the HA-dependent field.
Older frontends ignore it and may erase it if they reconstruct the marker.
`light_entity`, `toggle_entity` and `tap_target` are independent.
`marker.controls[]` additionally accepts `marker:<marker_id>` links to forced
plan sources. Runtime and old frontends continue to filter those strings out of
HA service calls. New writes validate target existence, forced-source role,
self-reference, duplicates and cycles; an already stored broken legacy link is
allowed to round-trip so unrelated edits cannot lock the plan. Deleting or
rebinding a target removes or rewrites references atomically. A space export
remaps links whose two ends are inside the exported space and reports/drops
links leaving it; full export preserves them literally.
`marker.value_badge` is an optional explicit value satellite. Its absence is
the legacy compatibility state: the historical temperature/humidity heuristic
and global `show_temperature` gate remain in force. An explicit
`{enabled:false,...}` suppresses that heuristic; an enabled badge stores one
discriminated source and one of four stable positions. Unknown sibling keys in
the badge and source objects are preserved. New/changed records are validated,
while an untouched old broken source remains readable and round-trippable.
`derived_marker_state.ref` uses the same `marker:<id>` namespace as controls:
space transfer remaps an internal target and disables/counts a link whose
target is outside the transfer. Older clients ignore the field and may erase
it if they reconstruct the same marker after a downgrade.
`marker.display` gained a fifth accepted token, `value_static_icon` (#588),
beside `badge`, `icon_ripple`, `value` and `static_icon` (plus the read-only
legacy `ripple`). The shape of the field does not change and there is no
migration. A client older than the mode meets an unknown token at its existing
read boundary (`normalizeDeviceDisplay`) and projects it to `badge`: the marker
appears as an ordinary coloured icon. The degradation is visible but safe — the
stored value is not rewritten, and only an explicit re-save of that marker by
the user would replace it. The backend accepts the new token on write and keeps
accepting every previous one.
`marker.value_source` is an optional explicit source for the inner face of a
`display: value` or `display: value_static_icon` marker. Absence or `null` preserves the historical automatic
entity-state choice; an object uses exactly the same discriminated source
contract and formatter as `marker.value_badge.source`. A missing explicit
source stays selected and renders `—` rather than silently falling back. The
top-level schema remains lossless: unchanged future literals round-trip, while
new or changed values receive strict delta validation. Marker-id rewrites and
full/space transfer preserve, remap or report/drop `derived_marker_state.ref`
through the same reference seam as controls and value badges. Older clients
ignore the field and may erase it if they reconstruct the marker.
## Active marker ID uniqueness (#625)
At every write boundary, a marker `id` may identify at most one active record
(`removed` is not `true`). A tombstone and one active marker with the same id
remain valid: the tombstone records lifecycle history and does not suppress the
live marker's layout update.
The structural `CONFIG_SCHEMA` remains permissive so an installation that
already contains two active legacy records is still readable. The semantic
validator compares the candidate with the previously stored document: an
unchanged duplicate group may survive an unrelated save, but a new duplicate
or any edit that leaves the group ambiguous is rejected as `invalid_config`
with the stable detail `duplicate active marker id`. Removing/tombstoning enough
records to leave one active marker is the supported repair. A full import is authoritative and therefore
strict even when its source document contains legacy duplicates; there is no
automatic deletion or migration.
## Atomic marker writes (#442)
The Device editor builds a separate complete config candidate and treats a
successful `houseplan/config/set` response as the durable boundary. If semantic
validation, transport or schema validation rejects that request, the card
restores the preceding server-confirmed config and content fingerprint, rebuilds
the visible marker set, and keeps the independent dialog draft available for
Retry. A revision loaded after a conflict or newer local content always wins;
an older rejection cannot overwrite either one.
Layout placement, obsolete-layout cleanup and copied-file cleanup happen only
after config acceptance. Their failure can report an error and keep the dialog
open, but cannot roll back a marker already accepted by the server. This is a
frontend transaction contract only: marker schema, validators, revision wire
format and downgrade behaviour are unchanged.
## Persistent manual virtual-light state
The exact `virtual` + `is_light:true` + `tap_action:toggle` combination has a
shared operational on/off state. It is not a Marker/ServerConfig field: the
integration stores `{rev, config_rev, off[]}` under a separate versioned Store
key and exposes an optional `virtual_lights` snapshot in `houseplan/config/get`.
It is excluded from layout, full/space export, import and HA entities. Missing
Store data or a missing wire field projects to `on`.
Compatibility matrix:
| Frontend | Backend | Behaviour |
|---|---|---|
| old | new | Extra snapshot/event are ignored; the marker keeps historical #84/#94 behaviour and config remains intact |
| new | old | Missing snapshot starts `on`; an unsupported toggle command reports an error and creates no optimistic local state |
| new | new | Server snapshot is authoritative; revisioned events synchronize full and static cards |
Every current config writer reconciles the Store. Rename, move, hide and
unrelated edits preserve off bits for still-eligible stable marker ids;
binding/role/action changes, tombstones and deletion prune them. Re-entering the
triple therefore starts on. If `config_rev` skips the revision recorded by the
operational Store — for example after downgrade, an old writer or an interrupted
pair — all off bits are cleared rather than resurrected against unknown marker
lifecycle history. Older integrations safely ignore the separate Store on
downgrade.
## Marker Area provenance (#126)
`settings.marker_area_snapshot` is optional internal lifecycle metadata. Each
entry records one exact `device:*` or `entity:*` binding and its last accepted
non-empty HA Area. The map is capped at 20,000 entries and remains subject to
the 2 MiB config limit. Absence triggers a conservative one-time backfill:
positions move only when another Area-bound room or another space proves that
the saved point is stale; boundary, outside and ambiguous points are preserved.
Same-source full backups preserve the map. Full imports from another source
drop it together with discovery lifecycle lists, and space-only imports never
carry this global metadata. Old frontends ignore the field; defensive reads in
new frontends skip malformed entries independently. Rebinding and marker
deletion remove the obsolete entry, and provenance advances only after the
corresponding stale layout position has been deleted successfully.
Automatic orphan cleanup is fail-safe. It uses the full Device/Entity Registry,
exact live entity states and saved live markers rather than the filtered list
of icons that can currently be drawn. A completely empty relevant registry
namespace never deletes provenance. A binding missing from a non-empty full
registry is removed only after the same absence is observed in two distinct
authoritative revisions; the first observation requests one shared registry
refresh. This confirmation is runtime-only and restarts after a card remount.
Explicit marker deletion, rebinding and leaving registry-following placement
keep their immediate lifecycle cleanup.
## Wall junction limits (#329)
Junction limits (minimum 15° between neighbouring walls of one node, at most
six walls per node, a wall at least 20 cm long and never shorter than its own
thickness, 5 cm between non-incident nodes and node to foreign wall, at least
25 cm² of room interior left after the masonry) are a WRITE contract, not a
document contract. An existing plan that violates them stays valid and stays
readable: migration to the current model, JSON import, backup restore and full/space
transfer never run the check, and an edit that does not touch the offending
element still saves.
The gate compares the candidate against the pre-edit document **after both
have gone through the same wall-segment migration** — `commitWallSegmentModel`
on the card, `commit_wall_segment_model` in
`custom_components/houseplan/junction_limits.py` — and counts violations **per
rule**, not per subject id: a structural write re-keys contour atoms, so
subject identity is not stable across the barrier. Only a rule whose violation
count grows is a refusal.
Migrating the baseline is not a detail. The limits read `wall_segments`, so a
document older than the catalogue reports no walls at all and therefore no
violations, whatever its geometry. Judged raw, such a baseline turns every
inherited violation of a real plan into a "new" one on the first structural
write after the card is updated, and an unrelated edit is refused.
Compatibility matrix:
| Frontend | Backend | Behaviour |
|---|---|---|
| old | new | No change: the limits live in the card's write barrier, the backend contract is untouched |
| new | old | No change: an inherited violation is never re-judged, so an old backend's documents keep loading and editing |
| new | new | A write that ADDS a violation is refused in the surface where it was made — a toast naming the rule for drawing and Thickness, a stopped wall for Resize |
Intermediate wall-chain clicks in a current model-v10 document use the bounded
local write proof from #461. This is runtime-only: each accepted
`partitions[]` record and the WebSocket payload remain the same full config,
including unknown fields. A pre-v10 first structural write, any diff not proven
to be one active-chain partition append, and room creation from a closed chain
retain the full-space migration, physical and junction barrier. Old frontends
and old backends therefore see no new field or protocol, and a backend rejection
still rolls the complete pending physical transaction back to its earliest
snapshot.
## Optimize plans: explicit whole-plan maintenance («Оптимизировать планы»)
Existing and imported plans may still hold grid-bound coordinates between the
nodes. Ordinary grid-bound editor operations do not create more; explicitly
continuous objects are exempt (the snap contract itself is `CANVAS.md` §9). General settings contain
a **Plan maintenance** group whose action previews and then repairs old
data through all current passes: model upgrades, mandatory grid
alignment, exact open-span canonicalisation and wall-interval compaction.
Unlike live snapping, the explicit maintenance pass also replaces a stored
coordinate which is only one or several ULPs away from its node with the exact
computed node. That has no visible displacement but removes topology noise at
its persisted source.
Why an action rather than a silent migration:
1. It moves the user's data without asking. A house plan is a drawing;
the card has no mandate to redraw it on a version bump.
2. A silent migration is unattributable. When a room looks 3 cm wrong
the owner cannot tell whether the card did it or they did.
3. An update that touches stored geometry cannot be rolled back by
downgrading the card. The explicit action has a one-deep snapshot and
can also simply not be pressed.
`optimizePlans(config, layout)` (`src/plan-optimizer.ts`) is the pure
orchestrator. It converts legacy fields with an exact mapping, projects
`open_spans` (or the `open_to` fallback) into stable zero-thickness wall atoms,
calls the grid projection, rekeys exact wall endpoints onto moved rooms,
compacts consecutive atoms only when thickness and physical ownership both
match, and stamps `model_version`. Outer/shared transitions and changes of
shared-room pair remain exact breakpoints even at equal thickness. Unknown
fields are preserved and every pass is idempotent.
The explicit pass also repairs pre-existing near-axis room walls, saved wall
chains and independent walls after ordinary grid alignment (#290). Coincident
room-owner copies count as one physical wall and move as one endpoint
equivalence class. The preview reports the unique count, maximum physical
movement and unsafe skipped candidates; only Confirm writes, and Undo restores
the prior geometry. Exact axes and true diagonals are not candidates.
The optimizer deliberately does **not** alter backdrop calibration or saved
view boxes, deduplicate markers, or delete files. It may delete an unattached
layout entry only after classifying its owner against current rooms, marker
tombstones and an authoritative HA device/entity roster. Proven-absent room
labels, devices and group markers are cleaned; live owners are preserved unless
the administrator explicitly opts into removing their old positions, and an
incomplete registry or unknown namespace always fails closed. The cleanup is
part of the pure candidate, Undo and idempotence contract. File collection
remains the backend's reference-aware scheduled job.
`alignAllToGrid(spaces, layout)` (`src/align-grid.ts`) is pure: it
copies its input, never mutates it, and returns the new spaces, the new
layout and the report. The dialog therefore measures and commits the
**same object** — the numbers it promises cannot differ from what it
does. The resulting config+layout pair is sent to
`houseplan/plan/optimize`; the backend persists a durable intent before
either store changes, commits both revisions, and retains one snapshot.
`houseplan/plan/optimize_undo` restores it only while neither revision
has changed since the optimization. A crash between store writes is
completed from the intent on the next integration setup.
The grid pass deliberately excludes the complete transform of `furniture`,
uploaded `image` decor and `spaces[].stairs[]`. Their position, size and
rotation are continuously authored values (#383, #663), so changing even one of those fields would make
Optimize create debt from a normal editor operation. Other decor kinds and
storage-level numeric canonicalization keep their existing grid contract
(#477).
The pair returned by `optimizePlans` passes the same lattice-aware boundary as
the storage writers **before** visible Align and before `changed` is computed.
This boundary is required because the normalized grid step `1 / 240` has no
finite decimal representation: an exact node and a nine-decimal JSON echo may
be visually identical but not `===`. Update-event reload and a cold read
therefore receive exactly the pair retained by the preview, and a second run
cannot manufacture fresh coordinate noise (#248, #291).
Model v8 adds a second, identity-preserving stage at this write boundary
(#282). `materializeWallSegmentModel()` atomizes canonical room contours into
`wall_segments[]`, keeps the deterministic parent ID on one split child, emits
UUIDs only for genuinely new v8 atoms, and refreshes `rooms[].wall_ids[]`,
draft IDs and tagged opening hosts together. The historical `walls[]` entries
are regenerated from this catalog as a compatibility view. Reading or fitting
the canvas never runs this migration; only physical edits, Optimize and a
v7-to-v8 import may materialise it. Failure keeps the previous view, history
and persisted revision intact.
Guarantees are covered by `test/align-grid.test.mjs` and the orchestration/
idempotence case in `test/plan-optimizer.test.mjs`:
* every grid-bound element ends on a node; a rect's FAR corner too (a
snapped *size* on an off-grid origin leaves the other side between
the nodes);
* an opening ends on its wall, at whole steps along it, inside it, and
**with the wall's own angle** — the angle is written, so it is part of
the diff (AUD-158B1-02: an opening already on its wall with a wrong
angle used to be returned changed inside `changed: false`, which made
it unfixable);
* a stray opening with no wall within 6 steps is left exactly where it
is rather than teleported;
* **idempotent across storage**: a second run in memory, after the lattice-aware
writer round-trip, after update-event reload or after a cold read reports
`moved: 0`, `changed: false`, and `latticeCoordinatesCanonicalized: 0`, and returns
objects deep-equal to the first persisted result;
* the report is an **upper bound**, not a sample (AUD-158B1-01).
Before a changed preview can expose Apply, `checkOptimizeGeometry(config)`
(`src/plan-geometry-preflight.ts`) runs the exact candidate through the shared
production input projection and canonical wall/floor boolean builders for every
space. `failed-core`, `degraded-extra` or an exception is a structural failure;
an empty successful geometry and an empty/image-only space are not. One failure
blocks the whole operation and the endpoint is not called. The dialog retains
only bounded statuses plus `contentFingerprint(candidate.config)`: unchanged
Apply reuses that result, while a changed fingerprint is checked again and
fails closed.
This frontend barrier does not replace backend permission, schema, revision or
crash-recovery checks and is not a security attestation from an untrusted
client.
The same projection has a one-space transaction entry point for ordinary
physical edits (#278). Room/wall/open-span/opening/partition/column
candidates are validated before entering Undo or the save queue. A physical
fingerprint is rechecked immediately before the deferred config write; failure
restores the saved geometry and produces no WebSocket call. Presentation-only
edits deliberately do not invoke this barrier, so a legacy degraded plan can
still be renamed, exported and inspected.
### The report is a promise
The confirmation is the decision gate in front of a geometry rewrite, so
`maxShift`/`maxShiftCm` must never be smaller than what the run does:
* displacement is measured on the geometry **actually written back** —
all FOUR corners of a rect, minimum-size correction included. The two
corners nobody used to measure are exactly the two that can be worst:
they carry the X error of one side together with the Y error of the
other, which is √2 of either;
* an opening is measured on its **ends**, flip-invariantly, so turning
it in place costs what it really costs and a 180° rewrite costs
nothing;
* the maximum is accumulated in **centimetres**, each space through its
own `cell_cm`, and the report names the space it belongs to. One
normalised maximum converted through the *first* space's cell size
promised 2.5 cm for a vertex that moved 50 cm on a 100 cm floor;
* the dialog rounds the last tenth **up** and, on a multi-space plan,
says which space the maximum is in; openings corrected in angle alone
are counted on a line of their own.
`latticeCoordinatesCanonicalized` counts individual near-node coordinate
components actually rewritten by the storage boundary. Its maximum is measured
in each value's own `cell_cm`, displayed with three significant digits and kept
separate from visible `moved/maxShift*`. Only touched spaces receive a detail
line; each line also states how many authored off-grid components were observed
and left unchanged. Layout values without a named space contribute only to the
summary. The older `coordsCanonicalized` remains an internal Align counter and
does not absorb this storage-only work.
One undo is available until the next config or layout edit. It restores
the stored snapshot; re-running optimization itself is never treated as
undo because a grid projection is not invertible.
## Square canvas and legacy `aspect` (v1.48)
Until v1.48 a space stored `aspect` and coordinates were normalised x by width,
y by height. The render space is now `NORM_W × NORM_W`; a plan image is fitted
by its own ratio (`fitInSquare`) and that ratio is stored as `plan_aspect` so
the layout does not jump before the file loads. Setup still upgrades a legacy
`aspect` once through `geometry_migration`: the box is padded to a square and
every coordinate re-expressed as one uniform scale plus offset in render units
(angles and proportions exact), with `cell_cm` scaled for tall plans. A durable
`geom_pending` intent is written to the layout store before the config half
removes `aspect`; the next start finishes whichever half is missing
(HP-1490-01), and update events fire only after both halves are durable.
Installations stranded before that intent existed are repaired only explicitly
with `houseplan/geometry/repair` (HP-1500-01).
## Legacy content URLs
`/houseplan_files/…` is a public static path for frontend code only (card,
panel, lazy chunks), because a Lovelace resource must load without
authentication. Plans and marker files are served only by the authenticated
`HouseplanContentView` (`/api/houseplan/content/<plans|files>/…`, signed URLs).
Stored configs may still hold older `/houseplan_files/plans|files/…` URLs:
`contentUrl()` rewrites them on every read and portable import accepts both
prefixes, so there is no storage migration.