feat(IDEA-213): centrale dispatcher voor managed queue dispatch (ST-1590) #144

Merged
janpeter merged 59 commits from feat/idea-213-dispatch into main 2026-09-21 19:23:29 +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

De centrale dispatcher in src/dispatch/ (IP-03 t/m IP-09): geïsoleerd DB-testharnas, geauthenticeerde intake, capaciteitsselectie met atomische reservering, claim/start-binding met start-permit, bronpinning en artefacten, resultaatverificatie, publicatie en herstel. Het directe 'Naar watcher'-queuepad is onaangeraakt.

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

  • T-1838 12651e6 — operator-herstel kan een geclaimde maar nooit gestarte poging sluiten (begrensd door het claim-moment).
  • T-1839 74d144b — verificatiefout wordt een receipt; reconciler vangt per rij en roteert op updated_at.
  • T-1844 1359dee — geauditeerde operator-resolutie voor een publicatie die voorgoed UNKNOWN blijft; zendt nooit.
  • ef13273 — race-test accepteert beide geldige weigeringen (faalde ~1 op 3).

Verificatie (lokaal)

  • npm run test:dispatch: 16 files / 187 tests groen tegen postgres:17, schema-root gepind op Scrum4Me 6dc581da.
  • npm test: 1705 groen; tsc --noEmit schoon.
  • Let op voor CI: de dispatch-integratietests vragen een geprovisioneerde DB en DISPATCH_TEST_SCHEMA_ROOT; of de bestaande workflow die draait is niet nagegaan.

Commits

  • 745eece test(IDEA-213): add isolated dispatch database and role harness
  • 1b1e299 fix(IDEA-213): harden dispatch source pin and cleanup
  • 99a0a34 feat(IDEA-213): authenticate and persist automatic queue requests
  • 19d67ad feat(IDEA-213): reserve eligible dispatch capacity and enqueue atomically
  • b8d2ffd fix(IDEA-213): exclude managed-only workers from ordinary tier priority
  • 3a3b41e feat(IDEA-213): bind managed job attempts and start authority
  • 554fb62 fix(IDEA-213): retain Task occupancy and expire uncertain attempts
  • da92349 test(IDEA-213): refuse occupied Task execution effects
  • fe45e48 feat(IDEA-213): bind managed runtime liveness and child capabilities
  • 4333a22 feat(IDEA-213): pin execution sources and preserve code artifacts
  • 5cf2ef0 fix(IDEA-213): preserve exact Git artifact data
  • 150029d feat(IDEA-213): accept verified results and audit uncertain recovery
  • c41cf1c fix(IDEA-213): share bounded control reserve with operator recovery
  • 8c395aa fix(IDEA-213): correct publication pooling stop expiry and admin cancellation
  • 12651e6 fix(IDEA-213): let operator recovery close an attempt that never started
  • 74d144b fix(IDEA-213): keep one bad publication from wedging the reconciler
  • ef13273 test(IDEA-213): accept either valid refusal in the enqueue race
  • 1359dee feat(IDEA-213): let an operator settle a publication nobody can reconcile

🤖 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 De centrale dispatcher in `src/dispatch/` (IP-03 t/m IP-09): geïsoleerd DB-testharnas, geauthenticeerde intake, capaciteitsselectie met atomische reservering, claim/start-binding met start-permit, bronpinning en artefacten, resultaatverificatie, publicatie en herstel. Het directe 'Naar watcher'-queuepad is onaangeraakt. ## Review-fixes (code-review 2026-09-20) - **T-1838** `12651e6` — operator-herstel kan een geclaimde maar nooit gestarte poging sluiten (begrensd door het claim-moment). - **T-1839** `74d144b` — verificatiefout wordt een receipt; reconciler vangt per rij en roteert op `updated_at`. - **T-1844** `1359dee` — geauditeerde operator-resolutie voor een publicatie die voorgoed UNKNOWN blijft; zendt nooit. - `ef13273` — race-test accepteert beide geldige weigeringen (faalde ~1 op 3). ## Verificatie (lokaal) - `npm run test:dispatch`: 16 files / 187 tests groen tegen postgres:17, schema-root gepind op Scrum4Me `6dc581da`. - `npm test`: 1705 groen; `tsc --noEmit` schoon. - **Let op voor CI:** de dispatch-integratietests vragen een geprovisioneerde DB en `DISPATCH_TEST_SCHEMA_ROOT`; of de bestaande workflow die draait is niet nagegaan. ## Commits - `745eece` test(IDEA-213): add isolated dispatch database and role harness - `1b1e299` fix(IDEA-213): harden dispatch source pin and cleanup - `99a0a34` feat(IDEA-213): authenticate and persist automatic queue requests - `19d67ad` feat(IDEA-213): reserve eligible dispatch capacity and enqueue atomically - `b8d2ffd` fix(IDEA-213): exclude managed-only workers from ordinary tier priority - `3a3b41e` feat(IDEA-213): bind managed job attempts and start authority - `554fb62` fix(IDEA-213): retain Task occupancy and expire uncertain attempts - `da92349` test(IDEA-213): refuse occupied Task execution effects - `fe45e48` feat(IDEA-213): bind managed runtime liveness and child capabilities - `4333a22` feat(IDEA-213): pin execution sources and preserve code artifacts - `5cf2ef0` fix(IDEA-213): preserve exact Git artifact data - `150029d` feat(IDEA-213): accept verified results and audit uncertain recovery - `c41cf1c` fix(IDEA-213): share bounded control reserve with operator recovery - `8c395aa` fix(IDEA-213): correct publication pooling stop expiry and admin cancellation - `12651e6` fix(IDEA-213): let operator recovery close an attempt that never started - `74d144b` fix(IDEA-213): keep one bad publication from wedging the reconciler - `ef13273` test(IDEA-213): accept either valid refusal in the enqueue race - `1359dee` feat(IDEA-213): let an operator settle a publication nobody can reconcile 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A supervisor that dies between claim and start leaves an UNCERTAIN
attempt with no scope and no start time. Recovery demanded both, the
broker path demanded the dead host's observation, and cancel only
waits, so the reservation, slot and Task binding stayed held forever.

No start permit exists for such an attempt, so nothing launched: bound
the attestation by the claim time instead of the start time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Artifact verification ran outside the publisher's try block, so a
transient git, tmp or timeout failure escaped instead of becoming a
receipt, and on the reconcile path it escaped again on every pass. The
batch loop had no per-row catch and always retried the oldest rows, so
a single throwing operation starved every later one while its request
kept its reservation.

