Decision (Armando, 2026-08-08): on Finish, the workspace transitions to
read-only immediately and in place — same window, no navigation away, no
reload — across every frame of the job, not only the last. In read-only:
the edit tool strip is removed entirely, the classes panel is removed, only
the objects panel remains; objects stay selectable for focus only; nothing
is editable — no vertex, bounding-box, or polygon manipulation of any kind;
and frame navigation stays fully functional — arrows, counter, and gallery
keep working. Only editing dies; moving between images does not.
Observed
Press Finish job on the last frame. The job completes — and the workspace stays in
edit mode: the tool strip, the classes panel, navigation and Save and next all
remain, on every frame. Editing still works on the canvas. Finish renders disabled
(Finished) on the last frame.
All the machinery for the correct end state already shipped (#431, the read-only mode).
What is missing is the bridge from the mutation's success to the mode flip.
Where the mode comes from
AnnotationPage derives it from the frame's own declaration —
readOnly = !declares(asset, ASSET_ACTION.annotate) — which is the capabilities
contract working as intended: the frontend renders what the wire declares and never
computes legality itself.
What HEAD does, measured
A TestClient walk over the real kernel — approve, start, annotate every asset, then
POST /jobs/{job_id}/complete — answers:
After POST /jobs/{job_id}/complete |
|
batch.state |
in_annotation |
batch.allowed_actions |
["complete", "repin", "delete"] |
job.state |
completed |
job.allowed_actions |
[] |
every asset's allowed_actions |
["annotate", "skip", "submit_for_review"] |
a subsequent POST /jobs/{job_id}/annotations |
201 Created |
Three consequences:
- Completing a job does not complete its batch.
JobService.complete states it in
its own docstring; BatchService derives batch completion from the jobs when asked,
and the workspace never asks.
- The declaration does not move.
asset_actions gates on batch state and asset
progress and never reads job state, so a completed job's assets keep declaring
annotate.
- The kernel still accepts the write.
AnnotationService gates on
require_open_batch plus WRITABLE_PROGRESS; job state is nowhere in the annotation
write path. Labels written after Finish are stored, so Finished is not yet a
statement about what may still be written.
The workspace is therefore showing a current answer, not a stale one.
useJobTransition's onSuccess already invalidates jobKeys.job(jobId), ["projects"]
and ["batches"] — and ["batches"] prefix-covers the workspace's own asset query key
(["batches", <batchId>, "assets", "job", <jobId>]). The refetch happens; the refetched
answer is identical.
The open question
The decision above is not in doubt. The layer that delivers it is.
Adding invalidation cannot deliver it, because nothing is stale. Computing the mode from
jobState inside the page would be the frontend deciding legality — the one pattern
ui-capabilities bans — and it would leave every other client (CLI, MCP, REST) still
writing into a finished job.
That leaves a wire-side ruling, which is a domain decision rather than an implementation
detail:
Is a completed job closed for writes?
If it is, then asset_actions takes job state (a completed job's assets declare
nothing), and AnnotationService refuses annotation writes through a completed job so
that the declaration and the refusal agree — and the mode then flips through the
invalidation that already exists. Two follow-on questions travel with it:
JobService.mark is likewise ungated on job state, so an asset in a completed job can
currently be un-skipped to unannotated, unsettling a job that says it is finished.
JOB_TRANSITIONS has no completed → in_progress edge, so the freeze is one-way and
correcting the work means a correction batch.
None of that is settled in the batch lifecycle model, so it is recorded here rather than
chosen inline.
Already true at HEAD
Three of the decision's surfaces are shipped and want assertion rather than
implementation:
The gap: the flow verbs (Skip, Save and next, Finish job) render
disabled-with-reason in read-only rather than absent.
cf. #431 (the read-only mode this transitions into), cf. #427 (the Finish tooltip — its
all-resolved, enabled state is the press this starts from).
Observed
Press Finish job on the last frame. The job completes — and the workspace stays in
edit mode: the tool strip, the classes panel, navigation and Save and next all
remain, on every frame. Editing still works on the canvas. Finish renders disabled
(
Finished) on the last frame.All the machinery for the correct end state already shipped (#431, the read-only mode).
What is missing is the bridge from the mutation's success to the mode flip.
Where the mode comes from
AnnotationPagederives it from the frame's own declaration —readOnly = !declares(asset, ASSET_ACTION.annotate)— which is the capabilitiescontract working as intended: the frontend renders what the wire declares and never
computes legality itself.
What HEAD does, measured
A
TestClientwalk over the real kernel — approve, start, annotate every asset, thenPOST /jobs/{job_id}/complete— answers:POST /jobs/{job_id}/completebatch.statein_annotationbatch.allowed_actions["complete", "repin", "delete"]job.statecompletedjob.allowed_actions[]allowed_actions["annotate", "skip", "submit_for_review"]POST /jobs/{job_id}/annotationsThree consequences:
JobService.completestates it inits own docstring;
BatchServicederives batch completion from the jobs when asked,and the workspace never asks.
asset_actionsgates on batch state and assetprogress and never reads job state, so a completed job's assets keep declaring
annotate.AnnotationServicegates onrequire_open_batchplusWRITABLE_PROGRESS; job state is nowhere in the annotationwrite path. Labels written after Finish are stored, so
Finishedis not yet astatement about what may still be written.
The workspace is therefore showing a current answer, not a stale one.
useJobTransition'sonSuccessalready invalidatesjobKeys.job(jobId),["projects"]and
["batches"]— and["batches"]prefix-covers the workspace's own asset query key(
["batches", <batchId>, "assets", "job", <jobId>]). The refetch happens; the refetchedanswer is identical.
The open question
The decision above is not in doubt. The layer that delivers it is.
Adding invalidation cannot deliver it, because nothing is stale. Computing the mode from
jobStateinside the page would be the frontend deciding legality — the one patternui-capabilitiesbans — and it would leave every other client (CLI, MCP, REST) stillwriting into a finished job.
That leaves a wire-side ruling, which is a domain decision rather than an implementation
detail:
Is a completed job closed for writes?
If it is, then
asset_actionstakes job state (a completed job's assets declarenothing), and
AnnotationServicerefuses annotation writes through a completed job sothat the declaration and the refusal agree — and the mode then flips through the
invalidation that already exists. Two follow-on questions travel with it:
JobService.markis likewise ungated on job state, so an asset in a completed job cancurrently be un-skipped to
unannotated, unsettling a job that says it is finished.JOB_TRANSITIONShas nocompleted → in_progressedge, so the freeze is one-way andcorrecting the work means a correction batch.
None of that is settled in the batch lifecycle model, so it is recorded here rather than
chosen inline.
Already true at HEAD
Three of the decision's surfaces are shipped and want assertion rather than
implementation:
‹›and then/mcounter are ungated bythe mode.
The gap: the flow verbs (Skip, Save and next, Finish job) render
disabled-with-reason in read-only rather than absent.
cf. #431 (the read-only mode this transitions into), cf. #427 (the Finish tooltip — its
all-resolved, enabled state is the press this starts from).