Batch membership editing reaches the wire, and stops clobbering itself (#281) - #335
Merged
Conversation
…ther `_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
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.
…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.
…ct 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.
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
enabled auto-merge (squash)
August 5, 2026 05:10
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #281. Closes #327.
edit_membershiphas been a declared capability with nothing behind it since #304 — the sameshape finding F24 had. This gives it a wire surface, an MCP twin and a control, and fixes the
concurrency defect that surface would otherwise have made reachable.
On "the capabilities contract has zero orphans"
The brief asked for that sentence. It would be false, so here is the measured one instead:
sixteen declared actions, fifteen reachable.
I enumerated
BatchAction/JobAction/AssetActionagainst the shipped routes rather thanassuming, and
BatchAction.DELETEis declared on everydraft,approvedandin_annotationbatch with no route, no MCP tool and no control —BatchService.deletehasexisted since M1 and nothing outside the SDK calls it. Filed as #331, with the real
question named: it has no caller and it is genuinely destructive (it takes the jobs and their
progress, not just the grouping), so withdrawing the declaration is as valid an answer as
routing it. Not fixed here — it is a different decision from this one and out of this task's
scope.
Worth recording because the naive version of that check is wrong in the other direction:
restore,submit_for_reviewandreturn_to_annotatorlook like orphans and are not — allthree are served by
PUT /jobs/{id}/assets/{id}/progress, which takes the target value.What is true, and is what #281 asked for:
edit_membershipis no longer a declared actionwithout a surface, and it was the last one with a caller waiting on it.
Part 0 — membership writes stop clobbering each other (#327)
_batch_sync_childrenhad the identical shape #302 fixed for progress:Repository.updatereplaces a whole entity, a
Batchcarries every member, and the child sync deleted everymembership row and re-inserted the caller's list. Two callers adding different assets to
one draft therefore lost one of the two, silently, answering
200twice — mitigated only bythere being no route. This PR removes the mitigation, so the fix is Part 0 rather than a
follow-up. Filed as #327 (nobody had), closed here.
The port gains its narrow non-repository writes beside
set_asset_progress:add_batch_assetsandremove_batch_assets, keyed on(batch_id, asset_id)— the row shapea disjoint write wants, and one that already existed.
No version column, and that is the argument rather than a shortcut. Row existence is
the contended datum: it is its own version stamp, and a column would be a second name for the
same fact. That is also the one place this differs from
set_asset_progress, whoseexpectedguard exists because progress is a value that moves between states.
Insert-if-absent was rejected as a half-fix, and it is the tempting one. A stale writer
would then resurrect a member another writer had just removed. Both directions of the
clobber come from one place — a whole-collection write derived from an out-of-date copy — so
the write is not narrowed, it is removed: membership is written once at creation and
afterwards only by the two narrow writes. Verified, not reasoned about; see the red runs below.
Semantics, per #302's no-op rule: adding a member the batch holds and removing one it does not
are both
200with nothing written. A loser whose target state already holds has nothing leftto do.
Two things this cost, both taken deliberately
Batch.asset_idsis no longer writable throughRepository.update, so a storedmembership cannot be reordered by handing back a permuted list. Nothing offers that, and its
only implementation was the defect.
test_updating_a_batch_does_not_touch_its_membershipasserts the new rule, so the removedcapability reads as a decision rather than as a hole.
Repository.addnow flushes the parent before writing children. It always needed to;it worked by accident, because every child writer began with a
session.execute(delete(...))whose autoflush pushed the parent out first. Removing the batch's delete removed the
accident, and the whole suite answered
FOREIGN KEY constraint failed.The concurrency proof, red first
tests/kernel/test_concurrent_membership.py— three cases, two workspace handles over onefile (two engines, no shared cache: what two processes look like to SQLite), sequenced on a
barrier, no sleeps, every thread joined and asserted dead. The gate sits on the last read
either writer makes before it decides, so each holds a membership that predates the other's
write on every run.
Against the pre-fix code, all three fail with the messages they were written for:
And against the insert-if-absent half-fix — which is why the removal case is a separate test
rather than a symmetry:
The third case has no race between two membership writers at all:
approvereads a wholebatch, sets
state, and saves it, so an add landing between the read and the save wassilently undone. It is the batch's version of #302's
JobService.completefinding, and thereason the fix is "updates stop touching membership" rather than "membership writes take a
lock".
Part 1 — the wire
Both
draft-only, refusing with the batch's own 409BATCH_NOT_EDITABLEpast that — thecapability is not widened anywhere. The ids go in a body to add and in repeated query
parameters to remove, which is
DELETE /jobs/{id}/annotations' shape and its reason. Bothhalves refuse an empty list: an edit naming no asset would be a
200that did nothing, whicha caller reads as success.
The response is the batch and
changed— the ids the call actually wrote. Reportingonly the final state leaves "removed 3" and "3 were already gone" indistinguishable, which is
ui-capabilities' third banned pattern.MembershipChange.changedis named that and notasset_idson purpose: the model carries aBatch, whose ownasset_idsmeans the membershipafterwards, and two fields one dot apart meaning "what moved" and "what is there now" is a
mistake nothing catches at the call site.
MCP twins ship:
add_batch_assetsandremove_batch_assets, bothWRITESand notDESTROYS— removing membership destroys nothing, anddelete_projectis still the onlyDESTROYS. The tool description says so where a model reads it, because an agent that reads"delete" and reaches for it to clean up a project would be reaching for something no tool here
can do.
Does the contract suite now express "declared ⇒ route exists"?
Honestly: not mechanically, and the one line is worth having.
tests/kernel/test_capabilities.pyenumerates the wholeBatchState × BatchActionsquarefrom the kernel's own tables and drives the services, so it proves
edit_membershipdeclared ⇔
add_assetssucceeds — and it structurally cannot see whether a route stands infront of one. That is precisely what #281 was.
A route cannot be enumerated from a
BatchAction, so closing it mechanically would need a newframework (an action → operation table), which this task did not add. What it adds instead is
test_membership_routes_agree_with_what_the_batch_declares, parametrised over all four batchstates: it reads what the batch declares over HTTP and asserts both routes agree with it. Not
derived from the declaration the way the kernel matrix is — but it fails if either side moves
alone, which is the guarantee that matters.
Removal's consequences are asserted rather than argued:
test_removing_from_a_draft_leaves_no_job_behind_because_there_are_nonepins the claim thatmakes
draftthe right gate — a draft has no jobs, so there is nothing downstream describingthe asset going away.
Part 2 — the bulk bar
Naming:
Remove from batch, notDelete frames— in the control, the dialog and thereport. The issue's phrasing is the founder's and the brief says it is not binding; it is the
wrong word by exactly the amount the confirmation would have had to un-teach. The frame stays
in its project, keeps its annotations, and stays in every other batch that holds it. A label
whose own dialog has to say "this does not really delete anything" has already misled somebody.
edit_membership, never onstate === "draft".sentence naming the moment rather than the state, so it reads the same on
approved,in_annotationandcompleted.asset_count, the segmentedcounts and
allowed_actionsall live onBatchOut, and a declaration is a cached answer.Selection is no longer tied to
showsProgress. That gate hid the bar in the one statewhere membership editing is legal — a draft has no jobs, so it has no progress to show, but
what may be picked is a different question. Three tests asserted "a draft offers no
selection", two of them naming
#281in their own comments as the premise: the ui-core unittest, the app e2e scenario, and the real-server cycle spec. All three are rewritten to the new
rule, with the progress moves still asserted dead for their own reason — and the cycle one is
the only place the batch's
allowed_actionsis the kernel's real answer rather than afixture's.
A defect this caught in my own work: the first version cleared the selection on success,
which unmounts the bar at zero selected — destroying the "Removed 2" report in the same commit
that rendered it. The bar now counts frames still in the listing, so a removed frame leaves
the selection on its own and there is nothing to clear.
Mutation-verified
batchKeys.batchinvalidationdeclares(batch, edit_membership)→ astatemirrorremovalIds.lengthinstead ofchanged.lengthOne test-infrastructure fix this run forced
#314's run-scoped project name did not cover a retry, and this PR's own gate is where it
showed.
projectForkeyed oninfo.repeatEachIndexalone; a retry is the same repetition runagain into the same persisted workspace, so a genuine failure left its project behind, the
retry died on
POST /projects → 409, and the report named the 409 — turning one readablefailure into two unreadable ones, which is the exact wall the scoping was added to remove. Now
keyed on
repeatEachIndexandretry, with the reason written where the old claim was.Fixed rather than recorded because it was actively masking this PR's own verification.
Found, not fixed
docs/api.md's route index is missingPOST /projects/{id}/batches— creation shippedwith Correction-batch surfaces: creation, corrections, and asset→batches (G1, G2, G7) #312 and the line was never added. One line, in a list this PR edits, left alone under
the no-adjacent-fixes rule.
total: 48while returning 3 items, which makesuseBatchAssets(an infinite query) page forever against a stub that answers the same threeevery time, accumulating duplicate ids. Fixed, because it is what made a new test fail — it
had only ever been invisible because nothing queried late enough to see it.
README.md's MCP tool count had already drifted — it said 33 while the generateddocs/mcp-tools.mdsaid 37, so nothing gated it and four tools' worth of drift hadaccumulated since docs: README with a real quickstart (no more "coming soon"), install guide, first-dataset tutorial, API and MCP tool docs #68. Corrected to 39 rather than recorded, because this PR is what moves
the number and shipping one I know is wrong is worse than the rule against adjacent fixes.
The underlying gap — a hand-written count beside a generated listing, with no gate between
them — is left alone.
IngestFailure.namefor a directory ingest is still a full server path (cf. kernel: IngestFailure.name leaks a full server path for a directory ingest #317'sneighbourhood); untouched here.
Out of scope, untouched
#282, G5/#315, F14, membership editing beyond
draft, and asset deletion from a project —which is not an operation this API has at all.
cf. #29 — the brief asked whether it is now fully satisfied. It is, and it has been for a
long time: its ask was the whole batch/job endpoint surface, and it was already resolved and
shut before this task started. Nothing here changes its state. Phrased without the keyword on
purpose (see #312 and #318, which each shut #281 from inside a sentence denying it).
No CI or ruleset change
No workflow job was added, removed or renamed, so the
mainruleset's twelve requiredcontexts are untouched. The two browser suites this PR extends already run as
annotator e2e (chromium)andbrowser cycle (chromium).Test plan
bash scripts/check.sh— all three suites, green end to end:New coverage:
tests/kernel/test_concurrent_membership.py(3, red first),tests/kernel/test_batch_service.py(+1 and three rewritten),tests/kernel/test_metadata_store.py(+1),tests/server/test_batches.py(+8 including the four-state contract sweep),tests/mcp/test_batch_tools.py(+4),frontend/ui-core/src/screens/gallery.test.tsx(+9),frontend/app/e2e/gallery.spec.ts(+2),frontend/app/cycle/cycle.spec.ts(extended).