Skip to content

feat(schemas): the narrowing refusals carry their report, and preview says it first - #590

Merged
JArmandoAnaya merged 2 commits into
mainfrom
feat/schema-refusal-contract
Aug 15, 2026
Merged

feat(schemas): the narrowing refusals carry their report, and preview says it first#590
JArmandoAnaya merged 2 commits into
mainfrom
feat/schema-refusal-contract

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #587.

The two schema-narrowing refusals computed exactly the per-class report a client needs and then
threw it away into an f-string. detail was null on both, CLI and MCP got one UUID-bearing
sentence, and nothing on the wire declared the inability before the attempt — so the browser
reconstructed the warning from ProjectService.stats, a separate query that agreed in scope only by
coincidence, with no test tying the two walks together.

The refusals now carry their report

{ "code": "DESTRUCTIVE_SCHEMA_CHANGE",
  "detail": { "classes": ["lane"] } }

{ "code": "SCHEMA_CHANGE_WOULD_ORPHAN",
  "detail": { "blockers": [ { "label_class": "lane", "annotations": 12, "assets": 3 } ] } }

Both follow LossyExportNotConsented exactly — the shipped precedent for this: a class attribute
with a None default rather than a constructor parameter, typed object | None because
kernel/errors.py may not import a domain model, recovered by an isinstance narrowing in
_detail_for. So every error stays constructible from one message and ERROR_RULES' exact-
correspondence test is untouched.

classes carries names only, no counts, and that asymmetry is deliberate. That refusal is about
intent and is raised before anything on disk is consulted; attaching counts would put a walk over
every asset in the project in front of the one refusal that does not need it, and would invert the
"intent first, then facts on disk" ordering both gates are built on. A caller who wants counts asks
the preview.

The wire says it first

POST /projects/{project_id}/schema/preview

SchemaService.preview existed for precisely this — "so a surface can warn before it asks" — and
had one caller in the whole repo (the MCP tool) and no REST route. It now returns the diff plus
the blockers, which gives one structure, three deliveries: the new route, the existing
preview_schema_change MCP tool upgraded for free, and the 409's own detail.

{ "diff": { "is_destructive": true, "destructive_classes": ["lane"], "changes": [] },
  "blockers": [ { "label_class": "lane", "annotations": 12, "assets": 3 } ],
  "is_refused": true }

is_destructive and is_refused are different questions — the first is answered by passing a
flag, the second by nothing at all — and conflating them is the loop SchemaChangeWouldOrphan sits
outside DestructiveSchemaChange's hierarchy to prevent. is_refused is published rather than left
to the client for the reason is_destructive already is: re-deriving it from blockers is the
hand-mirrored table ui-capabilities bans, in miniature.

