Skip to content

An additive schema version advances every open batch - #591

Merged
JArmandoAnaya merged 5 commits into
mainfrom
feat/advance-pins
Aug 15, 2026
Merged

An additive schema version advances every open batch#591
JArmandoAnaya merged 5 commits into
mainfrom
feat/advance-pins

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

An additive schema version advances every open batch

Closes #381 — direction given 2026-08-15: all repinnable batches, additive only.

Publishing a version and moving a batch's pin were two operations, composed differently by two
surfaces. The annotator ran save → publish → repin as a client-orchestrated chain; the schema
editor published and touched no batch at all. Same act, two behaviours, and neither belonged to
the kernel.

In use that produced a closed loop rather than friction: a project two versions ahead of its own
open batch, the class published in between invisible there, and a pin popover naming a remedy
that only the publish the add-class dialog had just refused could perform.

The rule, and why it needs no new gate

diff_classes already answers does an annotation valid under the old version stay valid under
the new one?
When it answers yes, moving a pin across that version cannot invalidate
anything already drawn — by construction, not by policy. So an additive version takes every
batch in REPINNABLE_STATES with it, in the same transaction, and a narrowing one takes none:
allow_destructive says publish this, never and drag every open batch across it.

It is also what makes it implementable. BatchService imports SchemaService, so the reverse
import would close a cycle — but on the additive path every one of repin's three gates is
provably vacuous, so _advance_pins needs only REPINNABLE_STATES (a domain constant) and
the repository. No layering inverted, no rule spelled twice.

repin is untouched: same route, same MCP tool, same capability, same allow_destructive. It is
now the manual route for the narrowing case, judged against one batch's own labels.

The invariant this rewrites was written down

Batch.schema_version said the pin "never follows the active version on its own — a schema that
evolved mid-batch would change the rules under work in flight". That is an argument about
narrowing, which still never follows; the docstring's own justification is what narrowed it.
test_a_later_schema_version_does_not_move_an_existing_pin inverts into three tests naming the
new boundary, plus one pinning #583's no-op.

Shape

create_version returns SchemaPublication — the version, plus the batches that moved — and
POST /schema/versions answers {published, advanced_batches}. The reads are unchanged:
which batches once followed a version is not something a GET knows or is asked, and a
permanently-empty list there would be a field a client reads meaning into.

Measured blast radius before committing to it: 28 call sites read the return, 71 ignore it.
The 71 are untouched.

No migration, no port change, no event — neither create_version nor repin emits one today,
and adding one is its own decision.

Frontend

The one line without which none of this is visible: useCreateSchemaVersion invalidated only
["projects", id] and never ["batches"], so the kernel would move the pins and no screen would
notice.

The add-a-class chain drops its third call, and useRepinBatch with it — nothing else used it.
Finding F23's row leaves the failure table: the version exists and the pin has not moved is
unrepresentable now rather than handled, because the two happen in one transaction. canRepin
stays, because the sentence it drives is still true: a completed batch keeps its version.

The Schema tab says what its publish did — the screen that could previously strand every open
batch in silence.

Tests

Kernel: an additive version moves approved and in_annotation and leaves draft and
completed; a narrowing one moves none with or without the flag; advanced_batches names
exactly what moved; #583's no-op moves nothing. Server and MCP assert the same over the wire, and
the MCP agent sequence "create the class, then re-pin" becomes "create the class" — with a
sibling proving the old two-call sequence still works, so an agent written against the previous
behaviour is not broken.

Mutation-verified: 5 cases, 5 red, each naming its intended test — narrowing advancing,
completed batches moving, nothing advancing, reporting nothing while still writing, and the no-op
catching batches up. Committed before the first mutation, reverted by the recorded diff, clean
tree asserted between cases. Two anchors initially missed because ruff format had reflowed the
expression; the harness refused rather than silently patching nothing, which is what that
assertion is for.

The cycle spec carries the end-to-end proof, and it is the only suite that can. The unit
suites stub the publish, so they assert what the client sent and rendered; whether the server
moved the pin in the same transaction is a fact about SchemaService, and that is the one run
where the service is real.

Gate

Every stage, exit code recorded, run in stages against the ~10-minute ceiling. Test directories
read from ls tests/ at run time.

Stage Exit
pytest kernel cli mcp 0
pytest server formats inference jobs 0
pytest architecture examples packaging scripts test_versioning.py 0
ruff check · ruff format --check · mypy · lint-imports 0 (4 contracts kept)
check.sh generated 0
check.sh frontend 0 — annotator 1027, ui-core 979
check.sh browser 0 — e2e 271, cycle 1
check.sh docs 0

A defect this found, and the walk is what found it

The first implementation diffed the new version against the version it replaced, then
advanced every open batch on that one answer. A batch that had already declined to follow a
narrowing is behind active — and a version that only widens the active contract can still be a
narrowing of that batch's. The publish dragged it across the very change it was protected
from.

