Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ POST /projects/{project_id}/schema/versions
GET /projects/{project_id}/schema/versions
GET /projects/{project_id}/schema/versions/{version}
GET /projects/{project_id}/schema/compare ?from=&to=
POST /projects/{project_id}/schema/preview would this publish?
POST /projects/{project_id}/sources/images multipart
POST /projects/{project_id}/sources/video multipart
GET /projects/{project_id}/sources
Expand Down Expand Up @@ -587,6 +588,59 @@ in `server/errors.py`, which `tests/server/test_errors.py` holds in exact corres
absent from both the detail and the message: on the ingest path it is an absolute path inside a
directory the operator, not the client, pointed at.

### The two narrowing refusals

They share a status, and only one of them has a way forward, so each carries the actionable half of
itself as structure rather than as prose:

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

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

`classes` is the blast radius a confirmation has to name. It carries **no counts**, and that is the
difference between the two: this refusal is about intent and is raised before anything on disk is
consulted, so attaching counts would put a walk over every asset in the project in front of the one
refusal that does not need it. A client that wants them asks the preview below.

`blockers` is why no flag helps, counted two ways — a thousand labels over a thousand images and the
same thousand over ten are the same `annotations` and a very different problem.

Neither is available by parsing `message`, and neither should be: `message`'s own field description
says the wording is not part of the contract.

### Asking before you are refused

```
POST /projects/{project_id}/schema/preview
```

The body is the **same document** `POST .../schema/versions` takes, so a client previews and
publishes without reshaping anything (`description` and `provenance` are accepted and ignored —
neither enters a diff). It writes nothing.

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

`diff.is_destructive` decides whether the publish needs `allow_destructive=true`. **`is_refused`
decides whether any flag would help** — and `blockers` is byte-for-byte the structure
`SCHEMA_CHANGE_WOULD_ORPHAN` puts in its `detail`, so one renderer serves the warning and the
refusal.

It is **advisory**. Nothing is locked and nothing is reserved: somebody can label a class between
the preview and the publish, in which case the publish refuses and that refusal is the
authoritative one. What the preview removes is the round trip that was doomed before it was sent,
not the need to handle being refused.

A POST because the proposal is a whole class list, which does not belong in a query string. It is
still a read.

---

## For contributors
Expand Down
30 changes: 22 additions & 8 deletions docs/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,16 +264,28 @@ Within one version, class names must be unique ignoring case, for the same reaso
beside `car` is two classes that read as one to everybody except the code.

The classifier lives in `kernel/domain/schema_diff.py` and is pure - two sequences in, a
verdict out. `preview` runs it against the active version without writing, so a surface can
verdict out. `preview` runs it against the active version without writing, and adds the half the
classifier cannot know - which of the classes being dropped already carry labels - so a surface can
warn before it asks:

```python
diff = schemas.preview(project.id, proposed)
diff.is_destructive # True
diff.destructive_classes # frozenset({'lane'})
diff.describe(ChangeKind.DESTRUCTIVE) # "class 'lane' removed"
preview = schemas.preview(project.id, proposed)
preview.diff.is_destructive # True - needs allow_destructive
preview.diff.destructive_classes # frozenset({'lane'})
preview.is_refused # False - and no flag would change that if True
preview.blockers # () - or (ClassCount(label_class='lane', ...),)
```

**`is_destructive` and `is_refused` are different questions**, and conflating them is the loop
`SchemaChangeWouldOrphan` sits outside `DestructiveSchemaChange`'s hierarchy to prevent: the first
is answered by passing a flag, the second by nothing at all. `blockers` is the same structure
`SCHEMA_CHANGE_WOULD_ORPHAN` puts in its `detail`, so one renderer serves the warning and the
refusal.

The preview is advisory: nothing is locked, so a label written between the preview and the publish
makes the publish refuse, and that refusal is the authoritative one. What it removes is the round
trip that was doomed before it was sent.

`compare(project_id, from_version, to_version)` does the same between two stored versions,
in either direction.

Expand Down Expand Up @@ -405,6 +417,8 @@ refusals are **both 409** with only one override between them, so it branches on
`code` and shows "Save anyway" for `DESTRUCTIVE_SCHEMA_CHANGE` and nothing but
"Close" for `SCHEMA_CHANGE_WOULD_ORPHAN`.

