Skip to content

The client reads capabilities instead of mirroring the kernel (F1, F6) - #305

Merged
JArmandoAnaya merged 2 commits into
mainfrom
feat/t2-ui-consumes-capabilities
Aug 4, 2026
Merged

The client reads capabilities instead of mirroring the kernel (F1, F6)#305
JArmandoAnaya merged 2 commits into
mainfrom
feat/t2-ui-consumes-capabilities

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Task 2 of the 2026-08 checkpoint audit remediation. Closes the consumption half of F6 and all of F1; #304 shipped the wire half.

The finding

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 short-circuit — 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, sent one request per frame, took N 409s, and told the user "0 moved, N refused" with every reason destroyed by an empty catch {}.

A drifted mirror is not a bug that gets fixed once. It is a bug that returns every time the kernel grows a precondition the client did not hear about. So the mirrors are deleted rather than corrected.

What changed

New data/capabilities.ts — the one seam a screen asks. declares(resource, action) / declaring(resources, action) read allowed_actions; withheldBecause(state) is the prose for why an action is unavailable. 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.

Deleted: canSkip, canRestore, isApprovable. Kept and re-documented: hasJobs — 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; its call site is renamed showsProgress to stop that happening again.

Three call sites moved off the mirrors: the gallery's approve button, the gallery's bulk bar, and BatchesScreen's Lifecycle chain (a fourth hand-mirror — correct today only because those four rows happen to be one-in one-out, and structurally unable to express promote, which is not a transition at all).

useBulkSetProgress keeps the refusals. BulkProgressResult.failed: number became refusals: Refusal[]. The bar reports moved and why the rest were refused, grouped by code — forty frames refused by one rule is one sentence.

New data/refusals.ts — one code→prose vocabulary, product-wide. This is nominally T4's deliverable; creating it here rather than a local map the next task deletes. T4 still owns folding in FINISH_REFUSALS + the approve-dialog map and migrating the remaining bare-code sites. Two migrated already because this PR touched them: BatchesScreen's promote and start errors.

The behaviour on a closed batch

Selection stays. Choosing a set of frames is the first half of making a correction batch out of them, so the screen does not refuse to let anything be picked; the bar states the batch-level reason once and disables its moves with it — disabled-with-reason over hidden, per the ui-capabilities skill. The completed sentence names the forward-only correction model, which is the difference between a dead end and a next step. T10 wires that sentence to a real action.

Decisions taken autonomously

  • refusals.ts created here rather than in T4 — writing a throwaway local prose map and deleting it one task later is worse than landing the shared one early. Scope noted above.
  • Selection kept on approved/completed — the audit's own note ("selection may remain for future correction-batch flows") and the lower-risk of the two readings: hiding it would have to be un-hidden by T10.
  • hasJobs kept, not deleted — it is not a legality claim, and deleting it would mean re-deriving "is a draft's zero a number" at three call sites.
  • NoInfer<A> on both helpers, so the generic binds to the resource and not to the action literal. Reason is written in the module.

Found, not fixed

  • The annotator still opens fully editable on a completed batch and has no read-only mode (F2) — T3.
  • Annotator Skip/Un-skip/Accept/Finish refusals are still silent (F3, F4) — T4.
  • No app-level error boundary or unhandledrejection handler (F7) — T4.
  • CompleteBatchButton still withholds the press client-side while work is outstanding. That is a UX refinement over a declared action, not a legality claim — batch_actions declares complete from the table alone and documents why (the jobs are a read it cannot do). Left as is; the refusal renders.

Test plan

  • bash scripts/check.shgreen (python 2094, ui-core vitest, all linters, import-linter, openapi + generated-client drift, mcp drift, version sync).
  • pnpm --filter @visionset/app e2e162 chromium scenarios green, including three new ones. Run locally because check.sh does not run Playwright.
  • New: src/data/capabilities.test.ts (24 cases) — the reading, the constants, the withheld sentences, and the grouping.
  • New in gallery.test.tsx: the batch-state matrix (approved/completed offer nothing and send nothing; in_annotation offers both; the sentence names the batch and the way forward; selection survives) and four refusal-rendering cases.
  • New in gallery.spec.ts (chromium): the same gating for both closed states, plus selection surviving on completed.
  • Both fixture modules (testing/wire.fixtures.ts, e2e/_wire.ts) now thread the batch state into assetActions, because that is what the server does — a mock declaring actions a real server would withhold is a mock that lies about the thing under test.

