feat(IDEA-213): queue-job-kinds en gedeelde document-reader (ST-1590) #102

Merged
janpeter merged 15 commits from feat/idea-213-dispatch into main 2026-09-21 19:07:36 +02:00
Owner

Draft — niet mergen. Geopend om CI te laten draaien; IP-10 t/m IP-14 van ST-1590 zijn nog niet uitgevoerd. Hoort bij Scrum4Me #251; de zeven feat/idea-213-dispatch-branches horen bij elkaar.

Inhoud

Job-kinds QUEUE_TASK/QUEUE_REVIEW in labels, kolommen en settings, en review-document-source-server.ts leunt op de gedeelde reader (regel voor regel gedragsbehoudend volgens de review).

Review-fixes (code-review 2026-09-20)

Geen wijzigingen in deze repo. Open MINOR: T-1852 — de settings-UI accepteert voor de queue-kinds onveilige waarden die daarna elke managed job laten falen (faalt dicht).

Verificatie (lokaal)

  • Niet opnieuw gedraaid in deze sessie.

Commits

  • 828694d feat(IDEA-213): pin execution sources and preserve code artifacts

🤖 Generated with Claude Code

Draft — niet mergen. Geopend om CI te laten draaien; IP-10 t/m IP-14 van ST-1590 zijn nog niet uitgevoerd. Hoort bij [Scrum4Me #251](https://git.jp-visser.nl/janpeter/Scrum4Me/pulls/251); de zeven `feat/idea-213-dispatch`-branches horen bij elkaar. ## Inhoud Job-kinds QUEUE_TASK/QUEUE_REVIEW in labels, kolommen en settings, en `review-document-source-server.ts` leunt op de gedeelde reader (regel voor regel gedragsbehoudend volgens de review). ## Review-fixes (code-review 2026-09-20) Geen wijzigingen in deze repo. Open MINOR: T-1852 — de settings-UI accepteert voor de queue-kinds onveilige waarden die daarna elke managed job laten falen (faalt dicht). ## Verificatie (lokaal) - Niet opnieuw gedraaid in deze sessie. ## Commits - `828694d` feat(IDEA-213): pin execution sources and preserve code artifacts 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(IDEA-213): pin execution sources and preserve code artifacts
All checks were successful
CI / Verify (pull_request) Successful in 1m58s
828694d816
fix(IDEA-213): keep workers queue maintenance off managed dispatch messages
All checks were successful
CI / Verify (pull_request) Successful in 2m19s
4669f74ab2
The retention purge archives and deletes in one statement, so merely
selecting a terminal managed thread would have made the row guard refuse
it and roll back every ordinary thread with it. Clearing the queue
tripped over the same guard, and cancel, requeue and fail surfaced a
bare database code.

Retention now skips every thread that holds a managed row, clear leaves
managed rows and their parent, archive changes such a thread only as a
whole once it is terminal, and the mutations refuse before any write
with a pointer to the dispatch route. The real-database fixtures load
the literal Scrum4Me row guards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
IP-12, server side. Messages can now reach the central dispatch service
without ever handing the browser a credential.

- lib/queue/dispatch-client-server.ts: REST client for /dispatch/v1, the
  same wire contract as the MCP client (scrum4me-mcp/src/dispatch/client.ts)
  and the s4m-queue CLI client — same root normalization, same paths, same
  Idempotency-Key header, same bounded read and single transport retry.
  Identity differs: a browser action cannot carry an ApiToken, so each
  request is signed with the §2.2 workers assertion (HMAC-SHA-256, issuer
  scrum4me-workers, audience queue-dispatch-v1, 30s, claims pinning method,
  path and the SHA-256 of the exact bytes sent). No bearer is sent, because
  the service refuses a request carrying both. server-only keeps the key out
  of any client bundle.
- actions/queue-dispatch.ts: submit/get/cancel/recover/evidence and profile
  save/revoke/list. Each action resolves the current admin cookie and then
  re-reads the user from the database, so a cookie minted before a demotion
  or a demo flag buys nothing, and a refused caller produces zero HTTP
  writes rather than a refusal at the far end.
- lib/queue/dispatch-view.ts: the one place that interprets meta.dispatch
  and meta.result, so the wording rules are testable without JSX — before
  the claim no specific job worker is named, and a delivery problem reads
  as "Antwoord nog niet bezorgd", never as a failed assignment.

Cancel, recover, evidence and the profile routes do not exist on the
service yet (IP-13); the client follows the REST matrix in §3 and is
verified against a stubbed fetch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat(IDEA-213): add automatic dispatch and recovery to Messages
All checks were successful
CI / Verify (pull_request) Successful in 2m20s
3ac45e8c98
IP-12, user interface. Messages now has two clearly separate entry points
and can follow, cancel and recover a managed request.

"Naar watcher" is the existing direct path, renamed so it says what it
does: the sender picks a destination and the message is pushed over the
ordinary queue. No dispatch request, no ClaudeJob, and no managed profile
required of that address — a regression test in the messages-view suite
pins that, and a mutation check confirmed it fails if the direct path ever
reaches the dispatch action.

"Automatisch uitvoeren" opens the managed dialog: free assignment, review
or existing task; collapsible requirements and delivery; managed
environment names instead of an absolute working directory. A review is
read-only and artefact-only by contract, so its publication section is
absent rather than disabled, and the pinned revision and hash of every
review document are shown, not just carried. One submission key survives a
double click and a timeout retry, and the dialog closes only once a
request id came back.

The row gains one concrete route, reason and state. Two wordings matter:
before the claim no specific job worker is named, because a reservation
picks a pool rather than a machine; and a delivery problem reads as
"Antwoord nog niet bezorgd", never as "Opdracht mislukt". The queue's own
cancel, requeue, reply and archive controls are not offered on a managed
row — the IP-10 guards refuse them in SQL, so the UI no longer promises
what the database will reject.

Recovery is visible only to the recovery role, names the exact attempt,
incarnation and scope, and has no bare "it stopped" checkbox: evidence is
uploaded as an immutable artefact first and the StopEvidence points at it.
A 409 refreshes and stops. Both guards were mutation-checked.

Worker settings gain Uitvoerprofielen: a new immutable revision or a
revocation, never an edit in place, and a slot table that keeps protocol
fitness, isolation proof, registration and actual occupancy apart, so
presence is never presented as a guarantee. A listener without a managed
profile reads as "Automatische uitvoering niet ingesteld".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat(IDEA-213): keep workers job actions off managed dispatch rows
All checks were successful
CI / Verify (pull_request) Successful in 2m2s
b9a12f93cf
IP-13, workers. Inventory of every workers write to `claude_jobs` and
`agent_message`, and a decision per path.

`claude_jobs` (only two writers outside the orchestrator):
  * `cancelClaudeJobAction` — a row bound to a dispatch request is routed to
    the central API (`getAutomaticDispatch` for the current version, then
    `cancelAutomaticDispatch`) with the authenticated user resolved inside
    `actions/queue-dispatch.ts`; no caller-supplied user id is forwarded. No
    local status write and no local NOTIFY: the projector owns that state.
  * `restartClaudeJobAction` — refused. The central equivalent is recovery
    with stop evidence (§2.4), which a restart button cannot express; the
    refusal falls before the status check so the text stays actionable.
  * `enqueueManualJobAction` — QUEUE_TASK/QUEUE_REVIEW refused. The kind read
    there comes from the drafts table, which `MANUAL_JOB_KINDS` does not bind.

Every guard falls before the write and before the NOTIFY.

`agent_message`: IP-10 already refuses managed rows in cancel/requeue/fail,
archive/unarchive, retention and clear. `replyToAgentMessage` was the gap —
a managed ROOT stays open while the dispatcher works, so the path reached it
and the Scrum4Me row guard only stopped it once the reply INSERT was on the
wire, with its own raw text. The refusal now sits up front, like the rest.

Display: a managed kind references its dispatch request id and title and no
longer falls through to the legacy target order, which would have presented a
neighbouring Task, Idea or Doc as the job's subject — a fabricated Task link
for work that has no Task.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
chore(IDEA-213): pin shared 5209199
All checks were successful
CI / Verify (pull_request) Successful in 2m23s
9f4578ca48
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With S4M_DISPATCH_URL absent or the assertion key shorter than 32 bytes, the
dialog let an admin compose a whole request and only then threw
DISPATCH_NOT_CONFIGURED at submit. Next strips a server-action error down to
a digest in production, so that message never reached the person who clicked.

listDispatchOptions -- which already runs when the dialog opens and reads
only the models database -- now reports `configured`, and the dialog says so
and disables sending. Nothing else changes: the option lists still load, no
HTTP request goes to the service, and the direct "Naar watcher" route is
untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs(IDEA-213): document dispatch rollout recovery and acceptance
All checks were successful
CI / Verify (pull_request) Successful in 2m7s
aebc8393d6
Name S4M_DISPATCH_URL and DISPATCH_WORKERS_ASSERTION_KEY with their owners
and blank example values, and state the two things about the key that break
a deployment silently: it is read as UTF-8 bytes with a 32-byte minimum, and
it must equal the dispatch service's value exactly.

Record the two preconditions that are not environment variables and still
block everything: the workers principal needs a global ADMIN role, and
queue_dispatch_reply_addresses must be populated before anyone can submit.

Describe what the UI actually does with the feature off, now that it reports
it rather than throwing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(IDEA-213): hold the settings UI to the managed job-kind guard
All checks were successful
CI / Verify (pull_request) Successful in 2m31s
7636e7ed32
QUEUE_TASK and QUEUE_REVIEW run as managed dispatch jobs, and the shared
runtime guard (resolveRuntimeJobConfig) throws DISPATCH_JOB_CONFIG_UNSAFE for
them on allow_all_tools, a tool outside the kind's shared default, a
permission mode that skips prompts, or a sandbox mode that is too wide. The
settings schema still accepted all of that, so one saved row silenced every
managed job — it fails closed, but it fails completely.

The bound is now asked of the guard instead of restated: every candidate value
is run through resolveRuntimeJobConfig for both runtimes, so the form and the
runtime cannot drift, not even when the shared pin moves. The input schema
refuses a refused row at save, and the form offers only what survives the
probe — a narrower permission list, a narrower sandbox list, no
allow_all_tools, and only the kind's own tools. Skills follow the same rule:
`Skill` is not in either managed default, so a skill saved there could never
fire and is no longer offered or accepted. A value already saved outside the
bound stays visible with an explicit remove action rather than disappearing.

Non-managed kinds are untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
chore(IDEA-213): pin shared ee7fe1a
All checks were successful
CI / Verify (pull_request) Successful in 2m24s
caa81bcc7e
Bump vendor/scrum4me-shared 5209199 -> ee7fe1a (feat/idea-213-dispatch).
Shared contract tightening m9a/m9b/m9c/m10; lib-only, no prisma change.
Regenerated prisma/schema.prisma is byte-identical (72 models / 47 enums).
verify 1270 passed/18 skipped; real-DB ops-db-realdb + ops-db-dispatch-realdb
16 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(IDEA-213): bind the idempotency key into the dispatch assertion
All checks were successful
CI / Verify (pull_request) Successful in 1m44s
8d38b9e505
The workers dispatch client signed the assertion over method/path/body
but left the Idempotency-Key in an unsigned header, so an intercepted
assertion could be replayed within its 30 s window under a fresh key to
mint a second request. Sign the exact Idempotency-Key the request will
send (submit's real key; the empty string for every other call) as a new
`idem` claim, matching the verifier in scrum4me-mcp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(IDEA-213): handle UNCERTAIN_UNSTARTED
All checks were successful
CI / Verify (pull_request) Successful in 2m38s
8c42ae672c
Bump vendored scrum4me-shared to 9c3ce16, which adds the DispatchState
value UNCERTAIN_UNSTARTED. Make the dispatch view total for the new value:
dispatchStateLabel groups UNCERTAIN_UNSTARTED with UNCERTAIN ("Uitvoering
onzeker"), and CLAIMED_STATES includes it so dispatchExecutorLabel still
surfaces the executor (dispatchRootStatus in shared already maps it to
'claimed'). No behaviour beyond making the projection total; generated
prisma/schema.prisma is byte-identical (72 models, 47 enums).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chore(IDEA-213): pin shared 925af45
All checks were successful
CI / Verify (pull_request) Successful in 2m8s
f14066833b
Bumps vendor/scrum4me-shared to 925af45 (start-permit key-id keyset + manifest
key id). No workers code touches the permit/manifest signer or verifier, so this
is unaffected: the generated Prisma schema is byte-identical (no model change) and
the full suite is green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
janpeter changed title from WIP: feat(IDEA-213): queue-job-kinds en gedeelde document-reader (ST-1590) to feat(IDEA-213): queue-job-kinds en gedeelde document-reader (ST-1590) 2026-09-21 18:00:10 +02:00
s4m-codex-reviewer requested changes 2026-09-21 18:05:17 +02:00
Dismissed
s4m-codex-reviewer left a comment

REQUEST_CHANGES

Geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden.

Findings

  • error — lib/queue/dispatch-client-server.ts:185 — once() ondertekent rawBody als bytes, maar verstuurt dezelfde body vervolgens als rawBody.toString('utf8'). Voor PUT /requests/:id/evidence/:key kan een bewijsbestand willekeurige bytes bevatten; niet-UTF-8 bytes worden door deze conversie vervangen/anders gecodeerd. Daardoor ontvangt de dispatchservice andere bytes dan de bytes waarvoor body_sha256 en X-Content-SHA256 berekend zijn, waardoor recovery-evidence corrupt raakt of verificatie faalt. Verstuur de Buffer/Uint8Array zelf als fetch-body en dek dit af met een binaire uploadtest.

  • error — app/(app)/queue/messages/_components/messages-view.tsx:373 — de recovery flow wordt zichtbaar gemaakt, maar DispatchRecoveryDialog krijgt altijd attempt={null} en publication={null}. In de dialog is de herstelknop disabled zolang attempt ontbreekt, en recover() retourneert dan direct. Een UNCERTAIN dispatch kan dus niet daadwerkelijk worden hersteld via de nieuwe UI, terwijl de gewone queue-acties voor managed rows juist verborgen zijn. Haal attempt/publication detail op uit de dispatchservice of verberg de recovery-actie tot die data beschikbaar is.

Verdict

REQUEST_CHANGES — de PR heeft brede testdekking en documentatie, maar deze twee runtime-paden blokkeren respectievelijk evidence upload en herstel van onzekere managed uitvoeringen.

# REQUEST_CHANGES Geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden. ## Findings - **error** — `lib/queue/dispatch-client-server.ts:185` — `once()` ondertekent `rawBody` als bytes, maar verstuurt dezelfde body vervolgens als `rawBody.toString('utf8')`. Voor `PUT /requests/:id/evidence/:key` kan een bewijsbestand willekeurige bytes bevatten; niet-UTF-8 bytes worden door deze conversie vervangen/anders gecodeerd. Daardoor ontvangt de dispatchservice andere bytes dan de bytes waarvoor `body_sha256` en `X-Content-SHA256` berekend zijn, waardoor recovery-evidence corrupt raakt of verificatie faalt. Verstuur de `Buffer`/`Uint8Array` zelf als fetch-body en dek dit af met een binaire uploadtest. - **error** — `app/(app)/queue/messages/_components/messages-view.tsx:373` — de recovery flow wordt zichtbaar gemaakt, maar `DispatchRecoveryDialog` krijgt altijd `attempt={null}` en `publication={null}`. In de dialog is de herstelknop disabled zolang `attempt` ontbreekt, en `recover()` retourneert dan direct. Een UNCERTAIN dispatch kan dus niet daadwerkelijk worden hersteld via de nieuwe UI, terwijl de gewone queue-acties voor managed rows juist verborgen zijn. Haal attempt/publication detail op uit de dispatchservice of verberg de recovery-actie tot die data beschikbaar is. ## Verdict REQUEST_CHANGES — de PR heeft brede testdekking en documentatie, maar deze twee runtime-paden blokkeren respectievelijk evidence upload en herstel van onzekere managed uitvoeringen.
fix(IDEA-213): send evidence bytes raw, never a UTF-8 re-encoding
Some checks failed
CI / Verify (pull_request) Has been cancelled
faa8b857a1
The dispatch REST client signed body_sha256 / X-Content-SHA256 over the
raw `rawBody` bytes but then shipped `rawBody.toString('utf8')`. For
`PUT /requests/:id/evidence/:key` an artefact can hold arbitrary
(non-UTF-8) bytes, so the utf8 round-trip replaced/re-encoded them and
the service received different bytes than the ones the signature/hash
covered — corrupt evidence or a failed verification.

Send the Buffer/Uint8Array directly (WHATWG/undici fetch accepts it) and
add a unit test that captures the wire body for non-UTF-8 input and
asserts it is byte-for-byte equal to the input (and to body_sha256).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chore(IDEA-213): re-pin shared c65f5a8 (F-S1)
All checks were successful
CI / Verify (pull_request) Successful in 2m29s
ef747d53d3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
s4m-codex-reviewer left a comment

Verdict: APPROVED

Geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden.

Findings

  • Geen blokkerende findings gevonden.

Review-notities

  • De implementatie volgt de productstandaard dat workers-app alleen admin/niet-demo gebruikers bedient; de nieuwe dispatch server-actions herlezen gebruiker/rollen vóór netwerkacties.
  • Managed dispatch rows worden consequent weggehouden van gewone queue/job-mutaties en krijgen eigen cancel/recovery-routes.
  • De diff bevat gerichte testdekking voor auth, idempotency, pinned reviewdocumenten, managed job-kinds, dispatch UI en queue-retentiegedrag.
  • Docs en env-voorbeeld beschrijven de nieuwe configuratie en failure-mode wanneer dispatch niet geconfigureerd is.
# Verdict: APPROVED Geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden. ## Findings - Geen blokkerende findings gevonden. ## Review-notities - De implementatie volgt de productstandaard dat workers-app alleen admin/niet-demo gebruikers bedient; de nieuwe dispatch server-actions herlezen gebruiker/rollen vóór netwerkacties. - Managed dispatch rows worden consequent weggehouden van gewone queue/job-mutaties en krijgen eigen cancel/recovery-routes. - De diff bevat gerichte testdekking voor auth, idempotency, pinned reviewdocumenten, managed job-kinds, dispatch UI en queue-retentiegedrag. - Docs en env-voorbeeld beschrijven de nieuwe configuratie en failure-mode wanneer dispatch niet geconfigureerd is.
Author
Owner

Review afgehandeld — F-W1 gerepareerd; F-W2 gepland als follow-up.

  • F-W1 (ERROR) lib/queue/dispatch-client-server.ts: once() tekende de evidence-bytes maar verstuurde rawBody.toString('utf8'), waardoor niet-UTF-8 bewijs hercodeerd raakte en afweek van de ondertekende bytes (body_sha256/X-Content-SHA256). Nu wordt de Buffer rauw verstuurd (body: rawBody), met een binaire regressietest die byte-voor-byte gelijkheid met de ondertekende bytes assert (rood-eerst bevestigd tegen de oude code). Commit faa8b857, npm run verify 1271 passed | 18 skipped.
  • F-W2 (recovery-dialog attempt/publication=null): correct waargenomen, maar dit is een bewuste IP-13-limiet (in de code gedocumenteerd — de dialog verzint geen ids waar het bewijs over zou gaan). De echte oplossing (de dispatch-view attempt/publication-detail laten exposeren zodat recovery bruikbaar wordt) is een feature-increment en staat gepland als T-1867.

Herpind op shared c65f5a8 (ef747d5).

**Review afgehandeld — F-W1 gerepareerd; F-W2 gepland als follow-up.** - **F-W1 (ERROR)** `lib/queue/dispatch-client-server.ts`: `once()` tekende de evidence-bytes maar verstuurde `rawBody.toString('utf8')`, waardoor niet-UTF-8 bewijs hercodeerd raakte en afweek van de ondertekende bytes (`body_sha256`/`X-Content-SHA256`). Nu wordt de `Buffer` rauw verstuurd (`body: rawBody`), met een binaire regressietest die byte-voor-byte gelijkheid met de ondertekende bytes assert (rood-eerst bevestigd tegen de oude code). Commit `faa8b857`, `npm run verify` 1271 passed | 18 skipped. - **F-W2 (recovery-dialog `attempt/publication=null`)**: correct waargenomen, maar dit is een bewuste IP-13-limiet (in de code gedocumenteerd — de dialog verzint geen ids waar het bewijs over zou gaan). De echte oplossing (de dispatch-view attempt/publication-detail laten exposeren zodat recovery bruikbaar wordt) is een feature-increment en staat gepland als **T-1867**. Herpind op shared `c65f5a8` (`ef747d5`).
Sign in to join this conversation.
No reviewers
No labels
severity/s4
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
janpeter/scrum4me-workers!102
No description provided.