Skip to content

Consolidate kernel truth and declare capabilities on the wire (F6, F11, F12, F13, F15) - #304

Merged
JArmandoAnaya merged 8 commits into
mainfrom
feat/t1-capabilities-contract
Aug 4, 2026
Merged

Consolidate kernel truth and declare capabilities on the wire (F6, F11, F12, F13, F15)#304
JArmandoAnaya merged 8 commits into
mainfrom
feat/t1-capabilities-contract

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Remediation task 1 of the 2026-08 checkpoint audit. Kernel + wire only — no frontend component or hook was touched; consuming allowed_actions in the UI is task 2.

Addresses F6 (no capabilities contract), F11 (annotation writes onto settled assets), F12 (completed batch deletable), F13 (repin outside the funnel), F15 (docs), and the structural half of F17.

Part A — the kernel gaps, each its own commit

f2ba90f — every batch-state gate consults a named set in the domain (F13).
repin hand-rolled its REPINNABLE_STATES membership check. require_move could not take it — re-pinning moves the pin, not the batch, so it appears in no row of BATCH_TRANSITIONS — so require_state is its sibling for that shape, funnelling to the same InvalidTransition. The two gates whose refusal is not InvalidTransition keep their own error and consult a named set beside the funnel instead: require_draft reads the new EDITABLE_STATES, DatasetService.promote the new PROMOTABLE_STATES. Behaviour unchanged; only the repin refusal's sentence is reworded. Also completes the docs/batches.md "Over HTTP" block with repin and promote (F15).

83d192f — labels cannot be written onto an asset whose labeling is over (F11).
WRITABLE_PROGRESS ({unannotated, annotated} — exactly what progress_after_annotating moves between) gates add/update/delete; AssetNotWritable → 409 ASSET_NOT_WRITABLE, naming the state. For skipped this closes a real silent loss: the labels were stored and then dropped at promotion, because PROMOTABLE_PROGRESS leaves that state out. Not a BatchNotInAnnotation (different subject, different remedy) and not an InvalidAnnotation (that base is safe to catch precisely because every member is a payload defect; this is a timing defect).

24edc98 — a completed batch cannot be deleted, and no flag lifts it (F12).
DELETABLE_STATES is everything except completed; BatchImmutable → 409. The state check runs before the confirmation one, so the refusal never names confirm=True as a remedy that would not work, and it is deliberately not a subclass of ConfirmationRequired — catch-the-base-and-retry would loop.

Part B — the capabilities projection

67a9ff9allowed_actions on BatchOut, JobOut, BatchAssetOut, across REST, MCP and the CLI's --json. Additive; no field removed or renamed.

kernel/domain/capabilities.py derives every declaration from the same tables and named sets the services enforce with — BATCH_TRANSITIONS decides approve, REPINNABLE_STATES decides repin, WRITABLE_PROGRESS decides annotate. No rule is encoded twice. The only genuinely new thing is the vocabulary (that skipped → unannotated is called restore), carried by Move.origins while the table still decides legality.

0910fd4 — the contract test suite, and the deliverable this task turns on. tests/kernel/test_capabilities.py drives the real services over a real workspace and compares outcome against declaration for every reachable state: sound (declared ⇒ accepted and lands where the name promises), complete (undeclared ⇒ refused, with the two kernel-documented exceptions derived rather than listed), covered (every table edge claimed by exactly one action or deliberately unnamed). 116 cases, enumerated from the tables and from what the kernel can be walked into — never a hand-written list.

Decisions worth reviewing

  • The actions are StrEnums, not bare str. The task specified list[str]; same JSON either way, and the generated TS client gets ("approve" | "start" | …)[] instead of string[] — which is what lets task 2 consume this with no free-string literals. Follows the GeometryType / AssetProgress precedent.
  • A batch's complete is declared from the transition table alone and documented as still refusable. Completion is derived from the jobs, a read two of the three serialization sites do not make. An answer that differed per endpoint would be worse than one honest caveat. A job's complete is refined by SETTLED_PROGRESS, because a job ships its own per-asset map for free.
  • JobOut.of now takes the batch, not a batch id; BatchAssetOut.in_batch and wire.batch_asset take batch_state. Both job actions and every asset action need the batch open — the exact dimension batchState.ts's mirror dropped. Every REST call site already read the batch.
  • Three new named sets in domain/batch.py. Without them capabilities.py would restate state is COMPLETED for promote — the parallel encoding this task exists to remove.

What the tests found

The coverage test failed on its first run and was right: unannotated → annotated is unnamed too, not just annotated → unannotated. Both are what an annotation appearing or disappearing does on its own, so UNNAMED_EDGES is now computed from progress_after_annotating rather than listed beside it, and can only grow if the domain's own rule does.

Mutation-verified in both directions, nine mutations, each turning a named test red:

Mutation Turns red
Drop the batch-state dimension from asset_actions (the F1/F2 drift) …[approved-unannotated-skip], …[completed-annotated-annotate]
Drop it from job_actions …[approved-pending-open-start], test_an_approved_batch_offers_its_jobs_nothing
Drop the SETTLED_PROGRESS refinement …[in_annotation-in_progress-open-complete]
restore claims an origin the table lacks test_every_named_move_is_an_edge_the_table_actually_has
promote declared from the wrong set …[approved-promote], …[completed-promote]
delete declared everywhere (F12 regression) …[completed-delete]
An action loses its name test_every_edge_is_named_by_an_action_or_deliberately_not
Kernel drops the AssetNotWritable gate …[in_annotation-skipped-annotate]
Kernel drops require_open_batch from mark …[approved-unannotated-skip]

Frontend files touched, and why only these