It is advisory and says so: nothing is locked, so a label written between the preview and the
publish makes the publish refuse, and that refusal is the authoritative one. The guarded insert
(#586, 74f56c1) is what makes the answer safe to act on. What this removes is the round trip that
was doomed before it was sent, not the need to handle being refused.

Why this vehicle, and what was rejected

  • A field on LabelClassBody — it is shared request-and-response, so a response-only field
    splits the OpenAPI component into -Input/-Output. That is a contract event, for a field only
    one of the two modes would ever carry.
  • A separate GET .../schema/class-usage — a new kernel read, route and model that answers a
    narrower question, does not upgrade the MCP tool, and leaves preview still callerless on REST.
  • Contract-ising ProjectStats.per_class alone — smallest diff, but removable stays
    client-derived, which is exactly the legality-mirroring ui-capabilities forbids outright.

Surfaces

  • REST — the typed detail above, plus the route. Messages dropped the interpolated project
    UUID.
  • CLI — two new _HINTS entries. DestructiveSchemaChange translates the service keyword to
    --allow-destructive (LossyExportNotConsented's reason, one refusal over); the
    SchemaChangeWouldOrphan one says there is no flag for this, because without it the
    neighbouring --allow-destructive reads as the obvious next thing to try, which is the loop.
  • MCP — keeps its deliberate absence of a code, gains the structure. The tool description now
    tells an agent in so many words not to retry is_refused with allow_destructive=true.

Test plan

10 new contract tests in tests/server/test_schema_refusals.py, plus three in
tests/kernel/test_schema_service.py. The one that matters most is
test_a_preview_and_the_refusal_report_the_same_blockers — asserted as equality of the two
structures rather than of two hand-written literals, so a shape that drifts on one side fails there
and nowhere else. test_the_flag_never_gets_a_labeled_class_removed is parametrized over
allow_destructive in both directions, pinning the audit's Q2 finding as a contract rather than an
observation.

Four mutations, each reverted by its exact recorded diff with the anchor asserted before and after
and the tree asserted clean:

Mutation Named tests it turned red
orphan detail branch removed test_an_orphan_refusal_carries_a_count_per_class, test_a_preview_and_the_refusal_report_the_same_blockers
destructive detail branch removed test_a_narrowing_refusal_names_the_classes_it_would_remove
is_refused forced to False test_a_preview_and_the_refusal_report_the_same_blockers, test_preview_names_the_classes_that_no_flag_would_get_past
blockers key renamed in wire/ both test_json_contract parity cases + test_preview_names_what_a_change_would_remove_without_writing_anything

The last row is the parity gate biting: wire.schema_change_preview is registered in
test_json_contract.py's PAIRS against SchemaChangePreviewOut, with a non-empty blockers
sample in tests/fixtures/samples.py — an empty one would leave the nested ClassCount projection
unchecked, which is the half a client actually renders.

openapi.json and frontend/ui-core/src/generated/ regenerated, never hand-edited: 79 operations,
one new component.

Full bash scripts/check.sh, run in stages against the ~10-minute harness ceiling, after rebasing
onto 74f56c1. Every stage's exit code verbatim:

Stage Exit Result
check.sh python 0 3369 passed, 13 skipped; ruff lint + format, mypy, import contracts clean
check.sh frontend 0 annotator 1027/1027, ui-core 983/983
check.sh generated 0 openapi + client + MCP reference + version sync current
check.sh browser 0 annotator + app e2e 271 passed; browser cycle, real server 1 passed
check.sh docs 0 site build, deterministic projection, internal links

Found, not fixed

  • The repo pins Node 24 and nothing local says so. Under Node 26.7.0 the ui-core vitest suite
    fails 8 tests in railState.test.ts and gallery.test.tsx with
    TypeError: Cannot read properties of undefined (reading 'getItem')globalThis.localStorage
    is undefined in vitest's jsdom environment. It reproduces on unmodified main; CI is green
    because CI uses 24, and the stage is green locally under 24. There is no .nvmrc and no engines
    field, so the pin lives only in .github/workflows/ci.yml. Distinct from ui-core's vitest suite times out under load, and it reproduces on main #555, which is a timeout
    under load.
  • ProjectService.stats and SchemaService._annotated_classes are still two independently
    written N+1 walks over the same set
    , agreeing by coincidence with no test tying them together.
    This change makes the legality answer authoritative, so the UI no longer needs to derive it from
    stats — but the two walks are not unified. Out of scope here.

Flags for Armando

  1. This declares a wire capability nothing renders yet. capabilities.py's own comment forbids
    that — "an orphan makes the wire the source of a control that cannot work" — and it is
    sanctioned here only because UI: schema class-removal flow — three dialogs, contradictory promises, raw API text #588 is the named consumer and is blocked on this landing. If
    UI: schema class-removal flow — three dialogs, contradictory promises, raw API text #588 stalls, this is a standing violation rather than a temporary one.
  2. The dialogs change text, and are improved rather than fixed. The schema editor renders
    failure.message verbatim, so both refusal dialogs lose the project UUID — but
    pass allow_destructive=True still reaches the browser, because rewriting that copy is UI: schema class-removal flow — three dialogs, contradictory promises, raw API text #588's
    job and the _HINTS precedent says the kernel names the service parameter while each surface
    translates it. Worth agreeing that this intermediate state is acceptable.
  3. The typed shape does not depend on DECISION: which orphan invariant does VisionSet enforce? (active-version vs. per-annotation-version) #585. The orphan veto over already-existing annotations
    survives both invariants, which is why this could land while that decision is open. What DECISION: which orphan invariant does VisionSet enforce? (active-version vs. per-annotation-version) #585
    will change is the copy in UI: schema class-removal flow — three dialogs, contradictory promises, raw API text #588, not this contract.
  4. preview is advisory by construction and the docs say so. If a reviewer expects it to
    reserve or lock anything, that expectation is wrong and the route's docstring argues why —
    the guarded insert from kernel: schema create_version and BatchService.repin race — count check is not snapshot-isolated #586 is the thing that makes a publish safe, not this.

`docs/api.md` gains the `detail` shape of both 409s and the preview route;
`docs/schemas.md`'s "`SchemaService.preview` is unrouted" is no longer true and
its example now shows the two questions apart — `is_destructive`, answered by a
flag, and `is_refused`, answered by nothing.
@JArmandoAnaya
JArmandoAnaya merged commit d0358d2 into main Aug 15, 2026
15 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/schema-refusal-contract branch August 15, 2026 12:15
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
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
… says it first (#590)

* feat(schemas): the narrowing refusals carry their report, and preview says it first

* docs: the narrowing refusals publish their report, and preview is routed

`docs/api.md` gains the `detail` shape of both 409s and the preview route;
`docs/schemas.md`'s "`SchemaService.preview` is unrouted" is no longer true and
its example now shows the two questions apart — `is_destructive`, answered by a
flag, and `is_refused`, answered by nothing.
JArmandoAnaya added a commit that referenced this pull request Aug 21, 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.

server/wire: schema orphan and narrowing refusals are prose-only, and the wire declares nothing ahead of time

1 participant