Verification failures are now receipts, each row is reconciled on its
own, and the batch rotates by updated_at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When the managed side wins, the ordinary handler is refused by its own
active-job check or by the Task guard, depending on the interleaving.
Both keep the Task exclusive; asserting only the first made the test
fail roughly one run in three.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat(IDEA-213): let an operator settle a publication nobody can reconcile
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Failing after 2m47s
1359dee5a2
A SENT operation whose push never happened reconciles to UNKNOWN
forever: the reconciler may not send again, and until the operation is
settled its request keeps the reservation, slot and Task binding while
cancel, recovery, result acceptance and retry all refuse.

An actor with the recovery right can now attest what the remote shows
and close the operation as failed. The attestation is stored as an
immutable control artifact, runs under the publisher's advisory lock,
is idempotent per action, and refuses a remote that already carries our
head, since that is a confirmation for reconciliation to find. It never
sends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The five inline outbox inserts stored only ids and a state, so delivery
would have had to read later state to build a message for an earlier
version. One writer now records the full projection at the request's
current version inside the caller's transaction, and the request view
reports delivery as failed after ten attempts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat(IDEA-213): project dispatch snapshots into Messages idempotently
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Has been cancelled
6674ddfe44
The projector applies one queue transaction per snapshot: the root is
upserted only to a strictly higher version, the single reply is inserted
once and never rewritten, and an id that belongs to anything else is
refused rather than adopted. Delivery marks a row published only after
the queue commit, backs off 1 to 60 seconds, reports failed after ten
attempts while it keeps retrying, and never touches execution state or
the canonical result.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
test(IDEA-213): follow the vendored shared pin and the tick's delivery counters
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Failing after 2m40s
9d74b12fea
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(IDEA-213): keep ordinary queue maintenance off managed dispatch rows
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Failing after 2m40s
77480585c7
The row guard refuses an ordinary role's write to a managed message, so
one forgotten managed reply claim made the whole stale sweep roll back
and ordinary work was never requeued. The sweep, request claim, claim
rollback and lease refresh now skip managed rows; reading the final
answer through the reply inbox stays the deliberate exception.

Archive and unarchive refuse a thread that holds a managed row until
every message in it is terminal, before any write, instead of failing
halfway on the guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat(IDEA-213): recover forgotten reply reads and retain threads archive-first
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Failing after 2m53s
2fef78ddb5
The ordinary sweep now skips managed rows, so a reader that crashed
after claiming its answer would hide it forever. After the CLI's four
hour inbox lease the projector makes exactly that reply readable again,
touching read-claim fields only.

Terminal retention copies a whole thread to the archive with an
explicit column list, proves every archived row equals its hot row, and
only then deletes, reply before root. Any difference, or a missing
insert grant, leaves the hot thread intact. Threads of requests that
went through recovery are kept as audit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
test(IDEA-213): prove delivery against a separate queue database
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Failing after 2m39s
95db6ea102
DISPATCH_QUEUE_DATABASE_URL may point somewhere else than the dispatch
database. A second database is rebuilt from the migrated catalog, with
the literal row guards, the reply foreign key and the projector grants,
and proves three things there: projection works across databases, a
real outage delays delivery and nothing else, and a conversation lost
to a queue restore comes back from the outbox without a candidate,
attempt, job or lifecycle event coming into being.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat(IDEA-213): expose automatic dispatch through MCP and CLI
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Failing after 2m42s
54be44d20b
dispatch_task, dispatch_review, get_dispatch and cancel_dispatch turn the
central dispatch service into tool calls. They live in the shared set because
they authorize on nothing but the caller's own bearer: no host identity, no
local worktree, so the HTTP server can serve them as safely as stdio. The
client deliberately has no service identity to fall back on — a caller whose
token lacks a right is refused, not quietly upgraded.

The action is chosen only by an explicit task_id; work_item metadata stays a
label, which is what keeps a traceability tag from becoming an execution
authorization. Input is parsed against the shared contract before the request
leaves the host, so a malformed request never reaches the service.

