Skip to content

Finishing a job leaves the workspace in stale edit mode — must flip to read-only in place #439

Description

@JArmandoAnaya

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:

  1. 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.
  2. 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.
  3. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingfrontendannotator / ui-core / app packageskernelvisionset.kernel — domain, ports, adapters, servicesui-corefrontend/ui-core workuxUser experience / interaction design

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions