feat(schemas): the narrowing refusals carry their report, and preview says it first - #590
Merged
Merged
Conversation
`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 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
detailwasnullon both, CLI and MCP got one UUID-bearingsentence, 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 bycoincidence, 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
LossyExportNotConsentedexactly — the shipped precedent for this: a class attributewith a
Nonedefault rather than a constructor parameter, typedobject | Nonebecausekernel/errors.pymay not import a domain model, recovered by anisinstancenarrowing in_detail_for. So every error stays constructible from one message andERROR_RULES' exact-correspondence test is untouched.
classescarries names only, no counts, and that asymmetry is deliberate. That refusal is aboutintent 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
SchemaService.previewexisted for precisely this — "so a surface can warn before it asks" — andhad 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_changeMCP tool upgraded for free, and the 409's owndetail.{ "diff": { "is_destructive": true, "destructive_classes": ["lane"], "changes": [] }, "blockers": [ { "label_class": "lane", "annotations": 12, "assets": 3 } ], "is_refused": true }is_destructiveandis_refusedare different questions — the first is answered by passing aflag, the second by nothing at all — and conflating them is the loop
SchemaChangeWouldOrphansitsoutside
DestructiveSchemaChange's hierarchy to prevent.is_refusedis published rather than leftto the client for the reason
is_destructivealready is: re-deriving it fromblockersis thehand-mirrored table
ui-capabilitiesbans, 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 thatwas doomed before it was sent, not the need to handle being refused.
Why this vehicle, and what was rejected
LabelClassBody— it is shared request-and-response, so a response-only fieldsplits the OpenAPI component into
-Input/-Output. That is a contract event, for a field onlyone of the two modes would ever carry.
GET .../schema/class-usage— a new kernel read, route and model that answers anarrower question, does not upgrade the MCP tool, and leaves
previewstill callerless on REST.ProjectStats.per_classalone — smallest diff, butremovablestaysclient-derived, which is exactly the legality-mirroring
ui-capabilitiesforbids outright.Surfaces
detailabove, plus the route. Messages dropped the interpolated projectUUID.
_HINTSentries.DestructiveSchemaChangetranslates the service keyword to--allow-destructive(LossyExportNotConsented's reason, one refusal over); theSchemaChangeWouldOrphanone says there is no flag for this, because without it theneighbouring
--allow-destructivereads as the obvious next thing to try, which is the loop.code, gains the structure. The tool description nowtells an agent in so many words not to retry
is_refusedwithallow_destructive=true.Test plan
10 new contract tests in
tests/server/test_schema_refusals.py, plus three intests/kernel/test_schema_service.py. The one that matters most istest_a_preview_and_the_refusal_report_the_same_blockers— asserted as equality of the twostructures 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_removedis parametrized overallow_destructivein both directions, pinning the audit's Q2 finding as a contract rather than anobservation.
Four mutations, each reverted by its exact recorded diff with the anchor asserted before and after
and the tree asserted clean:
detailbranch removedtest_an_orphan_refusal_carries_a_count_per_class,test_a_preview_and_the_refusal_report_the_same_blockersdetailbranch removedtest_a_narrowing_refusal_names_the_classes_it_would_removeis_refusedforced toFalsetest_a_preview_and_the_refusal_report_the_same_blockers,test_preview_names_the_classes_that_no_flag_would_get_pastblockerskey renamed inwire/test_json_contractparity cases +test_preview_names_what_a_change_would_remove_without_writing_anythingThe last row is the parity gate biting:
wire.schema_change_previewis registered intest_json_contract.py'sPAIRSagainstSchemaChangePreviewOut, with a non-emptyblockerssample in
tests/fixtures/samples.py— an empty one would leave the nestedClassCountprojectionunchecked, which is the half a client actually renders.
openapi.jsonandfrontend/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 rebasingonto
74f56c1. Every stage's exit code verbatim:check.sh pythoncheck.sh frontendcheck.sh generatedcheck.sh browsercheck.sh docsFound, not fixed
ui-corevitest suitefails 8 tests in
railState.test.tsandgallery.test.tsxwithTypeError: Cannot read properties of undefined (reading 'getItem')—globalThis.localStorageis undefined in vitest's jsdom environment. It reproduces on unmodified
main; CI is greenbecause CI uses 24, and the stage is green locally under 24. There is no
.nvmrcand noenginesfield, 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 timeoutunder load.
ProjectService.statsandSchemaService._annotated_classesare still two independentlywritten 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
capabilities.py's own comment forbidsthat — "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.
failure.messageverbatim, so both refusal dialogs lose the project UUID — butpass allow_destructive=Truestill reaches the browser, because rewriting that copy is UI: schema class-removal flow — three dialogs, contradictory promises, raw API text #588'sjob and the
_HINTSprecedent says the kernel names the service parameter while each surfacetranslates it. Worth agreeing that this intermediate state is acceptable.
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.
previewis advisory by construction and the docs say so. If a reviewer expects it toreserve 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.