queue_push gains one sentence pointing at these tools. Its own behaviour,
schema and delivery path are untouched: sending to one named watcher remains
the explicit route and still creates no job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
test(dispatch): budget real publication fixtures for parallel suite
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 4m44s
777861bfce
Merge pull request 'fix(ci): stabilize temporary Git cleanup and require dispatch integration' (#146) from codex/ci-dispatch-20260920 into feat/idea-213-dispatch
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 5m38s
1c12ab5128
Reviewed-on: #146
feat(IDEA-213): build managed job context from the pinned request
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 5m50s
dd5b8e6481
A managed QUEUE_TASK/QUEUE_REVIEW job gets its binding, profile, input
snapshot and source artifact refs from the dispatch rows it was authorized
against. That is the whole point: the latest Task, Idea or ProductDoc may
have moved since, and a reviewer who silently reads today's version is
answering a different question than the one that was approved. A free
managed request has no Task, Story or Sprint at all, so none is invented.

The branch is opt-in rather than automatic because the caller decides which
database role is in play. An ordinary worker's role may not read the dispatch
tables at all; it keeps falling through to assertUnmanagedJob and is refused
without ever reaching for them. An automatic branch turned that refusal into
"permission denied", which legacy-jobs.integration.test.ts caught.

A QUEUE kind without a dispatch request is refused outright: an unbound
managed kind has no authorized contract, so there is nothing a model could
safely be started on.

The four prompts are the child's entire contract — it has no MCP tools, no
completion tool and no host access, so the manifest at /output/result.json is
the only way anything comes back. The review prompt forbids fixing what it
reviews, because a repair destroys the evidence the verdict rests on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
test(IDEA-213): pin MCP/CLI request parity to one shared fixture
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 4m36s
e4406a2bfc
One logical request, one set of bytes. __tests__/dispatch/dispatch-parity.json
is committed byte-identically here and in s4m-queue as
test/fixtures/dispatch-parity.json, and both sides assert its sha256, so the
two repos cannot drift into proving parity against different fixtures.

The CLI half deliberately spells the same request with its keys shuffled: if
parity depended on how a submitter happened to order a file, it would not be
parity at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(IDEA-213): refuse an unknown key on a dispatch tool instead of dropping it
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 4m49s
efed548c80
The tool schemas were not strict, so an extra key — a PPE marker, or a
hand-written requirements block meant to widen access — was silently stripped
before the shared contract ever saw it. The caller would then believe it had
been honoured while it never travelled. Strict schemas refuse it at the tool
boundary, and the contract still refuses a marker that reaches the handler
another way. Prose is untouched: an objective that mentions PPE is an
objective, not a marker.

Also pins the token-expiry path: a 401 from the service surfaces its code, is
not retried, does not echo the token, and never reaches for a second identity.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
IP-13 turns the intake-only service into the full REST matrix of §3. Cancel,
recovery, recovery evidence, executor registration/heartbeat, claim, start,
reconcile, attempt heartbeat, stop evidence, result, artifact put/get, profiles,
slots and reply addresses are now served by the domain functions that IP-04..IP-12
already built; nothing is reimplemented behind them.

New in this commit is only what had no domain function yet: profile revisions,
profile revocation, the profile/slot listing, slot disabling and reply-address
binding (src/dispatch/administration.ts), all through the existing idempotent
withDispatchOperation receipt. The listing answers a superset of the MCP/CLI
`DispatchProfileView`/`DispatchSlotView` and the workers `DispatchProfileRevision`/
`DispatchSlotStatus`, so all three existing clients read it unchanged.

Supervisor-facing routes exist only where the deployment holds the credential and
start-permit keys that make their authority verifiable; otherwise they are absent.
Unreadable JSON is still answered before authentication and before any database
access, so intake keeps its 400.

The tick gains the missing pre-claim source preparation and publication
reconciliation stages, a bounded count per stage (25 requests, 100 outbox rows)
and a per-unit error catch, so one poisoned durable unit cannot stop the rest.
Importing the module still starts nothing. Shutdown closes the listener first,
then the timer, lets the running tick finish its own transactions and reports the
open scopes; it never clears attempts or reservations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
test(IDEA-213): integrate dispatch consumers and failure contracts
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 4m45s
5c079a99ab
End-to-end fixtures drive the whole managed lifecycle over the real HTTP API, the
real PostgreSQL roles and the real projector, with a fake RuntimePort and
PublisherPort whose call counts are observable. Three runs are exercised: a free
review on the job route, a free repo-write on the host route and an explicit Task,
across both supported runtimes. Each asserts the measured counters from the plan —
one model start, one canonical result, one root, one reply, no task update for free
work, no merge and no deploy job — plus slot occupancy and the authoritative rows.

The administration contract that the MCP, CLI and workers clients all read is
covered directly: immutable revisions with a real revision sequence, revocation
that never overwrites, the slot listing that keeps presence, protocol readiness,
isolation and occupancy as four separate facts, version-checked slot disabling and
reply-address binding, including the refusals for a principal without
product-administrator rights.

Failure matrix, all with barriers or injected transport failures and no sleeps:
intake loss before and after the commit, the selection race, claim against
retirement, cancel against start, a dead supervisor with a live child, the ordinary
stale-claim reset against a managed job row, host reincarnation, a review source
that disappears before the claim, a lost publication response, a lost result
response, a queue outage and an out-of-order outbox replay, and repeated recovery.
Every case checks the authoritative database state, the model-start count, slot
occupancy and the final Messages count.

Two route defects the fixtures found are fixed here: a null claim receipt was
serialized as an empty object, which handed the client a receipt shape where the
answer was "no work"; and a supervisor could not submit its broker stop
observation at all, because the reserved control key is refused by the artifact
upload, so POST /attempts/stop-evidence now also accepts the raw observation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An orchestrator has no dispatch identity, so readiness is unauthenticated. It is
therefore side-effect free, cached for one probe window and limited to four facts
about this build and its own connection: package version, the shared protocol
name, whether the running role sees the whole durable dispatch schema, and whether
that role is the contract role without any elevated flag. No credentials, no DSN,
no product or request data, and no database error text — an unreachable database is
simply not ready. It answers 200 either way, as `/health` in src/http.ts always did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four rollout gaps that IP-13 left without a way in, closed the way each one is
actually shaped:

- Queue repair and retention are projector maintenance, not operator decisions:
  they carry no actor, grant no authority and are idempotent. They become tick
  stages, bounded and caught per unit, and they run last so selection, leases and
  delivery never wait behind them, on their own interval rather than every five
  seconds. Repair runs wherever delivery runs; retention deletes hot queue rows,
  so a deployment opts in with DISPATCH_RETENTION_DAYS. recoverForgottenReplyReads
  now takes a batch limit, so one pass can never become a long queue transaction.
- Queue restore is an operator decision, so POST /dispatch/v1/outbox/republish
  hands the newest outbox snapshot per request back to the projector under the
  existing action receipt: exactly once per action id, global administrators only,
  never the browser issuer, and no older snapshot or execution state is touched.
- resolveUnknownPublication had no route after IP-13; it gets
  POST /dispatch/v1/publications/:id/resolve, present only where a publisher is
  configured and with the same authority as recovery.
- The entrypoint's own runtime is now tested: the tick lifecycle is a separate,
  injectable runner, and shutdown closes the listener first, stops the timer, waits
  for the tick in flight, reports the open scopes and never releases capacity.
  Shutdown is idempotent — a second signal used to reject on an already closed
  listener and pool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs(IDEA-213): document dispatch rollout recovery and acceptance
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 5m1s
365e29ddd3
README and .env.example now carry every environment variable the dispatch code
actually reads, with the process that reads it and the party that provisions it,
and with shapes instead of values. The plan named DISPATCH_CREDENTIAL_KEY; the
code reads DISPATCH_CREDENTIAL_KEYS plus DISPATCH_CREDENTIAL_KEY_VERSION, so the
code is what is documented — including the rotation rule that an old key version
stays in the list until every attempt issued under it has been retired.

Also documented: the readiness endpoint and why it answers 200 either way, the
two operator entries with their exact commands and authority, the maintenance
stages that need no operator at all, and a known-limitations list that keeps the
open items open — no NOTIFY producer, no bound-attempt read on GET /artifacts/:id,
and practical acceptance that has never run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`acceptDispatchResult` may rewrite the submitted outcome to failed or
cancelled, but the receipt only ever carried an id and a reason. A
supervisor that learned no more than an id cannot know what the service
actually accepted, so it cannot complete locally without guessing.

`finishResult` now returns the canonical result it stored, and both
replay paths return the stored payload, so a second delivery of the same
result answers with the same canonical bytes. The field is optional:
it is absent exactly where no canonical result exists yet, as on an
unresolved publication.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat(IDEA-213): serve the supervisor-facing dispatch surface
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 6m12s
ee898fe749
Four domain functions existed with a tested authority model and no route
in front of them. This wires them, and rewrites nothing.

POST /attempts/result now answers with `reason` and `canonical_result`
beside the existing `status`/`result_id`, so a supervisor completes on
what the service holds rather than on what it sent.

PUT /attempts/collected/:key is the route `stageCollectedArtifact` was
written for: original-supervisor authority proved by the historical
start binding, valid only after this supervisor's own stop was accepted,
which is exactly when PUT /attempts/artifacts/:key refuses by design.
`authorizeArtifactAttempt` is untouched.

POST /attempts/recovery/{lookup,stop,result} put NonLaunchRecoveryPort on
the wire. Same authenticated original supervisor, same historical
binding, no AttemptProof and no execution authority. This is not
/requests/:id/recover, which is the operator recovery role.

GET /agent/sources/:key and PUT /agent/outputs/:key mount
`createAgentGateway` with the authority its module prescribes: the child
calls the service directly holding one bounded, attempt-scoped
capability and no dispatch identity at all. A bearer or assertion
alongside it is refused rather than ignored, and the gateway re-derives
full database authority on every call. The capability is minted by the
service at POST /attempts/start and returned beside the permit, because
the supervisor is the only party that can place it in the container.
Without DISPATCH_AGENT_OUTPUT_KEY the two routes do not exist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat(IDEA-213): serve the two reads a bound attempt needs for its own sources
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 5m7s
52332d8966
The supervisor could sign nothing and fetch nothing. `preparedManifest` existed in
`sources.ts` but no route reached it, and `GET /artifacts/:id` authorised only the
requester and an authorized product administrator — not the bound attempt the REST
matrix also names. A supervisor holds no requester identity, so it could not fetch
the very bytes a manifest names.

`POST /attempts/sources/manifest` is that read, under the attempt's own proof and
present only where the deployment holds a permit key. Domain separation already
lives inside the signed bytes, so the key that signs start permits signs this too;
the operator broker therefore verifies both with the one public key it already pins.

`GET /artifacts/:id` now accepts `X-Dispatch-Attempt-Proof` and answers the bound
attempt with its own request's prepared sources — never attempt output, never
another request's bytes, and never without the same fresh database authority every
other attempt route runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The dispatch entrypoint built the source service without a repository source
producer, so every repo_write request reached its first tick and was failed
there with source_not-configured -- after intake had told the requester it was
accepted. Wire the producer behind operator-owned configuration, and where that
configuration is absent refuse such a request at intake instead, writing no row
at all.

DISPATCH_WORKSPACE_ROOT is the writable root, DISPATCH_GIT_PROTOCOLS the scheme
set (https by default, and https still needs DISPATCH_GIT_HOST). The producer
reuses the central forge host and token the publisher already reads; there is no
second credential path. Each request's checkout is now removed once its base has
been bundled, so the root stays bounded by what is running.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat(IDEA-213): wake the dispatch tick on NOTIFY as well as on the interval
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 6m22s
b24f35b8c2
The service advanced on its five-second interval alone, so a submitted request,
a returned slot or a written outbox row waited for the next tick even when the
service was idle. Emit a NOTIFY on dispatch_tick from the service's own
transactions -- no trigger, and therefore no migration -- and let a dedicated
LISTEN connection pull the next tick forward.

Two emit points cover every event-driven stage: the outbox write that every
durable request transition passes through, and a newly registered incarnation,
which writes no outbox row but is the capacity a waiting request was missing.
Deadlines and leases are elapsed time and stay on the interval. The payload
carries ids only.

The notification is a hint and the interval is the guarantee: a burst inside one
25 ms window costs a single early tick, a wake during a tick becomes exactly one
follow-up rather than a second concurrent one, a dropped session reconnects with
bounded backoff, and shutdown closes the listener before anything else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(IDEA-213): pin that a never-started attempt cannot be resumed
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Has been cancelled
24a35edb79
The shared state table allows CLAIMED -> lease_lost -> UNCERTAIN ->
resume_same_attempt -> RUNNING, a path that never passes 'start' and so
never issues a start permit. Measured: reconcileDispatchAttempt already
refuses such a resume (it requires started_at and scope_id), so no
consumer change is needed; this adds the test that proves the refusal
and keeps it from regressing. The state table itself is unchanged -
narrowing it needs a plan revision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(IDEA-213): keep the cleanup of an ended job off the current task binding
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Has been cancelled
68a04bbf13
The cleanup helpers guarded on task.dispatch_request_id, which records who
holds the task NOW. An ordinary job that already ended and whose task was
afterwards handed to a managed dispatch therefore hit DISPATCH_MANAGED_ROW
in releaseLocksOnTerminal, backupPushOnFailure and runDeferredWorktreeCleanup:
its in-memory file locks leaked and its worktree stayed behind. Those three
now read job-level markers of that job only (assertUnmanagedJobCleanup), as
does cleanupWorktreeForTerminalStatus, which runDeferredWorktreeCleanup calls.
The marker read is wrapped as well, so a DB failure is logged by error name
instead of breaking the never-throw promise of helpers that only free host
resources belonging to that one job.

Publishing paths (prepareDoneUpdate, the auto-PR calls, claim and context
reads, worktree setup) keep the wide guard: they act on the task.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(IDEA-213): finish a failed verification, keep cancel idempotent, name the busy Task
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 6m10s
ac4789bcac
(a) A malformed bundle or an overflowing git invocation made verifyCodeArtifact
throw DispatchSourceError, which is not a DispatchError and so escaped accept()
as a 500 while the request stayed RUNNING until someone cancelled it by hand.
That failure belongs to the attempt: it now becomes a failed result through
finishResult, which releases capacity. A DispatchError is a deliberate verdict
on the submission and keeps its own status.

(b) cancelDispatch left the candidate on its old state while cancelForStop moves
it, and a second cancel with a fresh action id repeated the transition, bumped
the version and wrote another outbox row. The stop request is now idempotent per
request, and the candidate travels with the attempt.

(c) When the ordinary enqueue path loses the race to a managed dispatch the user
got the bare guard code, either from the Task row it reads or from the Task
guard raising inside PostgreSQL. Both now give the same active-job message.
The DB refusal is matched on SQLSTATE 42501 plus the guard's message, walking
the cause chain: Prisma 7 driver adapters hand back a DriverAdapterError whose
cause carries the driver's code, and engine fields such as meta.target are gone.
Measured against a real driver-adapter error, not a mock. The assertion widened
in ef13273 is narrow again and now also forbids the bare code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(IDEA-213): pin shared ee7fe1a
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Failing after 4m52s
e58a9087ca
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).

Mirror m10 into the consumer test: START_PERMIT_CLOCK_SKEW_MS (5000ms) now
tolerates a bounded issuer clock lead, so a permit read as issued 1ms in the
future is accepted. __tests__/dispatch/start-permit.test.ts asserted the old
strict boundary (now-1); move it past the tolerated skew (now-5001), matching
scrum4me-shared's own boundary change. Test intent (reject a permit issued
beyond the skew) preserved.

typecheck/typecheck:tests clean; test 1758 passed/42 skipped;
test:dispatch 23 files/253 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(IDEA-213): bind the idempotency key into the dispatch assertion
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Has been cancelled
cc2e266d44
An intercepted dispatch assertion could be replayed within its 30 s
window with a fresh Idempotency-Key header, minting a second request:
the key was read outside the signed bytes (routes.ts) while the
assertion signed only method/path/body/issuer/aud/iat/jti.

Bind the Idempotency-Key into the signed claims (new `idem` claim) and
have intake verify the received `Idempotency-Key` header equals the
signed value. Every non-submit route signs, and is checked against, the
empty string its absent header decodes to, so their assertions are
unaffected. The bearer-token path is untouched.

This is the private signer/verifier contract between the workers/web
clients and the mcp verifier; it is not in vendor/scrum4me-shared, so no
shared change and no migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs(IDEA-213): note the start-permit key-rotation limitation
Some checks failed
CI / PR candidate (never published) (pull_request) Has been cancelled
CI / Final merge attestation and immutable publication (pull_request) Has been cancelled
4b66366d8b
The start permit carries no key id, so its Ed25519 signing key cannot be
rotated in place: rotating it makes every already-issued permit fail
verification until a permit-version bump and an updated portable fixture
carry the new key id. Record this next to the other measured dispatch
known-limitations. The clock-tolerance half of m10
(START_PERMIT_CLOCK_SKEW_MS) already landed; the key-id half is deferred
by decision. Docs only; no DB_ACCESS_HASH_FILES file is touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(IDEA-213): track the shared pin ee7fe1a in the parity test
Some checks failed
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Failing after 3m47s
64cb6d6f66
The pin bump to ee7fe1a left __tests__/ppe-bundle1-parity.test.ts
asserting the old staged gitlink 5209199, turning npm test red. Point
CURRENT_SHARED_COMMIT at the current pin; the generated schema is
byte-identical so nothing else changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(IDEA-213): enforce dispatch retry authority in the application
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 6m1s
567497f3eb
m5 application half (T-1864). The DB deliberately does not enforce retry
authority on a same-slot re-claim or a RESERVED-return with a generation
bump (migration 20260921090000_queue_dispatch_guard_scope note (a); the SQL
retry check lives only in the pool slot-transfer branch, which fires on a
slot change). Measured against the disposable DB: the application already
enforces retry authority on every generation-bump / re-claim path via
assertRetryAuthorization -- selection.ts:82 (RESERVED-return), attempts.ts:209
(re-claim) with single-use consumption at attempts.ts:255, plus cancel.ts:47
and sources.ts:119. A control run with both guards removed turns the three
negative tests red, proving the guards are load-bearing; no gap remained, so
no new enforcement was added (it would be dead code).

