Files
houseplan-card/docs/CONFIG-COMPATIBILITY.md
Sergey Matyunin d03a68b88a feat: добавить лестницы между этажами (#663)
Прямые и винтовые лестницы получили отдельную модель, инструменты редактора, безопасную межэтажную навигацию, вычитание из чистой площади и плоское отображение в 2.5D.

Issue: #663
User-Visible: yes
2026-09-26 21:00:43 +03:00

51 KiB

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.

This registry initially covers the known compatibility and internal-field debt identified by HP-DATA-01. It is not yet the complete canonical schema. The next stage is to register all current public fields and add automated parity against the TypeScript model and backend Voluptuous validation.

Offline inventory

Exported JSON can be inspected without uploading it or changing it:

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.

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.

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.

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.

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.