Reproduced against a real server before fixing: batch pinned v1 with car; v2 drops car and
the batch rightly stays; v3 adds a class to v2, is additive against active — and the batch jumped
to v3 with car gone from the contract its labels were written under. That is the state
SchemaChangeWouldOrphan exists to prevent, reached from a direction nothing guarded.

The diff is now per batch, against that batch's own pin — the same question repin asks, and
the reason it reads the pinned schema rather than the active one. Two of repin's three gates
really are vacuous on an additive change; the third is a real question and is now asked.

Nothing in the first commit could have caught it: all five mutations and all four tests had
their batch already on the active version, where the two diffs are the same one. The regression
tests put a batch two versions behind and a second beside it on active, and both go red against
the old code.

Verified end to end afterwards, on the same build: the lagging batch holds at v1 through both
publishes, repin --allow-destructive still catches it up by hand, and a batch on active
advances and is named in advanced_batches.

Copy, and the door that had no browser coverage

Two sentences still described the removed third call. The pin badge's "adding one from here
re-pins it"
— read on screen during the walk before it was noticed — now says what a behind
badge actually means after #381: a version narrowed the schema past this pin. And the dialog's
unconditional "moves this batch onto it", which could contradict the notice directly below it
on a completed batch, is conditional on the same canRepin that notice reads.

The annotator's add-a-class door had no browser coverage at allannotate.spec.ts only
asserts it is absent in read-only mode — so a chain that had just lost a step was tested entirely
against stubs. Given that stubs are exactly what hid the per-batch diff defect, the cycle now
opens the dialog inside a real job and asserts on the request log: one POST to
/schema/versions, none to /repin, then the pin followed anyway and the armed class is
drawable. Verified by breaking it — dropping the ["batches"] invalidation turns that assertion
red, which is what makes "this line is load-bearing" a measurement.

Found, not fixed

  • POST /batches/{id}/repin takes a bare bool for allow_destructive, where
    create_schema_version uses the annotated DestructiveQuery — so the schema route documents
    the parameter in OpenAPI and the repin route does not. Pre-existing, unrelated, one line.
  • IngestCompleted's docstring still says it is emitted by nobody, which stopped being true
    when IngestService.ingest started publishing it. docs/events.md is correct; the docstring
    is stale.

Flags for Armando

  1. A correction batch approved after an additive publish now pins the newer version. The
    cycle spec's assertion moved from v1 to v2 for that reason. It was always "pins the active
    version", so this is the same rule meeting a project whose active version now moves more
    often — worth knowing, because it is the one place the change is visible somewhere nobody was
    thinking about it.
  2. MCP agents written against "create the class, then re-pin" keep working — the second call
    is now a no-op rather than an error. There is a test for exactly that.
  3. The wire key is published, not version. {version: {version: 2}} reads badly, and
    published matches the domain field, which is itself named published because schema
    shadows BaseModel.schema and pydantic warns.

Publishing a version and moving a batch's pin were two operations, composed
differently by two surfaces: the annotator ran save → publish → repin as a
client-orchestrated chain, and the schema editor published and touched no batch
at all. Same act, two behaviours, and neither belonged to the kernel. In use that
produced a closed loop — a project ahead of its own open batch, and an add-class
dialog whose only route out was the publish it had just refused.

**The rule, and it is a construction rather than a policy.** `diff_classes`
already answers *does an annotation valid under the old version stay valid under
the new one?* When it answers yes, moving a pin across that version cannot
invalidate anything already drawn. So an additive version now takes every batch
in `REPINNABLE_STATES` with it, in the same transaction, and a narrowing one
still takes none — with `allow_destructive` or without it, because that flag says
*publish this*, never *and drag every open batch across it*.

It is also what makes it implementable. `BatchService` imports `SchemaService`, so
the reverse import would close a cycle — but on the additive path every one of
`repin`'s three gates is provably vacuous, so `_advance_pins` needs only
`REPINNABLE_STATES` (a domain constant) and the repository. No layering inverted,
and no rule spelled twice. `repin` itself is untouched and is now the manual
escape for the narrowing case, which is the only case that still needs one.

**The invariant this rewrites was written down**, and its own justification is
what narrowed it: `Batch.schema_version` said the pin "never follows the active
version on its own — a schema that evolved mid-batch would change the rules under
work in flight". That is an argument about *narrowing*, which still never
follows. `test_a_later_schema_version_does_not_move_an_existing_pin` inverts into
three tests naming the new boundary.

`create_version` returns `SchemaPublication` — the version, and the batches that
moved. A publish that silently caught two batches up is the invisible success the
UI rules ban by name, and the return value is the only thing that can tell an
additive version with two open batches from one with none. 28 call sites read the
return; the 71 that ignore it are untouched.