… 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.
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
JArmandoAnaya enabled auto-merge (squash) August 4, 2026 16:44
@JArmandoAnaya
JArmandoAnaya merged commit 67099bf into main Aug 4, 2026
14 checks passed
JArmandoAnaya added a commit that referenced this pull request Aug 4, 2026
* feat(ui-core): a finished batch can be corrected

Audit gap G6, and the end of the forward-only story.

A `completed` batch is immutable as a workflow unit — the kernel gives it no exit
and none is coming. The product's answer to "this frame is wrong" had been a dead
end dressed three different ways: an annotator that opened fully editable and
refused every save (#306 made it a viewer), a bulk bar whose buttons were live
and whose every request 409'd (#305 disabled them with a reason), and a sentence
naming a correction batch that nothing could create.

Each of those named the route onward deliberately, on the grounds that it beats a
friendlier lie. This is what they were waiting for.

**Scope is a choice and the default is the whole batch.** "Correct this batch" is
the ordinary ask, so `all` sends no `asset_ids` at all — the server's own default
is the parent's whole membership, and re-listing forty-eight ids to say so would
be the client telling the API something it already knows. `selection` is the
other ordinary ask, and it is offered only when there *is* one: a scope choice
whose second option covers nothing is a choice between doing something and doing
nothing.

There is deliberately no "filtered set" option. The gallery's segments are a
*view*, and a correction cut from whatever happens to be filtered at the moment
of pressing is a batch nobody can describe afterwards — where a selection is a
thing somebody chose.

`defaultCorrectionName` is pure and exported because it is the part with a
decision in it: a dialog that opened blank would make the common case cost a
sentence of typing, and numbering keeps a chain readable in a listing. The count
comes from the caller, which is already holding the project's batch listing.

`CorrectionOf` renders lineage as one hop — what this batch corrects, not how
deep the chain goes, which is the honest reading of the one field there is. It
renders nothing without a parent: "not a correction of anything" is most batches,
and a badge saying so on every one would be noise on the many to inform the few.

* feat(ui-core): every sentence that named a correction batch now reaches one

Four surfaces, and the point is that they agree.

**Gallery header** — the screen somebody is on when they find the frame that is
wrong. It takes the current selection, so "these three are bad" is one press
rather than a second pass inside the new batch.

**Batches row** — beside Promote rather than in an overflow menu, because a
completed batch has exactly two things left to do and hiding the second is hiding
the one somebody is hunting for. The row also renders what it corrects, so a
chain stays readable when the table is sorted by something else.

**The annotator's read-only banner** links to the gallery rather than opening a
dialog, and that is a product call: creating a batch is a curation act, curation
lives on the batch view, and a second place batches are made is a second place
the rules can drift. The annotator says which way is forward and hands the person
to the screen that owns it.

**The bulk bar's withheld sentence** gains "Create one", and the selection it is
already holding becomes the correction's scope.

The gallery holds the dialog's open state rather than `CorrectionButton` holding
its own, because that screen has two ways in and two independent dialogs would be
two states that can both be true. `CorrectionButton` is controlled when a caller
supplies `open`, uncontrolled otherwise — which keeps the Batches row a one-liner.

Lineage on both sides is derived from the project's batch listing rather than
fetched: how many corrections point at this batch and what this batch points at
are a filter and a lookup over one array the screen's siblings already request.

## Tests

Twelve vitest cases for the control and its lineage — the two scopes and exactly
what each sends, the selection scope absent when nothing is selected, navigation
to the child, a refusal rendered as prose with the dialog staying open (one a
dialog closes over is one nobody reads), and the suggested name.

The round-trip runs in `cycle.spec.ts` against a **real server**, because the two
claims worth making are about the kernel rather than about a stub: that the
parent is genuinely untouched after a correction is cut, and that the child is a
draft carrying the parent's name in its lineage. It re-checks the parent's trunk
count afterwards, so "untouched" means what it says rather than only "still
completed".

Both fixture modules gain `create_correction` on `completed`, because that is what
the server declares — a mock withholding it would describe a batch the API cannot
produce. `capabilities.test.ts`'s "names every action the wire has" guard caught
the missing constant, which is the job it was written for.

One recorder fix: the body-capturing stub in `promote.test.tsx` called
`JSON.parse` on every non-GET, and `promote` sends no body — so an empty string
threw and failed every test in the file for a reason none of them was about.
@JArmandoAnaya
JArmandoAnaya deleted the feat/t2-ui-consumes-capabilities branch August 4, 2026 23:52
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
* feat(ui-core): a finished batch can be corrected

Audit gap G6, and the end of the forward-only story.

A `completed` batch is immutable as a workflow unit — the kernel gives it no exit
and none is coming. The product's answer to "this frame is wrong" had been a dead
end dressed three different ways: an annotator that opened fully editable and
refused every save (#306 made it a viewer), a bulk bar whose buttons were live
and whose every request 409'd (#305 disabled them with a reason), and a sentence
naming a correction batch that nothing could create.

Each of those named the route onward deliberately, on the grounds that it beats a
friendlier lie. This is what they were waiting for.

**Scope is a choice and the default is the whole batch.** "Correct this batch" is
the ordinary ask, so `all` sends no `asset_ids` at all — the server's own default
is the parent's whole membership, and re-listing forty-eight ids to say so would
be the client telling the API something it already knows. `selection` is the
other ordinary ask, and it is offered only when there *is* one: a scope choice
whose second option covers nothing is a choice between doing something and doing
nothing.

There is deliberately no "filtered set" option. The gallery's segments are a
*view*, and a correction cut from whatever happens to be filtered at the moment
of pressing is a batch nobody can describe afterwards — where a selection is a
thing somebody chose.

`defaultCorrectionName` is pure and exported because it is the part with a
decision in it: a dialog that opened blank would make the common case cost a
sentence of typing, and numbering keeps a chain readable in a listing. The count
comes from the caller, which is already holding the project's batch listing.

`CorrectionOf` renders lineage as one hop — what this batch corrects, not how
deep the chain goes, which is the honest reading of the one field there is. It
renders nothing without a parent: "not a correction of anything" is most batches,
and a badge saying so on every one would be noise on the many to inform the few.

* feat(ui-core): every sentence that named a correction batch now reaches one

Four surfaces, and the point is that they agree.

**Gallery header** — the screen somebody is on when they find the frame that is
wrong. It takes the current selection, so "these three are bad" is one press
rather than a second pass inside the new batch.

**Batches row** — beside Promote rather than in an overflow menu, because a
completed batch has exactly two things left to do and hiding the second is hiding
the one somebody is hunting for. The row also renders what it corrects, so a
chain stays readable when the table is sorted by something else.

**The annotator's read-only banner** links to the gallery rather than opening a
dialog, and that is a product call: creating a batch is a curation act, curation
lives on the batch view, and a second place batches are made is a second place
the rules can drift. The annotator says which way is forward and hands the person
to the screen that owns it.

**The bulk bar's withheld sentence** gains "Create one", and the selection it is
already holding becomes the correction's scope.

The gallery holds the dialog's open state rather than `CorrectionButton` holding
its own, because that screen has two ways in and two independent dialogs would be
two states that can both be true. `CorrectionButton` is controlled when a caller
supplies `open`, uncontrolled otherwise — which keeps the Batches row a one-liner.

Lineage on both sides is derived from the project's batch listing rather than
fetched: how many corrections point at this batch and what this batch points at
are a filter and a lookup over one array the screen's siblings already request.

## Tests

Twelve vitest cases for the control and its lineage — the two scopes and exactly
what each sends, the selection scope absent when nothing is selected, navigation
to the child, a refusal rendered as prose with the dialog staying open (one a
dialog closes over is one nobody reads), and the suggested name.

The round-trip runs in `cycle.spec.ts` against a **real server**, because the two
claims worth making are about the kernel rather than about a stub: that the
parent is genuinely untouched after a correction is cut, and that the child is a
draft carrying the parent's name in its lineage. It re-checks the parent's trunk
count afterwards, so "untouched" means what it says rather than only "still
completed".

Both fixture modules gain `create_correction` on `completed`, because that is what
the server declares — a mock withholding it would describe a batch the API cannot
produce. `capabilities.test.ts`'s "names every action the wire has" guard caught
the missing constant, which is the job it was written for.

One recorder fix: the body-capturing stub in `promote.test.tsx` called
`JSON.parse` on every non-GET, and `promote` sends no body — so an empty string
threw and failed every test in the file for a reason none of them was about.
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