feat(process): one confirmation runs a set to its end, within what its card showed; no card for a change the person may not make #113

Open
james.maes wants to merge 6 commits from feat/one-card-for-a-set into develop
pull from: feat/one-card-for-a-set
Owner

What

A person who asks for the same change to a set of records now sees ONE card and confirms once; the run goes to its end. And nobody is offered a card for a change they may not make.

Design note and safety argument: docs/one-card-for-a-set.md (read that first; sections 3 and 4 are this pull request, section 5 is the next one in the stack).

Stacked on #112 (fix/render-widget-follows-data-access): merge that one first; until then this diff shows its commits too.

Part A: one confirmation for a set

  • The card shows the whole run: the process, what every screen says, every input with the value it will have (given, or the default), and the records: how many, a few by name, and every one of them on the card (records).
  • The set is frozen by primary key and by version: read as the person at the proposal, one SHA-256 digest over all the records. The confirmed run reads them again and does not start when one is gone, changed in any value or unreadable. It is sent the keys of the card, never a filter.
  • The confirmed run goes through the process's own init and step operations as the person (what the person's own screen calls, so a host's filters on a person's process requests apply). A screen is passed for the person only when it shows nothing their card did not:
    • a text or a form whose values are the card's: passed;
    • a review: passed when it counts exactly the card's records and, with every record validated first, has no warning and no error (a line that only informs passes for a table's own bulk action, not for a host's process);
    • anything else stops the run with nothing more done, and going on is a new card that shows the screen and why the run came back.
  • The assistant creates and starts no background job. When the application itself hands the work to a durable job, the job is the confirming person's; the run waits for it and the card says how it ended (rows done, failed and why). A job the application created waiting is cancelled, as the person, and the card says the run did not happen: no card says "done" for work that waits, and no waiting job is left to count against assistantMaxActiveJobs.
  • A run keeps what its records were, bounded: the count and a sample of 20 with their values (recordsBefore); the outcome carries counts (ok, failed, warnings) beside the process's own lines.

Part B: no card for what cannot happen

create_record, update_record, delete_record and a table's bulk edit and bulk delete ask the application, as the person, what it tells their own screens about the table (GET /metaData/table/{tableName}). A person who may not make that kind of change is told so and gets no card. The check only refuses; the confirmed change is still decided by the UI API.

What a reviewer should look at

  1. ProcessTools.screenDiffers and reviewDiffers: the only two places that decide a screen is passed for the person.
  2. ProcessTools.start: the stored proposal must say what its card showed (covers), carry the digest, and match it; guard still refuses a name the process keeps for itself.
  3. ProcessTools.job: reads a job, cancels one that waits; never starts one.
  4. This reverses a decision of the previous review ("a process that may run as a job is never run through init"). The design note, section 3.4, says what stands in its place.
  5. Existing security tests that changed, and why: UiApiToolsSecurityTest, WritesFollowThePersonTest (a person the application refuses is now refused before a card; the confirm-time refusal keeps its test with a permission revoked after the card), ProcessToolsTest (a table's bulk edit runs on one confirmation; a later screen that only says something is on the card), chat-tools-before-registry.txt (one description).

Build

Full gated build of this branch's head 18e893b (./mvnw verify: Checkstyle, SpotBugs, PMD, tests, coverage; no gate skipped): BUILD SUCCESS, 737 tests (614 in qbit-assistant), 0 failures, every coverage check met.

What a host must change

Nothing on core's defaults. A host whose application hands runs of 500 records or fewer to a background job sets DurableJobsMetaData.withAssistantAutoStartMaxRows (see UPGRADE.md); without it such a run ends as "did not run".

Not delivered

  • A record lock of scope WRITE cannot be asked before the card: core has no operation that answers "may this person change these records" without changing them (design note, section 7). It is found on confirmation (one record) or at the review (a set).
  • The card of a run still working when the confirmation stopped waiting says "running" and is not updated afterwards; get_run_status reads how it ended.
  • The panel's scrolling list of records (the data is sent; a released panel draws the card's lines).

🤖 Generated with Claude Code

