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
8 changes: 4 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,9 +493,9 @@ One status covers more than one situation, which is the whole reason to branch o

**409 - the request is well-formed; the resource's state refuses it.** The remedy is to change
that state and resubmit the identical request: finish the outstanding jobs, approve the batch,
promote something into the dataset, pass `confirm=true`. Name and tag collisions are here, as is
`UNSERIALIZABLE_MANIFEST` - the request body is fine and the defect is in state stored long
before, so the remedy is to fix the annotation and publish again.
promote something into the dataset, pass `confirm=true`. Name and tag collisions are here, as are
`UNSERIALIZABLE_MANIFEST` and `RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA` - the request body is fine
and the defect is in stored content, so reconcile the annotations and publish again.

**422 - the payload itself is wrong.** A blank name, a schema that declares two classes with one
name, an annotation that names a class the batch's pinned version does not have. Media failures
Expand Down Expand Up @@ -581,7 +581,7 @@ argument for branching on `code`.
| **401** | `UNAUTHORIZED` — with a `WWW-Authenticate: Bearer` challenge |
| **404** | `PROJECT_NOT_FOUND` · `SCHEMA_NOT_FOUND` · `SCHEMA_DRAFT_NOT_FOUND` · `BATCH_NOT_FOUND` · `JOB_NOT_FOUND` · `INGEST_JOB_NOT_FOUND` · `BACKGROUND_JOB_NOT_FOUND` · `ASSET_NOT_FOUND` · `SOURCE_NOT_FOUND` · `DATASET_NOT_FOUND` · `ANNOTATION_NOT_FOUND` · `RELEASE_NOT_FOUND` · `TOKEN_NOT_FOUND` · `INFERENCE_CONNECTION_NOT_FOUND` · `ASSET_NOT_IN_JOB` · `NO_SPLIT_RECIPE` · `EXPORT_FORMAT_NOT_FOUND` · `THUMBNAIL_NOT_CACHED` · `NOT_FOUND` (no such route) |
| **405** | `METHOD_NOT_ALLOWED` |
| **409** | `PROJECT_NAME_TAKEN` · `RELEASE_TAG_TAKEN` · `TOKEN_NAME_TAKEN` · `INFERENCE_CONNECTION_NAME_TAKEN` · `WORKSPACE_ALREADY_EXISTS` · `WORKSPACE_NOT_EMPTY` · `SCHEMA_VERSION_CONFLICT` · `INVALID_TRANSITION` · `STALE_WRITE` · `BATCH_NOT_EDITABLE` · `BATCH_IMMUTABLE` · `BATCH_NOT_IN_ANNOTATION` · `ASSET_NOT_WRITABLE` · `JOB_FINISHED` · `BATCH_NOT_COMPLETE` · `JOB_NOT_COMPLETE` · `EMPTY_BATCH` · `EMPTY_RELEASE` · `CONFIRMATION_REQUIRED` · `DESTRUCTIVE_SCHEMA_CHANGE` · `SCHEMA_CHANGE_WOULD_ORPHAN` · `SCHEMA_HAS_NO_DETECTABLE_CLASS` · `UNSERIALIZABLE_MANIFEST` · `LOSSY_EXPORT_NOT_CONSENTED` · `EXPORT_SOURCE_UNREADABLE` · `INFERENCE_CONNECTION_NOT_DOWNLOADABLE` · `INFERENCE_CONNECTION_NOT_CHECKABLE` · `WEIGHTS_DAMAGED` · `INFERENCE_CONNECTION_NOT_SET_UP` |
| **409** | `PROJECT_NAME_TAKEN` · `RELEASE_TAG_TAKEN` · `TOKEN_NAME_TAKEN` · `INFERENCE_CONNECTION_NAME_TAKEN` · `WORKSPACE_ALREADY_EXISTS` · `WORKSPACE_NOT_EMPTY` · `SCHEMA_VERSION_CONFLICT` · `INVALID_TRANSITION` · `STALE_WRITE` · `BATCH_NOT_EDITABLE` · `BATCH_IMMUTABLE` · `BATCH_NOT_IN_ANNOTATION` · `ASSET_NOT_WRITABLE` · `JOB_FINISHED` · `BATCH_NOT_COMPLETE` · `JOB_NOT_COMPLETE` · `EMPTY_BATCH` · `EMPTY_RELEASE` · `RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA` · `CONFIRMATION_REQUIRED` · `DESTRUCTIVE_SCHEMA_CHANGE` · `SCHEMA_CHANGE_WOULD_ORPHAN` · `SCHEMA_HAS_NO_DETECTABLE_CLASS` · `UNSERIALIZABLE_MANIFEST` · `LOSSY_EXPORT_NOT_CONSENTED` · `EXPORT_SOURCE_UNREADABLE` · `INFERENCE_CONNECTION_NOT_DOWNLOADABLE` · `INFERENCE_CONNECTION_NOT_CHECKABLE` · `WEIGHTS_DAMAGED` · `INFERENCE_CONNECTION_NOT_SET_UP` |
| **422** | `VALIDATION_ERROR` · `ASSET_NOT_IN_BATCH` · `ANNOTATION_NOT_FROM_MODEL` · `INVALID_NAME` · `INFERENCE_CONNECTION_INVALID` · `INVALID_SCHEMA` · `UNSUPPORTED_GEOMETRY` · `INVALID_ANNOTATION` · `LABEL_CLASS_NOT_IN_SCHEMA` · `DISALLOWED_GEOMETRY` · `ANNOTATION_GEOMETRY_OUT_OF_BOUNDS` · `DUPLICATE_CLASSIFICATION_TAG` · `MISSING_REQUIRED_ATTRIBUTE` · `UNKNOWN_ATTRIBUTE` · `INVALID_ATTRIBUTE_VALUE` · `INVALID_PARTITION` · `UNKNOWN_JOB_TYPE` · `MEDIA_ERROR` · `UNSUPPORTED_MEDIA` · `CORRUPT_MEDIA` · `UNSUPPORTED_PROMPT` · `PROMPT_POINT_OUT_OF_BOUNDS` |
| **503** | `WORKSPACE_BUSY` |
| **500** | `WORKSPACE_CORRUPT` · `NOT_A_WORKSPACE` · `WORKSPACE_FORMAT_TOO_NEW` · `WORKSPACE_SCHEMA_MISMATCH` · `ENTITY_NOT_FOUND` · `ENTITY_ALREADY_EXISTS` · `CONSTRAINT_VIOLATED` · `MEDIA_TOOL_UNAVAILABLE` · `LOCAL_INFERENCE_UNAVAILABLE` · `INFERENCE_CONNECTION_NOT_RUNNABLE` · `INFERENCE_OUT_OF_MEMORY` · `INTERNAL_ERROR` |
Expand Down
10 changes: 6 additions & 4 deletions docs/batches.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ batches.get(batch.id).schema_version # still 2
```

A schema that evolved mid-batch would change the rules under work already in flight, which is
exactly what versioning exists to prevent. Every annotation written into this batch's jobs is
validated against the pinned version, not against whatever is newest.
exactly what versioning exists to prevent. The pin is the validation contract for this batch's
in-progress work: every annotation written into its jobs is validated against the pinned version,
not against whatever is newest.

Approving a project that has no schema raises `SchemaNotFound`. Creating version 1 here would
be a second door to a schema, and [schemas.md](schemas.md) has only one.
Expand All @@ -93,8 +94,9 @@ invalidate anything already drawn. So there is nothing on this path for a manual
step to protect — and what the manual step cost was that a class published while
somebody was annotating stayed invisible to them until they found `repin`.

A **narrowing** version moves nothing, with `allow_destructive` or without it:
that flag says *publish this*, never *and drag every open batch across it*.
A **narrowing** version moves nothing, with `allow_destructive` or without it: it does not move
an open batch's validation contract. That flag says *publish this*, never *and drag every open
batch across it*.
Crossing a narrowing is `repin`, one batch at a time, judged against that batch's
own labels.

Expand Down
10 changes: 7 additions & 3 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,13 @@ images ingested on another machine produce a different manifest.

The project's **active** version - the highest one - with its classes. Each copied annotation
still carries the version its own batch pinned, and those can differ: two batches approved
against two versions can both be promoted into one trunk. The mixture is safe rather than sloppy,
because `SchemaChangeWouldOrphan` refuses to remove a class that annotations still depend on. Every
label in a manifest is still described by the classes in that manifest.
against two versions can both be promoted into one trunk. Publishing validates every copied
annotation against the active schema before freezing the manifest. If any do not conform, it
refuses with the affected class counts; reconcile that content before publishing.

That publication gate makes newly created manifests internally schema-consistent. Exporters still
defend against undeclared classes when reading archived or externally supplied malformed manifests,
because those documents may not have come through this publication path.

## Verification

Expand Down
8 changes: 7 additions & 1 deletion docs/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,13 @@ should not be caught by one `except`: `confirm` stands in front of destroying da
`ProjectService.delete`), and this stands in front of narrowing a contract, whose usual
remedy is "write a wider version", not "say yes harder".

The second gate has no flag at all:
An open batch keeps the version it pinned, so a destructive version can be published before that
batch writes another annotation. Its later annotation is valid under that batch's pin, not under
the newer active schema. If that content is promoted, release publication validates it against the
active schema and refuses the release with affected class counts until the content is reconciled.

The second gate has no flag at all when the proposed change would orphan annotations the schema
publication path must preserve:

```python
# annotations already exist under 'lane'
Expand Down
6 changes: 6 additions & 0 deletions frontend/ui-core/src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,12 @@ export interface paths {
* published, because writing that value as `null` would lose it silently and
* writing it as `NaN` would produce a manifest no other tool can read. The
* remedy is to correct the annotation and publish again.
*
* The active schema must also describe every annotation the release would
* freeze. Otherwise publishing is 409
* `RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA`, with per-class blockers in `detail`.
* Reconcile those annotations or restore a compatible active schema, then
* publish again.
*/
post: operations["publish_release"];
delete?: never;
Expand Down
2 changes: 1 addition & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6874,7 +6874,7 @@
]
},
"post": {
"description": "Freeze the trunk as it stands into an immutable, named snapshot.\n\nWhat is frozen is the content: every member asset, every annotation on it as\nit was, and the schema version those labels were judged against. Deleting an\nannotation afterwards cannot reach backwards into a published release.\n\nPublishing twice from an unchanged dataset produces byte-identical manifests\nand therefore the same `manifest_hash`, because nothing time-, machine- or\nidentity-specific goes inside the document. The tag, the timestamp and the\nbuild live on the release row instead.\n\n`split` is stored as a recipe, not materialized. `GET\n/releases/{release_id}/assignment` cuts the folds on demand, deterministically\nand from the frozen asset set. Fractions must sum to 1.0.\n\nTags are unique per dataset and **case-sensitive**, like a git tag: `v1.0` and\n`V1.0` are two releases, and reusing one is 409 `RELEASE_TAG_TAKEN`. A dataset\nwith no assets is 409 `EMPTY_RELEASE`; zero *annotations* is fine, since\nunlabeled images are legitimate training data. A project with no schema is 404\n`SCHEMA_NOT_FOUND`, because there is no version to pin, and an unknown dataset\nis 404 `DATASET_NOT_FOUND`.\n\nOne refusal is about the labels rather than about the request: an annotation\ncarrying a coordinate canonical JSON cannot express \u2014 a NaN or an infinity \u2014\nis 409 `UNSERIALIZABLE_MANIFEST`, and the message names it. Nothing is\npublished, because writing that value as `null` would lose it silently and\nwriting it as `NaN` would produce a manifest no other tool can read. The\nremedy is to correct the annotation and publish again.",
"description": "Freeze the trunk as it stands into an immutable, named snapshot.\n\nWhat is frozen is the content: every member asset, every annotation on it as\nit was, and the schema version those labels were judged against. Deleting an\nannotation afterwards cannot reach backwards into a published release.\n\nPublishing twice from an unchanged dataset produces byte-identical manifests\nand therefore the same `manifest_hash`, because nothing time-, machine- or\nidentity-specific goes inside the document. The tag, the timestamp and the\nbuild live on the release row instead.\n\n`split` is stored as a recipe, not materialized. `GET\n/releases/{release_id}/assignment` cuts the folds on demand, deterministically\nand from the frozen asset set. Fractions must sum to 1.0.\n\nTags are unique per dataset and **case-sensitive**, like a git tag: `v1.0` and\n`V1.0` are two releases, and reusing one is 409 `RELEASE_TAG_TAKEN`. A dataset\nwith no assets is 409 `EMPTY_RELEASE`; zero *annotations* is fine, since\nunlabeled images are legitimate training data. A project with no schema is 404\n`SCHEMA_NOT_FOUND`, because there is no version to pin, and an unknown dataset\nis 404 `DATASET_NOT_FOUND`.\n\nOne refusal is about the labels rather than about the request: an annotation\ncarrying a coordinate canonical JSON cannot express \u2014 a NaN or an infinity \u2014\nis 409 `UNSERIALIZABLE_MANIFEST`, and the message names it. Nothing is\npublished, because writing that value as `null` would lose it silently and\nwriting it as `NaN` would produce a manifest no other tool can read. The\nremedy is to correct the annotation and publish again.\n\nThe active schema must also describe every annotation the release would\nfreeze. Otherwise publishing is 409\n`RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA`, with per-class blockers in `detail`.\nReconcile those annotations or restore a compatible active schema, then\npublish again.",
"operationId": "publish_release",
"parameters": [
{
Expand Down
4 changes: 4 additions & 0 deletions src/visionset/cli/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
DestructiveSchemaChange,
LossyExportNotConsented,
NotAWorkspace,
ReleaseContentWouldViolateSchema,
SchemaChangeWouldOrphan,
VisionSetError,
)
Expand Down Expand Up @@ -93,6 +94,9 @@
"There is no flag for this one. Delete or relabel those annotations "
"first, or keep the class and change something else."
),
ReleaseContentWouldViolateSchema: (
"Reconcile the annotations or restore a compatible active schema."
),
}
"""A remedy a *terminal* can act on, printed under the error's own sentence.

Expand Down
7 changes: 3 additions & 4 deletions src/visionset/formats/classification/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,9 @@ def export(
image = f"{IMAGES_DIRNAME}/{fold}/{name}"
for annotation in asset.annotations:
if annotation.label_class not in declared:
# Cannot happen — `SchemaChangeWouldOrphan` refuses to remove
# a class annotations depend on — but a row naming a class
# outside `classes.txt` would be a silent lie in a file read
# as ground truth.
# Publication rejects new inconsistent manifests, but archived or
# externally supplied manifests can still be malformed. A row
# outside `classes.txt` would be a silent lie to its reader.
raise ExportSourceUnreadable(
f"asset {asset.asset_id} carries class {annotation.label_class!r}, "
f"which the release's schema does not declare"
Expand Down
7 changes: 3 additions & 4 deletions src/visionset/formats/coco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,9 @@ def _annotation(
) -> dict[str, Any] | None:
"""One COCO annotation row, or ``None`` if this label has no place in one."""
category_id = category_of.get(annotation.label_class)
# A label whose class the manifest does not declare cannot happen —
# `SchemaChangeWouldOrphan` refuses to remove a class annotations depend on —
# but a row carrying a made-up category id would be a silent lie in a file a
# trainer reads as ground truth.
# Publication rejects new inconsistent manifests, but archived or externally
# supplied manifests can still be malformed. An invented category id would
# silently mislabel a file a trainer reads as ground truth.
if category_id is None:
raise ExportSourceUnreadable(
f"asset {asset.asset_id} carries class {annotation.label_class!r}, "
Expand Down
14 changes: 14 additions & 0 deletions src/visionset/formats/lanes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ def _walk(
the picture into the label document, and the suffix is sniffed from the bytes
rather than taken from the asset's uri — so only ``write_image`` knows it.
"""
_validate_manifest_classes(manifest)
folds = folds_of(release, manifest)
walked = []
for asset in manifest.assets:
Expand All @@ -278,6 +279,19 @@ def _walk(
return walked


def _validate_manifest_classes(manifest: Manifest) -> None:
"""Refuse a manifest whose annotations name classes its frozen schema omits."""
declared = {label_class.name for label_class in manifest.classes}
for asset in manifest.assets:
for annotation in asset.annotations:
if annotation.label_class not in declared:
raise refuse(
asset,
f"carries class {annotation.label_class!r}, which the release's "
"schema does not declare",
)


def _write_json(into: Path, asset: ManifestAsset, suffix: str, document: object) -> None:
into.mkdir(parents=True, exist_ok=True)
(into / f"{asset.content_hash}{suffix}").write_text(
Expand Down
6 changes: 3 additions & 3 deletions src/visionset/formats/voc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,9 @@ def _as_box(
) -> BboxGeometry | None:
"""The box this annotation contributes, or ``None`` if it contributes none."""
if annotation.label_class not in declared:
# Cannot happen — `SchemaChangeWouldOrphan` refuses to remove a class
# annotations depend on — but an `<object>` naming a class the release
# does not declare would be a silent lie in a file read as ground truth.
# Publication rejects new inconsistent manifests, but malformed, archival,
# or external manifests can still bypass that gate. An undeclared
# `<object>` would be a silent lie in a file read as ground truth.
raise ExportSourceUnreadable(
f"asset {asset.asset_id} carries class {annotation.label_class!r}, "
f"which the release's schema does not declare"
Expand Down
7 changes: 3 additions & 4 deletions src/visionset/formats/yolo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,9 @@ def _rows(asset: ManifestAsset, index_of: Mapping[str, int]) -> Iterable[str]:
width, height = dimensions_of(asset)
for annotation in asset.annotations:
index = index_of.get(annotation.label_class)
# A label whose class the manifest does not declare cannot happen —
# `SchemaChangeWouldOrphan` refuses to remove a class annotations depend
# on — but writing an index derived from nothing would be a silent lie in
# a file a trainer reads as ground truth.
# Publication rejects new inconsistent manifests, but archived or externally
# supplied manifests can still be malformed. An invented class index would
# silently mislabel a file a trainer reads as ground truth.
if index is None:
raise ExportSourceUnreadable(
f"asset {asset.asset_id} carries class {annotation.label_class!r}, "
Expand Down
2 changes: 2 additions & 0 deletions src/visionset/kernel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
ProjectNameTaken,
ProjectNotFound,
PromptPointOutOfBounds,
ReleaseContentWouldViolateSchema,
ReleaseNotFound,
ReleaseTagTaken,
SchemaChangeWouldOrphan,
Expand Down Expand Up @@ -139,6 +140,7 @@
"ProjectNotFound",
"PromptPointOutOfBounds",
"ReleaseNotFound",
"ReleaseContentWouldViolateSchema",
"ReleaseTagTaken",
"SchemaChangeWouldOrphan",
"SchemaDraftNotFound",
Expand Down
2 changes: 2 additions & 0 deletions src/visionset/kernel/domain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@
orphanable_shapes,
)
from visionset.kernel.domain.schema_draft import DraftAttribute, DraftLabelClass, SchemaDraft
from visionset.kernel.domain.schema_validation import validate_schema_annotation
from visionset.kernel.domain.segmentation import (
AssetSegmentation,
Mask,
Expand Down Expand Up @@ -388,6 +389,7 @@
"SchemaDiff",
"SchemaDraft",
"SchemaProvenance",
"validate_schema_annotation",
"SingleJob",
"Source",
"SourceKind",
Expand Down
9 changes: 4 additions & 5 deletions src/visionset/kernel/domain/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,10 @@ class Manifest(BaseModel):

``schema_version`` and ``classes`` are the project's *active* version at
publication, while each :class:`ManifestAnnotation` keeps the version its own
batch pinned. Those can differ, because two batches can be approved against
two versions and both promoted into one trunk. The mixture is safe rather
than sloppy: ``SchemaChangeWouldOrphan`` refuses to remove a class that
annotations still depend on, so every label in here is still described by the
classes in here.
batch pinned. Batch writes validate against that pin; release publication
validates every copied annotation against the active schema before creating a
new manifest. Historic manifests remain readable, so exporters also defend
against malformed external or legacy content that did not traverse that gate.

**The assets sort themselves**, by content hash and then by id, and each
asset's annotations sort by id. That belongs here rather than in the service
Expand Down
Loading
Loading