Skip to content

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

Open
JArmandoAnaya wants to merge 2 commits into
mainfrom
fix/schema-idempotent-save
Open

fix(schemas): publishing the contract already in force writes nothing#583
JArmandoAnaya wants to merge 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.
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