## What A person who asks for the same change to a set of records now sees ONE card and confirms once; the run goes to its end. And nobody is offered a card for a change they may not make. Design note and safety argument: `docs/one-card-for-a-set.md` (read that first; sections 3 and 4 are this pull request, section 5 is the next one in the stack). **Stacked on #112** (`fix/render-widget-follows-data-access`): merge that one first; until then this diff shows its commits too. ## Part A: one confirmation for a set - **The card shows the whole run**: the process, what every screen says, every input with the value it will have (given, or the default), and the records: how many, a few by name, and every one of them on the card (`records`). - **The set is frozen by primary key and by version**: read as the person at the proposal, one SHA-256 digest over all the records. The confirmed run reads them again and does not start when one is gone, changed in any value or unreadable. It is sent the keys of the card, never a filter. - **The confirmed run goes through the process's own `init` and `step` operations as the person** (what the person's own screen calls, so a host's filters on a person's process requests apply). A screen is passed for the person only when it shows nothing their card did not: - a text or a form whose values are the card's: passed; - a review: passed when it counts exactly the card's records and, with every record validated first, has no warning and no error (a line that only informs passes for a table's own bulk action, not for a host's process); - anything else stops the run with nothing more done, and going on is a new card that shows the screen and why the run came back. - **The assistant creates and starts no background job.** When the application itself hands the work to a durable job, the job is the confirming person's; the run waits for it and the card says how it ended (rows done, failed and why). A job the application created waiting is cancelled, as the person, and the card says the run did not happen: no card says "done" for work that waits, and no waiting job is left to count against `assistantMaxActiveJobs`. - **A run keeps what its records were**, bounded: the count and a sample of 20 with their values (`recordsBefore`); the outcome carries counts (ok, failed, warnings) beside the process's own lines. ## Part B: no card for what cannot happen `create_record`, `update_record`, `delete_record` and a table's bulk edit and bulk delete ask the application, as the person, what it tells their own screens about the table (`GET /metaData/table/{tableName}`). A person who may not make that kind of change is told so and gets no card. The check only refuses; the confirmed change is still decided by the UI API. ## What a reviewer should look at 1. `ProcessTools.screenDiffers` and `reviewDiffers`: the only two places that decide a screen is passed for the person. 2. `ProcessTools.start`: the stored proposal must say what its card showed (`covers`), carry the digest, and match it; `guard` still refuses a name the process keeps for itself. 3. `ProcessTools.job`: reads a job, cancels one that waits; never starts one. 4. This reverses a decision of the previous review ("a process that may run as a job is never run through `init`"). The design note, section 3.4, says what stands in its place. 5. Existing security tests that changed, and why: `UiApiToolsSecurityTest`, `WritesFollowThePersonTest` (a person the application refuses is now refused before a card; the confirm-time refusal keeps its test with a permission revoked after the card), `ProcessToolsTest` (a table's bulk edit runs on one confirmation; a later screen that only says something is on the card), `chat-tools-before-registry.txt` (one description). ## Build Full gated build of this branch's head `18e893b` (`./mvnw verify`: Checkstyle, SpotBugs, PMD, tests, coverage; no gate skipped): BUILD SUCCESS, 737 tests (614 in `qbit-assistant`), 0 failures, every coverage check met. ## What a host must change Nothing on core's defaults. A host whose application hands runs of 500 records or fewer to a background job sets `DurableJobsMetaData.withAssistantAutoStartMaxRows` (see `UPGRADE.md`); without it such a run ends as "did not run". ## Not delivered - A record lock of scope WRITE cannot be asked before the card: core has no operation that answers "may this person change these records" without changing them (design note, section 7). It is found on confirmation (one record) or at the review (a set). - The card of a run still working when the confirmation stopped waiting says "running" and is not updated afterwards; `get_run_status` reads how it ended. - The panel's scrolling list of `records` (the data is sent; a released panel draws the card's lines). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes added 6 commits 2026-11-15 11:51:41 +00:00
A confirmed run_process goes through the process's own init and step
operations as the person and passes a screen for them only when it shows
nothing their card did not: its text and inputs were on the card with the
values the process has now, and a review counts exactly the records of the
card and, with every record validated first, has no warning and no error.
Anything else comes back to the person with a new card and nothing stored.

The set is frozen by primary key and by one digest over its records as the
person read them; a set that changed is not run. The card keeps what the
records were (how many, a sample of 20) and lists the whole set by name.

The assistant no longer creates a job: when the application itself hands the
work to a background job, the job is the person's, it is waited for, and its
outcome (rows done, failed and why) is the card's. A job the application
created waiting is cancelled and the run reported as not done.