It has no preview of the change being drafted, because `SchemaService.preview` is unrouted;
`compare` is routed, and the version navigator uses it to show what two *published* versions did
to each other. See [ui.md](ui.md#the-schema-editor-and-the-two-409s).
`POST /projects/{id}/schema/preview` now routes `SchemaService.preview`, so a client can ask
both questions about a *draft* before it publishes; `compare` remains the question about two
*published* versions, which is what the version navigator asks. See
[ui.md](ui.md#the-schema-editor-and-the-two-409s) and
[api.md](api.md#asking-before-you-are-refused).
127 changes: 127 additions & 0 deletions frontend/ui-core/src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1672,6 +1672,51 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects/{project_id}/schema/preview": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Preview Schema Change
* @description Say what publishing these classes would do, without publishing anything.
*
* Writes nothing, and answers both gates at once. `diff` is the classification
* `GET /compare` returns — whether this narrows the contract, and over which
* classes — so `diff.is_destructive` decides whether the publish needs
* `allow_destructive=true`.
*
* **`is_refused` is the answer no flag changes.** True means annotations already
* exist under a class this proposal drops, so `POST /versions` answers 409
* `SCHEMA_CHANGE_WOULD_ORPHAN` however it is called, and `blockers` names each
* such class with how many annotations and how many assets carry it. That is the
* **same structure** the refusal itself puts in `detail`, so one renderer serves
* the warning and the refusal. Retrying with `allow_destructive=true` against a
* refused preview is the loop `code` exists to prevent.
*
* A POST because the proposal is the whole class list and a class list does not
* belong in a query string. It is still a read: nothing is written, nothing is
* locked, and nothing is reserved. Somebody can label a class between this call
* and the publish, in which case the publish refuses and **that** refusal is the
* authoritative one — this removes the round trip that was doomed before it was
* sent, not the need to handle being refused.
*
* The body is the same shape `POST /versions` takes, so a client previews and
* publishes the identical document. `description` and `provenance` are accepted
* and ignored: neither enters a diff, and requiring a client to strip them would
* make the two calls differ for no reason.
*/
post: operations["preview_schema_change"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/schema/versions": {
parameters: {
query?: never;
Expand Down Expand Up @@ -3696,6 +3741,17 @@ export interface components {
/** Label Class */
label_class: string;
};
/**
* SchemaChangePreviewOut
* @description What publishing a proposed version would do, and what would stop it.
*/
SchemaChangePreviewOut: {
/** Blockers */
blockers: components["schemas"]["ClassCountOut"][];
diff: components["schemas"]["SchemaDiffOut"];
/** Is Refused */
is_refused: boolean;
};
/**
* SchemaDiffOut
* @description Every difference between two schema versions, and the verdict on them.
Expand Down Expand Up @@ -8419,6 +8475,77 @@ export interface operations {
};
};
};
preview_schema_change: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SchemaVersionCreate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SchemaChangePreviewOut"];
};
};
/** @description Missing or invalid bearer token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorBody"];
};
};
/** @description No such resource */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorBody"];
};
};
/** @description The request payload is not processable */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorBody"];
};
};
/** @description Unhandled server error, with an incident id */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorBody"];
};
};
/** @description The workspace is busy; retry after the header says */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorBody"];
};
};
};
};
list_schema_versions: {
parameters: {
query?: never;
Expand Down
4 changes: 4 additions & 0 deletions frontend/ui-core/src/generated/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@ export const checkSchemaChangeOut: Check<Schemas["SchemaChangeOut"]> =
export const checkSchemaDiffOut: Check<Schemas["SchemaDiffOut"]> =
/*#__PURE__*/ object({ "changes": [true, arrayOf(checkSchemaChangeOut)], "destructive_classes": [true, arrayOf(isString)], "is_destructive": [true, isBoolean] } as const);

export const checkSchemaChangePreviewOut: Check<Schemas["SchemaChangePreviewOut"]> =
/*#__PURE__*/ object({ "blockers": [true, arrayOf(checkClassCountOut)], "diff": [true, checkSchemaDiffOut], "is_refused": [true, isBoolean] } as const);

export const checkAttributeBody: Check<Schemas["AttributeBody"]> =
/*#__PURE__*/ object({ "default": [false, either([isBoolean, isNumber, isString, isNull] as const)], "kind": [true, oneOf(["string", "number", "boolean", "select"] as const)], "name": [true, isString], "options": [false, either([arrayOf(isString), isNull] as const)], "required": [true, isBoolean] } as const);

Expand Down Expand Up @@ -379,6 +382,7 @@ export const checkListReleases = checkReleasePage;
export const checkListSchemaVersions = checkSchemaVersionPage;
export const checkListSources = checkSourcePage;
export const checkNextPendingAssets = checkAssetPage;
export const checkPreviewSchemaChange = checkSchemaChangePreviewOut;
export const checkPromoteBatch = checkAssetPage;
export const checkPublishRelease = checkReleaseOut;
export const checkRegisterImageSource = checkSourceOut;
Expand Down
Loading
Loading