Repository navigation
A row closed in Review at a date before its start holds at no moment #1294
Description
I noted this in #994 on 10-15 as outside both of its cuts.
What happens
Review closes an open row at a typed date in three places: an unconfirmed fact's Close, a conflict's Close old when the new fact has no start, and a violation's Give the assertion an end date. 2024 is sent as 2024-01-01 at year precision and stored as given, so on a row that starts inside 2024 the end comes before the start.
"Ada works for Meridian from 2024-03-01", closed with 2024 through any of the three, gets a 200 and is stored as 2024-03-01 → 2024-01-01 (year). A row holds at T when holds_from <= T < holds_to, so this one holds at no moment:
- Before the close, the neighborhood showed the edge at 2024-03-01, 2024-06-01 and 2025-06-01. After it, the edge is gone at all of them, and at 2023-06-01 and 2024-02-01 too. The time slider and chat/MCP tools given
atread the same interval. - The entity panel files the row under "past" as
2024-03-01 ~ 2024, and the history saysclosed at 2024-01. So the panel reads "left in 2024" while every question at a date says Ada never worked there. - The item leaves its queue as decided. The violation route then materializes, so the blocked derivation lands: Ada works for Orion from 2024-09-01, and for Meridian at no moment.
- The end is a stated one, so the timeline never redraws it.
The same routes already refuse an end equal to the start: 2024-03 on this row gets 422 empty_state_span (#966). The correction route refuses this exact input with 422 interval_inverted.
Where (dev 86bd5da)
close_fact(crates/utopia-server/src/api/review_routes.rs:207),resolve_conflictthroughcrates/utopia-store/src/temporal.rs:1395, anddecide_violation'sfact_closedarm (review_routes.rs:928) all callclose_superseded(temporal.rs:878-893), which truncates the end and callsrewrite_end_tx.rewrite_end_tx's only check on the end (temporal.rs:972-980) is an end equal to the start of a declared state.- The correction route checks the order in
check_interval(crates/utopia-server/src/api/graph_routes.rs:333-339).
Evidence: route tests on dev 86bd5da, through the HTTP router against Postgres. an_unconfirmed_fact_closed_before_its_start_is_refused, a_conflict_closed_before_the_old_facts_start_is_refused and a_violation_closed_before_the_assertions_start_is_refused fail on dev; a_close_at_the_start_of_a_state_is_already_refused passes. With the check below in the three routes, all four pass.
What I think is right
- Refuse it, as the correction route does. If the end, truncated to its precision, is before the row's start, answer 422
interval_invertedand write nothing. The item stays in its queue, and the person can type a later date such as2024-09. Check it in the three routes before they callclose_superseded, not inside it, so the write path (insert_fact_on) is unchanged. - Show the refusal. The three cards' mutations (
web/src/pages/Review.tsx:1544-1597) have noonError, and the query client has no global handler, so today'sempty_state_spanrefusal is already silent there. A toast, as the batch and errata actions show (Review.tsx:1460,:1541), withS.errtext forinterval_inverted.
Rows already written this way, live rows with valid_to < valid_from, stay as they are. #994 cut 1 leaves these routes alone on purpose, so this fix doesn't depend on it.
Considered and not proposed
- The write path's period rule, which would store
2024on this row as 2025-01-01, as A binding to a state property says what a single date marks, and a state row whose ends are equal is never written #975 does for statements. The cards take one bare date, so they can't tell "until 2024" from "in 2024": the same2024closes a row from 2020 at 2024-01-01. The correction route doesn't translate either. If Review should, that belongs in the cards after An end that names a period prints as the period after it #994 cut 1, with this refusal behind it. - A database CHECK that
valid_tois aftervalid_from. Existing rows would need fixing first, and the write path would fail on a statement whose own span is inverted (An end that names a period prints as the period after it #994 cut 2 notes one incombine) instead of a refusal a person sees.
Open #1131 would send the conflict agent's close_old through the same call.
I'm glad to send the fix with these tests if the direction is right.
Activity
WaylandYang commentedon Oct 18, 2026
Thank you, and for splitting this out of #994 with the failing tests. Confirmed on dev: the three Review closes all reach
close_superseded, whose only guard is an end equal to a state's start, so2024on a row from 2024-03-01 is stored as an inverted interval.The direction is right: refuse with 422
interval_invertedwhen the end, truncated to its precision, is before the start (truncated the same way, as #1295 does), write nothing and leave the item in its queue, and give the three cards anonErrortoast withS.errtext, which also makes today's silentempty_state_spanvisible. One adjustment: please make the check inside the store under the row lock, as a close used only by the three Review paths, rather than in the routes. That avoids racing a concurrent correction, covers the conflict's default close at the new fact's start, and still leaves the write path's callers ingraph.rsuntouched. Leaving existing inverted rows as they are and not translating the period here are both fine. A PR is welcome; please rebase on #1295 if it lands first.Aschenbath commentedon Oct 19, 2026
I'll take this on from current dev with #1295 included: add the Review-only interval guard under the store row lock, localized error toasts for all three cards, and regression coverage for refusals leaving facts and queues unchanged, including the conflict's default close date.
WaylandYang commentedon Oct 19, 2026
Fixed by #1315, now in
dev. Thank you.