fix: the first write of a newer wall model is not an outdated client (#319)

A stale client can only echo the stored model_version, never raise it. The
'unchanged wall catalogue' refusal now applies only when the submitted model
is not above the stored one; the first v9 write over a v8 document with an
orphan open_span/open_to legitimately drops the legacy projection without
touching the catalogue and passes. The regression pair fixture is produced
by the real writers (stored: v1.68.0-beta.2, sent: current migration) and is
pinned on the frontend byte-for-byte so it cannot drift.

Issue: #319
User-Visible: yes
This commit is contained in:
Codex
2026-08-26 18:20:15 +03:00
parent 3d31d65735
commit 3ab6fa9f8a
6 changed files with 580 additions and 1 deletions
+7 -1
View File
@@ -190,13 +190,19 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
raise WallModelClientOutdatedError(
f"stored model={old_model}; submitted model={new_model}"
)
if old_model >= 8 and new_model >= 8 and contour_geometry_changed:
if old_model >= 8 and 8 <= new_model <= old_model and contour_geometry_changed:
# The realistic stale-client case echoes model_version and the unknown
# catalogue verbatim while changing room/contour geometry. Let the
# frontend show the dedicated reload guidance instead of a generic
# schema error. Independent drafts, partitions, columns and explicitly
# hosted openings are validated by CONFIG_SCHEMA without requiring a
# contour catalogue change (#314).
#
# A submitted model ABOVE the stored one is excluded on purpose (#319):
# a stale client can only echo the stored version, never raise it. The
# first write of a newer client legitimately drops legacy projection
# fields (an orphan open_span/open_to) without touching the catalogue;
# treating that as "outdated" wedged every structural write forever.
if _wall_catalog_projection(config) == _wall_catalog_projection(previous):
raise WallModelClientOutdatedError(
f"stored model={old_model}; unchanged wall catalogue"
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Updating to the new wall model no longer freezes editing: the server now
recognises the first write of a newer card over an older document (for
example one that still carried an orphan legacy border) instead of demanding
a card update that was already installed
([#319](https://github.com/Matysh/houseplan-card/issues/319)).
## v1.68.0-beta.3 — 2026-08-26
- Walls now use one model at every thickness. The separate **Boundary** tool
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Обновление на новую модель стен больше не замораживает редактирование:
сервер распознаёт первую запись более новой карточки поверх старого
документа (например, с осиротевшей легаси-границей) вместо требования
обновить уже обновлённую карточку
([#319](https://github.com/Matysh/houseplan-card/issues/319)).
## v1.68.0-beta.3 — 2026-08-26
- Теперь у стен одна модель при любой толщине. Отдельные инструмент
+505
View File
@@ -0,0 +1,505 @@
{
"comment": "stored: produced by the v1.68.0-beta.2 (model v8) writer from the 282 parity input plus one orphan open_span; sent: the same document after the current initial migration. The catalogue is byte-identical, the orphan span is dropped (#319).",
"stored": {
"spaces": [
{
"id": "floor",
"title": "Floor",
"rooms": [
{
"id": "large",
"poly": [
[
0,
0
],
[
1,
0
],
[
1,
0.25
],
[
1,
0.75
],
[
1,
1
],
[
0,
1
]
],
"wall_ids": [
"wall-7cu6gpb7e44ucopgzjm2",
"wall-vgpy3fkbbprvy2cofyq3",
"wall-g33xjvyd5marxwkowoqm",
"wall-tqy5sdtydjrx7syybupl",
"wall-j2ti4ktyymgw4e2r5ns5",
"wall-oq2aqjdsktorqgxatdwh"
]
},
{
"id": "small",
"poly": [
[
1,
0.25
],
[
1.5,
0.25
],
[
1.5,
0.75
],
[
1,
0.75
]
],
"wall_ids": [
"wall-d2hfqa5fvueqwrwo2da7",
"wall-ybr4j2bztfx2wkbwvbwd",
"wall-qancsrbiwkhhy5hos7io",
"wall-g33xjvyd5marxwkowoqm"
]
}
],
"walls": [
{
"key": "0.500000,0.000000@0.0000",
"cm": 15,
"a": [
0,
0
],
"b": [
1,
0
]
}
],
"openings": [
{
"id": "door",
"type": "door",
"x": 0.5,
"y": 0,
"angle": 0,
"length": 0.2,
"host": {
"kind": "wall",
"id": "wall-7cu6gpb7e44ucopgzjm2",
"t": 0.5
}
}
],
"room_drafts": [
{
"id": "draft",
"points": [
[
2,
0
],
[
2.5,
0
],
[
3,
0
]
],
"segments": [
{
"cm": 10,
"id": "wall-ulziozdfsjzlriamibvg"
},
{
"cm": 20,
"id": "wall-bvlpyhrsy6xjgox3g6e6"
}
]
}
],
"open_spans": [
{
"a": [
0.83,
0.91
],
"b": [
0.93,
0.91
]
}
],
"wall_segments": [
{
"id": "wall-oq2aqjdsktorqgxatdwh",
"a": [
0,
0
],
"b": [
0,
1
],
"cm": 0
},
{
"id": "wall-7cu6gpb7e44ucopgzjm2",
"a": [
0,
0
],
"b": [
1,
0
],
"cm": 15
},
{
"id": "wall-j2ti4ktyymgw4e2r5ns5",
"a": [
0,
1
],
"b": [
1,
1
],
"cm": 0
},
{
"id": "wall-vgpy3fkbbprvy2cofyq3",
"a": [
1,
0
],
"b": [
1,
0.25
],
"cm": 0
},
{
"id": "wall-g33xjvyd5marxwkowoqm",
"a": [
1,
0.25
],
"b": [
1,
0.75
],
"cm": 0
},
{
"id": "wall-d2hfqa5fvueqwrwo2da7",
"a": [
1,
0.25
],
"b": [
1.5,
0.25
],
"cm": 0
},
{
"id": "wall-tqy5sdtydjrx7syybupl",
"a": [
1,
0.75
],
"b": [
1,
1
],
"cm": 0
},
{
"id": "wall-qancsrbiwkhhy5hos7io",
"a": [
1,
0.75
],
"b": [
1.5,
0.75
],
"cm": 0
},
{
"id": "wall-ybr4j2bztfx2wkbwvbwd",
"a": [
1.5,
0.25
],
"b": [
1.5,
0.75
],
"cm": 0
}
]
}
],
"markers": [],
"settings": {},
"model_version": 8
},
"sent": {
"spaces": [
{
"id": "floor",
"title": "Floor",
"rooms": [
{
"id": "large",
"poly": [
[
0,
0
],
[
1,
0
],
[
1,
0.25
],
[
1,
0.75
],
[
1,
1
],
[
0,
1
]
],
"wall_ids": [
"wall-7cu6gpb7e44ucopgzjm2",
"wall-vgpy3fkbbprvy2cofyq3",
"wall-g33xjvyd5marxwkowoqm",
"wall-tqy5sdtydjrx7syybupl",
"wall-j2ti4ktyymgw4e2r5ns5",
"wall-oq2aqjdsktorqgxatdwh"
]
},
{
"id": "small",
"poly": [
[
1,
0.25
],
[
1.5,
0.25
],
[
1.5,
0.75
],
[
1,
0.75
]
],
"wall_ids": [
"wall-d2hfqa5fvueqwrwo2da7",
"wall-ybr4j2bztfx2wkbwvbwd",
"wall-qancsrbiwkhhy5hos7io",
"wall-g33xjvyd5marxwkowoqm"
]
}
],
"walls": [
{
"key": "0.500000,0.000000@0.0000",
"cm": 15,
"a": [
0,
0
],
"b": [
1,
0
]
}
],
"openings": [
{
"id": "door",
"type": "door",
"x": 0.5,
"y": 0,
"angle": 0,
"length": 0.2,
"host": {
"kind": "wall",
"id": "wall-7cu6gpb7e44ucopgzjm2",
"t": 0.5
}
}
],
"room_drafts": [
{
"id": "draft",
"points": [
[
2,
0
],
[
2.5,
0
],
[
3,
0
]
],
"segments": [
{
"cm": 10,
"id": "wall-ulziozdfsjzlriamibvg"
},
{
"cm": 20,
"id": "wall-bvlpyhrsy6xjgox3g6e6"
}
]
}
],
"wall_segments": [
{
"id": "wall-oq2aqjdsktorqgxatdwh",
"a": [
0,
0
],
"b": [
0,
1
],
"cm": 0
},
{
"id": "wall-7cu6gpb7e44ucopgzjm2",
"a": [
0,
0
],
"b": [
1,
0
],
"cm": 15
},
{
"id": "wall-j2ti4ktyymgw4e2r5ns5",
"a": [
0,
1
],
"b": [
1,
1
],
"cm": 0
},
{
"id": "wall-vgpy3fkbbprvy2cofyq3",
"a": [
1,
0
],
"b": [
1,
0.25
],
"cm": 0
},
{
"id": "wall-g33xjvyd5marxwkowoqm",
"a": [
1,
0.25
],
"b": [
1,
0.75
],
"cm": 0
},
{
"id": "wall-d2hfqa5fvueqwrwo2da7",
"a": [
1,
0.25
],
"b": [
1.5,
0.25
],
"cm": 0
},
{
"id": "wall-tqy5sdtydjrx7syybupl",
"a": [
1,
0.75
],
"b": [
1,
1
],
"cm": 0
},
{
"id": "wall-qancsrbiwkhhy5hos7io",
"a": [
1,
0.75
],
"b": [
1.5,
0.75
],
"cm": 0
},
{
"id": "wall-ybr4j2bztfx2wkbwvbwd",
"a": [
1.5,
0.25
],
"b": [
1.5,
0.75
],
"cm": 0
}
]
}
],
"markers": [],
"settings": {},
"model_version": 9
}
}
+17
View File
@@ -26,6 +26,23 @@ const segmentFor = (room, index, catalogue) => (
catalogue.find((segment) => segment.id === room.wall_ids[index])
);
test('the #319 pair fixture matches the current initial migration byte for byte', () => {
// Guards the fixture against drift: `sent` must stay exactly what the
// current writer produces from `stored` (a v1.68.0-beta.2 document with one
// orphan open_span), and the wall catalogue must stay byte-identical —
// that combination is the whole point of the backend regression test.
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/319-orphan-span-migration.json', import.meta.url), 'utf8',
));
const migrated = commitWallSegmentModel(structuredClone(fixture.stored)).config;
assert.deepEqual(migrated, fixture.sent);
assert.equal(
JSON.stringify(fixture.stored.spaces[0].wall_segments),
JSON.stringify(fixture.sent.spaces[0].wall_segments),
);
assert.equal('open_spans' in fixture.sent.spaces[0], false);
});
test('frontend migration matches the shared backend parity fixture', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/282-wall-identity-parity.json', import.meta.url), 'utf8',
+39
View File
@@ -223,6 +223,45 @@ def test_stale_client_echoing_v8_catalog_gets_the_named_error() -> None:
validate_wall_model_transition(non_structural, previous)
def test_first_write_of_a_newer_model_is_not_outdated_even_without_catalog_change() -> None:
"""#319: a client can only echo the stored model_version, never raise it.
The pair fixture is generated by real writers: `stored` by the
v1.68.0-beta.2 (model v8) writer with one orphan open_span, `sent` by the
current initial migration — the catalogue is byte-identical, the orphan
span is dropped. Before the fix this exact pair raised
WallModelClientOutdatedError and wedged every structural write forever.
"""
fixture = json.loads((
Path(__file__).parents[1] / "test" / "fixtures"
/ "319-orphan-span-migration.json"
).read_text(encoding="utf-8"))
stored, sent = fixture["stored"], fixture["sent"]
assert stored["model_version"] == 8 and sent["model_version"] == 9
assert stored["spaces"][0]["wall_segments"] == sent["spaces"][0]["wall_segments"]
assert "open_spans" in stored["spaces"][0]
assert "open_spans" not in sent["spaces"][0]
# AC1: the pure migration write passes.
validate_wall_model_transition(copy.deepcopy(sent), stored)
# AC2: the same write with the user's first draft (the first click of the
# Walls tool, independent geometry per #314) passes too.
with_draft = copy.deepcopy(sent)
with_draft["spaces"][0].setdefault("room_drafts", []).append({
"id": "d-319", "points": [[0.7, 0.7], [0.8, 0.7]],
"segments": [{"id": "seg-d-319", "cm": 20}],
})
validate_wall_model_transition(with_draft, stored)
# AC3: the same-version echo (9 → 9) with changed contours and an
# unchanged catalogue keeps the named refusal.
echoed = copy.deepcopy(sent)
echoed["spaces"][0]["rooms"][0]["poly"][0][0] += 0.01
with pytest.raises(WallModelClientOutdatedError, match="unchanged wall catalogue"):
validate_wall_model_transition(echoed, copy.deepcopy(sent))
def test_current_wall_model_independent_geometry_does_not_require_contour_catalog_change() -> None:
"""Drafts, partitions, columns and hosted openings own their identity (#314)."""
previous, _ = commit_wall_segment_model(_config({