Skip to content

feat(server): batch and job endpoints — the contract a third-party client drives (#29) - #101

Merged
JArmandoAnaya merged 1 commit into
mainfrom
feat/29-batch-job-endpoints
Jul 28, 2026
Merged

feat(server): batch and job endpoints — the contract a third-party client drives (#29)#101
JArmandoAnaya merged 1 commit into
mainfrom
feat/29-batch-job-endpoints

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #29.

The API could create a project, version a schema, upload media and run an ingest. It could not
use any of that: no way to approve a batch, hand an annotator work, or store a label. This is
the set that closes the gap, and it is deliberately the external contract — the medical-app
scenario. The official UI gets no private endpoints, so whatever a third-party client can drive,
it drives through exactly these routes.

Sixteen new operations across three route modules; ROUTERS in routes/__init__.py is still the
only wiring edit.

GET    /projects/{project_id}/batches
GET    /batches/{batch_id}                                detail, with per-state counts
POST   /batches/{batch_id}/approve                        partition spec in the body
POST   /batches/{batch_id}/start
POST   /batches/{batch_id}/complete
GET    /batches/{batch_id}/jobs
GET    /batches/{batch_id}/assets?limit=&offset=          paged, with per-asset progress
GET    /jobs/{job_id}
GET    /jobs/{job_id}/progress
POST   /jobs/{job_id}/start
POST   /jobs/{job_id}/complete
GET    /jobs/{job_id}/next?n=
PUT    /jobs/{job_id}/assets/{asset_id}/progress
GET    /jobs/{job_id}/assets/{asset_id}/annotations
POST   /jobs/{job_id}/annotations                         bulk, all-or-nothing
PATCH  /jobs/{job_id}/annotations                         bulk, all-or-nothing
DELETE /jobs/{job_id}/annotations?id=&id=

Ledger

  • No migration. FORMAT_VERSION stays 11, VERSION stays 0.0.1.dev0.
  • No new error class, no new domain model, no new event, nothing added to ERROR_RULES.
  • No dependency change.
  • 1295 tests, up from 1193.
  • openapi.json 73 KB → 148 KB, 19 → 35 operations.

Scope decided up front

Batch create / delete / add-assets / remove-assets are deliberately not here. A batch is born
from an ingest, and curating one out of an arbitrary subset of assets has no caller until M5's
gallery. BatchService still has the methods. The lifecycle is here because nothing downstream
is reachable without it — an annotation may only be written into a batch that is in_annotation.

Three findings worth carrying forward

1. batch_id on the ingest launch, the debt #28 deferred here. The objection was never the
feature: it was that a refusal must not leave a caller holding a 202 pointing at a job row nobody
wrote. It does not — enqueue resolves the batch in the same transaction that inserts the job, so
an unknown batch is a 404 and one past draft is a 409 BATCH_NOT_EDITABLE, both answered
synchronously. Two tests assert the source ends up with zero ingest jobs after each refusal.

2. Paging bounds the response, not the read — and says so. limit/offset land on the batch
asset listing and nowhere else, because that is the one collection that can hold fifty thousand
frames and M5's gallery (#55) is the caller. The kernel has no windowed read, so window() slices
a full list; total stays the size of the whole batch, so a client pages until it has seen total
items. docs/api.md states the cost rather than hiding it. When the read starts to cost, the fix
is a port method and this contract does not move.

3. VisionSetError.index, so a bulk refusal says which item. A typed class-level default — no
constructor change, so test_every_mapped_error_can_be_constructed_with_one_argument still holds
and ERROR_RULES' exact-correspondence test is untouched. AnnotationService sets it on the way
out of its per-item loop through one _blaming(index) context manager, so the five refusals in
_validate stay ignorant of the loop they are called from; server/errors.py publishes it as
detail.index beside the MediaError branch. It is a kernel fact — "the third annotation you gave
me" is about the call — and it is unrecoverable at the boundary, because all-or-nothing means
nothing was written. delete keeps the caller's own position for a repeated id: [a, a, b]
blames index 2, not index 1.

Two traps this hit

A defaulted discriminator is a lie in the contract. type: Literal[GeometryType.BBOX] = ...
emits type as optional, while pydantic reads the tag out of the input to pick a variant and
refuses a payload that omits it. The wire geometry and partition bodies therefore carry no
default
on their discriminator, unlike the domain models they mirror. Verified, then pinned by
test_a_partition_with_no_kind_cannot_pick_a_variant.

from __future__ import annotations collides with a module named annotations. It binds that
name to a __future__._Feature, so importing the submodule shadows it and mypy reports the
assignment. routes/__init__.py drops the future import and says why.

Otherwise this is #27's trap firing again, as expected: AnnotationCreate/AnnotationUpdate and
BatchApprove convert through to_domain() inside a model_validator(mode="after"), because a
pydantic ValidationError raised from a route body is neither a VisionSetError nor a
RequestValidationError and would answer 500 to provenance="model" with no model_ref, a
confidence of 2.0, a zero-area box, or a by_size of 0. And n carries ge=1, since
JobService.next_pending refuses a non-positive count with a bare ValueError.

Kernel changes — two, both small

  • JobService.batch(job_id), over a promoted public batch_of(uow, job) (was _batch_of).
    An AnnotationJob records only its task group, so a client holding a job id had no route to the
    schema version its work is judged against. JobOut.batch_id is what it exists for.
  • VisionSetError.index, above.

The batch asset listing needed no new read: the route projects {asset_id: (job_id, progress)}
off the jobs' own progress maps, which is two existing public reads and no join.

Acceptance

tests/server/test_external_client.py drives a job start to finish over HTTP alone — project →
schema → upload → ingest → poll → approve (by_size, two jobs) → start → next → submit → re-read →
mark → complete job → complete batch → paged listing — using none of the test helpers, so the
whole walk is visible in one function. Every request's status is asserted, not just the final
state. Bulk-submit transactionality is asserted by re-reading after a refusal, not by trusting the
status code.

MCP tools this implies, for #35

list_batches, get_batch, approve_batch, start_batch, complete_batch, list_batch_jobs,
list_batch_assets, get_job, get_job_progress, start_job, complete_job,
next_pending_assets, set_asset_progress, list_asset_annotations, add_annotations,
update_annotations, delete_annotations.

Watch this in #30

tests/architecture/test_tracked_file_sizes.py caps a tracked file at 200 KB and
openapi.json is now 148 KB, running ~4.2 KB per operation — roughly 13 more operations of
headroom. #30 adds dataset, release, export and download-by-hash. Not pre-emptively allowlisted;
this is the warning, not the fix.

…ient drives (#29)

The API could create a project, version a schema, upload media and run an
ingest. It could not use any of it: no way to approve a batch, hand an annotator
work, or store a label. This is the set that closes the gap, and it is
deliberately the external contract — the official UI gets no private endpoints,
so whatever a third-party client can drive, it drives through exactly these
sixteen operations.

Batch create, delete and membership are deliberately absent. A batch is born
from an ingest, and curating one out of an arbitrary subset of assets has no
caller until M5's gallery. The lifecycle is here because nothing downstream is
reachable without it: an annotation may only be written into a batch that is
`in_annotation`.

`batch_id` on the ingest launch arrives with it — the debt #28 deferred. The
objection was never the feature but that a refusal must not leave a caller
holding a 202 pointing at a job row nobody wrote. It does not: `enqueue`
resolves the batch in the same transaction that inserts the job, so an unknown
batch is a 404 and one past `draft` is a 409, both answered synchronously.

Paging lands on the batch asset listing and nowhere else, because that is the
one collection that can hold fifty thousand frames and M5's gallery is the
caller. It bounds the response, not the read — the kernel has no windowed read,
so `total` stays the size of the whole batch and a client pages until it has
seen that many items. `docs/api.md` states the cost rather than hiding it.

Two kernel additions, both because the surface cannot answer a client's question
without them. `JobService.batch` over a promoted public `batch_of`: an
`AnnotationJob` records only its task group, so a client holding a job id had no
route to the schema version its work is judged against. And
`VisionSetError.index` — a typed class-level default, no constructor change —
which `AnnotationService` sets on the way out of its per-item loop so a bulk
refusal can say which annotation caused it. All-or-nothing means nothing was
written, so the position is unrecoverable at the boundary.

Two traps. A defaulted discriminator is a lie in the contract: pydantic reads the
tag out of the input to pick a variant, so a payload that omits it is refused
however the field is declared, while the default emits it as optional. The wire
geometry and partition bodies carry none. And `from __future__ import
annotations` binds that very name, so a package with an `annotations` module
cannot have it — `routes/__init__.py` drops it and says why.

No migration: `FORMAT_VERSION` stays 11, `VERSION` stays 0.0.1.dev0, and nothing
was added to `ERROR_RULES`.
@JArmandoAnaya
JArmandoAnaya merged commit 6beb6c4 into main Jul 28, 2026
3 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/29-batch-job-endpoints branch July 28, 2026 08:13
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
…ient drives (#29) (#101)

The API could create a project, version a schema, upload media and run an
ingest. It could not use any of it: no way to approve a batch, hand an annotator
work, or store a label. This is the set that closes the gap, and it is
deliberately the external contract — the official UI gets no private endpoints,
so whatever a third-party client can drive, it drives through exactly these
sixteen operations.

Batch create, delete and membership are deliberately absent. A batch is born
from an ingest, and curating one out of an arbitrary subset of assets has no
caller until M5's gallery. The lifecycle is here because nothing downstream is
reachable without it: an annotation may only be written into a batch that is
`in_annotation`.

`batch_id` on the ingest launch arrives with it — the debt #28 deferred. The
objection was never the feature but that a refusal must not leave a caller
holding a 202 pointing at a job row nobody wrote. It does not: `enqueue`
resolves the batch in the same transaction that inserts the job, so an unknown
batch is a 404 and one past `draft` is a 409, both answered synchronously.

Paging lands on the batch asset listing and nowhere else, because that is the
one collection that can hold fifty thousand frames and M5's gallery is the
caller. It bounds the response, not the read — the kernel has no windowed read,
so `total` stays the size of the whole batch and a client pages until it has
seen that many items. `docs/api.md` states the cost rather than hiding it.

Two kernel additions, both because the surface cannot answer a client's question
without them. `JobService.batch` over a promoted public `batch_of`: an
`AnnotationJob` records only its task group, so a client holding a job id had no
route to the schema version its work is judged against. And
`VisionSetError.index` — a typed class-level default, no constructor change —
which `AnnotationService` sets on the way out of its per-item loop so a bulk
refusal can say which annotation caused it. All-or-nothing means nothing was
written, so the position is unrecoverable at the boundary.

Two traps. A defaulted discriminator is a lie in the contract: pydantic reads the
tag out of the input to pick a variant, so a payload that omits it is refused
however the field is declared, while the default emits it as optional. The wire
geometry and partition bodies carry none. And `from __future__ import
annotations` binds that very name, so a package with an `annotations` module
cannot have it — `routes/__init__.py` drops it and says why.

No migration: `FORMAT_VERSION` stays 11, `VERSION` stays 0.0.1.dev0, and nothing
was added to `ERROR_RULES`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: batch/job endpoints — approve, partition, "next N pending assets", annotation submission, progress (the third-party-app contract)

1 participant