Skip to content

A row closed in Review at a date before its start holds at no moment #1294

Closed

Description

@Maya-Kid

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 at read the same interval.
  • The entity panel files the row under "past" as 2024-03-01 ~ 2024, and the history says closed 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_conflict through crates/utopia-store/src/temporal.rs:1395, and decide_violation's fact_closed arm (review_routes.rs:928) all call close_superseded (temporal.rs:878-893), which truncates the end and calls rewrite_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_inverted and write nothing. The item stays in its queue, and the person can type a later date such as 2024-09. Check it in the three routes before they call close_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 no onError, and the query client has no global handler, so today's empty_state_span refusal is already silent there. A toast, as the batch and errata actions show (Review.tsx:1460, :1541), with S.err text for interval_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

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

  1. WaylandYang commented on Oct 18, 2026

    @WaylandYang
    Contributor

    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, so 2024 on a row from 2024-03-01 is stored as an inverted interval.

    The direction is right: refuse with 422 interval_inverted when 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 an onError toast with S.err text, which also makes today's silent empty_state_span visible. 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 in graph.rs untouched. 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.

  2. Aschenbath commented on Oct 19, 2026

    @Aschenbath
    Contributor

    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.

  3. WaylandYang commented on Oct 19, 2026

    @WaylandYang
    Contributor

    Fixed by #1315, now in dev. Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions