Skip to content

fix(schemas): publishing the contract already in force writes nothing - #583

Merged
JArmandoAnaya merged 2 commits into
mainfrom
fix/schema-idempotent-save
Aug 15, 2026
Merged

fix(schemas): publishing the contract already in force writes nothing#583
JArmandoAnaya merged 2 commits into
mainfrom
fix/schema-idempotent-save

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #582.

On a freshly created project, pressing Save version twice with no edits in between published
two identical versions — the version panel itself then rendered "Nothing changed between v1 and
v2". Only the third press was caught. Two independent causes, and either one alone leaves the
defect reachable.

The kernel had no no-op guard at all

create_version computed diff_classes only to decide whether the change was destructive; an
empty diff fell straight through to the insert. It now returns the active version unchanged when
the proposed classes compare equal to it, before the destructive gate — an identical version
cannot be destructive, and the gate would raise on a no-op.

Identity is exact content equality, not an empty diff. _class_changes excludes color
deliberately — it classifies whether existing annotations survive, and a swatch does not decide
that — so gating on the diff would answer "saved" to somebody who changed a colour and then throw
the colour away. Equality implies an empty diff and never the reverse, so domain/schema_diff.py
remains the single definition of changed in a way that matters and no second one is written.
test_a_colour_only_change_is_a_change is that boundary, in both the kernel and the server suites.

Only the active version is compared: re-publishing an older version's classes is a revert, and
answering it with that old version would leave the newer one in force.

The editor refreshed its baseline in a callback that does not always fire

The re-base already existed — the onSuccess passed to publish.mutate, which sets
seed = created.classes. On a fresh project it never ran, and the chain was measured rather than
assumed (a probe logging the query state on every SchemaSection render):

{isPending:true,  status:'pending', fetchStatus:'fetching', isFetched:false}   <- first load
{isPending:false, status:'error',   fetchStatus:'idle',     isFetched:true}    <- the 404
{isPending:true,  status:'pending', fetchStatus:'fetching', isFetched:true}    <- THE SAVE
{isPending:false, status:'success', fetchStatus:'idle',     isFetched:true}

useActiveSchema answers 404 on a schema-less project and therefore holds no data;
useCreateSchemaVersion.onSuccess returns the invalidateQueries promise, so the mutation
awaits the refetch it triggers; TanStack v5's fetchState resets status to pending whenever
data === undefined, so the invalidated errored query goes back to pending rather than staying
in error; SchemaSection returns <LoadingState/> on schema.isPending, unmounting the editor
mid-mutation; and MutationObserver runs mutate()-level callbacks only while hasListeners().

The draft came back holding seed: [], read as dirty — the status line said "unsaved changes
create v2" — and the next press published. After v2 the query has data, nothing unmounts, the
callback fires, and press three is caught. That is the reported 1‑2‑3 pattern, and why it was
fresh-projects-only.

dirty now measures against active?.classes ?? [], the version in force. That is the same
question the kernel answers, so the two cannot disagree — and active is a prop, which cannot be
dropped with an observer.

A third defect, found while testing the second

same() was JSON.stringify over the objects. The draft builds its own class literals and the
wire sends every optional field LabelClassBody/AttributeBody declare — a hand-added attribute
has no options key at all where the server sends null. Stringify calls two identical
contracts unequal. Harmless while both sides came off the same response; load-bearing the moment
the comparison is against active. It is now a projection in the wire's own field order with the
wire's own defaults.

No wire shape change

The decision on the issue was 200-for-no-op / 201-for-created. It cannot land:
scripts/generate_client.mjs refuses any operation declaring more than one 2xx response —

Error: create_schema_version declares more than one 2xx response: 200, 201

— because it emits exactly one response check per operationId. Rather than relax a shared gate
every operation passes through for one operation's benefit, the status stays 201 either way.

  • openapi.json: one line, the route's own description. No schema, parameter or response change.
  • frontend/ui-core/src/generated/api.ts: +7 lines, the same prose as a doc comment. No type change.

Stated rather than hidden: an HTTP client cannot tell a no-op from a creation except by
comparing the returned version against what it already knew. Nothing needs to today — the
editor's own guard means it never sends a duplicate, unwrap discards the status anyway, and the
CLI and MCP call the service in process where no status code exists.

Tests

A trap worth recording: the frontend test passed when first written. With an instant fetch
stub the defect does not reproduce — the refetch resolves before React commits, the pending render
never happens, nothing unmounts, and the callback fires. It needs a delay on the post-publish
GET, which is the honest model rather than a contrivance: every real request takes longer than
zero. That is written into the test's docstring so nobody removes it as noise.

Mutation-verified, each mutation applied with its anchor asserted present exactly once and
reverted by git apply -R on its own recorded diff, with a clean tree asserted either side:

Mutation Result
Delete the kernel guard FAILED tests/kernel/test_schema_service.py::test_an_identical_version_is_a_no_op, and only that
dirty back to showing.seed × issues one request on a project that had no schemaexpected 2 to be 1; the other 7 in the file still pass
same() back to JSON.stringify green — 982 passed, whole ui-core suite

That third green was a real finding rather than a formality: the canonical projection was
unverified code. compares a hand-built attribute with the wire's own spelling of it was added,
which drives the editor's own "add attribute" control and answers the POST the way AttributeBody
spells it; the mutation then reds it.

Five existing tests were updated, none weakened:

  • test_an_identical_version_is_still_a_new_version → replaced by test_an_identical_version_is_a_no_op.
    It asserted the reported behaviour, and its docstring argued the case against fixing it
    ("refusing a no-op would need an equality rule we would then have to defend against reordering
    and colors"). Both concerns now have tests of their own: test_reordering_the_classes_is_a_change
    and test_a_colour_only_change_is_a_change.
  • tests/cli/test_schema_commands.py::test_applying_again_creates_the_next_version
    test_applying_the_same_document_again_adds_nothing, now also asserting the version list has one entry.
  • Three published the same classes twice for reasons unrelated to identity
    (test_versions_are_numbered_one_past_the_highest_stored,
    test_provenance_is_not_part_of_what_a_version_declares,
    test_the_listing_carries_each_versions_own_provenance). Their fixture data now varies per
    version; every assertion is unchanged.

CLI and MCP docstrings both claimed "this always adds one", which is now false; both corrected.
docs/schemas.md gains a section and loses the paragraph asserting the opposite.

Test plan

bash scripts/check.sh in groups, because of the ~10-minute harness ceiling. Every group,
including both browser suites and the opt-in docs group, since docs/ was touched.

Group Exit Result
python 0 3351 passed, 13 skipped in 103s; ruff lint + format, mypy, import contracts (4 kept, 0 broken)
frontend 0 build; annotator 1027 passed, ui-core 983 passed; lint + typecheck
generated 0 openapi drift, generated-client drift, MCP tool reference, version sync
browser 1 — annotator + app e2e (chromium) 0 271 passed (2.2m)
browser 2 — browser cycle, real server (chromium) 0 1 passed (31.6s)
docs 0 Astro site build

No migration, no FORMAT_VERSION move, no VERSION move, no new dependency, no CI job added,
renamed or removed.

Found, not fixed

  • A description- or provenance-only save is now silently a no-op. Neither is part of the
    contract, so neither enters the comparison: somebody who edits only the "Why this version" box
    and presses Save gets "No changes to save" and the message is not recorded. That follows from
    content identity and matches the editor's pre-existing dirty, which never watched the note —
    but it is a behaviour change to a shipped property, and
    test_provenance_is_not_part_of_what_a_version_declares had to be rewritten around it.
  • The same unmount still causes a one-render flash. During the post-publish refetch on a fresh
    project active is null for one render, so the draft falls back to empty and the class list
    blinks out and back. Same mechanism, different symptom, outside this issue's scope.
  • visionset schema apply prints the version in force without saying nothing happened. The
    sentence is true for a no-op, so only the --help docstring was corrected. Saying "unchanged"
    would need a pre-read.

The general lesson, for whoever meets it next: a callback passed to mutate() is best-effort, so
nothing whose absence changes what the next click does may live only in one.

On a freshly created project, pressing Save version twice with no edits in
between published two identical versions — the version panel itself then
rendered "Nothing changed between v1 and v2". Two independent causes, and
either one alone leaves the defect reachable.

The kernel had no no-op guard at all: `create_version` computed the diff only
to decide whether the change was destructive, and an empty one fell straight
through to an insert. It now returns the active version unchanged when the
proposed classes compare equal to it. Equality rather than an empty
`diff_classes`, deliberately: the diff classifies whether existing annotations
survive and ignores `color` on purpose, so gating on it would answer "saved"
to somebody who changed a swatch and then discard the swatch. Equality implies
an empty diff and never the reverse, so the diff stays the one definition of
changed-in-a-way-that-matters.

The editor measured "is there anything to save" against the snapshot the draft
was seeded from, and refreshed that snapshot in the callback passed to
`publish.mutate`. TanStack drops those callbacks when the observer's component
unmounts — which is exactly what happens on a project that had no schema,
because the invalidated 404 goes back to `pending` (`fetchState` resets the
status whenever `data === undefined`) and `SchemaSection` swaps the editor for
a loading state while the refetch flies. The draft came back holding an empty
seed, read as dirty, and the next press published. It now measures against
`active`, which is a prop and cannot be missed that way — the same question the
kernel answers, so the two cannot disagree.

The comparison itself was also wrong for this use: the draft builds its own
class literals and the wire sends every optional field `LabelClassBody`
declares, so a hand-added attribute has no `options` key where the server sends
null, and `JSON.stringify` calls two identical contracts unequal. It is now a
projection in the wire's own field order with the wire's own defaults.

No wire shape change: the status stays 201 either way, because the API declares
one 2xx response per operation and a client that branched on "did this
succeed" would see no difference in any case. `openapi.json` and the generated
client move only by the route's own prose.
The canonical comparison had no test: reverting it to `JSON.stringify` left
the whole ui-core suite green, which makes it unverified code rather than a
guard. This is the case it exists for — an attribute added in the editor
carries no `options` key where the wire sends null, so a stringify calls one
identical contract two.
@JArmandoAnaya
JArmandoAnaya merged commit e61d3ed into main Aug 15, 2026
15 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the fix/schema-idempotent-save branch August 15, 2026 07:47
JArmandoAnaya added a commit that referenced this pull request Aug 15, 2026
* feat(kernel): a label class accepts a set of geometries

`LabelClass.geometry` becomes `geometries`, a non-empty deduplicated tuple kept
in one sorted order. A class labelled as a box on some frames and as a polygon on
others is one class; splitting it in two made every consumer downstream re-unify
them, and COCO's own docstring already recorded the cost.

The write gate in `AnnotationService._validate` becomes a membership test in that
class's own set — still not the version's union, which is wider. `schema_diff`
answers the module's governing question per geometry, the shape the `select`
options rule already had: one added is additive, one removed is destructive. So
widening a class is an ordinary save and narrowing stays behind the flag.

No migration. `annotation_schema.classes` is a JSON column, so a
`model_validator(mode='before')` on `LabelClass` reading the retired singular key
covers stored schemas and stored release manifests alike — and MCP, which takes
the domain model directly. `LabelClassBody` deliberately does not read it: a REST
client sending it is better told so than silently reinterpreted.

`MANIFEST_VERSION` moves to 2, since `Manifest.classes` carries these verbatim.
Documents already published keep their bytes and their hashes and still load, so
every existing release stays verifiable.

The export report is now one row per (class, geometry). Keyed by class alone it
carried one verdict for a class YOLO answers twice — writing the boxes whole and
reducing the polygons — and would have misdescribed half its own output. No
exporter changed: every one of them already branches per annotation.

cf. #584

* feat(annotator): a tool is resolved against the class's set, not derived from it

`LabelClass.geometries` mirrors the wire, and `toolFor` gains the tool the host
currently holds: it keeps that tool when the class accepts it and falls to the
class's first drawable geometry when it does not. **An active tool the selected
class forbids is unrepresentable**, because one function decides and it never
returns one.

`InputHost` gains `activeTool`. Without it `activate-class` compares two class
defaults rather than two resolved tools, and misses a real move: a host drawing
polygons under a both-shapes class that switches to a boxes-only one does change
tool, and a polygon in flight has to be cancelled.

`isTaggableClass` and `drawableGeometries` stop being each other's negation — a
class may accept a tag and a shape — so `classAction` sends `toggle-tag` only when
the class draws nothing. Folding a drawable class into it would tag the asset
where somebody pressing a class digit meant to arm it.

`allowedGeometriesFor` filters instead of wrapping a scalar, which its own
docstring had predicted was the only change a set would need.

cf. #584

* test(annotator): the tool resolution rule a geometry set made necessary

cf. #584

* test(annotator): a class that is both taggable and drawable arms rather than tags

The one rule the mutation battery found nothing watching: no class could be both
before #584, so `classAction`'s two tests had never been ordered against each
other. `PALETTE` gains a seventh row that is a tag and a box at once.

cf. #584

* feat(ui): a class's geometries are a checkbox group, and a name that exists is an offer

The schema editor's single-select becomes a checkbox group under the same
category headings, using the native input this form already uses for an
attribute's `required` flag — no new dependency, no new primitive, and what a
class accepts is readable without opening anything. The last ticked box does not
come off and carries why.

**A defect the new test caught, worth stating:** the first draft refused that last
box with `preventDefault()` on the input's click. React synthesises a checkbox's
`onChange` from the same native click, so cancelling the click does not cancel
the change — the class went to an empty set while the tick stayed on screen, a
control lying about what it had just done. The refusal now lives where the value
is computed, which cannot come apart.

**The rescue flow.** A name the published version already declares stops being a
red box and becomes an offer: the alert says what the class accepts today and
what publishing would add, and the primary reads `Add polygon to sign`. It
carries the **existing** class's colour and attributes, so a form opened to make
a new class cannot quietly wipe what the old one declared. The refusal that
remains is a name typed twice in one sitting, which has nothing to offer because
both entries are being written now.

`composeVersion` replaces a same-named class **in place** rather than appending:
two classes with one name is what `create_version` refuses outright, and
appending would also renumber the digit hotkeys, which are positions in the
authored order.

The tool strip narrows to the held class's own geometries, and the page holds the
preferred tool beside the drawing class at job scope, for the query-key reason
the drawing class is already there.

cf. #584

* feat(app): the demo, styleguide and browser suites follow the geometry set

cf. #584

* docs: geometry sets, the rescue flow, and export unchanged

cf. #584

* test(ui): the retarget guard, which no fixture with a two-shape class could see

cf. #584

* docs: the last two places that called a class's geometry singular

cf. #584

* feat(ui): one vocabulary for geometries, and it is not the wire's

The interface was showing users database identifiers, in two vocabularies.
`ToolPalette` had a private `TOOL_LABELS` saying `Box`; every other surface —
class rows, the reassignment menu, the add-a-class dialog's checkboxes and prose
and its primary button, the schema editor's badges, the project summary — printed
the raw `GeometryType`. So one thing was `Box` on the left of the canvas and
`bbox` on the right, and a tag class's row read `classification_tag`.

`GEOMETRY_LABELS` lives beside `GEOMETRY_CATEGORY` in the module that already owns
geometry presentation, total over the union by `satisfies` so a ninth member fails
the build until somebody names it. The strip capitalises at its own control; every
other caller reads the word as-is.

**Lowercase, because the same word is used two ways** — as a chip in a dense row
(`box · polygon`) and inside a sentence ("Publishing adds polygon to it"). Only
the first letter is a sentence-position question, which the test states as *never
starts with a capital* rather than *is lowercase*: `3D box` is an acronym and the
stricter rule would have forced `3d box`, wrong in every position.

`formatGeometries` joins with ` · ` rather than ` or `. A middot is what a set
reads as at this density, and in a 248px row those four characters come out of the
class name.

It is also the largest width saving available in the class list — a tag class's
row spent about 110px of 248 on `classification_tag` and now spends 22 on `tag`,
against the 32px widening the whole panel would buy.

Tests address a checkbox by `data-testid`, which keeps the wire value, so a test
says *which* geometry without also asserting what it is called.

cf. #584

* feat(ui): the armed class row is the shape picker

Arming a class stopped answering which shape the next drag produces, and until
now the only place that answer lived was the tool strip at the **far left** of the
canvas while the class was chosen on the right — one decision split across the
width of the picture, in a loop repeated hundreds of times a job.

The armed row's geometry words become a segmented control: the active shape lit,
pressing another switches the tool **without moving the class**. That retarget
rule already shipped in `ToolPalette` and is tested in both directions, so the
panel is a second caller of an existing rule rather than a new one.

**Only the armed row, and the accessible answer and the density answer agree.**
`ClassListRow` is documented as "a real `<button>` spanning the whole row", and
HTML forbids interactive descendants inside a button — so a row offering a choice
has to become a group with an inner name button. That is also what you want at the
fifty-class ontologies principle 7 is written for: an unarmed row has no live
choice, and fifty pickers for one decision is noise. Exactly one row is armed, so
the extra tab stops are bounded at (shapes − 1).

The row is gated on `drawableGeometries`, not `geometries`: a class may accept a
tag beside a box, and a tag has no canvas gesture — offering it would be a tool
the canvas cannot answer.

`toolForClass` is extracted from `toolFor` and exported, because a list iterating
`schema.classes` holds the class and has no document. Writing
`drawable.find(…) ?? drawable[0]` in the panel instead would be two spellings of
one fallback, which is how a strip and a panel come to disagree about which shape
is lit — the one thing they must not do. It is also why the lit segment resolves
through it rather than comparing the raw preference: the held tool may be one this
class forbids.

The panel takes `2xl:w-80`. The extra 32px is headroom for a class naming three
shapes, not a fix, and it is withheld below 1536px on purpose —
`ANNOTATOR_MIN_VIEWPORT_PX` is 768, where a collapsed rail already leaves a 384px
stage, and a width chosen on a large monitor must not be charged to the smallest
screen the editor opens on at all. `EditorNotice`'s clearance arithmetic is stated
at 1280px and stays true.

cf. #584

* fix(ui): the add-a-class dialog was undersized, and could hide its own footer

`ClassFields` splits Name | Geometry on `md:`, a **viewport** breakpoint rather
than a container one, so on any desktop the grid splits however narrow the box is
— at the default `max-w-lg` each column was ~224px against a geometry row needing
~269px, and the checkboxes wrapped onto three lines. The box has to be wide enough
for a split it cannot prevent. `2xl` is the smallest that clears it.

Second defect, same string: the dialog carried no `max-h` and no scroll, and
`DialogContent` is centred with `-translate-y-1/2` — so content taller than the
viewport overflowed off both edges and took the footer with it, which a class with
a few attributes reaches.

cf. #584

* docs: dialog widths, the armed row's shape picker, and the panel's breakpoint

cf. #584

* test(ui): arm a class by its name, not by the row's centre

`activate()` clicked the row's centre. That worked by about fourteen pixels: once
an armed row carries a shape picker, a longer class name or a third shape moves
that centre onto a shape segment — and the press would switch the **tool** while
`data-selected` still read true, so every assertion around it kept passing.

Both markups now put a `-name` handle on the name, so choosing a class has one
target whether the row is a plain button or a group.

cf. #584

* test(kernel): pin the narrowing gate's over-refusal, and say so in the docs

Found by checking this branch against `main` rather than by a conflict: a class
that can hold two shapes makes the orphan gate coarser than the question it
stands in for.

`car` accepts `bbox · polygon` and the project holds one **bbox** `car`. Taking
`polygon` away orphans nothing, and is refused anyway by the refusal no flag
overrides. `SchemaDiff.destructive_classes` is a set of *names*, and that set is
what reaches `add_schema_version_unless_annotated`, whose predicate asks whether
the project holds any `car` — never whether it holds one drawn as the shape being
removed. While a class held a single geometry those were the same question.

Conservative in the safe direction: it refuses rather than orphaning, so nothing
is lost and no invariant moves. Pinned rather than fixed because making it exact
changes the port method and the guarded-insert contract #589 landed for the TOCTOU
race — argued in #592, which the test names and which `docs/schemas.md` links.

The test is a tripwire for that work: invert it when the gate learns about
geometry, never quietly delete it.

cf. #584, #592

* fix(ui): a geometry set has no order, and the draft comparison has to know

Integration with what landed on main while this branch was open — three
places where code that arrived after the geometry sweep still spelled a class's
shape singular, and one that is a real defect rather than a rename.

`SchemaEditor`'s `canonical` projection — #583's fix for a draft that read as
dirty against the version it had just published — compared `declared.geometry`.
That field is gone, and swapping in the array is not enough: the two sides spell
one set differently. The domain sorts and dedupes, so the active version always
reads canonical, while a draft's copy is whatever order the boxes were ticked in.
Untick the shape a class already had and tick it back, and the editor offered to
publish a version identical to the one in force.

Verified by breaking it: with the sort removed, `does not call a reordered
geometry set an unsaved change` fails and the other nine in that file pass.

Asserted on `dirty` rather than through a save, and the reason is worth writing
down — going through a save **cannot see this**. The draft is re-based onto the
wire's own copy afterwards, so both sides come out canonical whatever the
comparison does; the first version of this test round-tripped a save, passed, and
passed just as happily with the fix removed.

The other two are renames in fixtures that arrived with #590 and #591:
`test_schema_refusals.py` and `test_batch_tools.py` posted the retired `geometry`
key, which `LabelClassBody` refuses with `extra_forbidden` — the wire is strict
on purpose while `LabelClass` still lifts the old spelling, so stored documents
keep loading and a client sending it is told rather than reinterpreted.

cf. #584

* test(cycle): the real-server walk posted the retired geometry key

#591's own addition to the cycle, written before a class held a set. The wire
refuses the singular spelling with `extra_forbidden`, so the publish that walk
makes to prove a pin advances answered 422 and the whole cycle failed on it.

The browser suites were again the only ones that saw it: the class list this
line extends comes back off the wire already plural, so nothing in Python or in
vitest reads this payload.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Schema save creates a duplicate identical version on freshly created projects

1 participant