Adds retry-authority.integration.test.ts pinning: unauthorized RESERVED-return
refused (DISPATCH_STATE_CONFLICT, no generation bump, no new candidate); a
same-slot re-claim with a cleared authorization refused (no new attempt);
replay of an already-consumed authorization refused; an authorized retry
consumes its authorization exactly once and succeeds; and two concurrent
claims of one authorized retry serialize to a single consumption (barrier).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(IDEA-213): translate the managed-row refusal on the status-update path
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 5m6s
ae748f58e8
update_task_status refused a dispatch-managed task by leaking the bare
DISPATCH_MANAGED_ROW guard code (via assertUnmanagedTask's sentinel Error).
Relocate the single isManagedTaskRefusal matcher to dispatch/managed-job.ts
(re-exported from task-implementation.ts for existing importers), make
assertUnmanagedTask throw a driver-adapter-shaped refusal the matcher
recognizes, and translate it in handleUpdateTaskStatus to a friendly,
fail-closed message worded for the status-update context. No managed row is
mutated; behaviour is unchanged apart from the message.

The other path recorded in runbook §10.2 (idea-jobs claudeJob.create) is not
touched: measured against the disposable DB and both claude_jobs guards, an
idea-only job (no task_id, no dispatch markers) cannot raise
DISPATCH_MANAGED_ROW, so there is no leak there to translate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(IDEA-213): close a pre-scope refusal with claim-bound stop evidence
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 6m9s
379cbe4be2
A CLAIMED attempt whose supervisor refused before any runtime scope existed
(typically DISPATCH_PREPARED_SOURCES_REFUSED, since source preparation runs
before the broker create) had no closure path: every stop-evidence shape binds
to a scope id it never had, so the request stayed CLAIMED with its reservation
held and a retry of the same supervisor looped.

Add a claim-bound stop-evidence acceptance bound to the CLAIM (attempt id + the
first claim, a bounded DISPATCH_* reason, never raw text). It is admissible only
for the exact pre-scope signature (never scoped, never started, no broker
observation, still CLAIMED); an attempt that entered a scope is refused and must
use the scoped path. Writing stop_accepted + stopped_at lets a subsequent failed
result reach FAILED through the ordinary completion path (sources_prepared is
already emitted at selection), releasing the reservation with exactly one
canonical result. Same supervisor authority as the scoped stop path; idempotent
replay preserved. No migration and no shared-contract change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(IDEA-213): handle UNCERTAIN_UNSTARTED
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 5m6s
75cb164912
Bump vendored scrum4me-shared to 9c3ce16, which adds the DispatchState
value UNCERTAIN_UNSTARTED and narrows the state table. Option A: the mcp
keeps writing 'UNCERTAIN' for CLAIMED-origin lease loss, so uncertain()
and its membership arrays are unchanged and no migration is added. The
only edit is the ppe-bundle1 parity test, which pins the staged gitlink:
update CURRENT_SHARED_COMMIT to 9c3ce16. Generated prisma/schema.prisma is
byte-identical (72 models, 47 enums).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(IDEA-213): sign the start permit and source manifest under key ids
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 6m0s
de15d721ad
Pins shared 925af45 and makes the mcp signer emit key-id-carrying tokens for
zero-downtime Ed25519 rotation:

- The start-permit signer sets `kid` from DISPATCH_START_PERMIT_KEY_ID (permit v2);
  the permit routes exist only where the key AND its kid are configured together.
- The prepared-sources manifest signs under its OWN key + kid from
  DISPATCH_SOURCE_MANIFEST_PRIVATE_KEY / DISPATCH_SOURCE_MANIFEST_KEY_ID; if the
  manifest key is not configured the manifest route stays `unavailable` (no silent
  fallback to the permit key). Domain separation stays inside the signed bytes.
- `verifyStartPermit` now takes the trusted keyset and selects the public key by
  the claim's kid (algorithm stays Ed25519); an unknown kid is refused.
- Every derived binding schema omits `kid`, which is a key selector, not identity.
- ppe-bundle1-parity CURRENT_SHARED_COMMIT bumped to 925af45; generated schema
  byte-identical (no prisma change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
janpeter changed title from WIP: feat(IDEA-213): centrale dispatcher voor managed queue dispatch (ST-1590) to feat(IDEA-213): centrale dispatcher voor managed queue dispatch (ST-1590) 2026-09-21 18:00:09 +02:00
s4m-codex-reviewer requested changes 2026-09-21 18:05:09 +02:00
Dismissed
s4m-codex-reviewer left a comment

Verdict: REQUEST_CHANGES

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

Findings

  • ERROR .env.example:50 / README.md:533 — De runtime vereist inmiddels ook DISPATCH_START_PERMIT_KEY_ID voordat executor wordt geconfigureerd (src/dispatch/server.ts:103-114), maar zowel de voorbeeld-env als de environment-tabel documenteren alleen DISPATCH_START_PERMIT_PRIVATE_KEY. Een operator die de README/.env.example volgt krijgt dus geen /attempts/* executor-routes, ondanks geldige credential keys en private key. Dit maakt de nieuwe managed dispatch-uitrol niet werkend volgens de meegeleverde configuratie-instructies. Voeg de key-id expliciet toe aan .env.example en README, inclusief dezelfde validatie/rotatieverwachting als de code.

  • WARNING .env.example:50 / README.md:533 — De code heeft ook aparte source-manifest signing env vars (DISPATCH_SOURCE_MANIFEST_PRIVATE_KEY en DISPATCH_SOURCE_MANIFEST_KEY_ID in src/dispatch/server.ts:106-110) waarmee /attempts/sources/manifest beschikbaar wordt, maar die zijn nergens in de env-matrix opgenomen. Omdat de README wel prepared source manifests beschrijft, is dit een uitrol-blinde vlek: prepared-source flows kunnen zonder zichtbare configuratie gedeeltelijk beschikbaar lijken maar de manifest-route missen.

Checks

  • node node_modules/typescript/bin/tsc --noEmit na submodule init + Prisma generate: groen.
  • node node_modules/typescript/bin/tsc --noEmit -p tsconfig.type-tests.json: groen.
  • Vitest kon lokaal niet starten door een native rolldown binding-load probleem in deze tijdelijke omgeving; niet als PR-finding gebruikt.
# Verdict: REQUEST_CHANGES geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden. ## Findings - **ERROR** `.env.example:50` / `README.md:533` — De runtime vereist inmiddels ook `DISPATCH_START_PERMIT_KEY_ID` voordat `executor` wordt geconfigureerd (`src/dispatch/server.ts:103-114`), maar zowel de voorbeeld-env als de environment-tabel documenteren alleen `DISPATCH_START_PERMIT_PRIVATE_KEY`. Een operator die de README/.env.example volgt krijgt dus geen `/attempts/*` executor-routes, ondanks geldige credential keys en private key. Dit maakt de nieuwe managed dispatch-uitrol niet werkend volgens de meegeleverde configuratie-instructies. Voeg de key-id expliciet toe aan `.env.example` en README, inclusief dezelfde validatie/rotatieverwachting als de code. - **WARNING** `.env.example:50` / `README.md:533` — De code heeft ook aparte source-manifest signing env vars (`DISPATCH_SOURCE_MANIFEST_PRIVATE_KEY` en `DISPATCH_SOURCE_MANIFEST_KEY_ID` in `src/dispatch/server.ts:106-110`) waarmee `/attempts/sources/manifest` beschikbaar wordt, maar die zijn nergens in de env-matrix opgenomen. Omdat de README wel prepared source manifests beschrijft, is dit een uitrol-blinde vlek: prepared-source flows kunnen zonder zichtbare configuratie gedeeltelijk beschikbaar lijken maar de manifest-route missen. ## Checks - `node node_modules/typescript/bin/tsc --noEmit` na submodule init + Prisma generate: groen. - `node node_modules/typescript/bin/tsc --noEmit -p tsconfig.type-tests.json`: groen. - Vitest kon lokaal niet starten door een native `rolldown` binding-load probleem in deze tijdelijke omgeving; niet als PR-finding gebruikt.
Merge branch 'main' into feat/idea-213-dispatch
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 5m3s
b2f353ead3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
s4m-codex-reviewer left a comment

REQUEST_CHANGES

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

Findings

  • error — src/dispatch/server.ts:45 / .env.example:36 / README.md:540: de productie-entrypoint vereist naast DISPATCH_START_PERMIT_PRIVATE_KEY ook DISPATCH_START_PERMIT_KEY_ID voordat executor wordt opgebouwd, en gebruikt daarnaast DISPATCH_SOURCE_MANIFEST_PRIVATE_KEY, DISPATCH_SOURCE_MANIFEST_KEY_ID en DISPATCH_AGENT_OUTPUT_KEY voor de manifest- en child-gateway routes. Die variabelen ontbreken in .env.example, en de README environment table documenteert alleen de private start-permit key en agent-output key, niet de verplichte start-permit key id of de manifest key/id. Een operator die de meegeleverde configuratie volgt krijgt daardoor een service waarbij /executors/*, /attempts/* en/of /attempts/sources/manifest als niet-geconfigureerd/404 blijven, terwijl de docs suggereren dat de private key + credential keys voldoende zijn. Dit blokkeert rollout van de managed queue dispatcher omdat de centrale dispatch wel intake/selectie kan draaien, maar workers geen start/manifest-contract kunnen krijgen.

Verdict

REQUEST_CHANGES — de nieuwe dispatcher is uitgebreid getest en de architectuur sluit grotendeels aan bij de productdocs, maar de runtime-configuratie is niet compleet gedocumenteerd voor verplichte routes. Werk de env example en README environment table bij met alle daadwerkelijk gelezen dispatch-vars en hun required/optional semantics voordat dit veilig te deployen is.

# REQUEST_CHANGES Geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden. ## Findings - **error** — `src/dispatch/server.ts:45` / `.env.example:36` / `README.md:540`: de productie-entrypoint vereist naast `DISPATCH_START_PERMIT_PRIVATE_KEY` ook `DISPATCH_START_PERMIT_KEY_ID` voordat `executor` wordt opgebouwd, en gebruikt daarnaast `DISPATCH_SOURCE_MANIFEST_PRIVATE_KEY`, `DISPATCH_SOURCE_MANIFEST_KEY_ID` en `DISPATCH_AGENT_OUTPUT_KEY` voor de manifest- en child-gateway routes. Die variabelen ontbreken in `.env.example`, en de README environment table documenteert alleen de private start-permit key en agent-output key, niet de verplichte start-permit key id of de manifest key/id. Een operator die de meegeleverde configuratie volgt krijgt daardoor een service waarbij `/executors/*`, `/attempts/*` en/of `/attempts/sources/manifest` als niet-geconfigureerd/404 blijven, terwijl de docs suggereren dat de private key + credential keys voldoende zijn. Dit blokkeert rollout van de managed queue dispatcher omdat de centrale dispatch wel intake/selectie kan draaien, maar workers geen start/manifest-contract kunnen krijgen. ## Verdict REQUEST_CHANGES — de nieuwe dispatcher is uitgebreid getest en de architectuur sluit grotendeels aan bij de productdocs, maar de runtime-configuratie is niet compleet gedocumenteerd voor verplichte routes. Werk de env example en README environment table bij met alle daadwerkelijk gelezen dispatch-vars en hun required/optional semantics voordat dit veilig te deployen is.
docs(dispatch): document permit kid and manifest signer env vars
Some checks failed
CI / PR candidate (never published) (pull_request) Has been cancelled
CI / Final merge attestation and immutable publication (pull_request) Has been cancelled
a67a75a15e
F-M1: .env.example and the README env table only listed
DISPATCH_START_PERMIT_PRIVATE_KEY. An operator following the docs sets the
private key without DISPATCH_START_PERMIT_KEY_ID; server.ts requires both
(permit && permitKid) so the executor is left undefined and no /attempts/*
routes come up despite valid keys.

F-M2: the same docs omitted DISPATCH_SOURCE_MANIFEST_PRIVATE_KEY and
DISPATCH_SOURCE_MANIFEST_KEY_ID, leaving the prepared-source-manifest route a
deployment blind spot.

Documents the exact env names read by src/dispatch/server.ts and their
semantics (kid part of the executor condition; manifest signer distinct from
the permit key, both required, no fallback). Docs only; no code or pins changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chore(IDEA-213): re-pin shared c65f5a8 (F-S1)
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 5m17s
1b0b50f146
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
s4m-codex-reviewer left a comment

Verdict: COMMENT

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

Findings:

  • minor — README.md:555 / .env.example — De README documenteert DISPATCH_AGENT_OUTPUT_KEY als door de dispatch service gelezen configuratie, maar .env.example bevat geen voorbeeldregel voor deze variabele. Daardoor is de operatorconfig niet volledig reproduceerbaar vanuit de voorbeeld-env, terwijl de rest van de nieuwe dispatch-config daar juist uitgebreid is vastgelegd.
  • minor — README.md:701 — De nieuwe README-sectie zegt nog dat de start permit geen key id draagt en daardoor niet in-place roteerbaar is. De code en tests in deze PR introduceren juist DISPATCH_START_PERMIT_KEY_ID, startPermitKeyId en v2-permitverificatie met kid. Dit maakt de operationele documentatie intern tegenstrijdig.
  • info — README.md:699 — De documentatie noteert dat praktische acceptance tegen een live dispatch service nog niet heeft gelopen. Dat is geen code-blocker op zichzelf, maar bij deze grote dispatcher-introductie blijft het een expliciete rollout/test-gap om vóór productiegebruik af te sluiten.

Geen blocking/error-severity findings gevonden in de beoordeelde diff. Omdat de PR groot is, geen gekoppeld plan heeft en de docs nog inconsistenties/test-gap noemen, kies ik safe-default voor COMMENT in plaats van APPROVED.

## Verdict: COMMENT Geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden. Findings: - minor — `README.md:555` / `.env.example` — De README documenteert `DISPATCH_AGENT_OUTPUT_KEY` als door de dispatch service gelezen configuratie, maar `.env.example` bevat geen voorbeeldregel voor deze variabele. Daardoor is de operatorconfig niet volledig reproduceerbaar vanuit de voorbeeld-env, terwijl de rest van de nieuwe dispatch-config daar juist uitgebreid is vastgelegd. - minor — `README.md:701` — De nieuwe README-sectie zegt nog dat de start permit geen key id draagt en daardoor niet in-place roteerbaar is. De code en tests in deze PR introduceren juist `DISPATCH_START_PERMIT_KEY_ID`, `startPermitKeyId` en v2-permitverificatie met `kid`. Dit maakt de operationele documentatie intern tegenstrijdig. - info — `README.md:699` — De documentatie noteert dat praktische acceptance tegen een live dispatch service nog niet heeft gelopen. Dat is geen code-blocker op zichzelf, maar bij deze grote dispatcher-introductie blijft het een expliciete rollout/test-gap om vóór productiegebruik af te sluiten. Geen blocking/error-severity findings gevonden in de beoordeelde diff. Omdat de PR groot is, geen gekoppeld plan heeft en de docs nog inconsistenties/test-gap noemen, kies ik safe-default voor `COMMENT` in plaats van `APPROVED`.
s4m-codex-reviewer left a comment

Verdict: COMMENT

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

Findings

  • low — .env.example:63: de README claimt dat de environment-tabel alle variabelen bevat die de dispatch-code leest en documenteert DISPATCH_AGENT_OUTPUT_KEY, en src/dispatch/server.ts leest die variabele ook om de /agent/* gateway te activeren. In .env.example ontbreekt deze variabele echter in het dispatch-blok. Dat maakt lokale/operator-configuratie onvolledig voor deployments die child output capabilities willen inschakelen.

Samenvatting

De PR introduceert een grote, goed afgeschermde centrale dispatch-service met expliciete off-by-default configuratie, beperkte route-activatie op basis van aanwezige keys, managed-job guards op bestaande jobtools en uitgebreide dispatch-testdekking. Ik heb geen blokkerende correctness- of architectuurfinding gevonden in de beoordeelde diff; bovenstaande configuratie-docs drift is klein genoeg voor een comment-verdict.

# Verdict: COMMENT Geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden. ## Findings - low — `.env.example:63`: de README claimt dat de environment-tabel alle variabelen bevat die de dispatch-code leest en documenteert `DISPATCH_AGENT_OUTPUT_KEY`, en `src/dispatch/server.ts` leest die variabele ook om de `/agent/*` gateway te activeren. In `.env.example` ontbreekt deze variabele echter in het dispatch-blok. Dat maakt lokale/operator-configuratie onvolledig voor deployments die child output capabilities willen inschakelen. ## Samenvatting De PR introduceert een grote, goed afgeschermde centrale dispatch-service met expliciete off-by-default configuratie, beperkte route-activatie op basis van aanwezige keys, managed-job guards op bestaande jobtools en uitgebreide dispatch-testdekking. Ik heb geen blokkerende correctness- of architectuurfinding gevonden in de beoordeelde diff; bovenstaande configuratie-docs drift is klein genoeg voor een comment-verdict.
Author
Owner

Review afgehandeld — F-M1 + F-M2 gerepareerd (docs).

  • F-M1 (ERROR): .env.example en de README env-tabel misten DISPATCH_START_PERMIT_KEY_ID, dat de executor-routes vereist (executor = credentials && permit && permitKid). Toegevoegd met de kid-eis (key zonder kid = ongeconfigureerde signer → geen /attempts/*).
  • F-M2 (WARNING): idem voor de aparte manifest-signer DISPATCH_SOURCE_MANIFEST_PRIVATE_KEY + DISPATCH_SOURCE_MANIFEST_KEY_ID (beide vereist of de manifest-route blijft unavailable; geen fallback op de permit-sleutel).

Commit a67a75a. Verificatie: npm test 1762 passed | 42 skipped. Deze branch is ook met origin/main gemerged (b2f353e) en herpind op shared c65f5a8 (1b0b50f, parity groen).

**Review afgehandeld — F-M1 + F-M2 gerepareerd (docs).** - **F-M1 (ERROR)**: `.env.example` en de README env-tabel misten `DISPATCH_START_PERMIT_KEY_ID`, dat de executor-routes vereist (`executor = credentials && permit && permitKid`). Toegevoegd met de kid-eis (key zonder kid = ongeconfigureerde signer → geen `/attempts/*`). - **F-M2 (WARNING)**: idem voor de aparte manifest-signer `DISPATCH_SOURCE_MANIFEST_PRIVATE_KEY` + `DISPATCH_SOURCE_MANIFEST_KEY_ID` (beide vereist of de manifest-route blijft unavailable; geen fallback op de permit-sleutel). Commit `a67a75a`. Verificatie: `npm test` 1762 passed | 42 skipped. Deze branch is ook met `origin/main` gemerged (`b2f353e`) en herpind op shared `c65f5a8` (`1b0b50f`, parity groen).
docs(dispatch): add DISPATCH_AGENT_OUTPUT_KEY to .env.example
All checks were successful
CI / Final merge attestation and immutable publication (pull_request) Has been skipped
CI / PR candidate (never published) (pull_request) Successful in 5m7s
b59cdbf609
The child-output gateway var was read by src/dispatch/server.ts and
documented in the README env table, but missing from .env.example,
leaving operator config incomplete for deployments enabling the
/agent/* capability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

Re-review-comment afgehandeld (low). .env.example miste DISPATCH_AGENT_OUTPUT_KEY (server.ts leest 'm voor de /agent/*-gateway; README documenteerde 'm al). Toegevoegd in het dispatch-blok, comment gelijk aan het echte gedrag (opt-in HMAC ≥32 bytes; afwezig → geen /agent/*-routes, geen capability). Commit b59cdbf, npm test 1762 passed | 42 skipped.

**Re-review-comment afgehandeld (low).** `.env.example` miste `DISPATCH_AGENT_OUTPUT_KEY` (server.ts leest 'm voor de `/agent/*`-gateway; README documenteerde 'm al). Toegevoegd in het dispatch-blok, comment gelijk aan het echte gedrag (opt-in HMAC ≥32 bytes; afwezig → geen `/agent/*`-routes, geen capability). Commit `b59cdbf`, `npm test` 1762 passed | 42 skipped.
s4m-codex-reviewer left a comment

Verdict: COMMENT

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

Findings

  • info — algemeen: geen blokkerende findings gevonden in de diff. De centrale dispatch-laag volgt de bestaande scheiding tussen shared DB/network tools en stdio-only worktree/queue-tools; de task-dispatch contracten worden via de shared validator afgedwongen voordat de snapshot-code repositorygegevens gebruikt.
  • info — verificatie: node node_modules/typescript/bin/tsc --noEmit is groen na git submodule update --init --recursive en node node_modules/prisma/build/index.js generate.
  • info — verificatie: node node_modules/typescript/bin/tsc --noEmit -p tsconfig.type-tests.json is groen, conform de product-doc over aparte typecheck voor src en tests.
  • warning — testomgeving: node node_modules/vitest/vitest.mjs run --config vitest.config.ts kon in deze review-container niet starten door een native rolldown binding/DLOPEN-fout (failed to map segment from shared object). Dit is geen inhoudelijke failing test uit de PR, maar daardoor is de gewone Vitest-suite hier niet bewezen groen.

Conclusie

Geen reden voor REQUEST_CHANGES, maar door de niet-uitgevoerde Vitest-suite kies ik safe-default COMMENT in plaats van APPROVED.

# Verdict: COMMENT Geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden. ## Findings - info — algemeen: geen blokkerende findings gevonden in de diff. De centrale dispatch-laag volgt de bestaande scheiding tussen shared DB/network tools en stdio-only worktree/queue-tools; de task-dispatch contracten worden via de shared validator afgedwongen voordat de snapshot-code repositorygegevens gebruikt. - info — verificatie: `node node_modules/typescript/bin/tsc --noEmit` is groen na `git submodule update --init --recursive` en `node node_modules/prisma/build/index.js generate`. - info — verificatie: `node node_modules/typescript/bin/tsc --noEmit -p tsconfig.type-tests.json` is groen, conform de product-doc over aparte typecheck voor `src` en tests. - warning — testomgeving: `node node_modules/vitest/vitest.mjs run --config vitest.config.ts` kon in deze review-container niet starten door een native `rolldown` binding/DLOPEN-fout (`failed to map segment from shared object`). Dit is geen inhoudelijke failing test uit de PR, maar daardoor is de gewone Vitest-suite hier niet bewezen groen. ## Conclusie Geen reden voor `REQUEST_CHANGES`, maar door de niet-uitgevoerde Vitest-suite kies ik safe-default `COMMENT` in plaats van `APPROVED`.
Sign in to join this conversation.
No reviewers
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-mcp!144
No description provided.