A batch is the unit of annotation work: a curated set of project assets processed together. Annotation requires a frozen target that identifies the assets, schema version, and job partition. Approval freezes those values.
from visionset.kernel.domain import BySize
from visionset.kernel.services import BatchService, ProjectService, WorkspaceService
with WorkspaceService.open("./road-signs") as workspace:
project = ProjectService(workspace).list()[0]
batches = BatchService(workspace)
batch = batches.create(project.id, "city centre", asset_ids)
batches.add_assets(batch.id, more_asset_ids) # draft only
batches.approve(batch.id, BySize(size=25)) # pins the schema, cuts into jobs
batches.start(batch.id) # open for annotation
batches.jobs(batch.id) # what the annotators get
batches.complete(batch.id) # only once every job is donedraft ──approve──> approved ──start──> in_annotation ──complete──> completed
BATCH_TRANSITIONS in kernel/domain/batch.py is the whole of what is legal; the service
consults it rather than restating it in guards, and anything absent raises
InvalidTransition naming where the batch can go from here.
There is no route back to draft. A batch's schema version is pinned at approval and its
jobs are already partitioned against that pin, so reopening membership would silently
invalidate work already done. Making another batch is cheap; un-freezing one is not.
add_assets and remove_assets raise BatchNotEditable from approved onward. After
approval the batch has been cut into jobs: adding an asset would leave it in no job, and
removing one would leave a job describing work that no longer exists.
Membership is a set - adding an asset the batch already holds changes nothing, and
removing one it does not hold is a no-op. Order is the order assets were first added. Every
asset must belong to the batch's project, else AssetNotFound.
Reading it back is batches.assets(batch_id), which answers with the Asset rows in that
stored order - DatasetService.assets over the trunk, applied to a batch. It is how "what did
that ingest actually gather" is answered, and a member whose asset is not stored is
WorkspaceCorrupt rather than a silently shorter list: batch_asset.asset_id cascades from
asset, so that cannot happen while foreign keys are on, and a batch quietly holding less than
it says is worse than a refusal.
Excluding an asset after approval is a different act: it is marked skipped, a per-asset
progress decision the record keeps rather than a membership edit that erases it. Somebody
decided not to label that asset, and that decision is worth more than a tidy list. The
progress states themselves belong to the job service - see jobs.md.
Batch.schema_version is None while a draft, and set once at approval from the project's
active version. It never follows the active version:
batches.approve(batch.id) # pins version 2
schemas.create_version(project.id, wider) # → version 3
batches.get(batch.id).schema_version # still 2A schema that evolved mid-batch would change the rules under work already in flight, which is 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 has only one.
A version that only widens the contract moves every open batch onto it, in the
same transaction that publishes it (#381). create_version answers with the
version and the batches it moved:
published = schemas.create_version(project.id, [*current, LANE])
published.published.version # 2
published.advanced_batches # every batch that was `approved` or `in_annotation`The safety argument is the whole rule, and it is a construction rather than a
policy: diff_classes answers does an annotation valid under the old version
stay valid under the new one?, and when it answers yes a wider contract cannot
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: 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.
REPINNABLE_STATES is what both routes read. A draft has no pin — approval takes
the active version, which is the new one anyway — and a completed batch's pin is
the record of what its finished work was judged against.
The pin also moves when somebody asks, which is how a narrowing version is crossed:
batches.repin(batch.id) # → pinned to 3, the current active versionThis exists because the common case is adding a class, and without it a label class created
after approval is invisible in every batch already in flight - the annotator would have to
abandon a batch to use the label they just made. What the pin protects is a stable validation
target and jobs partitioned against it; neither is harmed by a wider contract. It does not
protect release reproducibility, which the system already treats as mixed: publish stamps the
manifest with the active version while annotations carry their batch-pinned ones.
The gate is the same classifier create_version uses, asked from the other end
(schemas.md):
| The active version, against the pinned one | What repin does |
|---|---|
Additive (a class added, an optional attribute, a wider select) |
Goes through, no flag |
| Destructive, nothing in this batch labeled under it | DestructiveSchemaChange; retry with allow_destructive=True |
| Destructive, and this batch holds labels under an affected class | SchemaChangeWouldOrphan, and no flag overrides it |
The orphan check is scoped to this batch: only labels judged by this pin are at stake, so a
label written in some other batch does not block a re-pin here. That is the one place this
refusal differs from SchemaService's project-wide one.
Legal only while the batch is approved or in_annotation - REPINNABLE_STATES in
kernel/domain/batch.py. A draft has no pin yet; a completed batch's pin is history, and
rewriting it would rewrite the record rather than the rules. Both refuse with
InvalidTransition, asked through require_state in kernel/domain/transitions.py - the
sibling of require_move for the operations that need the batch to be somewhere rather than
to go somewhere. Re-pinning is one: it moves the pin, not the batch, so it appears in no row
of BATCH_TRANSITIONS and would otherwise be the one legality question asked outside the
funnel.
Re-pinning onto the version already pinned is a no-op: the same batch comes back, nothing is
written and nothing is announced. Annotations already written keep the schema_version they
were stamped with - only new writes are judged against the new pin.
This used to be the annotation page's third call, and is not any more. #233's add a class
without leaving the job was save → create_version → repin, and on that path the change is
additive by construction — so the version now carries the batch along and there is no third
call to make. What is left for this method is the case the gate was always really for: somebody
else narrowed the schema past this batch's pin, and crossing that is a decision about this
batch's labels. See ui.md.
approve cuts the batch into segments, one AnnotationJob each. The partition is always
exact: the segments are pairwise disjoint and their union is the batch.
Both halves are load-bearing. An asset in two jobs is two annotators labeling it without
knowing about each other; an asset in no job is a batch that can never complete, because
completion is derived from its jobs. Both failures are silent, which is why
partition_assets in kernel/domain/partition.py establishes the invariant in one pure
function rather than trusting each caller.
| Strategy | What it does |
|---|---|
SingleJob() |
One job for the whole batch. The default. |
BySize(size=n) |
Jobs of n assets each; the last takes the remainder. |
BySegments(segments=...) |
Exactly these segments - checked against the batch. |
BySegments is checked, not trusted: a missing asset, a repeated one, an asset that is not
in the batch, or an empty segment each raise InvalidPartition naming the offending ids. A
caller who wrote the segments out by hand has a concrete list to fix.
An empty batch cannot be approved (EmptyBatch) - it would partition into no jobs at all,
and a batch completes when all its jobs complete, so it could never finish.
Approval is one transaction. A refusal anywhere leaves a draft batch with no task group
and no jobs, never a half-partitioned one.
Approval creates one TaskGroup holding one job per segment. The group is the round of
work; the jobs are its parts:
Batch "city centre" (60 assets)
└─ TaskGroup "round 1"
├─ AnnotationJob assets 1–25
├─ AnnotationJob assets 26–50
└─ AnnotationJob assets 51–60
An asset starts unannotated - unless it already carries labels, in which case it starts
annotated. See Corrections open on the labels that are already there.
A later review round would be a second group beside the first, with no schema change.
batches.complete(batch.id) # BatchNotComplete: 2 of 5 jobs still unfinishedcomplete reads the jobs and refuses if any is outstanding. Derived does not mean automatic
- it means the service recomputes rather than taking the caller's word, because a completed
batch is what lets its annotated assets be promoted into the Dataset. Moving a job to
completedis the job service's business - see jobs.md; this service only reads it.
A completed batch has no exit, so the answer to "this frame is wrong" is a correction
batch - a new batch over the same assets, carrying parent_batch_id back to the one it
corrects. What that batch starts with is the subject of this section, and it is settled
policy (audit G5): the correction opens on the labels the earlier round left.
Nothing is copied to make that happen. An Annotation hangs off its asset_id and nothing
else, so the labels are already on the asset:
parent = batches.create_correction(finished.id, "fix the three bad frames", [frame])
batches.approve(parent.id)
(job,) = batches.jobs(parent.id)
annotations.for_asset(job.id, frame) # the earlier round's boxes, drawn and editableEach of those labels still records the round that wrote it (Annotation.job_id) and the
schema version it was judged against, and being seen by a later job rewrites neither. A label
the correction edits is re-stamped with the correcting job and the correction's own pinned
version; one it leaves alone keeps both.
A seeded asset starts annotated, not unannotated. An asset displaying three boxes
while a gallery filters it under "Unannotated" is a lie, and annotated is also the state
the progress machine can move out of when somebody deletes the last box. The rule reads the
asset, never the lineage - an ordinary batch cut by hand over already-labeled assets is
seeded exactly the same way, because a rule that asked "is this a correction?" would be wrong
about whichever case it had not been written for.
One asset reads differently, and the rule still reads the asset: an asset whose every label is
a model's — whatever an earlier batch made of them — opens pre_labeled, because a second batch
must not be the thing that turns unreviewed predictions promotable. Provenance is the whole
question here; what an earlier round's progress said about the frame is not read at all. A frame
confirmed in one batch therefore opens pre_labeled in a later one cut over it, and is
confirmed again there. An edit keeps a label's provenance — a box a person moved is still a
model's box — so an asset whose labels are all model-made, adjusted or not, opens pre_labeled
in a later batch and is within reach of a replacing run there.
The honest consequence: annotated is in SETTLED_PROGRESS, so a correction whose every
asset seeded annotated can be completed with no edits at all. That is the intended reading -
a correction is opt-in per asset, and the alternative is making somebody re-declare work
nobody disputed.
Promotion moves membership and nothing else, and the replacement semantics fall out of that rather than being implemented on top of it:
| What happens in a correction | What the trunk projects afterwards |
|---|---|
| A box is edited | the edited box, and not also the original |
| A box is deleted | nothing - deletion is expressible |
| A box is added | the addition, beside what was kept |
| An asset is left alone | exactly what the parent round left |
An asset is skipped |
exactly what the parent round left - skipping is no statement, not delete |
Two completed batches over one asset therefore do not accumulate two rounds; there was only ever one set for them to write into. What the trunk holds is whoever wrote last, in whichever order the batches are promoted - defined behaviour rather than a race.
That projection is live, and it is the part that surprises: an edit inside an open batch
reaches the trunk when it is saved, not when the batch is promoted. Membership is what
promotion gates, and an asset already in the trunk needs no second admission for its labels
to move. Snapshotting instead would mean the trunk naming annotations as well as assets,
which is the second source of truth DatasetMember exists to refuse - see
datasets.md.
A Release is unaffected either way. Its manifest is a frozen blob and its hash is the
contract, so correcting the trunk afterwards cannot reach back into one that was already
published - including through verify, which re-reads and re-hashes rather than trusting the
row. That is the immutability hierarchy doing its job: releases are content-immutable,
completed batches are workflow-immutable, the trunk is live.
Still open, deliberately: an asset sitting in several ordinary batches has one progress per job and nothing reconciles them (F14). This policy governs what promotion writes, not how two batches coordinate.
Every BatchOut carries allowed_actions, derived in kernel/domain/capabilities.py from the
same table and named sets this service enforces with - never a second copy of them:
| State | Declares | From |
|---|---|---|
draft |
approve, edit_membership, delete |
BATCH_TRANSITIONS, EDITABLE_STATES, DELETABLE_STATES |
approved |
start, repin, delete |
BATCH_TRANSITIONS, REPINNABLE_STATES, DELETABLE_STATES |
in_annotation |
complete, repin, pre_label, delete |
as above, plus PRE_LABELABLE_STATES |
completed |
promote, create_correction |
PROMOTABLE_STATES, CORRECTABLE_STATES |
Six of the nine change no state at all and so appear in no row of BATCH_TRANSITIONS - which
is why those sets are named rather than written inline. Promotion is the clearest: it moves
assets into the trunk and leaves the batch exactly where it was. pre_label is declared from
the batch's state alone, on complete's precedent - whether the local runtime is installed and
whether the pinned schema has a class a detection can land on are not facts about the batch, and
hiding the control on either ground would leave their refusals nowhere to be shown. A JobOut
declares pre_label on the same precedent, from the job's own openness and the batch's - it is
the declaration a control that launches a run reads, since the run is over a job.
delete is declared last, and it is the one action that ends the batch rather than moving it
along. It was withdrawn in #331, when the rule and BatchService.delete were real but nothing
outside the SDK reached them: allowed_actions is a promise a client is entitled to keep, so
declaring an action nothing can perform obliges every conforming client to offer a control that
cannot work. #376 brought it back with DELETE /batches/{id}, the delete_batch MCP tool and
the two overflow controls, all in one change - which is what the withdrawal asked for. The gate
is DELETABLE_STATES itself, referenced from BATCH_GATES rather than restated, so the
declaration and the refusal can never disagree.
completed is therefore the one state that declares no delete, and no flag lifts it - see
BatchService.delete below.
complete is the one declaration that can still be refused. Completion is derived from the
jobs, and a projection cannot read them, so it is declared wherever the transition table allows
it and answers BatchNotComplete if the work is not done. The alternative - the same batch
declaring differently depending on which endpoint answered - is worse than one honest caveat.
A job of a batch that is in_annotation can ask a text-prompt model to label its untouched
assets - unannotated, and carrying no annotations at all. The job is the unit a run is over: a
batch's frames are partitioned into jobs, so the set of frames a run reaches is a question only a
job answers, and JobAction.PRE_LABEL is declared on an open job of an in_annotation batch.
An asset already pre_labeled - unless the run
is asked to replace, below - annotated, skipped, review_pending or accepted is passed over,
and so is an unannotated one that still carries a person's boxes from an earlier round that was
skipped and then restored - progress alone does not prove untouched, since that sequence deletes
nothing. Either way, a run never writes over what a person did in this job, and it supersedes an
earlier run's own labels only where it is asked to - the second-run paragraph below.
An asset somebody starts working while a run is still going is passed over too, not fatal. The
batch is in_annotation, so that is the ordinary case rather than a race: the run skips it and
keeps going, and the outcome's assets_skipped says how many.
The batch's pinned schema is the prompt. The model is asked for each class the schema declares
that the model's answer can be written as - a class admitting a shape the model produces - the same
class names an annotator would use. A text-prompted detector answers with text decoded from spans
over that prompt rather than a choice from the list, so an answer naming one of the classes,
matched case-insensitively, is written under the schema's own spelling, and an answer naming none
of them - a span that crossed the boundary between two phrases, most often - is discarded rather
than guessed onto either half; the outcome's regions_discarded says how many. A mapped region
whose geometry has no overlap with a measured asset is discarded separately, and
regions_out_of_bounds says how many; unmeasured assets remain eligible. A schema with no such
class is refused up front; see
inference.md.
A class is left out of the prompt for either of two reasons, and both are published. It admits
no shape the model produces (no_producible_geometry); or it declares a required attribute, which
a bare prediction carries no value for (required_attribute). Neither is visible in the counters a
run reports - a schema whose vehicle requires a color completes a run, labels no vehicles, and
the counts say nothing about why - so GET /batches/{id}/pre-label answers both halves before a
run starts: asked_classes is the prompt, and excluded_classes names the rest with every reason
that holds against each. Every class the pinned schema declares appears in exactly one of the two
lists. It takes the connection (?connection_id=), because the prompt is a property of the schema
and the model — a schema of polygon classes is askable of a segmenter and refused for a
detector — and answers produces, the shapes the run will write; the dialog re-reads it when the
model changes. A batch whose schema has no askable class at all is refused with the same
SCHEMA_HAS_NO_DETECTABLE_CLASS the launch answers, rather than reported as an empty prompt.
The plan is a property of the batch's pin and the model, so it is the same for every job of the
batch and the route stays batch-scoped. At a terminal visionset batch pre-label writes the same
two lines to stderr once, before the first job's first forward pass. The MCP tool
get_pre_label_plan answers the same two halves, and there alone the plan also travels in the
outcome: pre_label_job returns it under plan, and pre_label_batch returns one items entry
per job each carrying its own, so an agent that asked for nothing it expected never needs a second
call.
What a run writes is every shape the model produces, unless the request says which. A model
declares the shapes it answers in (ConnectionOut.produces); a model declaring both a box and a
polygon answers one of each for every region it finds, and the run writes one annotation per
emitted region with no pairing between them, so such a run writes both shapes for every detection.
geometries - the request field on both launches, the ?geometries= query on the plan, the MCP
parameter, and a repeatable --geometry at a terminal - narrows that to the shapes named: a region
in any other shape is discarded and counted in regions_discarded, and a class is asked for only
when it admits one of the named shapes, so a polygon-only class leaves the prompt the moment
polygons do. Omitted, every surface behaves as it did before the field existed. The selection is
per run rather than per class, it is checked right after the connection on every surface, and it
is kept on the queued row, so a run claimed later executes what was asked. Naming a shape the
model does not produce is refused before anything runs - 422 GEOMETRY_NOT_PRODUCED, with the
shapes it does answer in - and an empty list is a validation error; neither becomes a run that
writes nothing and reports success. The plan reports the effective set: produces is the
selection when there is one, and excluded_classes moves every class only the left-out shapes
could hold under no_producible_geometry. In the browser a model writing several shapes gets one
ticked checkbox per shape in both pre-label dialogs, the plan re-read on every tick; a model
writing one shape gets no control, and the selector row says what each connection writes before
it is chosen.
What lands enters at pre_labeled, never annotated. Nobody judged it, so it arrives in its
own editable state rather than claiming to be somebody's work - see
annotations.md. It is not
review_pending either: that state is a person's submission, waiting on a reviewer who cannot
edit it in the meantime, and a detector's unreviewed guesses need correcting far more often than
a person's finished work needs a second opinion. One asset is one transaction: its labels and its
move to pre_labeled commit together, so a run that stops midway has either not touched an asset
or fully entered it.
A second run extends the first, and can be asked to redo it. A plain run is a continuation
and nothing else: since without the flag it only writes onto unannotated, running it again
after a partial run, an interruption, or a person handling some assets in the meantime costs
nothing on what already landed. replace_model_labels - the request field, the MCP parameter,
and --replace-model-labels at a terminal - widens that reach to every frame still
pre_labeled: the model's earlier labels there are deleted and the new ones land in the same
transaction, one frame at a time, so no frame is ever briefly bare. A frame anybody edited,
confirmed or skipped in this job is passed over whatever the flag says, and a pre_labeled
frame the second run finds nothing on returns to unannotated. Both the outcome and
pre_label_run carry annotations_replaced, the count of earlier model labels the run
superseded.
visionset.inference.pre_label is the one implementation an SDK caller, the API and MCP
all run - see background-jobs.md for the annotation.pre_label job type
this is queued as over HTTP, and mcp.md for the synchronous pre_label_batch tool.
Over HTTP, the server queues annotation.pre_label because it has a dispatcher. At a terminal,
the same operation runs inline because there is no worker to claim a queued job. Interruption
leaves only whole assets entered: an asset's labels and progress state commit together, and a
later run considers only assets still untouched - plus, where it is asked to replace, those
still pre_labeled.
The job remembers its own run. JobService.latest_pre_label_run reads the queue for the most
recent annotation.pre_label row naming this job - live or settled - and projects it as
PreLabelRun, on ConnectionJob's reasoning: a run outlives the request that launched it, so a
reload, a second tab or a run started at a terminal can only be shown by the job itself saying
so. Counted in assets, the unit this handler works in, and carrying the outcome
prelabel.py's run returns once the job has settled - stopped_early, assets_labeled,
annotations_replaced, regions_discarded, regions_out_of_bounds - so a client can tell a
cancelled run from an untouched job. Derived, never stored, and published on JobOut as
pre_label_run, null where none ever ran; the row names both ids, annotation_job_id for the
job it is over and job_id for the queue row to poll. BatchOut.pre_label_run is the newest run
across the batch's jobs, read from the same queue in one pass by
BatchService.pre_label_runs, and it is what a batch listing shows a pre-labeling… mark from.
Beyond one job, the job is still the unit. POST /batches/{id}/pre-label fans a launch out
over the batch's jobs that are still open, and POST /projects/{id}/batches/pre-label fans out
over the project's batches that are in_annotation - every one of them, or exactly the
batch_ids it names - and within each over that batch's open jobs. Both queue, or join, per job
the same annotation.pre_label row POST /jobs/{job_id}/pre-label queues, and both answer
PreLabelFanOutOut: one item per open job, naming batch_id, batch_name, annotation_job_id,
the queue row under job, and joined where a run was already in flight for that job. A
finished job is passed over, so a batch whose every job is complete contributes no item. Each row
is polled, cancelled and remembered per job, and JobOut.pre_label_run reads it afterwards
exactly as if that job had been launched alone. There is no batch-level or project-level total,
because neither is one run. A batch launch is refused on the batch (409 BATCH_NOT_IN_ANNOTATION
for one that is not open); the project launch is refused whole, up front, and no refusal creates
a row: a named batch outside the project (404), a named batch that is not open or a project with
no open batch (409 BATCH_NOT_IN_ANNOTATION; an empty batch_ids names nothing and is refused
the same way), or any selected batch whose pin holds no class a shape the model produces can be
written as (409 SCHEMA_HAS_NO_DETECTABLE_CLASS, naming the batch, so it can be left out by
name). Assets that sit in no batch are not reached: a model's labels are written through an open
job, so the answer there is to cut a batch first. visionset batch pre-label and visionset project pre-label, and the MCP tools pre_label_batch and pre_label_project, run the same
selection inline, one job after another.
approve and complete each publish a domain event - BatchApproved, carrying
the pin and the job ids, and BatchCompleted, which is the announcement that this batch is now
promotable. Both fire after the transaction commits, so a subscriber never sees a partition
that was rolled back and a subscriber that raises cannot roll one back. start announces
nothing: no work is frozen or finished by it.
Guarded by a parameter, like every destructive operation in the kernel:
batches.delete(batch_id) # ConfirmationRequired
batches.delete(batch_id, confirm=True) # goneThe cascade is the database's: every foreign key into the batch subtree is
ON DELETE CASCADE, so one statement takes the task groups, the jobs, their per-asset
progress and the membership rows.
Annotations are not touched. They hang off assets, not off batches, so deleting the unit of work never deletes the work. Neither the assets nor any blob are touched either - see projects.md for why blobs are never deleted.
A completed batch cannot be deleted, and no flag lifts it.
batches.delete(finished_batch_id, confirm=True) # BatchImmutableDELETABLE_STATES is everything else. BATCH_TRANSITIONS already says a completed batch has no
exit; a delete that emptied one anyway would be an exit through the back door, and it would take
the record with it - which assets were labeled, against which pinned schema version, and which
were deliberately skipped. Promotion, releases and any later correction are all read against
that.
The state check runs before the confirmation one, so the refusal never names confirm=True
as a remedy that would not work.
ConfirmationRequired and BatchImmutable are two errors on purpose, and the second is not a
subclass of the first: a caller catching "you need a flag" and retrying with the flag would
otherwise loop, which is the shape SchemaChangeWouldOrphan already argues for.
visionset batch list --project road-signs
visionset batch approve "$BATCH" --jobs-of 100 [--start]
visionset batch start "$BATCH"
visionset batch pre-label "$BATCH" CONNECTION [--minimum-confidence FLOAT] [--replace-model-labels] [--geometry SHAPE ...]
visionset batch complete "$BATCH" [--promote]
visionset batch promote "$BATCH"Each is one service call, and the listing carries the progress counts because a batch's name and
state do not say whether anybody has started on it. --start and --promote are the next
command made in the same run - approve then start, complete then promote - and
ingest --start is approve (one job) then start on the batch it filled. No transition is
added for them: start needs only approved and promote only completed, so the second call
is legal whenever the first succeeded, and each commits on its own. A refused second step
leaves the first step's state in place and the output names it. The MCP tools carry the same
pair as start and promote parameters, reported back as started and promoted.
--jobs-of N is BySize; with no flag the batch becomes one job. There is no flag for
BySegments, and that is a decision rather than an omission: its own contract is that the caller
has already decided the split, and the only caller that ever holds an exact partition is a program -
which has the SDK and the API. It is also the one partition that can be wrong, with four distinct
refusals, and putting it behind a shell's quoting of tuples of UUIDs is a way to meet all of them.
If it is ever wanted it arrives as --segments FILE.json.
--jobs-of carries min=1 at the Click layer, because BySize.size is gt=0 and a pydantic error
is not a VisionSetError - it would print a traceback rather than a sentence.
There is no batch create, and no membership editing. Both are on the API and on MCP (#281),
and the CLI is the one surface where they have found no caller: a batch is born from an ingest, and
picking an arbitrary subset of assets by pasting UUIDs into a shell is what a gallery exists to do
instead. BatchService still has all four methods; this is a decision about this surface alone.
promote is here rather than under a dataset group because DatasetService.promote takes a batch
id and derives the dataset from it - the same argument its route makes.
The API is this service with the curation half left off.
GET /projects/{id}/batches → 200 BatchPage
GET /batches/{id} → 200 BatchOut, with per-state counts
and the newest pre_label_run of its jobs
POST /batches/{id}/approve { "partition": … } → 200 BatchOut
POST /batches/{id}/start → 200 BatchOut
POST /batches/{id}/repin?allow_destructive= → 200 BatchOut
POST /batches/{id}/complete → 200 BatchOut
GET /batches/{id}/pre-label?connection_id=&geometries= → 200 PreLabelPlanOut, the prompt,
every class left out of it, and
`produces` (the selection, if one)
POST /batches/{id}/pre-label { "connection_id": …, "minimum_confidence": …,
"replace_model_labels": …, "geometries": … }
→ 202 PreLabelFanOutOut, one row per open
job; `POST /jobs/{id}/pre-label` is the
same launch over a single job
POST /batches/{id}/promote → 200 AssetPage, the assets that entered
GET /batches/{id}/jobs → 200 JobPage
GET /batches/{id}/assets?limit=&offset=&progress=&sort= → 200 BatchAssetPage
POST /projects/{id}/batches { "name": …, "asset_ids": […] } → 201 BatchOut
POST /batches/{id}/assets { "asset_ids": […] } → 200 BatchMembershipOut
DELETE /batches/{id}/assets?id=&id= → 200 BatchMembershipOut
DELETE /batches/{id}?confirm=true → 204
progress repeats per state and narrows total with it; sort=confidence puts the frame whose
weakest model label scores lowest first, unscored last, ties in membership order. Each item
carries annotation_count and min_confidence.
A batch is born from an ingest in the ordinary case, and that has not changed: an ingest run
puts what it gathered into a batch (batch_name for a new one, batch_id to join an existing
draft - see ingest.md). What the gallery needed and the API did not have was curating
one by hand, so creation landed with #312 and membership editing with #281, and deleting a
batch landed with #376 - the last of the three, and the one that had to wait for somebody to
ask for it.
DELETE /batches/{id} takes the same ?confirm=true gate every other destructive route takes,
and answers 409 BATCH_IMMUTABLE for a completed batch whether or not the flag is there. The
state check runs first, deliberately: a refusal naming confirm=true as the remedy would be
naming a flag that does not work.
Both routes are draft only, which is what edit_membership in a batch's allowed_actions
declares - read the declaration, do not re-derive it. Past draft they answer 409
BATCH_NOT_EDITABLE, and no flag lifts it: the batch is already cut into jobs against a pinned
schema, so an added asset would belong to no job and a removed one would leave a job describing
work that no longer exists. From then on the way to exclude an asset is to mark it skipped.
The ids go in a body to add and in repeated query parameters to remove - the shape
DELETE /jobs/{id}/annotations chose, because a request body on DELETE is legal in OpenAPI 3.1
and stripped by enough proxies to be a bad thing to require. Both refuse an empty list: an edit
naming no asset would be a 200 that did nothing, which a caller reads as success.
The response is the batch and changed - the ids this call actually wrote:
{ "batch": { "asset_count": 47, "…": "…" }, "changed": ["…", "…"] }Both directions are idempotent, and changed is what makes that legible rather than lossy:
adding an asset the batch already holds, or removing one it does not, is a 200 with
"changed": []. Reporting only the final state would leave "removed 3" and "3 were already
gone" indistinguishable.
Removing membership is not deleting an asset. The asset stays in its project, keeps its annotations and its blob, and stays in every other batch that carries it. Deleting an asset from a project is not an operation this API has at all.
The lifecycle is on the wire, because nothing downstream is reachable without it - an
annotation may only be written into a batch that is in_annotation. Each move keeps the
refusal this service already makes: a non-draft approve and an unapproved start are 409
INVALID_TRANSITION, an empty batch is 409 EMPTY_BATCH, a project with no schema to pin is
404 SCHEMA_NOT_FOUND, and a batch with an unfinished job is 409 BATCH_NOT_COMPLETE.
The asset listing was the API's first paged collection, and the gallery is why; the trunk's
own listing is the other one. limit and offset bound the response, never the read: total is the size of the whole batch, so a
client pages until it has seen total items rather than until the total moves. Each item
carries the job that holds it and where it has got to, both null while the batch is a draft -
because a draft has no jobs. That pair is a projection over assets and jobs, not a new
query; the partition is exact, so every asset appears under exactly one job.
The batch table renders the row of BATCH_TRANSITIONS a batch is on and offers the
one action that row allows - never a revert, because there is none. The version
column is empty until approval, since that is when the pin happens. The approval
dialog offers single and by_size; by_segments is the SDK's and the API's. See
ui.md.