A change to records of a table is proposed only to a person the application
lets make it (what it tells their own screens); otherwise they are told so
and there is no card.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A person the application refuses is refused before a card where the
application says so beforehand; the confirm-time refusal (a permission that
goes between the card and the confirmation) keeps its tests, with the UI's
own status and message. The tool-set file changes in one description
(run_process), replaced as text.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
docs/one-card-for-a-set.md is the design and the safety argument for parts A
to D; README, UPGRADE, CHANGELOG and the agent notes follow it. UI API
extension assistant 1.11.0 (additive): AssistantActionV1.recordCount,
records and recordsBefore for a run over a set.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
fix(tools): render_widget follows the assistant's data access; no widget is rendered until a host allows it
maven / build (push) Successful in 8m50s
build / maven (push) Successful in 8m56s
50845aa99b
A widget is code of the application and can show rows of any table, so
render_widget could hand a model the rows of a table the assistant may not
read. The tool now refuses, before the application is asked, every widget
the host did not allow by name (withAllowedWidgetNames, empty by default),
one it hid, and one that reads a table the assistant may not read
(new: withWidgetTables).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
# Conflicts:
#	CHANGELOG.md
#	UPGRADE.md
fix(process): a card shows all a screen says; a screen that says more than a card shows is not passed
maven / build (push) Successful in 9m20s
build / maven (push) Successful in 9m20s
18e893b835
The first card showed the first 300 characters of a screen's text and then
passed the screen. It now shows the whole text (up to 2,000 characters), and
a screen whose text is longer is one the card did not show in full: the run
stops there.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Author
Owner

Note on hosted CI: the first run of this branch failed, and its rerun (the same commit, alone on the runner) passed.

The first run shared the runner with the build of the next pull request in the stack. Four existing tests that send several tool calls in one reply (TurnEngineTest.testParallelToolResultsInOneMessage, GoldenEvalTest.golden[7], RedTeamTest.testNoPermissionNoLeak, PresentResultsToolTest.testPersistenceAndReplay) each waited 61 seconds and failed. That is reproducible on develop without this change, with two processors:

./mvnw test -pl qbit-assistant -am -Dtest='TurnEngineTest#testParallelToolResultsInOneMessage' -Djacoco.skip=true -Dsurefire.failIfNoSpecifiedTests=false "-DargLine=-XX:ActiveProcessorCount=2"

Cause, read in code: ToolCallService.execute holds synchronized(admission) while the first offer of a scope waits for a task it submitted to the runtime's virtual-thread executor; a virtual thread that blocks holding or entering a monitor is pinned to its carrier, so with two carriers the task never runs and every call of the batch waits for toolCallTimeoutSeconds. It is not changed here (it is the lock of the tool-call service and deserves its own review); it is written up as a follow-up.

Note on hosted CI: the first run of this branch failed, and its rerun (the same commit, alone on the runner) passed. The first run shared the runner with the build of the next pull request in the stack. Four existing tests that send several tool calls in one reply (`TurnEngineTest.testParallelToolResultsInOneMessage`, `GoldenEvalTest.golden[7]`, `RedTeamTest.testNoPermissionNoLeak`, `PresentResultsToolTest.testPersistenceAndReplay`) each waited 61 seconds and failed. That is reproducible on `develop` without this change, with two processors: ``` ./mvnw test -pl qbit-assistant -am -Dtest='TurnEngineTest#testParallelToolResultsInOneMessage' -Djacoco.skip=true -Dsurefire.failIfNoSpecifiedTests=false "-DargLine=-XX:ActiveProcessorCount=2" ``` Cause, read in code: `ToolCallService.execute` holds `synchronized(admission)` while the first offer of a scope waits for a task it submitted to the runtime's virtual-thread executor; a virtual thread that blocks holding or entering a monitor is pinned to its carrier, so with two carriers the task never runs and every call of the batch waits for `toolCallTimeoutSeconds`. It is not changed here (it is the lock of the tool-call service and deserves its own review); it is written up as a follow-up.
All checks were successful
maven / build (push) Successful in 9m20s
build / maven (push) Successful in 9m20s
You are not authorized to merge this pull request.
This pull request can be merged automatically.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/one-card-for-a-set:feat/one-card-for-a-set
git checkout feat/one-card-for-a-set
Sign in to join this conversation.