No migration, no port change, no event — neither `create_version` nor `repin`
emits one today, and adding one is its own decision.

cf. #381
…ays what it moved

`runAddClass` was save → publish → repin. The kernel moves the pin now, inside
the publish's own transaction, so the third call is gone — along with the
`canRepin` preflight that guarded it and `useRepinBatch`, which nothing else
used. Finding F23's row leaves the failure table too: *the version exists and the
pin has not moved* is unrepresentable rather than handled.

`canRepin` still reaches the dialog, because the sentence it drives is still
true: a completed batch keeps its version, and somebody publishing from inside
one should be told before they press.

**The one line without which none of this is visible**: `useCreateSchemaVersion`
invalidated only `["projects", id]` and never `["batches"]`, so the kernel would
move the pins and no screen would notice. A publish now changes `schema_version`
on resources that key does not cover.

The Schema tab says what its publish did. Until now it published and touched no
batch at all, which is how a project came to be two versions ahead of the batch
somebody was annotating in; a screen that answered only "saved" would leave that
to be discovered from a batch opened later.

The cycle spec carries the end-to-end proof, and it is the only suite that can:
the unit suites stub the publish, so whether the *server* moved the pin in the
same transaction is a fact about `SchemaService` and this is the one run where
that service is real. A correction batch approved after it now pins v2, which is
that publish showing up where the walk already looks.

cf. #381
…nst active

Found by walking the feature in a browser, which is what the walk was for.

`_advance_pins` diffed the new version against the version it replaced, and
advanced every open batch on that one answer. A batch that had already declined
to follow a **narrowing** is behind the active version — and a version that only
widens the *active* contract can still be a narrowing of **that batch's**. The
publish then dragged it across the very change it had been protected from.

Reproduced against a real server: batch pinned v1 with `car`; v2 drops `car` and
the batch rightly stays; v3 adds a class to v2 and is additive against active —
and the batch jumped to v3, losing `car` from the contract its labels were
written under. That is the state `SchemaChangeWouldOrphan` exists to prevent,
reached from a direction nothing guarded.

The diff is now per batch, against that batch's own pinned version — the same
question `BatchService.repin` asks, and the reason it reads the pinned schema
rather than the active one. For a batch already on active it is the same diff the
caller ran, so the common case costs nothing; the versions are read once for the
whole call through the existing `_by_version`.

The claim in the previous commit that all three of `repin`'s gates are "provably
vacuous" was true only for a batch already on the active version, and I
generalised it. Two of them still are — an additive change orphans nothing, and
this path offers no flag — but the third is a real question and is now asked.

A pin naming a version that is not stored raises `WorkspaceCorrupt`, which is the
answer `BatchService._pinned_schema` already gives. Versions are never deleted,
so it is damage rather than a state any operation leaves behind.

**None of the five mutations or four tests in the previous commit could see
this**, because every fixture had its batch already on the active version, where
the two diffs are the same one. The two tests added here put a batch two versions
behind and a second batch beside it on active, and both go red against the old
one-diff-for-all code.

cf. #381
…walk for the door it left

Two sentences went on describing a step that no longer exists, and one of them was
read on screen during the walk before it was noticed.

**The pin badge** said "Classes published since are not available on this batch —
adding one from here re-pins it". Neither half survives #381: the kernel moves the
pin, not the dialog, and a batch that is *behind* has now declined something
rather than merely not been asked. So it says which — a version narrowed the
schema past this pin — and offers no remedy, because crossing a narrowing is a
decision about this batch's labels and `repin` is where that lives.

**The add-a-class dialog** promised "and moves this batch onto it" unconditionally
while the notice below it could simultaneously say the batch would stay. Only one
of those can be true on a completed batch; the sentence is now conditional on the
same `canRepin` the notice reads.

**And the door itself had no browser coverage at all.** `annotate.spec.ts` only
asserts the dialog is absent in read-only mode, and the demo it runs against has
no project behind it — so a chain that just lost a step was tested entirely
against stubs. That is the shape of gap that hid the per-batch diff defect two
commits ago, so it is closed rather than argued about: the cycle now opens the
dialog inside a real job, publishes a class, and asserts on the **request log** —
one POST to `/schema/versions`, none to `/repin` — then that the pin followed
anyway and the armed class is drawable on the frame.

Verified by breaking it: dropping the `["batches"]` invalidation from
`useCreateSchemaVersion` turns that assertion red, which is what makes the claim
that the line is load-bearing a measurement rather than a remark.

A correction batch approved afterwards now pins v3, which is the second additive
publish showing up where the walk already looks.

cf. #381
@JArmandoAnaya
JArmandoAnaya merged commit 7d99bae into main Aug 15, 2026
15 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/advance-pins branch August 15, 2026 12:40
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.

Kernel operation: publish a schema version and repin open batches

1 participant