Eight ui-core test modules. Their mocked responses stopped satisfying the generated runtime shape checks (generated/checks.ts, #225) the moment allowed_actions became required — 44 failures, none of them a component defect. src/testing/wire.fixtures.ts holds the server's answer once rather than eight copies of a table; it is a stand-in for the server, excluded from dist/ beside the test files, and documented as something production code may never consult. 418/418 green.

Found, not fixed

  • F14 — no reconciliation for one asset carried by several batches with divergent progress. Out of scope; the batch-lifecycle skill lists it as explicitly unsettled.
  • F17's other half — promotion is still unobservable on any read model. This PR adds the promote capability; recording that it happened is a separate change.
  • BatchImmutable has no route. Batch delete is SDK-only. Mapped in ERROR_RULES anyway, because the exact-correspondence test is what keeps that table honest and an unmapped kernel error would answer 500 the day a route appears.
  • IngestFailure.name is a full server path for a directory ingest, not a basename. Pre-existing, already noted in PR feat(mcp): real tools over the SDK — thirty-three of them, and an agent that can see (#35) #107's body; untouched here.
  • AssetAction.ANNOTATE and the review actions have no UI caller yet. submit_for_review / accept / return_to_annotator name kernel edges that F24 records as reachable only via API/MCP. Declaring them is what task 2 needs to build the review surface; nothing regressed by naming them now.

Also included

b46d85b — the four skills the audit produced, in .agents/skills/, indexed in AGENTS.md (requested by the author mid-task): domain/batch-lifecycle, frontend/ui-capabilities, frontend/information-architecture, process/refactor-protocol. Two new categories; scripts/setup_agents.sh discovers categories, so it needed no change. Run it after pulling to refresh the ignored symlink trees.

Test plan

bash scripts/check.sh green end to end: python tests, ruff, mypy, import contracts (2 kept / 0 broken), frontend build + tests + lint, openapi drift, generated-client drift, mcp tool reference drift, version sync. openapi.json and frontend/ui-core/src/generated/{api,checks}.ts regenerated — three new components, no operation added or moved.

Two pre-existing tests changed because behaviour did, not because they were wrong: test_a_job_completes_once_every_asset_is_settled asserts a whole body and a completed job declares nothing; and the dataset test that deleted a promoted batch now records the stronger fact — a batch that has promoted cannot be deleted at all — with the trunk-independence claim moved onto a second batch.

…domain

`BatchService.repin` hand-rolled its `REPINNABLE_STATES` membership check —
the one legality question in the kernel asked outside `domain/transitions.py`
(audit finding F13). Fold it in.

`require_move` could not take it: re-pinning moves the *pin*, not the batch, so
it appears in no row of `BATCH_TRANSITIONS`. `require_state` is the sibling for
exactly that shape — an operation whose precondition is a named set of states —
and it funnels to the same `InvalidTransition`, because a caller cannot usefully
tell the two apart.

The two gates whose refusal is *not* `InvalidTransition` keep their own error
and wording and consult a named set beside the funnel instead: `require_draft`
reads `EDITABLE_STATES`, `DatasetService.promote` reads `PROMOTABLE_STATES`.
Both were `state is not X` literals. Naming them is what lets something other
than the service ask the same question without spelling the rule a second time —
which is how the browser's copy of these rules drifted from the kernel's.

Behaviour unchanged; only the repin refusal's sentence is reworded.

Also completes the `docs/batches.md` "Over HTTP" block with the shipped `repin`
and `promote` routes (F15).
…s over

`AnnotationService.add`, `update` and `delete` gated on batch state and schema
alone. An asset in `skipped`, `review_pending` or `accepted` accepted the write:
`progress_after_annotating` has no move for those states, so the labels landed
and the progress stayed put, with nothing anywhere saying the two disagreed
(audit finding F11).

For `skipped` that is worse than untidy. `PROMOTABLE_PROGRESS` leaves the state
out, so the work was accepted, stored, and then **silently dropped at
promotion** — the failure mode the settled model says must be impossible.

`WRITABLE_PROGRESS` — exactly the two states `progress_after_annotating` knows
how to move between — is the gate, and `AssetNotWritable` (409
`ASSET_NOT_WRITABLE`) is the refusal, naming the state the asset is in. Not a
`BatchNotInAnnotation`: that one is about the batch and its remedy is to start
it. Not an `InvalidAnnotation` either — catching that base is safe because every
member is a defect in the payload, and this is a defect in the timing.

The remedy is the transition table. `skipped -> unannotated` is the take-it-back
edge; `accepted` has no exit, which is why correcting accepted work needs a new
batch rather than a progress move.

`test_a_decision_somebody_made_is_not_overwritten_by_a_label` encoded the old
behaviour and now asserts the refusal, plus that the write left nothing behind.
`BatchService.delete` had no state guard: a completed batch and all its history
went on `confirm=True` (audit finding F12). `BATCH_TRANSITIONS` already says a
completed batch has no exit — a delete that emptied one anyway was an exit
through the back door.

`DELETABLE_STATES` is everything except `completed`; `BatchImmutable` (409
`BATCH_IMMUTABLE`) is the refusal. The state check runs *before* the
confirmation one, so it never names `confirm=True` as a remedy that would not
work — and it is deliberately not a subclass of `ConfirmationRequired`, because
a caller catching that base and retrying with the flag would loop.

Not routed today (batch delete is SDK-only), and mapped in `ERROR_RULES` anyway:
the exact-correspondence test is what keeps that table honest, and an unmapped
kernel error would answer 500 the day a route appears.
…tecture and refactor-protocol

Four skills the 2026-08 checkpoint audit produced, in the committed
`.agents/skills/{category}/{name}/` layout, indexed in AGENTS.md's available and
auto-invoke tables. Two new categories, `domain/` and `process/`; the setup
script discovers categories, so it needed no change.

- `domain/batch-lifecycle` — the settled batch/job/asset-progress model, to be
  consulted in *any* layer before touching state. Records the decisions this PR
  implements (F11, F12, F13) plus the two that are explicitly not settled.
- `frontend/ui-capabilities` — the frontend renders what the wire declares and
  never computes legality; the banned patterns behind F1–F10.
- `frontend/information-architecture` — the canonical sitemap, and the rule that
  a PR moving a screen updates it in the same PR.
- `process/refactor-protocol` — worktree isolation, scope discipline, testing
  requirements, PR/CI, cleanup.

Run `bash scripts/setup_agents.sh` after pulling to refresh the ignored symlink
trees.
Nothing on the wire said what a resource could be asked to do, so a client had
no answer but to re-derive the kernel's rules — and the browser did exactly
that. A helper in `batchState.ts` described itself as "a mirror of two rows of
the kernel's `ASSET_PROGRESS_TRANSITIONS`", and the mirror drifted by dropping
the batch-state dimension. Two shipped blockers came out of that one omission
(audit findings F6, F1/F2).

`kernel/domain/capabilities.py` answers it, and answers it *from the same tables
and named sets the services consult*: `BATCH_TRANSITIONS` decides `approve`,
`REPINNABLE_STATES` decides `repin`, `WRITABLE_PROGRESS` decides `annotate`. No
rule is encoded twice. What is genuinely new is only the vocabulary — that
`skipped -> unannotated` is called `restore` — and `Move.origins` carries that
naming while the table still decides legality.

`allowed_actions` is published on `BatchOut`, `JobOut` and `BatchAssetOut`
across REST, MCP and the CLI's `--json`. Additive: no field removed or renamed.

Two things the pure function needs and now receives:

- `JobOut.of` takes the **batch**, not a batch id. Both job actions require the
  batch open — the exact dimension the mirror dropped — and every REST call site
  already read the batch. `complete` is refined here rather than caveated, since
  a job carries its own per-asset map and `SETTLED_PROGRESS` is the kernel's own
  extra condition.
- `BatchAssetOut.in_batch` and `wire.batch_asset` take `batch_state`. It is an
  argument and not a field: it belongs to the batch and is published there.

A batch's `complete` is declared from the transition table alone and documented
as still refusable — completion is derived from the jobs, which two of the three
serialization sites do not have in hand, and an answer that differed per
endpoint would be worse than one honest caveat.

The actions are `StrEnum`s rather than bare strings, on the `GeometryType` and
`AssetProgress` precedent. Same JSON either way, and the generated client gets
closed unions (`"approve" | "start" | ...`) instead of `string[]` — which is what
lets the UI task consume this without free-string literals.

`openapi.json` and the generated TypeScript client regenerated (three new
components; no operation added or moved).

Two existing tests changed because behaviour did, not because they were wrong:
`test_a_job_completes_once_every_asset_is_settled` asserts the whole body, and a
completed job declares nothing; and the dataset test that deleted a promoted
batch now records the stronger fact — a batch that has promoted cannot be
deleted at all — with the trunk-independence claim moved onto a second batch.
…s are one rule

The primary deliverable. `tests/kernel/test_capabilities.py` drives the real
services over a real workspace and compares what happened against what was
declared, for every state a resource can reach — so the two halves cannot move
apart without the suite going red.

Three claims, none of them hand-enumerated:

- *sound* — a declared action, invoked, is not refused, and lands the resource
  where its name promises (a call that returned and did nothing does not count);
- *complete* — an undeclared action, invoked, is refused, with the two documented
  exceptions **derived** rather than listed: `JobService.mark`'s no-op when the
  target is the state the asset is already in, and `UNNAMED_EDGES`;
- *covered* — every edge of every table is claimed by exactly one action or falls
  in `UNNAMED_EDGES`, so a new edge cannot arrive with no capability.

The matrices come from the tables and from what the kernel can actually be walked
into (`JOB_SCENARIOS`, `ASSET_SCENARIOS`), never from a hand-written list of
cases. 116 cases today; adding a state, an edge or an action changes that number
with nobody editing a list.

The coverage test found a real gap on its first run: `unannotated -> annotated`
is unnamed too, not just `annotated -> unannotated`. Both are what an annotation
appearing or disappearing does on its own — so `UNNAMED_EDGES` is now *computed
from* `progress_after_annotating` rather than listed beside it, and can only grow
if the domain's own rule does.

Mutation-verified in both directions, nine mutations, each turning a *named* test
red: dropping the batch-state dimension from `asset_actions` or `job_actions`
(the drift that shipped F1/F2), dropping the `SETTLED_PROGRESS` refinement, an
origin the table does not back, `promote` declared from the wrong set, `delete`
declared everywhere (F12), an action losing its name — and, from the enforcement
side, removing the `AssetNotWritable` gate or `require_open_batch` from `mark`.

Also here, because the wire change broke them and only them: the eight ui-core
test modules whose mocked responses now fail the generated runtime shape checks.
`src/testing/wire.fixtures.ts` holds the server's answer once rather than eight
copies of a table — a stand-in for the server, excluded from `dist/` beside the
test files, and explicitly not a rule production code may consult. No component
or hook was touched; consuming `allowed_actions` is task 2.

`docs/api.md`, `docs/batches.md` and `docs/jobs.md` document the contract,
including the one caveat (`complete` on a batch) and the two unnamed edges.
…nly un-offered

`docs/ui.md` described the silent-loss-at-promotion path as something the page
avoids by offering Un-skip. Part A.2 made it unreachable. The page's design is
unchanged and still right — it is now the good path rather than the only guard.
@JArmandoAnaya
JArmandoAnaya enabled auto-merge (squash) August 4, 2026 10:55
`annotator e2e (chromium)` was the one CI job the local check script does not
run — Playwright is CI-only per CONTRIBUTING — and it went red for the same
reason the ui-core unit fixtures did: four specs fulfil `**/api/**` with
hand-written bodies, and `@visionset/ui-core`'s generated runtime shape checks
reject a batch, job or asset payload missing a now-required field. Every screen
errored before rendering, so `annotation-page` never appeared.

`e2e/_wire.ts` holds the server's answer once instead of four copies inline. It
is a second file rather than an import of `ui-core/src/testing/wire.fixtures.ts`
because `frontend/app` resolves ui-core through its `dist/`, and that directory
is deliberately excluded from the shipped build — test support must not ship.
Both files say the same thing in their docstrings: this is a stand-in for the
server, and production code may never consult it.

159/159 green in real chromium locally, plus `pnpm --filter @visionset/app lint`
(eslint + `tsc -p tsconfig.e2e.json`).
@JArmandoAnaya
JArmandoAnaya merged commit 35b9f0e into main Aug 4, 2026
14 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/t1-capabilities-contract branch August 4, 2026 11:14
JArmandoAnaya added a commit that referenced this pull request Aug 4, 2026
#305)

* refactor(ui-core): the client reads capabilities instead of mirroring the kernel

`batchState.ts` carried `canSkip`/`canRestore`, and its own docstring said what
they were: "a mirror of two rows of the kernel's `ASSET_PROGRESS_TRANSITIONS`".
The mirror reproduced the progress dimension and dropped the batch-state one —
`JobService.mark` runs `require_open_batch` first, deliberately, before it even
reaches the no-op check — so on an `approved` or `completed` batch the gallery's
bulk bar drew both buttons enabled over frames the kernel refuses without
looking at their progress at all (audit finding F1).

`allowed_actions` landed on the wire with #304, derived in
`kernel/domain/capabilities.py` from those same tables. So this deletes the
mirrors rather than fixing them: `canSkip`, `canRestore` and `isApprovable` are
gone, and `data/capabilities.ts` is the one seam a screen asks.

Three call sites moved:

- The gallery's approve button, `isApprovable(state)` -> `declares(batch, approve)`.
- The gallery's bulk bar, per-frame `allowed_actions` for the counts. On a batch
  that is closed to writing every list is empty by construction, so instead of
  two zeroed buttons the bar states the batch-level reason once and disables
  them with it. The *selection* stays: choosing a set of frames is the first
  half of making a correction batch out of them.
- `BatchesScreen`'s `Lifecycle` chain, which was a fourth hand-mirror — correct
  today only because those four rows happen to be one-in one-out, and unable to
  express `promote`, which is not a transition at all.

`hasJobs` survives, re-documented and renamed at its call site to
`showsProgress`. It answers whether a draft's documented-zero counts are data,
which is a display question; it used to double as the permission gate, which is
how it came to be true for two states that refuse every write.

Action names are constants (`BATCH_ACTION`, `JOB_ACTION`, `ASSET_ACTION`), so
the wire's vocabulary has one spelling in the client and a free-string literal
cannot be scattered past a rename.

* test(ui-core): the batch-state dimension, asserted across the matrix

The gating tests the old mirror could not have passed. Each fixture puts frames
in a progress state the transition table says is skippable or restorable, and
varies only the *batch* — so a client that reproduces one dimension and drops
the other fails on the second scenario and not the first.

`assetActions` in both fixture modules now takes the batch state, because that
is what the server does: `asset_actions` returns `[]` for every frame of a batch
that is not `in_annotation`, whatever the frame's own progress is. A mock that
declared actions a real server would withhold is a mock that lies about the
thing under test.

In chromium as well as vitest, on the `refactor-protocol` rule: the claim is
about what a person can press, and a `disabled` attribute jsdom reports is not
quite the statement that a browser will not activate a control — and the
sentence beside it has to be visible.

Refusal rendering, for the mutation this touches: a 409 mid-gesture (the batch
moving under the press, which is the only way a refusal still reaches here) is
asserted to render prose and to keep the kernel's identifier away from the user,
to say one sentence for a rule that refused forty frames, to fall through to the
server's own message for a code the vocabulary has never heard of, and to still
report the frames that did move.
JArmandoAnaya added a commit that referenced this pull request Aug 4, 2026
…#306)

* feat(annotator): the canvas can be opened read-only, and means it

A host cannot make this component read-only from outside. Pointer input goes
straight into the interaction machine, so a greyed-out toolbar and a disabled
Save still leave a drag drawing a box — which is exactly what shipped: an editor
open over a completed batch, drawing work whose every save the kernel refuses.

`readOnly` closes the two entry points, and there are exactly two ways a document
changes from inside here:

- a **primary** press does nothing. Not "selects but does not drag": a press on a
  shape body *is* the start of a move, and a rule with a carve-out is a rule with
  a hole in it. Selection stays reachable from a host's object list, which cannot
  start one.
- a keystroke runs only if it resolves to a `host` action — the rows core declares
  and does not implement. Placed after `resolve` so a claimed chord is still
  swallowed, or `mod+z` would fall through to the browser's own undo.

Panning, the wheel zoom, `mod+0`, hover and the cursor stay live. None of them
touch the document, and a read-only mode you cannot move around in is a
screenshot.

`adapters/react` only — `core/` is untouched, and the store is not frozen: a host
may still drive `store.execute` for its own reasons. This governs input.

* feat(ui-core): the annotator opens as a viewer when nothing can be written

Audit finding F2. The page consulted `batchState` in exactly two places, both
auto-start effects, so on a `completed` batch it opened a fully live editor: the
canvas drew, the palette armed tools, the panel deleted objects, and the first
Save rendered `BATCH_NOT_IN_ANNOTATION` as a raw badge. Navigation was blocked
with it — moving between frames commits first, and the commit rejected — so the
only way out of a page full of work was to undo it.

One derivation carries the whole mode: `annotate` on the frame's own
`allowed_actions`. The kernel builds it from both dimensions, so it answers "is
this batch closed" and "is this frame settled" at once and neither is re-derived
here.

- A banner says it is viewing only and **why**. Two causes with two remedies get
  two sentences: the closed-batch one names the correction batch, which is the
  answer to "then how do I fix this frame". A skipped frame keeps the notice it
  already had, since that one carries the way back on the same bar.
- `readOnly` goes to the canvas, where the guarantee lives, and to the panel,
  which is the *other* road into the document — a live panel beside a read-only
  canvas is a read-only mode with a hole in it. Visibility toggles stay: hiding
  is a view decision the document has no field for.
- The palette is hidden rather than disabled. Every control on it picks a drawing
  tool, so a disabled palette over a canvas that cannot be drawn on explains
  nothing the banner has not already said.

Save, Skip, Un-skip and Accept each read their own declaration. **Accept changes
behaviour and the old gate was wrong**: it offered the press on an `annotated`
frame, and `ASSET_PROGRESS_TRANSITIONS` gives `annotated` three exits, none of
them `accepted`. It was offering a refusal, and the refusal was a silent one.
Reaching `accepted` needs `annotated -> review_pending -> accepted`, whose first
half has no control yet — that is F24, and it is the next task but one.

Finish job now reads the job's declaration instead of counting outstanding
frames, which is the same rule the kernel applies and one fewer place to keep it.

The gallery says it a screen earlier: **View frames** on the header and **View**
on a tile when nothing in the batch declares `annotate`. Same door, honest word.

Add-class (F23): the chain ran save -> publish -> re-pin unconditionally, and
`REPINNABLE_STATES` excludes `completed` — so on a settled batch the version
published and the pin then refused, leaving a new version nobody is judged
against and an error about a step nobody asked for. Three requests are not a
transaction, so the fix is to ask first: `repin` is `null` when the batch will
not take it, the dialog says the batch keeps its version *and* that the publish
still counts, and the button reads "Publish without re-pinning".

* test(ui-core): read-only asserted where a document change could still get through

Two existing scenarios asserted behaviour the kernel forbids, and both are
rewritten rather than patched:

- *"Accept is offered only where the kernel's machine allows the move"* asserted
  Accept was **enabled** on an `annotated` frame. `annotated -> accepted` is not
  an edge; the button was offering a refusal, and a silent one. It now runs
  against `annotated` and `review_pending` and asserts the real answer.
- *"annotating a skipped asset saves"* is now *"a skipped asset cannot be drawn
  on at all"*. #304 made that write a 409, and drawing work that can never be
  kept is the thing read-only exists to prevent.

One scenario changed how it forces re-renders. It drew a box to prove the refused
opening POST is not re-sent on every render — but a batch whose start was refused
is still `approved`, so its frames are read-only now. The zoom is the re-render
that survives, because it moves the viewport and not the document.

New: the completed batch opens as a viewer and says so; a full drag on its canvas
draws nothing and sends nothing (the claim that a greyed-out toolbar could not
make); navigation still works, because nothing can be dirty. Panel: delete, class
reassignment and tag toggle are all out, visibility stays, and a control case
proves the fixture is not simply rendering nothing. Gallery: View on the header
and on a tile, and it still opens.

Both e2e fixture modules thread the batch state into `assetActions`, because that
is what the server does — a mock declaring `annotate` on a completed batch would
never exercise any of this.

* fix(ui-core): a job's declarations go stale exactly like its counts do

Finish job now reads the job's `allowed_actions` instead of counting outstanding
frames — and the job listing was never invalidated after a save, so the button
kept the answer it was given when every asset was still `unannotated`. The
kernel refines `complete` by whether every asset has settled (it can, because a
job carries its own per-asset map), so the *first* save of a job changes what
that job may be asked to do. Nothing told the client.

The full-cycle browser run is the only suite that annotates three assets and then
presses the button, and it is what caught it; `check.sh` does not run Playwright
and the annotator suite never gets a job to the end. Confirmed by putting the
invalidation back and taking it away again — with it, one pass; without it, the
same timeout.

`useSetAssetProgress` gets the same line for the same reason: skipping the last
outstanding frame settles the job.

The general shape, worth stating once: **a declaration is a cached answer, and
every mutation that could change the answer has to invalidate it.** The old count
survived this because `useJobProgress` was already on the refetch list; the
declaration is a different key and needed its own.
JArmandoAnaya added a commit that referenced this pull request Aug 5, 2026
#281) (#335)

* fix(kernel): concurrent batch membership edits stop clobbering each other

`_batch_sync_children` deleted every membership row and re-inserted the
caller's list, so two `add_assets` on one draft lost one of the two and
answered 200 twice — the #302 clobber, one collection over. Unreachable only
because membership has no route; the next commits give it one.

Membership is now written once at creation and afterwards only through two
narrow port writes keyed on `(batch_id, asset_id)`. Insert-if-absent was
rejected as a half-fix: a stale writer would resurrect a member another had
just removed.

Closes #327

* feat(api): batch membership editing is on the wire, with MCP twins

POST and DELETE /batches/{id}/assets, draft-only, refusing with the batch's
own BATCH_NOT_EDITABLE past that — the surface `edit_membership` has declared
since #304 with nothing behind it.

Both answer the batch plus `changed`, the ids the call actually wrote, so an
idempotent edit can report "removed 3" apart from "3 were already gone".
Removing membership deletes nothing: the tool description and the docs both
say so, because an agent reading "delete" would be reaching for something no
tool here can do.

* feat(ui-core): the gallery's bulk bar can take frames out of a draft batch

Capability-gated on the batch's own `edit_membership`, disabled with the
reason past draft. The control is "Remove from batch", not "Delete frames":
a label whose confirmation has to un-teach the word has already misled
somebody, and the frame stays in its project and in every other batch.

Selection is no longer tied to `showsProgress` — that gate hid the bar in the
one state where membership editing is legal. The report counts what the server
removed, not what was asked, because removal is idempotent.

* test(cycle): a draft offers selection, and a retry gets its own project name

Two premises the real-server spec carried: that a draft offers no selection —
the third copy of the claim #281 removes, and the only one where the batch's
allowed_actions is the kernel's own answer — and #314's assumption that
repeatEachIndex alone scopes a run. A retry is the same repetition into the
same persisted workspace, so a genuine failure left its project behind and the
retry died on POST /projects 409, naming the 409 instead of the real failure.

* docs: the README's MCP tool count follows the generated listing

It said 33 against a generated docs/mcp-tools.md saying 37 — nothing gates a
hand-written count beside a generated one, so four tools' worth of drift had
accumulated. This PR adds two more, which is why it is corrected here.
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
…1, F12, F13, F15) (#304)

* refactor(kernel): every batch-state gate consults a named set in the domain

`BatchService.repin` hand-rolled its `REPINNABLE_STATES` membership check —
the one legality question in the kernel asked outside `domain/transitions.py`
(audit finding F13). Fold it in.

`require_move` could not take it: re-pinning moves the *pin*, not the batch, so
it appears in no row of `BATCH_TRANSITIONS`. `require_state` is the sibling for
exactly that shape — an operation whose precondition is a named set of states —
and it funnels to the same `InvalidTransition`, because a caller cannot usefully
tell the two apart.

The two gates whose refusal is *not* `InvalidTransition` keep their own error
and wording and consult a named set beside the funnel instead: `require_draft`
reads `EDITABLE_STATES`, `DatasetService.promote` reads `PROMOTABLE_STATES`.
Both were `state is not X` literals. Naming them is what lets something other
than the service ask the same question without spelling the rule a second time —
which is how the browser's copy of these rules drifted from the kernel's.

Behaviour unchanged; only the repin refusal's sentence is reworded.

Also completes the `docs/batches.md` "Over HTTP" block with the shipped `repin`
and `promote` routes (F15).

* feat(kernel): labels cannot be written onto an asset whose labeling is over

`AnnotationService.add`, `update` and `delete` gated on batch state and schema
alone. An asset in `skipped`, `review_pending` or `accepted` accepted the write:
`progress_after_annotating` has no move for those states, so the labels landed
and the progress stayed put, with nothing anywhere saying the two disagreed
(audit finding F11).

For `skipped` that is worse than untidy. `PROMOTABLE_PROGRESS` leaves the state
out, so the work was accepted, stored, and then **silently dropped at
promotion** — the failure mode the settled model says must be impossible.

`WRITABLE_PROGRESS` — exactly the two states `progress_after_annotating` knows
how to move between — is the gate, and `AssetNotWritable` (409
`ASSET_NOT_WRITABLE`) is the refusal, naming the state the asset is in. Not a
`BatchNotInAnnotation`: that one is about the batch and its remedy is to start
it. Not an `InvalidAnnotation` either — catching that base is safe because every
member is a defect in the payload, and this is a defect in the timing.

The remedy is the transition table. `skipped -> unannotated` is the take-it-back
edge; `accepted` has no exit, which is why correcting accepted work needs a new
batch rather than a progress move.

`test_a_decision_somebody_made_is_not_overwritten_by_a_label` encoded the old
behaviour and now asserts the refusal, plus that the write left nothing behind.

* feat(kernel): a completed batch cannot be deleted, and no flag lifts it

`BatchService.delete` had no state guard: a completed batch and all its history
went on `confirm=True` (audit finding F12). `BATCH_TRANSITIONS` already says a
completed batch has no exit — a delete that emptied one anyway was an exit
through the back door.

`DELETABLE_STATES` is everything except `completed`; `BatchImmutable` (409
`BATCH_IMMUTABLE`) is the refusal. The state check runs *before* the
confirmation one, so it never names `confirm=True` as a remedy that would not
work — and it is deliberately not a subclass of `ConfirmationRequired`, because
a caller catching that base and retrying with the flag would loop.

Not routed today (batch delete is SDK-only), and mapped in `ERROR_RULES` anyway:
the exact-correspondence test is what keeps that table honest, and an unmapped
kernel error would answer 500 the day a route appears.

* docs(skills): add batch-lifecycle, ui-capabilities, information-architecture and refactor-protocol

Four skills the 2026-08 checkpoint audit produced, in the committed
`.agents/skills/{category}/{name}/` layout, indexed in AGENTS.md's available and
auto-invoke tables. Two new categories, `domain/` and `process/`; the setup
script discovers categories, so it needed no change.

- `domain/batch-lifecycle` — the settled batch/job/asset-progress model, to be
  consulted in *any* layer before touching state. Records the decisions this PR
  implements (F11, F12, F13) plus the two that are explicitly not settled.
- `frontend/ui-capabilities` — the frontend renders what the wire declares and
  never computes legality; the banned patterns behind F1–F10.
- `frontend/information-architecture` — the canonical sitemap, and the rule that
  a PR moving a screen updates it in the same PR.
- `process/refactor-protocol` — worktree isolation, scope discipline, testing
  requirements, PR/CI, cleanup.

Run `bash scripts/setup_agents.sh` after pulling to refresh the ignored symlink
trees.

* feat(wire): batches, jobs and batch assets declare what they allow

Nothing on the wire said what a resource could be asked to do, so a client had
no answer but to re-derive the kernel's rules — and the browser did exactly
that. A helper in `batchState.ts` described itself as "a mirror of two rows of
the kernel's `ASSET_PROGRESS_TRANSITIONS`", and the mirror drifted by dropping
the batch-state dimension. Two shipped blockers came out of that one omission
(audit findings F6, F1/F2).

`kernel/domain/capabilities.py` answers it, and answers it *from the same tables
and named sets the services consult*: `BATCH_TRANSITIONS` decides `approve`,
`REPINNABLE_STATES` decides `repin`, `WRITABLE_PROGRESS` decides `annotate`. No
rule is encoded twice. What is genuinely new is only the vocabulary — that
`skipped -> unannotated` is called `restore` — and `Move.origins` carries that
naming while the table still decides legality.

`allowed_actions` is published on `BatchOut`, `JobOut` and `BatchAssetOut`
across REST, MCP and the CLI's `--json`. Additive: no field removed or renamed.

Two things the pure function needs and now receives:

- `JobOut.of` takes the **batch**, not a batch id. Both job actions require the
  batch open — the exact dimension the mirror dropped — and every REST call site
  already read the batch. `complete` is refined here rather than caveated, since
  a job carries its own per-asset map and `SETTLED_PROGRESS` is the kernel's own
  extra condition.
- `BatchAssetOut.in_batch` and `wire.batch_asset` take `batch_state`. It is an
  argument and not a field: it belongs to the batch and is published there.

A batch's `complete` is declared from the transition table alone and documented
as still refusable — completion is derived from the jobs, which two of the three
serialization sites do not have in hand, and an answer that differed per
endpoint would be worse than one honest caveat.

The actions are `StrEnum`s rather than bare strings, on the `GeometryType` and
`AssetProgress` precedent. Same JSON either way, and the generated client gets
closed unions (`"approve" | "start" | ...`) instead of `string[]` — which is what
lets the UI task consume this without free-string literals.

`openapi.json` and the generated TypeScript client regenerated (three new
components; no operation added or moved).

Two existing tests changed because behaviour did, not because they were wrong:
`test_a_job_completes_once_every_asset_is_settled` asserts the whole body, and a
completed job declares nothing; and the dataset test that deleted a promoted
batch now records the stronger fact — a batch that has promoted cannot be
deleted at all — with the trunk-independence claim moved onto a second batch.

* test(kernel): prove the capability declarations and the kernel's gates are one rule

The primary deliverable. `tests/kernel/test_capabilities.py` drives the real
services over a real workspace and compares what happened against what was
declared, for every state a resource can reach — so the two halves cannot move
apart without the suite going red.

Three claims, none of them hand-enumerated:

- *sound* — a declared action, invoked, is not refused, and lands the resource
  where its name promises (a call that returned and did nothing does not count);
- *complete* — an undeclared action, invoked, is refused, with the two documented
  exceptions **derived** rather than listed: `JobService.mark`'s no-op when the
  target is the state the asset is already in, and `UNNAMED_EDGES`;
- *covered* — every edge of every table is claimed by exactly one action or falls
  in `UNNAMED_EDGES`, so a new edge cannot arrive with no capability.

The matrices come from the tables and from what the kernel can actually be walked
into (`JOB_SCENARIOS`, `ASSET_SCENARIOS`), never from a hand-written list of
cases. 116 cases today; adding a state, an edge or an action changes that number
with nobody editing a list.

The coverage test found a real gap on its first run: `unannotated -> annotated`
is unnamed too, not just `annotated -> unannotated`. Both are what an annotation
appearing or disappearing does on its own — so `UNNAMED_EDGES` is now *computed
from* `progress_after_annotating` rather than listed beside it, and can only grow
if the domain's own rule does.

Mutation-verified in both directions, nine mutations, each turning a *named* test
red: dropping the batch-state dimension from `asset_actions` or `job_actions`
(the drift that shipped F1/F2), dropping the `SETTLED_PROGRESS` refinement, an
origin the table does not back, `promote` declared from the wrong set, `delete`
declared everywhere (F12), an action losing its name — and, from the enforcement
side, removing the `AssetNotWritable` gate or `require_open_batch` from `mark`.

Also here, because the wire change broke them and only them: the eight ui-core
test modules whose mocked responses now fail the generated runtime shape checks.
`src/testing/wire.fixtures.ts` holds the server's answer once rather than eight
copies of a table — a stand-in for the server, excluded from `dist/` beside the
test files, and explicitly not a rule production code may consult. No component
or hook was touched; consuming `allowed_actions` is task 2.

`docs/api.md`, `docs/batches.md` and `docs/jobs.md` document the contract,
including the one caveat (`complete` on a batch) and the two unnamed edges.

* docs(ui): the skipped-asset write is refused by the kernel now, not only un-offered

`docs/ui.md` described the silent-loss-at-promotion path as something the page
avoids by offering Un-skip. Part A.2 made it unreachable. The page's design is
unchanged and still right — it is now the good path rather than the only guard.

* test(e2e): the browser stubs declare allowed_actions too

`annotator e2e (chromium)` was the one CI job the local check script does not
run — Playwright is CI-only per CONTRIBUTING — and it went red for the same
reason the ui-core unit fixtures did: four specs fulfil `**/api/**` with
hand-written bodies, and `@visionset/ui-core`'s generated runtime shape checks
reject a batch, job or asset payload missing a now-required field. Every screen
errored before rendering, so `annotation-page` never appeared.

`e2e/_wire.ts` holds the server's answer once instead of four copies inline. It
is a second file rather than an import of `ui-core/src/testing/wire.fixtures.ts`
because `frontend/app` resolves ui-core through its `dist/`, and that directory
is deliberately excluded from the shipped build — test support must not ship.
Both files say the same thing in their docstrings: this is a stand-in for the
server, and production code may never consult it.

159/159 green in real chromium locally, plus `pnpm --filter @visionset/app lint`
(eslint + `tsc -p tsconfig.e2e.json`).
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
#305)

* refactor(ui-core): the client reads capabilities instead of mirroring the kernel

`batchState.ts` carried `canSkip`/`canRestore`, and its own docstring said what
they were: "a mirror of two rows of the kernel's `ASSET_PROGRESS_TRANSITIONS`".
The mirror reproduced the progress dimension and dropped the batch-state one —
`JobService.mark` runs `require_open_batch` first, deliberately, before it even
reaches the no-op check — so on an `approved` or `completed` batch the gallery's
bulk bar drew both buttons enabled over frames the kernel refuses without
looking at their progress at all (audit finding F1).

`allowed_actions` landed on the wire with #304, derived in
`kernel/domain/capabilities.py` from those same tables. So this deletes the
mirrors rather than fixing them: `canSkip`, `canRestore` and `isApprovable` are
gone, and `data/capabilities.ts` is the one seam a screen asks.

Three call sites moved:

- The gallery's approve button, `isApprovable(state)` -> `declares(batch, approve)`.
- The gallery's bulk bar, per-frame `allowed_actions` for the counts. On a batch
  that is closed to writing every list is empty by construction, so instead of
  two zeroed buttons the bar states the batch-level reason once and disables
  them with it. The *selection* stays: choosing a set of frames is the first
  half of making a correction batch out of them.
- `BatchesScreen`'s `Lifecycle` chain, which was a fourth hand-mirror — correct
  today only because those four rows happen to be one-in one-out, and unable to
  express `promote`, which is not a transition at all.

`hasJobs` survives, re-documented and renamed at its call site to
`showsProgress`. It answers whether a draft's documented-zero counts are data,
which is a display question; it used to double as the permission gate, which is
how it came to be true for two states that refuse every write.

Action names are constants (`BATCH_ACTION`, `JOB_ACTION`, `ASSET_ACTION`), so
the wire's vocabulary has one spelling in the client and a free-string literal
cannot be scattered past a rename.

* test(ui-core): the batch-state dimension, asserted across the matrix

The gating tests the old mirror could not have passed. Each fixture puts frames
in a progress state the transition table says is skippable or restorable, and
varies only the *batch* — so a client that reproduces one dimension and drops
the other fails on the second scenario and not the first.

`assetActions` in both fixture modules now takes the batch state, because that
is what the server does: `asset_actions` returns `[]` for every frame of a batch
that is not `in_annotation`, whatever the frame's own progress is. A mock that
declared actions a real server would withhold is a mock that lies about the
thing under test.

In chromium as well as vitest, on the `refactor-protocol` rule: the claim is
about what a person can press, and a `disabled` attribute jsdom reports is not
quite the statement that a browser will not activate a control — and the
sentence beside it has to be visible.

Refusal rendering, for the mutation this touches: a 409 mid-gesture (the batch
moving under the press, which is the only way a refusal still reaches here) is
asserted to render prose and to keep the kernel's identifier away from the user,
to say one sentence for a rule that refused forty frames, to fall through to the
server's own message for a code the vocabulary has never heard of, and to still
report the frames that did move.
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
…#306)

* feat(annotator): the canvas can be opened read-only, and means it

A host cannot make this component read-only from outside. Pointer input goes
straight into the interaction machine, so a greyed-out toolbar and a disabled
Save still leave a drag drawing a box — which is exactly what shipped: an editor
open over a completed batch, drawing work whose every save the kernel refuses.

`readOnly` closes the two entry points, and there are exactly two ways a document
changes from inside here:

- a **primary** press does nothing. Not "selects but does not drag": a press on a
  shape body *is* the start of a move, and a rule with a carve-out is a rule with
  a hole in it. Selection stays reachable from a host's object list, which cannot
  start one.
- a keystroke runs only if it resolves to a `host` action — the rows core declares
  and does not implement. Placed after `resolve` so a claimed chord is still
  swallowed, or `mod+z` would fall through to the browser's own undo.

Panning, the wheel zoom, `mod+0`, hover and the cursor stay live. None of them
touch the document, and a read-only mode you cannot move around in is a
screenshot.

`adapters/react` only — `core/` is untouched, and the store is not frozen: a host
may still drive `store.execute` for its own reasons. This governs input.

* feat(ui-core): the annotator opens as a viewer when nothing can be written

Audit finding F2. The page consulted `batchState` in exactly two places, both
auto-start effects, so on a `completed` batch it opened a fully live editor: the
canvas drew, the palette armed tools, the panel deleted objects, and the first
Save rendered `BATCH_NOT_IN_ANNOTATION` as a raw badge. Navigation was blocked
with it — moving between frames commits first, and the commit rejected — so the
only way out of a page full of work was to undo it.

One derivation carries the whole mode: `annotate` on the frame's own
`allowed_actions`. The kernel builds it from both dimensions, so it answers "is
this batch closed" and "is this frame settled" at once and neither is re-derived
here.

- A banner says it is viewing only and **why**. Two causes with two remedies get
  two sentences: the closed-batch one names the correction batch, which is the
  answer to "then how do I fix this frame". A skipped frame keeps the notice it
  already had, since that one carries the way back on the same bar.
- `readOnly` goes to the canvas, where the guarantee lives, and to the panel,
  which is the *other* road into the document — a live panel beside a read-only
  canvas is a read-only mode with a hole in it. Visibility toggles stay: hiding
  is a view decision the document has no field for.
- The palette is hidden rather than disabled. Every control on it picks a drawing
  tool, so a disabled palette over a canvas that cannot be drawn on explains
  nothing the banner has not already said.

Save, Skip, Un-skip and Accept each read their own declaration. **Accept changes
behaviour and the old gate was wrong**: it offered the press on an `annotated`
frame, and `ASSET_PROGRESS_TRANSITIONS` gives `annotated` three exits, none of
them `accepted`. It was offering a refusal, and the refusal was a silent one.
Reaching `accepted` needs `annotated -> review_pending -> accepted`, whose first
half has no control yet — that is F24, and it is the next task but one.

Finish job now reads the job's declaration instead of counting outstanding
frames, which is the same rule the kernel applies and one fewer place to keep it.

The gallery says it a screen earlier: **View frames** on the header and **View**
on a tile when nothing in the batch declares `annotate`. Same door, honest word.

Add-class (F23): the chain ran save -> publish -> re-pin unconditionally, and
`REPINNABLE_STATES` excludes `completed` — so on a settled batch the version
published and the pin then refused, leaving a new version nobody is judged
against and an error about a step nobody asked for. Three requests are not a
transaction, so the fix is to ask first: `repin` is `null` when the batch will
not take it, the dialog says the batch keeps its version *and* that the publish
still counts, and the button reads "Publish without re-pinning".

* test(ui-core): read-only asserted where a document change could still get through

Two existing scenarios asserted behaviour the kernel forbids, and both are
rewritten rather than patched:

- *"Accept is offered only where the kernel's machine allows the move"* asserted
  Accept was **enabled** on an `annotated` frame. `annotated -> accepted` is not
  an edge; the button was offering a refusal, and a silent one. It now runs
  against `annotated` and `review_pending` and asserts the real answer.
- *"annotating a skipped asset saves"* is now *"a skipped asset cannot be drawn
  on at all"*. #304 made that write a 409, and drawing work that can never be
  kept is the thing read-only exists to prevent.

One scenario changed how it forces re-renders. It drew a box to prove the refused
opening POST is not re-sent on every render — but a batch whose start was refused
is still `approved`, so its frames are read-only now. The zoom is the re-render
that survives, because it moves the viewport and not the document.

New: the completed batch opens as a viewer and says so; a full drag on its canvas
draws nothing and sends nothing (the claim that a greyed-out toolbar could not
make); navigation still works, because nothing can be dirty. Panel: delete, class
reassignment and tag toggle are all out, visibility stays, and a control case
proves the fixture is not simply rendering nothing. Gallery: View on the header
and on a tile, and it still opens.

Both e2e fixture modules thread the batch state into `assetActions`, because that
is what the server does — a mock declaring `annotate` on a completed batch would
never exercise any of this.

* fix(ui-core): a job's declarations go stale exactly like its counts do

Finish job now reads the job's `allowed_actions` instead of counting outstanding
frames — and the job listing was never invalidated after a save, so the button
kept the answer it was given when every asset was still `unannotated`. The
kernel refines `complete` by whether every asset has settled (it can, because a
job carries its own per-asset map), so the *first* save of a job changes what
that job may be asked to do. Nothing told the client.

The full-cycle browser run is the only suite that annotates three assets and then
presses the button, and it is what caught it; `check.sh` does not run Playwright
and the annotator suite never gets a job to the end. Confirmed by putting the
invalidation back and taking it away again — with it, one pass; without it, the
same timeout.

`useSetAssetProgress` gets the same line for the same reason: skipping the last
outstanding frame settles the job.

The general shape, worth stating once: **a declaration is a cached answer, and
every mutation that could change the answer has to invalidate it.** The old count
survived this because `useJobProgress` was already on the refetch list; the
declaration is a different key and needed its own.
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
#281) (#335)

* fix(kernel): concurrent batch membership edits stop clobbering each other

`_batch_sync_children` deleted every membership row and re-inserted the
caller's list, so two `add_assets` on one draft lost one of the two and
answered 200 twice — the #302 clobber, one collection over. Unreachable only
because membership has no route; the next commits give it one.

Membership is now written once at creation and afterwards only through two
narrow port writes keyed on `(batch_id, asset_id)`. Insert-if-absent was
rejected as a half-fix: a stale writer would resurrect a member another had
just removed.

Closes #327

* feat(api): batch membership editing is on the wire, with MCP twins

POST and DELETE /batches/{id}/assets, draft-only, refusing with the batch's
own BATCH_NOT_EDITABLE past that — the surface `edit_membership` has declared
since #304 with nothing behind it.

Both answer the batch plus `changed`, the ids the call actually wrote, so an
idempotent edit can report "removed 3" apart from "3 were already gone".
Removing membership deletes nothing: the tool description and the docs both
say so, because an agent reading "delete" would be reaching for something no
tool here can do.

* feat(ui-core): the gallery's bulk bar can take frames out of a draft batch

Capability-gated on the batch's own `edit_membership`, disabled with the
reason past draft. The control is "Remove from batch", not "Delete frames":
a label whose confirmation has to un-teach the word has already misled
somebody, and the frame stays in its project and in every other batch.

Selection is no longer tied to `showsProgress` — that gate hid the bar in the
one state where membership editing is legal. The report counts what the server
removed, not what was asked, because removal is idempotent.

* test(cycle): a draft offers selection, and a retry gets its own project name

Two premises the real-server spec carried: that a draft offers no selection —
the third copy of the claim #281 removes, and the only one where the batch's
allowed_actions is the kernel's own answer — and #314's assumption that
repeatEachIndex alone scopes a run. A retry is the same repetition into the
same persisted workspace, so a genuine failure left its project behind and the
retry died on POST /projects 409, naming the 409 instead of the real failure.

* docs: the README's MCP tool count follows the generated listing

It said 33 against a generated docs/mcp-tools.md saying 37 — nothing gates a
hand-written count beside a generated one, so four tools' worth of drift had
accumulated. This PR adds two more, which is why it is corrected here.
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.

1 participant