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
13 changes: 10 additions & 3 deletions .agents/skills/domain/batch-lifecycle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,20 @@ description: The settled domain model for batch, job, and asset-progress lifecyc
The kernel tables are authoritative. Quote them; never re-derive them.

- `BATCH_TRANSITIONS` (`kernel/domain/batch.py`): `draft → approved → in_annotation → completed`. **One-way. `completed` has no exit.**
- `JOB_TRANSITIONS` (`kernel/domain/task.py`): `pending → in_progress → completed`.
- `JOB_TRANSITIONS` (`kernel/domain/task.py`): `pending → in_progress → completed`. **One-way; `completed` has no exit either.**
- `ASSET_PROGRESS_TRANSITIONS` (`kernel/domain/task.py`): `unannotated ↔ annotated`, `annotated|unannotated → skipped → unannotated`, `annotated → review_pending → annotated|accepted`, `accepted` terminal.
- Derived sets: `SETTLED_PROGRESS = {annotated, skipped, accepted}` (doesn't block completion), `PROMOTABLE_PROGRESS = {annotated, accepted}` (`skipped` never promotes).
- Derived sets: `OPEN_JOB_STATES = {pending, in_progress}` (the job states with a move left — it gates writes, decision 2), `SETTLED_PROGRESS = {annotated, skipped, accepted}` (doesn't block completion), `PROMOTABLE_PROGRESS = {annotated, accepted}` (`skipped` never promotes).

**All legality checks go through the `require_move` funnel** (`domain/transitions.py`) or a named set consulted beside it. Hand-rolled membership checks outside the funnel are forbidden (the `repin` hand-roll was finding F13 and has been/is being folded in).

## Settled decisions

1. **Forward-only correction; no reopen.** A `completed` batch is immutable as a workflow unit. There is no `completed → *` transition and none will be added. Corrections happen through a **correction batch**: a new batch over a chosen asset set, pinning the active schema at its own approval, carrying lineage to its parent. Decisions #301/#303 ("settled batches re-enterable to edit") are **superseded** — the legitimate intent behind them ("add one more box later") is served by correction batches. UI on a `completed` batch offers view-only entry plus "Create correction batch" (once it exists), never editing.
2. **Annotation writes are gated on progress** (F11, accepted 2026-08): the kernel refuses annotation add/update/delete unless the asset's progress is `unannotated` or `annotated`. Correcting a `skipped`/`review_pending`/`accepted` asset means moving its progress first (where legal) or a correction batch. Silent label-drop at promotion must be impossible.
2. **Annotation writes are gated on progress *and* on the job** (F11, accepted 2026-08; the job dimension #439, shipped in #447): the kernel refuses annotation add/update/delete unless the asset's progress is `unannotated` or `annotated` (`WRITABLE_PROGRESS`, else `AssetNotWritable` / 409 `ASSET_NOT_WRITABLE`) **and** the job it lives in is still open (`OPEN_JOB_STATES`, else `JobFinished` / 409 `JOB_FINISHED`). `JobService.mark` reads the same set, so a finished job freezes progress moves too. Correcting a `skipped`/`review_pending`/`accepted` asset means moving its progress first (where legal) or a correction batch. Silent label-drop at promotion must be impossible.
- **`OPEN_JOB_STATES` is the single source both sides read** — the declaration (`asset_actions`, which takes batch state, job state and progress: three dimensions, none of them optional) and the refusal (`JobService.require_open_job`, called by the three annotation writes and by `mark`). Declaration and refusal cannot disagree because they are not two rules. A finished job's assets therefore declare *nothing*, which is what turns the annotation workspace into a viewer.
- **The batch gate does not imply the job gate.** `JobService.complete` does not complete the batch — `BatchService` derives that separately, when asked — so the ordinary state of a finished job is *inside a batch that is still `in_annotation`*, where the batch gate has nothing to say. Until #439 a completed job went on accepting labels and progress moves, and an MCP test docstring had written the hole down as a rule ("Writing here is legal — the gate is the batch"). Any prose claiming batch state alone gates annotation writes is stale; correct it rather than reasoning from it.
- **Reads pass no lifecycle gate at all**, only membership. A viewer over finished work has to be able to show it.
- Nothing re-opens a job, by decision 1's argument one level down: correcting finished work is a correction batch, never a move.
3. **`completed` batches cannot be deleted** (F12, accepted 2026-08): `BatchService.delete` refuses `completed` regardless of `confirm`. History is not disposable.
4. **Review is a product flow, not an API-only edge** (F24, decided 2026-08): the annotator provides `annotated → review_pending` (submit for review) and the review-side moves (`→ annotated` reject, `→ accepted`). The gallery's "In review" grouping is backed by reachable UI.
5. **Promotion is not a transition.** It is idempotent trunk-union from a `completed` batch; batch state does not change. Its result must be observable (promoted count, trunk membership on a read model) — invisible success is a bug, not a design.
Expand All @@ -29,6 +33,9 @@ The kernel tables are authoritative. Quote them; never re-derive them.
- **None of that is machinery, and that is the point.** An `Annotation` hangs off its `asset_id` and nothing else, so both rounds write into the same set by construction. Do not add supersession links, per-round filtering, or annotation ids on `DatasetMember` — `promote` moves membership and nothing else.
- **Seeding is likewise storage, not a copy.** A correction opens on the labels already on the asset. What approval *does* add is `initial_progress`: an asset that already carries labels starts **`annotated`**, not `unannotated`. The rule reads the asset, never the lineage — an ordinary batch over labeled assets is seeded identically. Its accepted consequence is that a fully seeded correction can be completed with no edits.
- **The projection is live**: an edit inside an open batch reaches the trunk on save, not on promotion. Releases are unaffected — the manifest is a frozen blob.
9. **Closure is a job-level fact, and the workspace reads it at job level** (#439, shipped in #447). Read-only is a *transition*, not only an entry state: finishing a job flips the open workspace in place — same window, every frame, from the re-read declaration. Two rulings come with it, and both look like untidiness to a later reader:
- **Frame-verb gating is job-level, never frame-level.** `Skip` / `Un-skip` and the flow verb stop rendering when the *job* is closed (or its batch is), not when the *frame* is read-only. A `skipped` frame is read-only per-frame and still needs its `Un-skip` — the one edge back out of `skipped` — and the navigation cluster is measured to one width, so a slot that emptied and refilled as somebody walked a mixed job would move the arrows under their cursor. cf. #423, #416.
- **`complete` is the job's declaration, not the last frame's.** `Finish job` stays reachable on a frame that is itself settled and read-only: a job whose last frame is `annotated` is precisely the job that is ready to finish. Withdrawing it along with the frame's verbs strands the job with no way to close it.

## What is NOT settled (do not improvise)

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/frontend/ui-capabilities/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ description: Rules for how the VisionSet frontend decides which actions to offer
## Required patterns

- **Disabled-with-reason over hidden** for actions absent from `allowed_actions` but meaningful in context: render disabled with a tooltip stating why ("Batch is completed — create a correction batch to edit"). Fully hide only actions that are never meaningful on that screen.
- **Read-only is a mode, not an accident.** Any surface that can open in a state where writes are not permitted (annotator on a non-`in_annotation` batch) must render an explicit read-only mode: visible banner, editing tools disabled, no dirty state possible. "Open and let saves fail" is forbidden.
- **Read-only is a mode, not an accident.** Any surface that can open in a state where writes are not permitted (annotator on a non-`in_annotation` batch, on a **finished job**, or on a settled frame) must render an explicit read-only mode: visible banner, editing tools disabled, no dirty state possible. "Open and let saves fail" is forbidden. **It is also a transition, not only an entry state** — a mutation made *in* the window can close that window's writes, and the mode must then arrive in place: same page, no navigation, no reload, every frame, out of the re-read declaration and never out of a `setState` mirror of the rule. The kernel's three dimensions are in the `batch-lifecycle` skill; the browser's job is to render whichever of them answered. — #439
- **Every mutation call site** answers three questions in code review: where does a refusal render? where does success render? what happens to the rejected promise? If any answer is "nowhere", the change is incomplete.
- **A declaration is a cached answer, so invalidate it.** Every mutation that could change what a resource may be asked to do must invalidate that resource's own query — not only its counts or its data. `allowed_actions` goes stale exactly like a number does, and a stale declaration is the cache-side twin of the hand-mirror: the client is again showing something the kernel no longer agrees with. It shipped as a Finish-job button disabled over a job that was finished, because the job's declaration still described a moment when every asset was `unannotated`. — 2026-08 run, T3
- The app-level error boundary and `unhandledrejection` handler are load-bearing; never remove or bypass them.
Expand Down
27 changes: 17 additions & 10 deletions docs/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,9 @@ another object is the service's.

The one exception to the rule in [projects.md](projects.md) and [batches.md](batches.md).
Deleting a box is the ordinary annotator edit loop — draw it, look at it, take it off again —
not the destruction of a lifecycle entity the way deleting a project or a batch is. The batch
gate is the guard instead: once the work closes, nothing here can touch it at all.
not the destruction of a lifecycle entity the way deleting a project or a batch is. The
lifecycle gates are the guard instead: once the work closes — the batch, or just this job —
nothing here can touch it at all.

## Progress follows the annotations — two edges of it

Expand All @@ -175,17 +176,23 @@ applies it inside its own transaction, so labels and progress commit together. I
`JobService.mark`, which would open a second session and write from it while the first is
still open.

## Work only happens inside an open batch
## Work only happens inside an open batch, and inside an open job

Every write requires the job's batch to be `in_annotation`, else `BatchNotInAnnotation` — the
same error `JobService` raises, reached through the same two lookups (`require_job`,
`require_open_batch`) rather than a second copy of the ladder.
Every write requires the job's batch to be `in_annotation`, else `BatchNotInAnnotation`, **and
the job itself to be open** — `OPEN_JOB_STATES`, else `JobFinished` — the same two errors
`JobService` raises, reached through the same three lookups (`require_job`,
`require_open_batch`, `require_open_job`) rather than a second copy of the ladder.

The gate fires **before** the payload is looked at. A write into a closed batch is a bug
whether or not the annotation is also wrong, and hearing about it only sometimes would hide it.
The second gate is not implied by the first and arrived last, in #439. `JobService.complete`
does not complete the batch, so a finished job ordinarily sits inside one that is still
`in_annotation`: the batch gate had nothing to say, and a job whose work was over went on
accepting labels. See [jobs.md](jobs.md) for the set and the reasoning.

Reads are not gated: `get` and `for_asset` work long after the batch closed, because a label
outlives the work that produced it.
The gates fire **before** the payload is looked at. A write into closed work is a bug whether
or not the annotation is also wrong, and hearing about it only sometimes would hide it.

Reads are not gated: `get` and `for_asset` work long after the batch closed or the job
finished, because a label outlives the work that produced it.

## Over HTTP

Expand Down
9 changes: 6 additions & 3 deletions docs/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ though the table alone would call it startable. That dimension is exactly what a
re-deriving the rules from `JOB_TRANSITIONS` would drop. `complete` is refined by
`SETTLED_PROGRESS` as well, which costs nothing: a job carries its own per-asset map.

**Per asset, inside an `in_annotation` batch:**
**Per asset, inside an open job in an `in_annotation` batch:**

| Progress | Declares |
| --- | --- |
Expand All @@ -185,10 +185,13 @@ re-deriving the rules from `JOB_TRANSITIONS` would drop. `complete` is refined b
| `accepted` | *nothing* |

Anywhere else — a draft, an approved batch, a completed one — every asset declares nothing,
because nothing may be written into a batch nobody opened or one that has closed.
because nothing may be written into a batch nobody opened or one that has closed. **A finished
job empties the column the same way**, inside a batch that is still open: `asset_actions` reads
`OPEN_JOB_STATES`, so the table above is what an asset says while its job is `pending` or
`in_progress`, and `completed` is *nothing*, whatever the progress column would otherwise allow.

`annotate` is not a progress move: it is the right to add, change or remove labels, which is
`WRITABLE_PROGRESS` and the batch gate together. The five others each name one edge of
`WRITABLE_PROGRESS` and the two lifecycle gates together. The five others each name one edge of
`ASSET_PROGRESS_TRANSITIONS`. Two legal edges deliberately have **no** name — `unannotated ↔
annotated`, the pair an annotation appearing or disappearing makes on its own. They are the
consequence of `annotate`, which is declared; offering either as its own control would mean
Expand Down
5 changes: 3 additions & 2 deletions docs/mcp-walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,8 +379,9 @@ Three things were deliberately **not** changed:
description, so a destructive tool self-authorises in one call. If this surface wants a real gate
it belongs in the server's configuration, out of the agent's reach. Post-beta.
- [#109](https://github.com/Robomous/VisionSet/issues/109) — whether `start_job` earned its place
at all, given that writes are gated on the batch. **Settled: it did not.** The tool is gone and
every write starts a `pending` job, reporting `job_started`. See
at all, given that writes were gated on the batch and not on the job. **Settled: it did not.**
The tool is gone and every write starts a `pending` job, reporting `job_started` — and #439's
job gate does not bring it back, because `pending` is one of the open states. See
[mcp.md](mcp.md#there-is-no-start_job-the-first-write-starts-it).

The limits in [mcp.md](mcp.md) were not re-litigated and none of them caused a failure: ingest and
Expand Down
13 changes: 9 additions & 4 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,12 @@ the first move**. Every tool that writes — the three annotation tools, `set_as
**`job_started`** in its answer, so the move is a fact you are told rather than one that happens
behind you. `job_started` is `false` on every later call.

Only `pending` moves. A job that is already `in_progress` reports no start, a `completed` one is
left alone, and a job whose batch is not `in_annotation` refuses exactly as it always did — the
batch gate is checked first, so a closed batch is not quietly marked as being worked on.
Only `pending` moves. A job that is already `in_progress` reports no start; a `completed` one is
left alone here and then **refused by the write's own gate** — `JobFinished` (409
`JOB_FINISHED`), since #439 — so the auto-start neither re-opens finished work nor hides the
refusal behind an `InvalidTransition` of its own. A job whose batch is not `in_annotation`
refuses exactly as it always did: the batch gate is checked first, so a closed batch is not
quietly marked as being worked on.

`complete_job` starts a job too, which is not redundant: a correction batch cut over
already-labeled assets opens fully settled (see [batches.md](batches.md)), so its job can be
Expand All @@ -150,7 +153,9 @@ both hops still go through the same funnel; the REST API and the CLI keep their
because the annotator page is what drives REST and it has always started a job when a human opens
one, while a CLI's explicitness is its contract. #109 has the measurements: two of #36's twelve
real agent runs labeled a whole job and then had `complete_job` refuse, having had no reason to
start it — writing is gated on the *batch*, so nothing in the loop forced the call until the end.
start it — writing was gated on the *batch* and not on the job, so nothing in the loop forced the
call until the end. #439 has since added a job gate, but it changes none of this: `pending` is an
*open* state, so the first write still starts the job it walks into.

### Datasets, releases and export

Expand Down
14 changes: 11 additions & 3 deletions docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,17 @@ caught it the moment the review moves landed, at 3 of 3 becoming 2 of 3.
#### Read-only is a mode, not an accident

The annotator opens as a **viewer** whenever the frame it is showing does not
declare `annotate` — which the kernel derives from both dimensions at once: the
batch must be `in_annotation` *and* the frame's progress must be in
`WRITABLE_PROGRESS`. One question, both causes.
declare `annotate` — which the kernel derives from all three dimensions at once:
the batch must be `in_annotation`, the job must be in `OPEN_JOB_STATES`, *and*
the frame's progress must be in `WRITABLE_PROGRESS`. One question, three causes.

**And it is a transition, not only a way to open** (#439). Pressing `Finish job`
closes the job under a window that is already open, so the workspace flips to the
viewer *in place* — same page, no navigation, no reload, on every frame of the
job rather than the last one. Nothing on the page computes that: the mutation
invalidates the frames' declarations and the wire's answer has moved, because
`asset_actions` reads the job's state. Before #439 it did not move, and the page
stayed a live editor over work it had just been told was over.

Before this it had no such notion. `batchState` reached the page and was consumed
only by the two auto-start effects, so a `completed` batch opened a fully live
Expand Down
11 changes: 6 additions & 5 deletions frontend/ui-core/src/annotator/AnnotationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1052,11 +1052,12 @@ function Workspace({
* turns on (audit finding F2).
*
* `annotate` is the wire's name for *the right to write labels here at all*,
* and the kernel derives it from both dimensions: the batch must be
* `in_annotation` **and** the frame's progress must be one the labels can still
* move with (`WRITABLE_PROGRESS`, which #304 made a real gate rather than a
* convention). So one question answers both "is this batch closed" and "is this
* frame settled", and neither is re-derived here.
* and the kernel derives it from all three dimensions: the batch must be
* `in_annotation`, the job must still be open (`OPEN_JOB_STATES`, #439), **and**
* the frame's progress must be one the labels can still move with
* (`WRITABLE_PROGRESS`, which #304 made a real gate rather than a convention).
* So one question answers "is this batch closed", "is this job finished" and
* "is this frame settled" alike, and none of the three is re-derived here.
*
* What it replaces: nothing. There was no read-only mode. `batchState` reached
* this component and was consumed **only** by the two auto-start effects, so on
Expand Down
Loading
Loading