diff --git a/.kiro/specs/scheduler-dispatch-commit/design.md b/.kiro/specs/scheduler-dispatch-commit/design.md
new file mode 100644
index 00000000..6bf26998
--- /dev/null
+++ b/.kiro/specs/scheduler-dispatch-commit/design.md
@@ -0,0 +1,344 @@
+# Design Document
+
+## Overview
+
+**Purpose**: Move a job's transition to a running state from worker-confirmation time to dispatch-decision-commit time, on both the scheduler and the controller, and build the dispatch-failure retry/terminal-resolution machinery this requires — closing bug-1624 (resource-in-use totals silently drop a dispatched-but-unconfirmed job at the next round boundary) and bug-1625 (the scheduler discards the controller's existing dispatch-failure notification).
+
+**Users**: Cluster operators who rely on accurate live resource-in-use accounting and correct job status; scheduler/controller maintainers who need dispatch failures to resolve deterministically instead of leaking stale allocation state.
+
+**Impact**: `sched_commit_decision()` (scheduler) and `dispatch_a_job_()` (controller) each gain a state transition alongside the resource-allocation commit they already perform. A new scheduler-side notify handler and a new controller-side classification step are added. No existing component is replaced; both integration points are extended in place.
+
+### Goals
+- A job's state becomes RUN at the moment its dispatch is committed, on both scheduler and controller, with no window where the two disagree.
+- `sched_account_jobs()`'s existing RUN-state gate — unmodified — naturally includes a dispatched job from the round it was dispatched.
+- A dispatch failure resolves deterministically: transient/environmental failures retry the job back to PEND (bounded), local/invalid-decision failures fail the job permanently. Both outcomes release the stale allocation/commitment data from the failed attempt, on both sides.
+- The worker's actual confirmed-start fact is still durably recorded, unchanged, for `start_time` and audit purposes.
+
+### Non-Goals
+- Changing simulation-mode's own dispatch/commit path — `sched_commit_decision()` is not called by the simulation driver at all (confirmed in Research Log); no code change is needed there.
+- Implementing preemption delivery or victim selection — `scheduler-preemption` owns that; this design only leaves its cross-reference note intact.
+- Changing `scheduler-allocation`'s own allocation-plan logic — this design only changes when a job reaches RUN, not how a plan is chosen.
+- Redesigning the WAL entry header format or the raft-apply-decoupling deferred-apply mechanism — this design adds one new WAL fact type using the existing `wal_jobstate_t` shape and the existing synchronous-mutation discipline `job.allocs` already follows, not a new mechanism.
+
+## Boundary Commitments
+
+### This Spec Owns
+- The scheduler-side state transition inside `sched_commit_decision()`'s DISPATCH branch.
+- The controller-side state transition inside `dispatch_a_job_()`, gated on `xce_startjob_req()` success, correctly synchronized under `RTTI_LOCK_WRITE`, and the new WAL fact type recording it durably.
+- The controller-side dispatch-failure classification (retryable vs. terminal, including the bounded-retry check) and its two resolution paths.
+- The new scheduler-side `NotifyData_data_jobDispatchFail` handler (the actual fix for bug-1625) — a single, narrow case that applies `JOB_STAT_PEND`.
+
+### Out of Boundary
+- `scheduler.c`'s `sched_update()`/`sched_job_state_change()` — confirmed dead in the live driver (Research Log); not modified, not reused, not removed (removal is a separate cleanup, not required for this feature to be correct).
+- `core_events.c`'s `XCE_CORE_EVENT_JOB_STATE_CHANGE` case — already correct and already idempotent for a repeated RUN application; unmodified.
+- `builtin_fcfs`'s picker hooks (`on_dispatch`, `on_job_state_change`) — unmodified; their existing idempotency is relied upon, not extended.
+- The `limit` plugin's own usage accounting — confirmed to not share bug-1624's class of gate (Research Log); not investigated further here.
+- `mark_job_fail()`'s own mutate-then-notify body and its existing `HOOK_DETAIL_JOB_FAIL` → `schedFailJob` chain — reused as-is for the terminal resolution path; not modified.
+- Any change to `xcecli`/REST API response shapes — job status already reflects `job.state` directly; no new field is added there.
+- Fixing bug-1626 at `job_ctrl()`'s own pre-existing (and unrelated to dispatch) call to `mark_job_fail()` (`job.c:1083`) — this design's new call sites correctly take `RTTI_LOCK_WRITE` themselves, but `job_ctrl()`'s own gap is a separate, pre-existing condition this spec does not fix as a drive-by patch.
+
+### Allowed Dependencies
+- `sched_commit_decision()` (scheduler.c) and `sched_account_jobs()`'s existing `JOB_IS_RUN` gate (scheduler.c) — read, not modified, except for the one new transition line.
+- `mark_job_fail()` (job.c) and its existing hook chain — called, not modified, under a caller-supplied `RTTI_LOCK_WRITE` (its own documented precondition).
+- `notify_dispatch()` (job.c) — called, not modified (its meaning narrows by convention, not by signature change).
+- `persist_submit_job_fact_ex()` (persist.c) and `wal_jobstate_t` (wal_types.h) — extended with one new WAL entry type, following the existing `_pad`-discriminated shape.
+- `job_t`'s in-memory-only wrapper fields (`xce/types.h`) — extended with one new in-memory-only counter, following `submit_lsn`'s existing precedent.
+- `job_lock`/`job_unlock` (`src/lib/jobj.c`) via the existing `lock(j, RTTI_LOCK_WRITE)`/`unlock(j)` calling convention already used correctly at most other job-mutation sites in `job.c` — this design's new mutation sites must follow the same convention, not the unlocked pattern `dispatch_a_job_()` and `job_ctrl()`'s existing `mark_job_fail()` call site currently use (bug-1626, pre-existing — see `research.md`'s Addendum).
+
+### Revalidation Triggers
+- Any change to `sched_account_jobs()`'s RUN-state gate itself (not just what feeds it).
+- Any change to `dispatch_a_job_()`'s failure-point ordering or return codes — the classification table (Components, below) is keyed to today's exact failure points.
+- Any change to `mark_job_fail()`'s hook chain or to `HOOK_DETAIL_JOB_STOP`/`HOOK_DETAIL_JOB_FAIL`'s wiring to `sched.c`'s notify translation.
+- `scheduler-preemption` moving to design — its AC6/AC9 pending-revision note depends on this design's RUN-timing conclusion being final.
+- Any future fix to bug-1626 (the pre-existing `job_ctrl()`/`mark_job_fail()` unlocked call site, out of this spec's boundary) that changes `mark_job_fail()`'s locking contract itself, as opposed to fixing individual callers.
+
+## Architecture
+
+### Existing Architecture Analysis
+
+Two independently-maintained job records exist for the same logical job: the scheduler's `xce_sched_job_t` (`sched/types.h`, decision-making working copy) and the controller's `job_t`/`xce_job_t` (`xce/types.h`, the durable, user-facing record). They are today synchronized by two one-directional channels: scheduler → controller decision publish (`sched_commit_decision()` → `xcecli_jobdispatch()`), and controller → scheduler notify (`sched_handle_notify()`'s switch, `handler.c`). This design adds a state transition to each side's own record at its own commit point, rather than introducing a third synchronization channel — the two sides reach the same conclusion (RUN) independently, from the same triggering event (the dispatch decision), with the notify channel used only for the asymmetric case where the controller's downstream attempt fails after the scheduler already committed.
+
+A second, load-bearing discovery (Research Log): `scheduler.c`'s notify-apply path (`sched_update()`/`sched_job_state_change()`) is dead code in the current driver — the live path is `core_events.c`'s `XCE_CORE_EVENT_JOB_STATE_CHANGE` case, reached via `xce_sched_driver_translate_pending_events()`. All references below to "the existing RUN-confirmation path" mean this live path.
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+sequenceDiagram
+ participant Picker as Scheduler picker/allocator
+ participant SchedCommit as sched_commit_decision
+ participant Ctrl as Controller dispatch_a_job_ (owns resolution + notify)
+ participant Worker as Worker
+ participant Notify as Controller notify (existing)
+ participant SchedNotify as Scheduler sched_handle_notify
+
+ Picker->>SchedCommit: DISPATCH decision
+ SchedCommit->>SchedCommit: set job-accounts, job-allocs, state=RUN
+ SchedCommit-->>Ctrl: publish decision
+ Ctrl->>Worker: xce_startjob_req
+ alt RPC succeeds
+ Worker-->>Ctrl: ok
+ Ctrl->>Ctrl: lock j write, commit job-allocs, state=RUN, append WAL_JOB_DISPATCH fact, unlock
+ Note over Ctrl: worker later confirms actual start
WAL_JOB_START still recorded separately, unchanged
+ else RPC fails, transient
+ Ctrl->>Ctrl: increment dispatch_fail_count, stay PEND
+ Ctrl->>Notify: jobDispatchFail
+ Notify->>SchedNotify: NotifyData_data_jobDispatchFail
+ SchedNotify->>SchedNotify: apply JOB_STAT_PEND (new case)
+ else RPC fails, terminal or retry bound exceeded
+ Ctrl->>Ctrl: lock j write, mark_job_fail (existing), unlock
+ Ctrl->>Notify: schedFailJob (existing, unmodified path)
+ Notify->>SchedNotify: NotifyData_data_schedFailJob
+ SchedNotify->>SchedNotify: apply JOB_STAT_EXIT (existing, unmodified)
+ end
+```
+
+**Architecture Integration**:
+- Selected pattern: extend two existing, independent commit points rather than add a synchronization protocol. Reuses the existing asymmetric notify channel only for the failure case, where it is unavoidable (the scheduler cannot know the controller's downstream outcome any other way).
+- Domain/feature boundaries: scheduler-side accounting correctness and controller-side dispatch/failure handling remain separately owned, matching the existing component split; neither gains knowledge of the other's internals beyond the existing decision-publish/notify contracts.
+- Existing patterns preserved: `sched_account_jobs()`'s RUN-state gate, `mark_job_fail()`'s mutate-then-notify shape, `wal_jobstate_t`'s `_pad`-discriminated WAL fact shape, `job_t`'s in-memory-only wrapper-field convention.
+- New components rationale: a classification step and a bounded counter are new because no equivalent existed; a new WAL fact type is new because the transition now needs a durable record that `job.allocs`'s own (non-durable) precedent doesn't provide.
+- Steering compliance: no controller self-termination path is introduced; the terminal-failure path stays entirely inside the existing "fail loud, mutate, notify" discipline `mark_job_fail()` already follows.
+
+## File Structure Plan
+
+### Modified Files
+- `src/scheduler/src/scheduler.c` — `sched_commit_decision()`'s DISPATCH branch gains `job->state = JOB_STAT_RUN;`, alongside the existing `job->allocs`/`job->accounts` assignment. No new field.
+- `src/scheduler/src/handler.c` — `sched_handle_notify()`'s switch gains one new `case NotifyData_data_jobDispatchFail:`, applying `JOB_STAT_PEND` through the existing `XCE_SCHED_OP_JSTATE_CHANGE` enqueue path (same shape as the adjacent `schedExitJob`/`schedFailJob` cases).
+- `src/controller/job.c` — two coupled changes:
+ - `dispatch_a_job_()`'s success/failure handling is restructured: `job.allocs`'s commit block (today unconditional, `job.c:947-954`, run regardless of `xce_startjob_req()`'s `rc`) moves inside an `if (rc >= 0)` branch. On success, the function takes `lock(j, RTTI_LOCK_WRITE)`, commits `job.allocs`/`job.state`, appends the `WAL_JOB_DISPATCH` fact, then `unlock(j)` — closing bug-1626 for this call site as a direct consequence of restructuring it. On `xce_startjob_req()` failure (`XCE_DISPATCH_FAIL_SITE_RPC_FAILURE`, one of the six sites Controller Dispatch-Failure Classification enumerates), the function follows that component's same classify-resolve procedure. At each of the six post-lookup failure points, the function classifies and resolves inline (under its own `lock(j, RTTI_LOCK_WRITE)`): retryable (four sites, including RPC failure) → increment `dispatch_fail_count`, `unlock(j)`, `dereference(j)` (closing bug-1627's leak on these four paths), call `notify_dispatch()`; terminal (two sites) → call `mark_job_fail()` under the write lock, `unlock(j)` after (these two already correctly `dereference(j)` today, unaffected by bug-1627).
+ - `handle_jobdispatch()`'s loop drops its existing per-job `notify_dispatch(c, ep, alloc->jobid, ...)` calls (both the success-path and failure-path ones) — notification is now entirely owned by `dispatch_a_job_()`, preventing the double-notify race found in round 2 of this review (a terminal failure previously would have produced both `jobDispatchFail` and `schedFailJob`).
+- `src/include/xce/types.h` — `xce_job_t`'s wrapper `job_t` (not the wire type itself) gains `uint32_t dispatch_fail_count;`, following `submit_lsn`'s existing "in-memory only, reset on replay" precedent.
+- `src/include/wal_types.h` — one new `wal_entry_type` value, `WAL_JOB_DISPATCH = 12`, reusing the existing `wal_jobstate_t{jobid, state, _pad=0}` payload shape (the "legacy: state is absolute" discipline, matching `job.allocs`'s own synchronous-commit precedent — see Data Models).
+- `src/controller/persist.c` — `WAL_JOB_DISPATCH` is added as a new `case` label to two existing fall-through groups: `apply_wal_entry()`'s (recovery-replay path, `persist.c:3452-3466`) and `apply_committed_entry()`'s (live-apply path, `persist.c:3700-3706`) — both already apply `wal_jobstate_t.state` as an absolute value for every type in their respective groups; no new function or case body.
+- `src/controller/job.c` (same file, no new header) — `xce_classify_dispatch_fail()`, `xce_dispatch_fail_site_t`, and `xce_dispatch_fail_class_t` are declared `static`/file-local next to `dispatch_a_job_()`, its only caller — not in a shared header, since nothing outside this file consumes them (see Controller Dispatch-Failure Classification's placement-correction note).
+
+### New/Modified Test Files
+- `src/scheduler/testsuite/test_scheduler.c` (or a new focused file) — `sched_commit_decision()` DISPATCH-branch RUN-transition.
+- `src/scheduler/testsuite/test_handler.c` (or equivalent) — new `jobDispatchFail` case applies `JOB_STAT_PEND`, frees stale `accounts`/`allocs` via the existing shared path.
+- `src/controller/testsuite/test_job_dispatch.c` (or equivalent existing dispatch test file) — gated commit on RPC success/failure for all seven failure returns (the jobid-lookup case's two variants unchanged, the six post-lookup cases newly classified); retryable vs. terminal classification; bounded-retry escalation; WAL fact durability across a simulated restart/replay.
+- HA soak (`/xce-test-ha`) — no new file; existing sequential/chaotic regression re-run per Requirement 5.4.
+
+## System Flows
+
+See the sequence diagram above (Architecture Pattern & Boundary Map) for the end-to-end flow across both success and both failure branches. No additional flow diagrams are needed — the state-transition logic itself is a simple two-branch decision per side, not a multi-step process.
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1, 1.2, 1.3 | Scheduler RUN transition at commit time | Scheduler Dispatch Commit | `sched_commit_decision` | Sequence: SchedCommit branch |
+| 2.1, 2.3 | Controller RUN transition at dispatch success | Controller Dispatch Commit | `dispatch_a_job_` | Sequence: RPC succeeds branch |
+| 2.2 | Confirmed-start fact still recorded separately | Controller Dispatch Commit (unmodified sibling) | `record_job_start_fact` (unmodified) | n/a |
+| 2.4 | Durability of the new transition | WAL Dispatch Fact | `persist_submit_job_fact_ex`, `WAL_JOB_DISPATCH` apply cases (live + recovery) | Sequence: RPC succeeds branch |
+| 3.1, 3.2 | Scheduler processes dispatch-failure notify | Scheduler Dispatch-Failure Handler | `sched_handle_notify` new case | Sequence: transient branch |
+| 4.1, 4.2 | Retryable vs. terminal classification | Controller Dispatch-Failure Classification | classification table | Sequence: both failure branches |
+| 4.3 | Bounded retry count | Controller Dispatch-Failure Classification | `dispatch_fail_count` | Sequence: transient branch |
+| 4.4, 4.5 | Release stale allocation/commitment data | Scheduler: Scheduler Dispatch-Failure Handler (retry), `mark_job_fail` (terminal, unmodified). Controller: satisfied vacuously — Controller Dispatch Commit never commits on any failure path, so there is nothing to release | `core_events.c`'s existing free-on-non-RUN logic (scheduler side) | Sequence: both failure branches |
+| 5.1 | Confirmed-start fact recorded unchanged | Controller Dispatch Commit (unmodified sibling) | n/a | n/a |
+| 5.2 | Simulation mode unaffected | n/a (no simulation code touched) | n/a | Research Log |
+| 5.3, 5.4 | Regression / HA soak | n/a (test-only) | n/a | n/a |
+
+## Components and Interfaces
+
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
+|-----------|---------------|--------|---------------|---------------------------|-----------|
+| Scheduler Dispatch Commit | Scheduler / scheduler.c | Transition a job to RUN in the same operation that commits its dispatch decision | 1.1-1.3 | `sched_get_job` (P0), `sched_charge_account` (P0) | State |
+| Scheduler Dispatch-Failure Handler | Scheduler / handler.c | Apply PEND to a job whose dispatch was reported as retryably failed | 3.1, 3.2, 4.4 | `XCE_SCHED_OP_JSTATE_CHANGE` enqueue (P0), `core_events.c`'s existing apply case (P0) | Event |
+| Controller Dispatch Commit | Controller / job.c | Transition a job to RUN, and durably record it, only once dispatch to the worker actually succeeds | 2.1-2.4, 5.1 | `xce_startjob_req` (P0), WAL Dispatch Fact (P0) | State, Batch |
+| Controller Dispatch-Failure Classification | Controller / job.c | Decide retryable-vs-terminal for a dispatch failure, including the bounded-retry escalation | 4.1-4.3 | `job_t.dispatch_fail_count` (P0), `mark_job_fail` (P0), `notify_dispatch` (P0) | Service |
+| WAL Dispatch Fact | Controller / persist.c, wal_types.h | Durable record of the dispatch-time RUN transition, replayed on recovery | 2.4 | `persist_submit_job_fact_ex` (P0), `wal_jobstate_t` (P0) | Batch, State |
+
+### Scheduler (scheduler.c, handler.c)
+
+#### Scheduler Dispatch Commit
+| Field | Detail |
+|-------|--------|
+| Intent | Set `job->state = JOB_STAT_RUN` in the same operation that commits `job->allocs`/`job->accounts` |
+| Requirements | 1.1, 1.2, 1.3 |
+
+**Responsibilities & Constraints**
+- Runs inside `sched_commit_decision()`'s existing `XCE_DECISION_DISPATCH` branch, after `job->accounts` is built and before `sched_commit_job_alloc()` charges it — ordering among these three lines is not load-bearing to each other, but all three must complete before this function returns.
+- Does not call `plugin_job_state_change()`/`on_job_state_change` — the picker is already notified of this dispatch via the existing, unchanged `on_dispatch()` call in `sched_allocator()`. Firing a second hook is unnecessary and was confirmed unneeded via `builtin_fcfs_on_dispatch()`'s own pendJobs-removal behavior (Research Log).
+- The later, now-redundant `schedStartJob`-triggered application of `JOB_STAT_RUN` (via the existing, unmodified `core_events.c` path) is a confirmed-safe no-op against an already-RUN job — no guard is added here to suppress it; suppressing it would be unnecessary defensive code against an already-idempotent path.
+
+**Contracts**: State [x]
+
+##### State Management
+- State model: `job->state` transitions PEND → RUN synchronously, in-process, no deferred-apply (the scheduler has no WAL/durability concept — `xce_sched_job_t` is a working copy, not a system of record).
+- Persistence & consistency: none — a scheduler restart already rebuilds `xce_sched_job_t` from a fresh controller push, which will already reflect whatever the controller's own (durable) state is by then.
+- Concurrency strategy: unchanged — `sched_commit_decision()` already runs single-threaded on the driver's decision-drain loop; no new locking is introduced.
+
+#### Scheduler Dispatch-Failure Handler
+| Field | Detail |
+|-------|--------|
+| Intent | The actual fix for bug-1625: process `NotifyData_data_jobDispatchFail` instead of falling to `default: -EINVAL` |
+| Requirements | 3.1, 3.2, 4.4 |
+
+**Responsibilities & Constraints**
+- New `case NotifyData_data_jobDispatchFail:` in `sched_handle_notify()`'s switch (`handler.c`), positioned alongside the existing `schedStartJob`/`schedExitJob`/`schedFailJob` cases.
+- Calls the same enqueue helper those cases already use (`sched_job_state_change(sched, notify->data.job_dispatch_fail->jobid, JOB_STAT_PEND)` — the `handler.c`-local queuing wrapper, not the dead `scheduler.c` mutator of the same name), so the actual state application flows through the live `xce_sched_driver_translate_pending_events()` → `core_events.c` path identically to every other notify-driven state change.
+- No new classification logic here — `jobDispatchFail`'s meaning is narrowed (Research Log) to exclusively mean "return this job to pending"; the controller has already decided this is the right resolution before sending it.
+- The existing `core_events.c` `XCE_CORE_EVENT_JOB_STATE_CHANGE` case's `!JOB_IS_RUN(new_state)` branch frees `job->accounts`/`job->allocs` automatically — this component does not duplicate that logic (Requirement 4.4 is satisfied by the existing, unmodified free-on-non-RUN path, reused rather than reimplemented).
+
+**Contracts**: Event [x]
+
+##### Event Contract
+- Subscribed events: `NotifyData_data_jobDispatchFail` (existing capnp/notify type, already sent by `notify_dispatch()`, currently unhandled).
+- Ordering / delivery guarantees: same as every other notify — delivered in-order on the same connection, applied on the next round's `xce_sched_driver_translate_pending_events()` drain (same latency characteristics as `schedStartJob`/`schedExitJob`/`schedFailJob` today).
+
+### Controller (job.c, persist.c)
+
+#### Controller Dispatch Commit
+| Field | Detail |
+|-------|--------|
+| Intent | Commit `job.allocs` and transition to RUN only once `xce_startjob_req()` succeeds; append a durable fact recording the transition |
+| Requirements | 2.1, 2.3, 2.4, 5.1 |
+
+**Responsibilities & Constraints**
+- `dispatch_a_job_()`'s existing unconditional `j->job.allocs = ...` assignment (today placed *after* the `xce_startjob_req()` call but *not* gated on its return code) becomes conditional: only committed when `rc >= 0`. `j->job.state = JOB_STAT_RUN` is set in the same conditional block.
+- This ordering choice supersedes requirements.md 2.1's literal "before attempting to notify the worker" phrasing (Research Log: gating on RPC success removes an entire reversal-path category that gating on decision-receipt would require). The observable guarantee requirements.md 2.1/2.3 actually asks for — the transition and the allocation commit happen as one atomic operation, and status queries reflect RUN once that operation completes — is preserved exactly; only the specific instant within `dispatch_a_job_()` moves later, to right after a successful RPC instead of right before the RPC attempt.
+- On success, appends one `WAL_JOB_DISPATCH` fact (see WAL Dispatch Fact, below) recording `{jobid, state=JOB_STAT_RUN}`, in addition to the synchronous in-memory mutation — mirrors `job.allocs`'s existing synchronous-commit style, not `record_job_start_fact()`'s deferred-apply style (Research Log: no same-job racing-fact concern exists for the first post-PEND transition).
+- Requirement 2.2's worker-confirmed-start fact (`record_job_start_fact()`/`WAL_JOB_START`) is entirely unmodified — it continues to fire later, from the existing worker-confirmation code path, and continues to set `start_time`.
+- Requirement 2.3 (status queries reflect RUN) requires no new code — `job.state` is read directly by every existing status/listing path today; committing it earlier is sufficient.
+
+**Contracts**: State [x] / Batch [x]
+
+##### State Management
+- State model: PEND → RUN, gated on RPC success; on RPC failure, state is untouched (still PEND — never entered RUN, so no reversal is needed, per the Research Log finding).
+- Persistence & consistency: the in-memory mutation and the WAL fact append happen back-to-back under `lock(j, RTTI_LOCK_WRITE)` (acquired explicitly by this component, held across both the `job.allocs`/`job.state` mutation and the `persist_submit_job_fact_ex()` call, released via `unlock(j)` immediately after) — `_pad = 0` (legacy/absolute), matching `wal_jobstate_t`'s documented convention for a mutation with no staleness window.
+- Concurrency strategy: **corrects a pre-existing gap (bug-1626), does not merely reuse it.** `dispatch_a_job_()`'s existing locking, traced directly (`job.c:831-963`), takes only `RTTI_LOCK_READ` on `c->jobmgr`/`c->workermgr`/the worker `w` — it never takes any lock on the job object `j` itself, despite `j->job.allocs` already being mutated today. `RTTI_LOCK_READ` is a genuine `pthread`-backed reader lock (`my_rwlock_rlock()`, confirmed in `src/lib/jobj.c:708-725`), not a liveness/reference guard, so this is a real gap, not a documentation inaccuracy. This component's mutation (and the pre-existing `job.allocs` mutation it sits beside, which this restructuring already touches) is brought under `RTTI_LOCK_WRITE`, matching the convention most other job-mutating call sites in `job.c` already correctly follow (e.g. `job.c:768,1687,1795,2058`). `job_ctrl()`'s own separate, unrelated call to `mark_job_fail()` (`job.c:1083`) has the same gap and is intentionally left unfixed here (Out of Boundary) — only this feature's own new/restructured call sites are corrected.
+
+##### Batch / Job Contract
+- Trigger: successful `xce_startjob_req()` return within `dispatch_a_job_()`.
+- Input / validation: `jobid`, target state (`JOB_STAT_RUN`) — no new validation beyond what `dispatch_a_job_()` already performs before reaching this point.
+- Output / destination: one `WAL_JOB_DISPATCH` WAL entry, replicated and applied through the existing commit/apply pipeline.
+- Idempotency & recovery: `WAL_JOB_DISPATCH`'s case in both `apply_committed_entry()` (live) and `apply_wal_entry()` (recovery replay) applies `state` as an absolute value (matching every `_pad == 0` job-state fact today) — replaying it any number of times is a no-op after the first, identical to how every other absolute job-state fact already behaves.
+
+#### Controller Dispatch-Failure Classification
+| Field | Detail |
+|-------|--------|
+| Intent | Decide retryable vs. terminal for one of `dispatch_a_job_()`'s six post-lookup failure points, including the bounded-retry escalation, and route to the corresponding resolution mechanism — fully inside `dispatch_a_job_()`, not deferred to its caller |
+| Requirements | 4.1, 4.2, 4.3 |
+
+**Responsibilities & Constraints**
+- A small classification table maps each of `dispatch_a_job_()`'s six post-lookup failure sites to one of two classes:
+ - **Retryable** (transient/environmental): worker lookup failure, worker not found, worker-not-ready (`E_XCE_BAD_WORKER`), the `xce_startjob_req()` RPC call itself failing.
+ - **Terminal** (local/invariant): invalid allocation (`nc <= 0`), queue-exec-info lookup failure.
+ - The seventh failure point (jobid lookup failure, `j` never resolved) is not part of this table at all — see the dedicated bullet below.
+- **Placement correction, found in the same round as the site-identity fix above**: this table has no consumer outside `job.c` — nothing on the scheduler side classifies (that assumption was superseded when classification moved to be controller-authoritative). It is declared `static` in `job.c`, not in the shared `src/include/xce/errnox.h` — a shared header is for cross-component contracts, and this classification is an internal implementation detail of one function's restructuring.
+- `xce_startjob_req()`'s own RPC failure (`job.c:956-960`, the function's final failure return) is the sixth site, `XCE_DISPATCH_FAIL_SITE_RPC_FAILURE` — see Controller Dispatch Commit for the accompanying `job.allocs`-gating and `dereference(j)` fixes at that exact call site.
+- **Ownership correction found during `/kiro-validate-design` round 2**: classification and resolution happen *inside* `dispatch_a_job_()`, at each of its six post-lookup failure points, using the `j`/`job_t*` pointer already held there — not in its caller `handle_jobdispatch()`. Two independent reasons: (1) `handle_jobdispatch()` only has `alloc->jobid` after `dispatch_a_job_()` returns (`alloc` is freed immediately after), so resolving there would require a second `key_find()` lookup and reopen a TOCTOU gap between the two; (2) `handle_jobdispatch()`'s existing loop unconditionally calls `notify_dispatch()` for *every* `dispatch_a_job_()` return, success (`code=0`) or failure — `notify_dispatch()` itself always emits `NotifyData_data_jobDispatchFail` as the event type regardless of `code` (confirmed by reading its body, `job.c:1108-1125`). Left unchanged, a terminal failure would produce *both* a `jobDispatchFail` notify (from the existing unconditional call) *and* a `schedFailJob` notify (from `mark_job_fail()`'s hook chain) for the same event, racing to apply PEND and EXIT to the same job depending on arrival order.
+- **Fix**: `handle_jobdispatch()`'s existing per-job `notify_dispatch()` calls (both the success-path `notify_dispatch(c, ep, alloc->jobid, 0)` and the failure-path `notify_dispatch(c, ep, alloc->jobid, rc1)`) are removed from that loop. `dispatch_a_job_()` now owns notification as part of resolution: it calls `notify_dispatch()` itself, only on the retryable-failure path, and never on success or on a terminal failure (`mark_job_fail()`'s own hook chain is the sole notification for that case). `handle_jobdispatch()`'s loop becomes a thin dispatch-and-free loop with no notification logic of its own.
+- **Every one of the six post-lookup failure points calls `xce_classify_dispatch_fail(site)` with its own `xce_dispatch_fail_site_t` identity** (not the raw `rc`/`errno` value that point happened to return — see Service Interface for why) — classification is never implicit in which branch executed; the table is the single source of truth, actually called at every site, not merely documentation the branch structure happens to match. This also makes the function genuinely load-bearing (not dead weight the Unit Tests section would otherwise be testing in isolation).
+- **Single critical section per failure point** — deliberately structured to avoid an unlock/relock gap between the count check and the resolution it drives (a `dispatch_fail_count` read separated from its acting-upon by a lock release would be a fresh race, on top of the one this design already closes). At each of the six post-lookup failure points, inside `dispatch_a_job_()`, before that point's existing `dereference(j)`/return (or, for the four sites bug-1627 identifies, where that `dereference(j)` is being newly added):
+ 1. `lock(j, RTTI_LOCK_WRITE)`.
+ 2. Call `xce_classify_dispatch_fail(site)` with this failure point's own `xce_dispatch_fail_site_t` value. If `XCE_DISPATCH_FAIL_TERMINAL`, skip to step 3's terminal branch immediately (no counter involved). If `XCE_DISPATCH_FAIL_RETRYABLE`, increment `job.dispatch_fail_count` and capture the resulting value into a local.
+ 3. **Still under the same lock**: for a retryable classification whose incremented count is within the configured bound (design default: 3 consecutive failures; exact value is a task-level config decision, not hardcoded here), nothing further needs the lock — this failure resolves retryable. For a retryable classification whose count exceeds the bound, or a classification that was terminal from step 2, this failure resolves terminal **without releasing and reacquiring the lock** — call `mark_job_fail()` directly here (already satisfying its `RTTI_LOCK_WRITE`-held precondition), with a `job_failinfo_t` describing the dispatch failure (`jobid`, `code` = the original error, `detail` = a short diagnostic string; `.stage` left at 0, which correctly routes `apply_job_fail_transition()`'s switch to its `default:` branch, `job->job.state = JOB_STAT_EXIT` — confirmed by reading that function directly; the two named `.stage` cases are for post-exec failures, not applicable to a job that never started).
+ 4. `unlock(j)`, then `dereference(j)` (closing bug-1627's leak on the four failure points where it exists today, found during rounds 3-4 of this review — the other two, `nc <= 0` and `get_queue_exec()` failure, already correctly `dereference(j)`, unaffected by that bug — this step is uniform across all six regardless).
+ 5. Only now, entirely outside the lock: if step 3 resolved retryable, call `notify_dispatch(c, ep, jobid, rc)` — now the *only* caller of this function for the dispatch-failure case, so `jobDispatchFail` unambiguously means "retry." If step 3 resolved terminal (whichever of the two ways it got there), `notify_dispatch()` is **not** called — `mark_job_fail()`'s hook chain (already fired, under the lock, in step 3) is the sole notification.
+- `job.dispatch_fail_count` is reset to 0 by Controller Dispatch Commit's own success path (above, under that component's own `RTTI_LOCK_WRITE`) — a fresh successful dispatch clears any prior failure history for that job.
+- **How the controller side satisfies Requirement 4.4/4.5's "release stale allocation/commitment data"**: not by cleaning up after a failed commit, but by never committing in the first place — `job.allocs`/`job.state` are only ever written inside the `rc >= 0` success branch (Controller Dispatch Commit), so on any of the six classified failure paths there is nothing to release; the requirement is satisfied vacuously by construction, not by an explicit cleanup step. This is the direct benefit of the RPC-gated commit ordering decision in `research.md`.
+- `dispatch_a_job_()`'s earliest failure point (jobid lookup failure — two code variants, `key_find()` itself failing or returning a NULL `j`, before `j` is ever resolved) is a distinct case outside this table entirely: there is no `job_t*` to mark failed or count against. This is not a job-state resolution problem, it is "the job does not exist" — the function's existing `return rc`/`return -errno` is sufficient, unchanged. Requirement 4's classification and this component's locking/counting/notification logic apply only to the six failure points reached *after* `j` is successfully resolved and referenced.
+
+**Contracts**: Service [x]
+
+##### Service Interface
+```c
+/*
+ * Classifies one of dispatch_a_job_()'s six post-lookup failure points as
+ * retryable or terminal. Deliberately keyed on WHICH failure point was hit
+ * (an explicit site identity), not on the raw errno-style `int rc` that
+ * point returns -- key_find() (workermgr) and get_queue_exec() are two
+ * distinct, independently-implemented failure sources that are not
+ * guaranteed to return disjoint error codes (e.g. both could plausibly
+ * return a generic -ENOENT for unrelated reasons), so classifying by rc
+ * value alone would be ambiguous whenever two sites happen to collide.
+ * Does not itself mutate job state, take any lock, or send notifications --
+ * called from within dispatch_a_job_() itself, at each site, which uses the
+ * result to choose between notify_dispatch() (retry) and mark_job_fail()
+ * (terminal).
+ */
+typedef enum {
+ XCE_DISPATCH_FAIL_SITE_WORKER_LOOKUP = 0, /* workermgr key_find() rc<0 */
+ XCE_DISPATCH_FAIL_SITE_WORKER_NULL = 1, /* worker not found (w==NULL) */
+ XCE_DISPATCH_FAIL_SITE_WORKER_NOT_READY = 2, /* E_XCE_BAD_WORKER */
+ XCE_DISPATCH_FAIL_SITE_INVALID_ALLOC = 3, /* nc <= 0 */
+ XCE_DISPATCH_FAIL_SITE_QUEUE_EXEC = 4, /* get_queue_exec() failure */
+ XCE_DISPATCH_FAIL_SITE_RPC_FAILURE = 5, /* xce_startjob_req() rc<0 */
+} xce_dispatch_fail_site_t;
+
+typedef enum {
+ XCE_DISPATCH_FAIL_RETRYABLE = 0,
+ XCE_DISPATCH_FAIL_TERMINAL = 1,
+} xce_dispatch_fail_class_t;
+
+xce_dispatch_fail_class_t xce_classify_dispatch_fail(xce_dispatch_fail_site_t site);
+```
+- Preconditions: `site` is one of the six named enumerators. **Corrected during task 1.1 implementation review**: a default-less `switch` does NOT compel a compile error in plain C without `-Werror` (this project sets none) — it only emits a non-fatal `-Wreturn-type` warning and falls through to undefined behavior on an unhandled value. The actual implementation instead follows this codebase's established fail-loud convention (matching `persist.c`'s `wal_classify()`, `persist.c:6030-6037`): a `default:` arm that ERROR-logs and returns a safe `XCE_DISPATCH_FAIL_TERMINAL`, with `assert(0 && ...)` gated to `#ifdef XCE_TESTING` only, so a future unhandled site fails loud in test builds and degrades safely (never silently misclassifies as retryable) in production.
+- Postconditions: pure function, no side effects; the caller separately applies the bounded-retry override. `WORKER_LOOKUP`, `WORKER_NULL`, `WORKER_NOT_READY`, `RPC_FAILURE` classify `XCE_DISPATCH_FAIL_RETRYABLE`; `INVALID_ALLOC`, `QUEUE_EXEC` classify `XCE_DISPATCH_FAIL_TERMINAL`.
+- Invariants: the same `site` always classifies the same way — no hidden state, no time-dependence, no dependence on the specific `rc`/`errno` value that accompanied it.
+
+### WAL Dispatch Fact (persist.c, wal_types.h)
+
+#### WAL Dispatch Fact
+| Field | Detail |
+|-------|--------|
+| Intent | Durable record of the dispatch-time RUN transition, so a controller failover recovers the same state a live run would have reached |
+| Requirements | 2.4 |
+
+**Responsibilities & Constraints**
+- New `wal_entry_type` value `WAL_JOB_DISPATCH = 12` (next free value in the job-fact range, after `WAL_JOB_ARRAY_SUBMIT = 11`).
+- Reuses the existing `wal_jobstate_t{jobid, state, _pad}` payload verbatim — no new struct. `_pad` is always `0` for this type (the "legacy: state is absolute value" convention `wal_jobstate_t`'s own header comment documents), matching the synchronous, non-deferred-apply commit style Controller Dispatch Commit uses.
+- **Two separate functions, both needing the new case** — found by tracing precisely rather than assuming a single switch: `apply_wal_entry()` (`persist.c:3375+`, the recovery/replay-time path reading WAL segments from disk after restart) has its own fall-through group at `persist.c:3452-3466` (`WAL_JOB_START` through `WAL_JOB_RUSAGE`, including `WAL_JOB_FAIL` — this recovery path applies every type as an absolute overwrite, unconditionally). `apply_committed_entry()` (`persist.c:3570+`, the live path invoked immediately when a WAL entry commits during normal operation — the one `raft-apply-decoupling` actually introduced the `_pad`-discriminated routing for) has its *own separate* fall-through group at `persist.c:3700-3706` (`WAL_JOB_START`, `WAL_JOB_FINISH`, `WAL_JOB_STOP`, `WAL_JOB_RESUME`, `WAL_JOB_RUSAGE`, `WAL_JOB_CKPT`, `WAL_JOB_PREASON` — deliberately excluding `WAL_JOB_FAIL`/`WAL_JOB_UPDATE`, which get their own `_pad`-aware handling immediately after). That group's own comment states the exact rationale this design already relies on: these types' payloads are "deterministic/state-independent... an absolute `state` baked at receipt time never goes stale" — `WAL_JOB_DISPATCH` fits this group by the same reasoning (Research Log: no same-job racing-fact concern for the first post-PEND transition). `WAL_JOB_DISPATCH` joins both fall-through lists — no new apply-time function or standalone case body is introduced in either.
+- Does not carry or restore `job.allocs`/`job.accounts` — those remain non-durable in-memory fields, unchanged from today's behavior (Boundary: Out of Boundary does not list a new durability guarantee for allocation data itself, only for the state transition).
+
+**Contracts**: Batch [x] / State [x]
+
+##### Batch / Job Contract
+- Trigger: Controller Dispatch Commit's success path.
+- Input / validation: `jobid`, `state = JOB_STAT_RUN`.
+- Output / destination: standard WAL append → replication → commit pipeline, identical to every other job-state fact.
+- Idempotency & recovery: absolute-value apply, safe to replay any number of times.
+
+## Data Models
+
+### Domain Model
+- No new domain entities. `job_t.dispatch_fail_count` (via `xce_job_t`'s wrapper, not the wire type) is a new scalar field on an existing entity, not a new aggregate. This counter is controller-side only — the scheduler has no equivalent field, since only the controller's copy is read for classification (see Controller Dispatch-Failure Classification); a scheduler-side mirror would be dead weight with no consumer.
+
+### Logical Data Model
+- `job_t` (`xce/types.h`, the `{xce_job_t job; uint64_t submit_lsn;}` wrapper — **not** `xce_job_t` itself): `+ uint32_t dispatch_fail_count` — in-memory only, following `submit_lsn`'s exact precedent ("recomputed... never serialized"). A controller failover resets this to 0, which is a safe, conservative degradation (more retries allowed post-failover, never fewer, and never a misrouted terminal/retry decision).
+- `wal_entry_type` (`wal_types.h`): `+ WAL_JOB_DISPATCH = 12`, payload = existing `wal_jobstate_t`.
+
+## Error Handling
+
+### Error Strategy
+Two independent failure surfaces, each already governed by an existing discipline this design does not change: the scheduler-side notify handler follows the same `-ENOENT`/`-EINVAL` conventions every sibling case in `sched_handle_notify()` already uses; the controller-side classification is keyed on an exhaustive, compiler-checked failure-site enum (no default/unrecognized branch is reachable) rather than on a possibly-ambiguous raw return code.
+
+### Error Categories and Responses
+- **Unrecognized dispatch-fail notify jobid** (scheduler side): same as every existing case — `sched_job_state_change()`'s enqueue wrapper does not validate jobid existence at enqueue time; the live apply path's `sched_get_job() == NULL` branch (already present in `core_events.c`) logs a WARN and no-ops, unchanged.
+- **A new post-lookup failure point added to `dispatch_a_job_()` in the future without updating the classification** (controller side): fails to compile — `xce_classify_dispatch_fail()`'s `switch` over `xce_dispatch_fail_site_t` has no `default` case, so a new enumerator with no matching `case` is a compiler error, not a silent runtime default.
+- **Bounded-retry exhaustion**: not an error path — a deliberate, expected transition to terminal, using the exact same `mark_job_fail()` call as any other terminal classification.
+
+### Monitoring
+No new logging infrastructure — `mark_job_fail()`'s existing hook-fired logging and `sched_handle_notify()`'s existing per-case `TRACE`/`WARN` conventions already cover both new code paths; only the log lines' content changes to reference the new case names.
+
+## Testing Strategy
+
+### Unit Tests
+- `sched_commit_decision()`'s DISPATCH branch sets `job->state == JOB_STAT_RUN`, in addition to the existing `accounts`/`allocs` assertions.
+- `sched_handle_notify()`'s new `jobDispatchFail` case enqueues an `XCE_SCHED_OP_JSTATE_CHANGE` targeting `JOB_STAT_PEND`; a follow-up drain via `xce_sched_driver_translate_pending_events()`/`core_events.c` confirms `job->accounts`/`job->allocs` are freed (reusing the existing free-on-non-RUN assertion pattern from other state-change tests).
+- `dispatch_a_job_()`: for each of the seven failure returns (jobid lookup's two variants, plus the six post-lookup sites), confirm `job.state` remains PEND and `job.allocs` is not committed; for a successful `xce_startjob_req()`, confirm both are committed together with one `WAL_JOB_DISPATCH` fact appended. For the four retryable-class paths, additionally assert `j`'s reference count returns to its pre-call value (regression test for bug-1627's leak).
+- `xce_classify_dispatch_fail()`: table-driven test covering all six `xce_dispatch_fail_site_t` enumerators against their documented classification (four retryable, two terminal).
+- Bounded-retry escalation: three consecutive retryable failures for the same job route to `notify_dispatch()`; the fourth (exceeding the design-default bound of 3) routes to `mark_job_fail()` instead.
+
+### Integration Tests
+- End-to-end: scheduler commits a DISPATCH decision → controller processes it successfully → scheduler's `sched_account_jobs()` includes the job's resource consumption from that same round onward, across a simulated multi-round gap before the worker's actual confirmation arrives (this is bug-1624's regression test).
+- End-to-end retryable failure: controller's `xce_startjob_req()` fails (simulated worker-unreachable) → scheduler receives `jobDispatchFail` → job returns to PEND → scheduler's picker reconsiders it in a later round.
+- End-to-end terminal failure: controller's jobid/allocation lookup fails → `mark_job_fail()` fires → scheduler receives the existing `schedFailJob` notify (unmodified path) → job resolves to EXIT with `accounts`/`allocs` freed. Assert the scheduler does **not** also receive a `jobDispatchFail` notify for this same failure (regression test for the round-2 double-notify finding) — `handle_jobdispatch()`'s loop must not call `notify_dispatch()` for a failure `dispatch_a_job_()` already resolved as terminal.
+- WAL durability: append a `WAL_JOB_DISPATCH` fact, simulate a controller restart/replay, confirm `job.state == JOB_STAT_RUN` is recovered without requiring the (separate, unmodified) `WAL_JOB_START` fact to also be present.
+
+### Performance/Load
+- Not applicable — this feature adds O(1) work per dispatch decision and per notify; no new per-round scan or O(n) pass is introduced (`sched_account_jobs()` itself is unmodified).
+
+## Supporting References
+- `.kiro/specs/scheduler-dispatch-commit/research.md` — full discovery findings, the three design decisions this document builds on (dead-code path identification, RPC-gated commit ordering, controller-authoritative classification), and risk mitigations.
diff --git a/.kiro/specs/scheduler-dispatch-commit/requirements.md b/.kiro/specs/scheduler-dispatch-commit/requirements.md
new file mode 100644
index 00000000..81136c4e
--- /dev/null
+++ b/.kiro/specs/scheduler-dispatch-commit/requirements.md
@@ -0,0 +1,71 @@
+# Requirements Document
+
+## Project Description (Input)
+
+**Who has the problem:** scheduler and controller maintainers, and cluster operators who rely on accurate live resource accounting and (eventually) preemption. Surfaced while investigating bug-1611/1613-adjacent behavior and while designing the sibling `scheduler-sim-eager-reconciliation` spec.
+
+**Current situation:** `job->state`'s transition to RUN only happens later, via a separate worker-confirmation event — scheduler's `sched_job_state_change()` (`scheduler.c:963-988`) fires only on the controller's `NotifyData_data_schedStartJob` notify; controller's `job.state` flips to RUN only via `on_job_start()`/`record_job_start_fact()` (`job.c`), driven by the worker's own confirmation. Meanwhile, resource-commitment data is set much earlier and independently: scheduler's `job->accounts`/`job->allocs` are set synchronously inside `sched_commit_decision()`'s DISPATCH branch (`scheduler.c:1441-1446`), and controller's `job.allocs` is set synchronously inside `dispatch_a_job_()` (`job.c:831-963`) — both well before any worker confirmation.
+
+This split (intentional and, on its own, correct — "scheduled/dispatched" and "confirmed running" are legitimately different signals) has a concrete correctness consequence: every scheduler round, `xce_core_begin_session()` (`core_session.c:66-108`) unconditionally zeroes `core->sharedInUse`/`worker->resInUse`, and `sched_account_jobs()` (`scheduler.c:1258-1292`) re-derives those totals only from jobs where `JOB_IS_RUN(job->state)` is true — silently skipping a job that is dispatched (accounts already set) but still PEND (not yet confirmed). Normal-mode rounds run on a 1-second cadence (`main.c:453-460`), so any dispatch-to-confirmation latency over ~1s (ordinary for a real worker RPC + fork/exec) wipes that job's resource charge at the very next round and does not restore it until confirmation lands. The allocator's admission check (`sched_check_host_satisfy_`/`sched_check_shared_satisfy_`, `resource.c:252-370`, called from `seqjob_allocator.c:130`) reads exactly this same `resInUse`/`sharedInUse`, with no separate reserved/committed pool (`xce_sched_job_t` has only `state`/`allocs`/`accounts`, `sched/types.h:96-116`) — so the resource looks free again and can be double-granted to a second job. This is a genuine live over-commit/double-booking risk in normal-mode operation, logged as **bug-1624** (unfixed).
+
+Separately, and necessary for any fix to be safe: the controller already reports dispatch-RPC failure back to the scheduler via `NotifyData_data_jobDispatchFail` (`notify_dispatch()`, `job.c:1108-1125`), but the scheduler's `sched_handle_notify()` switch (`handler.c:499-544`) has zero case for it — it silently falls to `default: -EINVAL`. So today a dispatch failure leaves `job->accounts`/`job->allocs` (scheduler) and `j->job.allocs` (controller) stale forever with no cleanup, logged as **bug-1625** (unfixed). There is also currently no code path anywhere in `job.c` that ever sets a job's state back to `JOB_STAT_PEND` after dispatch — `mark_job_fail()`/`apply_job_fail_transition()` only ever resolves forward onto a terminal EXIT/DONE-ish state; a genuine retry-back-to-PEND path does not exist yet.
+
+**What should change:**
+1. Redefine the RUN transition to occur synchronously at DISPATCH-decision-commit time rather than at worker-confirmation time, on BOTH scheduler (inside `sched_commit_decision()`'s DISPATCH branch, alongside where `accounts`/`allocs` already get set) and controller (inside `dispatch_a_job_()`, alongside where `job.allocs` already gets set) — so neither side's internal state diverges from the other for the same job, and `sched_account_jobs()`'s existing `JOB_IS_RUN` gate naturally includes a dispatched job from the round it was dispatched, fixing bug-1624 with no change to that gate's own logic.
+2. Build the dispatch-failure handling this redefinition requires to be safe: scheduler-side handling for `NotifyData_data_jobDispatchFail` (fixing bug-1625), a retryable-vs-terminal failure classification (which failure codes/conditions resolve back to PEND for retry vs. forward to EXIT/FAIL permanently — no such classification exists today), and correct reversal of state/accounts/allocs/`job.allocs` on both scheduler and controller when a dispatch is retried back to PEND.
+3. Document (not implement delivery for) that a job holding committed-but-unconfirmed resources is a legitimate future preemption victim candidate under the new RUN definition — `scheduler-preemption` (sibling spec, still at requirements-generated) currently assumes in its AC6/AC9 that every RUN victim has a live, signalable process; that assumption is worth revising before that spec proceeds to design. Actually implementing preemption delivery/victim selection remains `scheduler-preemption`'s own boundary.
+
+## Sequencing
+
+The user has directed that this spec land **before `scheduler-allocation`** begins implementation. `scheduler-allocation`'s new iterator-based `try_alloc_on_host()` will read the same `resInUse`/`sharedInUse` totals bug-1624 corrupts, and its planned `XCE_ALLOC_PREEMPT` borrowing mode interacts with what counts as committed/borrowable — building the new allocator on top of today's broken round-boundary accounting would compound rather than fix the problem.
+
+## Boundary Context
+
+- **In scope**: synchronous RUN-state transition at dispatch-decision-commit time on scheduler (`sched_commit_decision()`) and controller (`dispatch_a_job_()`); scheduler-side handling of `NotifyData_data_jobDispatchFail`; retryable-vs-terminal dispatch-failure classification; PEND-reversion of state/accounts/allocs on both scheduler and controller for retryable failures; a documented note (not implementation) that a RUN-but-unconfirmed job is a valid future preemption victim candidate.
+- **Out of scope**: `scheduler-controller-msg-gap` (already implementation-complete — bug-1625 is thematically identical but a new, separately-discovered gap; not a reopening of that closed spec); `scheduler-sim-decision-parity` (implementation-complete, do not reopen); `scheduler-sim-eager-reconciliation` (sibling, in-flight, explicitly simulation-mode-only and explicitly excludes normal-mode behavior changes — this spec is the normal-mode counterpart but a distinct spec, not a merge); `scheduler-preemption`'s own preemption delivery/victim-selection mechanics (cross-referenced via a pending-revision note, not merged in); `scheduler-allocation`'s iterator/plan-generation work itself (unrelated axis — HOW allocation plans are computed, not WHEN state/accounting transitions happen); any change to how a job's state is displayed to end users beyond what naturally falls out of the state-transition-timing change; PEND job candidacy/picker-selection logic changes beyond what the retry-to-PEND path requires.
+- **Adjacent expectations**: this spec extends `scheduler-sim-decision-parity`/`scheduler-sim-eager-reconciliation`'s and `scheduler-controller-msg-gap`'s existing mechanisms without reopening them. `scheduler-preemption`'s requirements.md received a cross-reference note (2026-08-23) flagging its AC6/AC9 as pending revision once this spec's design is settled. Because simulation mode replays whatever the controller actually recorded (it does not independently decide state-transition timing), a recording captured *after* this feature ships will naturally carry the dispatch decision and the running-state transition in the same round — narrowing, though not eliminating, the cross-round case `scheduler-sim-eager-reconciliation` handles for older or otherwise-delayed recordings. This is a beneficial side effect to note, not a dependency either spec should design around.
+
+## Requirements
+
+### Requirement 1: Synchronous running-state transition on dispatch commit (Scheduler)
+**Objective:** As a scheduler maintainer relying on accurate live resource accounting, I want a job's state to become running as soon as the Scheduler commits its dispatch decision, so that per-round resource-in-use totals never lose track of a job's committed resources solely because worker confirmation has not yet arrived.
+
+#### Acceptance Criteria
+1. When the Scheduler commits a dispatch decision for a job, the Scheduler shall transition that job to a running state in the same operation that records the job's resource allocation.
+2. While a job's dispatch decision has been committed and no dispatch failure has since been reported for it, the Scheduler shall include that job's resource consumption in worker and shared resource-in-use totals from the round it was dispatched onward, regardless of how many rounds elapse before the worker confirms the job has started.
+3. The Scheduler shall not change the existing rule that only jobs in a running state contribute to a round's resource-in-use totals; this feature changes only when a job reaches that state, not how totals are computed from it.
+
+### Requirement 2: Synchronous running-state transition on dispatch receipt (Controller), consistent with Scheduler
+**Objective:** As a maintainer relying on scheduler-side and controller-side bookkeeping staying consistent for the same job, I want the Controller to mark a job running at the same moment it processes the Scheduler's dispatch decision, so the two components never disagree about whether a job has begun consuming its allocated resources.
+
+#### Acceptance Criteria
+1. When the Controller's attempt to notify the assigned worker of a dispatch decision succeeds, the Controller shall transition that job to a running state in the same operation that commits the job's resource allocation. The transition and the allocation commit shall not depend on any later worker confirmation of actual process start.
+2. The Controller shall continue to record the worker's confirmation that a job has actually started, including the job's actual start time, as a separate durable fact, independent of and in addition to the state transition performed under Criterion 1.
+3. When a status query or job listing is served after a job's dispatch decision has been processed, the Controller shall report that job's state as running, whether or not the worker has yet confirmed the job started.
+4. The Controller shall apply the state transition in Criterion 1 through the same durability and failover-consistency discipline it already applies to a job's other state transitions, so the transition is neither lost nor left divergent across a controller failover.
+
+### Requirement 3: Scheduler-side dispatch-failure handling
+**Objective:** As a maintainer relying on the Scheduler not silently losing track of failed dispatches, I want the Scheduler to actually process the Controller's existing dispatch-failure notification, so a dispatch that never reaches a worker does not leave stale committed-resource bookkeeping behind indefinitely.
+
+#### Acceptance Criteria
+1. When the Scheduler receives a dispatch-failure notification for a job from the Controller, the Scheduler shall process that notification rather than discard it as an unrecognized event.
+2. When the Scheduler processes a dispatch-failure notification for a job, the Scheduler shall resolve that job according to the retryable-vs-terminal classification defined in Requirement 4.
+
+### Requirement 4: Retryable-vs-terminal dispatch-failure resolution
+**Objective:** As a cluster operator, I want a dispatch failure caused by a transient or environmental condition to be retried automatically, and a dispatch failure caused by a locally-invalid decision to fail immediately without endless retrying, so failed dispatches resolve correctly in both directions.
+
+#### Acceptance Criteria
+1. When a dispatch failure is caused by a transient or environmental condition — for example the assigned worker being unreachable or not in a ready state, or the dispatch request itself failing to be delivered — the Scheduler and Controller shall return the job to a pending state, making it eligible for the Scheduler to attempt dispatch again in a subsequent round.
+2. When a dispatch failure is caused by a locally-invalid decision — for example the job or its allocation no longer being resolvable by the Controller — the Scheduler and Controller shall resolve the job to a terminal failed state without returning it to a pending state.
+3. While a job has accumulated consecutive transient/environmental dispatch failures with no intervening successful dispatch, when the number of such failures reaches a bounded limit, the Scheduler and Controller shall resolve the job to a terminal failed state rather than returning it to pending again. The exact limit is a design-phase decision; this requirement only constrains it to be bounded, never infinite.
+4. When a job is returned to a pending state following a dispatch failure, the Scheduler and Controller shall each release the resource-allocation and commitment data associated with the failed dispatch attempt, so a subsequent dispatch decision for that job is not blocked by stale data from the failed attempt.
+5. When a job is resolved to a terminal failed state following a dispatch failure, the Scheduler and Controller shall each release the resource-allocation and commitment data associated with the failed dispatch attempt.
+
+### Requirement 5: No regression to existing guarantees
+**Objective:** As a maintainer, I want this feature to preserve existing dispatch, accounting, and lifecycle guarantees outside the specific gap it closes.
+
+#### Acceptance Criteria
+1. When a job's dispatch succeeds and the worker later confirms the job has started, the Controller shall continue to record the confirmed-start fact exactly as it does today, in addition to (not instead of) the earlier state transition performed under Requirement 2.
+2. The Scheduler shall not apply this feature's dispatch-time state transition when operating in simulation/replay mode; simulation mode's own recorded-event replay path is unaffected by this feature.
+3. Existing scheduler and controller job-lifecycle test suites shall continue to pass, except for assertions that directly depended on the state transition occurring only at worker-confirmation time, which shall be updated to reflect the new timing.
+4. A full HA soak (sequential and chaotic `/xce-test-ha` regression) shall show no new job loss, state divergence, or liveness stall attributable to this feature.
diff --git a/.kiro/specs/scheduler-dispatch-commit/research.md b/.kiro/specs/scheduler-dispatch-commit/research.md
new file mode 100644
index 00000000..34d4fc84
--- /dev/null
+++ b/.kiro/specs/scheduler-dispatch-commit/research.md
@@ -0,0 +1,76 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: `scheduler-dispatch-commit`
+- **Discovery Scope**: Extension (existing scheduler + controller subsystems)
+- **Key Findings**:
+ - `scheduler.c`'s `sched_update()`/`sched_job_state_change()` (the notify-driven state-change apply path) is **dead code in the live production driver** — `main.c`'s loop calls only `xce_sched_driver_run_normal_mode_round()`, which drains pending controller events via `xce_sched_driver_translate_pending_events()` → `xce_core_push_events()` → `core_events.c`'s `XCE_CORE_EVENT_JOB_STATE_CHANGE` case, a parallel, independently-maintained mirror of the same logic. Design must target the live path, not the vestigial one.
+ - `builtin_fcfs_on_job_state_change()` is confirmed idempotent for a repeated RUN notification (comment: "a no-op, via `ordq_del`'s 'not found' return, if it wasn't a candidate already — e.g. two non-pending transitions in a row"). A later, now-redundant `schedStartJob` confirmation re-applying `JOB_STAT_RUN` to an already-RUN job is therefore safe with zero code change to that path.
+ - `sched_commit_decision()`'s DISPATCH branch (`scheduler.c:1441-1447`) is the correct, still-live integration point for the scheduler-side transition — it is the sole call site of `sched_charge_account()`/`job->accounts` assignment already, and is invoked from the decision callback (`xce_sched_driver_on_decision()`), a separate (and still-live) mechanism from the notify-apply path above.
+ - `dispatch_a_job_()` (`job.c:831-963`) currently sets `j->job.allocs` **unconditionally**, after attempting `xce_startjob_req()`, regardless of that call's return code — an existing, unrelated latent bug (stale allocation left behind on RPC failure) that Requirement 4's cleanup obligation subsumes rather than leaving as a separate patch.
+ - The `limit` plugin (`plugins/limit.c`) does not gate any of its own usage accounting on `JOB_IS_RUN`/`job->state` (`grep` found zero matches) — it is not a second instance of bug-1624's class of bug, and is correctly out of this spec's boundary.
+ - `commit_resolve_dispatch_t`/`commit_resolve_dispatch_entry` (`commit.c`) is the generic WAL-commit continuation dispatch primitive from `raft-apply-decoupling` — a naming coincidence ("dispatch" = vtask dispatch, not job dispatch), not a pre-built mechanism for this feature. The relevant precedent instead is `record_job_fail_fact()`'s "durable append only, no synchronous job mutation, apply-time recompute" pattern.
+
+## Research Log
+
+### Which driver code path actually applies a `JOB_STATE_CHANGE` in production
+- **Context**: Requirement 1 requires the scheduler's dispatch-commit transition to interoperate correctly with the *existing* RUN-confirmation transition. Needed to find where that existing transition is actually applied today, not just where an equivalently-named function exists.
+- **Sources Consulted**: `src/scheduler/src/main.c` (production loop), `src/scheduler/src/driver.c` (`xce_sched_driver_run_normal_mode_round`, `xce_sched_driver_translate_pending_events`), `src/scheduler/src/scheduler.c` (`sched_update`, `sched_job_state_change`), `src/scheduler/src/core_events.c` (`XCE_CORE_EVENT_JOB_STATE_CHANGE` case).
+- **Findings**: `main.c`'s `while(1)` loop calls only `xce_sched_driver_run_normal_mode_round(&sched)`, whose body never calls `sched_update()`. `sched_update()` (and therefore `sched_job_state_change()`, only reachable through it) has no live caller in the current driver architecture. The actual apply path is `xce_sched_driver_translate_pending_events()` → `xce_core_push_events()` → `core_events.c`'s `XCE_CORE_EVENT_JOB_STATE_CHANGE` case, documented there as mirroring `sched_job_state_change()` "logic-identical, signature-relocated."
+- **Implications**: The design does not modify or reuse `scheduler.c`'s `sched_job_state_change()`/`sched_update()`. All discussion of "the existing RUN-confirmation path" in the design refers to `core_events.c`'s case, which needs **no code change** — it already behaves correctly (idempotently) when applied to a job whose state is already RUN.
+
+### Ordering of the controller-side state transition relative to the worker RPC
+- **Context**: Requirement 2 AC1 (as drafted) says the transition happens "before attempting to notify the assigned worker." Tracing `dispatch_a_job_()`'s actual failure points revealed a simpler alternative.
+- **Findings**: `dispatch_a_job_()` has five failure points that return before ever attempting `xce_startjob_req()` (jobid lookup, invalid alloc, worker lookup, worker-not-ready, queue-exec-info lookup) and one failure point at the RPC call itself. If the state transition (and `job.allocs` commitment) is gated on `xce_startjob_req()` returning success, none of these six failure modes ever produces a RUN transition that needs to be reversed — the job simply remains PEND, exactly where it already was. This eliminates an entire class of "revert RUN back to PEND" complexity on the controller side that would otherwise be needed to match Requirement 4.
+- **Implications**: Design decision below (**Gate controller commit on RPC outcome**) supersedes the literal "before attempting to notify" phrasing in requirements.md AC1 — the *intent* (transition and allocation commit as one atomic, worker-latency-independent operation, not gated on separate confirmation) is preserved; only the specific ordering relative to the RPC changes, and only because it removes a whole reversal path rather than adding one. Flagged for requirements.md wording cleanup during task generation, not a scope change.
+
+## Design Decisions
+
+### Decision: Gate the controller's dispatch-time RUN transition and allocation commit on RPC success, not on decision receipt
+- **Context**: Where exactly within `dispatch_a_job_()` to place the state transition, and whether dispatch-RPC failure needs a state-reversal path.
+- **Alternatives Considered**:
+ 1. Transition to RUN immediately on receiving the decision, before any worker contact — matches the literal AC1 wording, but requires a full reversal path (RUN → PEND) for every one of `dispatch_a_job_()`'s six failure points.
+ 2. Transition to RUN (and commit `job.allocs`) only after `xce_startjob_req()` returns success — no reversal path needed for any failure point; the job simply never leaves PEND on failure.
+- **Selected Approach**: Option 2.
+- **Rationale**: Every one of `dispatch_a_job_()`'s failure points already returns before or at the RPC call. Gating on RPC success collapses "was RUN, must revert" into "never became RUN," which is strictly simpler and removes a whole category of possible bugs (a reversal that's forgotten on one of the five early-return paths).
+- **Trade-offs**: A job dispatched to a worker that never replies at all (hangs, rather than erroring) would not reach RUN until the RPC layer's own timeout fires — acceptable, since that is already how `job.allocs` commitment behaves today, and no requirement asks for a stronger guarantee under a hung RPC.
+- **Follow-up**: None — this closes the finding above rather than opening a new question.
+
+### Decision: Reuse `mark_job_fail()`/WAL-fact-append pattern for durability of the new transition, rather than the deferred-apply-first pattern `record_job_start_fact()` uses
+- **Context**: Requirement 2 AC4 requires the transition to survive a controller failover. `record_job_start_fact()` uses a "durable append only, no synchronous mutation, recompute at apply time" pattern specifically because a JOB_START fact can race against other pending facts for the same job (merge-at-apply-time, per `wal_types.h`). The dispatch-commit transition has no such racing-fact concern: it is the *first* transition after a job leaves PEND, triggered synchronously by the one function that already holds the authoritative decision.
+- **Alternatives Considered**:
+ 1. Full deferred-apply (submit fact, mutate only when the WAL entry is later applied) — correct but makes the transition NOT synchronously observable, conflicting with Requirement 2 AC1/AC3's "before/from" ordering language.
+ 2. Synchronous in-memory mutation (matching today's `j->job.allocs` assignment) plus a separate, best-effort durable WAL fact append recording the same transition for recovery purposes.
+- **Selected Approach**: Option 2.
+- **Rationale**: Matches this function's own existing precedent for `job.allocs` (synchronous, not deferred-apply) while still giving the transition a durable record for post-failover recovery, without introducing a same-job merge-ordering hazard `record_job_start_fact()`'s pattern exists to solve elsewhere.
+- **Trade-offs**: A crash in the narrow window after the in-memory mutation but before the WAL fact durably commits would leave the pre-crash primary's own view momentarily ahead of what a promoted standby recovers to. Bounded by the existing commit-durability guarantee (`ha-commit-durability`) once the fact is submitted; the narrow pre-submission window is the same class of gap `job.allocs`'s own non-durable commit already accepts today, not a new one this feature introduces.
+- **Follow-up**: Design phase specifies the new WAL fact's contract (extends `wal_jobstate_t`'s existing `_pad`-discriminated shape, precedent: `WAL_JOB_START`/`WAL_JOB_FAIL`/`WAL_JOB_UPDATE`). Exact bit layout is implementation-task detail.
+
+### Decision: Controller classifies once, authoritatively; `jobDispatchFail` becomes exclusively the retry signal, reusing the existing `schedFailJob` path for terminal resolution
+- **Context**: Requirement 4 requires the same failure to resolve the same way (retry vs. terminal) regardless of which component acts on it. The controller observes a dispatch failure synchronously and directly (it makes the RPC); the scheduler only learns of it later, asynchronously.
+- **Alternatives Considered**:
+ 1. Each side classifies independently against a shared error-code table — risks drift if the bounded-retry counter (Requirement 4 AC3) is tracked separately on each side.
+ 2. Controller classifies once (owns the freshest information and the durable retry counter) and sends the scheduler an explicit resolution field alongside the existing `jobDispatchFail` notify.
+ 3. Controller classifies once, and routes the two outcomes through two *already-distinct* existing channels instead of one overloaded one: terminal failures call the existing `mark_job_fail()` (`job.c:1844`), whose already-wired `HOOK_DETAIL_JOB_FAIL` → `sched.c` hook handler already sends `NotifyData_data_schedFailJob` — a notify the scheduler's `sched_handle_notify()` **already handles today**, unmodified, correctly freeing `job->accounts`/`job->allocs` on the transition away from RUN. Retryable failures keep using the existing (currently unhandled) `notify_dispatch()`/`NotifyData_data_jobDispatchFail`, whose meaning narrows to exclusively "return this job to pending."
+- **Selected Approach**: Option 3.
+- **Rationale**: Discovered while tracing `mark_job_fail()`'s hook chain (`job.c` → `HOOK_DETAIL_JOB_FAIL` → `sched.c:502` → `NotifyData_data_schedFailJob`) — the terminal path already has a fully-working, already-scheduler-handled notification mechanism with zero new code required. This reduces the scheduler-side new-code surface (bug-1625's actual fix) to a single new `case` in `sched_handle_notify()`'s switch that unconditionally applies `JOB_STAT_PEND` — no resolution field, no classification logic, no new payload shape needed on the scheduler side at all.
+- **Trade-offs**: None identified — this is strictly less new surface than Option 2 for an equivalent guarantee.
+- **Follow-up**: Design phase specifies exactly where the controller-side classification (including the bounded-retry check) is computed and where `dispatch_fail_count` is stored/reset.
+
+## Addendum (2026-08-23, found during `/kiro-validate-design`)
+
+- **`dispatch_a_job_()` and `job_ctrl()`'s existing call to `mark_job_fail()` never take `RTTI_LOCK_WRITE` on the job object, despite `apply_job_fail_transition()`'s own doc comment requiring it** (a genuine `pthread` rwlock, `my_rwlock_wlock()`, confirmed in `src/lib/jobj.c:708-725` — not a liveness/reference guard). Traced directly: `dispatch_a_job_()` (`job.c:831-963`) takes only `RTTI_LOCK_READ` on the containing maps and the worker, never on the job itself; `job_ctrl()` releases its own `RTTI_LOCK_READ` on the job (`job.c:1016`) well before calling `mark_job_fail()` (`job.c:1083`). Logged as bug-1626, pre-existing, independent of this feature. This directly affected the "no staleness window" locking claim originally drafted for design.md's Controller Dispatch Commit component — corrected there to explicitly acquire `RTTI_LOCK_WRITE` around this feature's own new/restructured mutations, closing the gap for those call sites specifically (`job_ctrl()`'s own separate call site is left as the still-open, out-of-boundary instance of bug-1626).
+- **The scheduler-side `dispatch_fail_count` field originally planned for `xce_sched_job_t` was removed** — nothing in the design ever reads it (only the controller's copy drives classification, per the Decision above), so it was dead weight. Caught during the same validation pass.
+- **`notify_dispatch()` (`job.c:1108-1125`) unconditionally emits `NotifyData_data_jobDispatchFail` regardless of whether `code` is 0 (success) or negative (failure) — confirmed by reading its body.** `handle_jobdispatch()`'s existing loop calls it for *every* `dispatch_a_job_()` return, success or failure alike. Combined with classification originally being planned for `handle_jobdispatch()` (the caller), this would have produced two conflicting notifies for a terminal failure — `jobDispatchFail` (apply PEND) racing against `schedFailJob` (apply EXIT) for the same event. Fixed by moving classification and all dispatch-outcome notification ownership fully inside `dispatch_a_job_()` (which also resolves the redundant-lookup/TOCTOU concern a caller-side classification would have had, since `handle_jobdispatch()` only retains `alloc->jobid` after `dispatch_a_job_()` returns) and removing `handle_jobdispatch()`'s own `notify_dispatch()` calls entirely. Found during round 2 of `/kiro-validate-design`.
+- **`persist.c` has TWO separate functions that each need their own new `WAL_JOB_DISPATCH` case, not one** — `apply_wal_entry()` (`persist.c:3375+`, recovery/replay-from-disk path) and `apply_committed_entry()` (`persist.c:3570+`, live-apply-on-commit path, the one `raft-apply-decoupling` introduced `_pad`-discriminated routing for) each have their own independent fall-through group for simple absolute-value job-state facts (`persist.c:3452-3466` and `persist.c:3700-3706` respectively). An earlier draft of design.md pointed only at the first (recovery) group, which alone would satisfy replay-after-restart but not live application when the WAL entry actually commits during normal operation — the primary case Requirement 2.4 is actually about. Found by tracing both functions directly rather than assuming a single switch statement.
+- **`dispatch_a_job_()` leaks one `reference(j)` on exactly its three retryable-class failure paths** (workermgr lookup failure, worker not found, worker not ready) — `reference(j)` is taken once at entry (`job.c:854`) but never released with a matching `dereference(j)` on these three returns, confirmed by direct comparison against the two terminal-class failure paths (`nc <= 0`, `get_queue_exec()` failure), both of which already correctly `dereference(j)`. Logged as bug-1627, pre-existing. Since this design already restructures exactly these three call sites (to add the retryable-path lock/counter/notify sequence), the missing `dereference(j)` is added there too — closed as a side effect of the restructuring already underway, not a separate patch. Found during round 3 of `/kiro-validate-design`.
+
+## Risks & Mitigations
+- **Risk**: A dispatched-then-terminally-failed job's resource-allocation cleanup on the scheduler side must run through the same code that already frees `job->accounts`/`job->allocs` on any transition away from RUN (`core_events.c`'s `XCE_CORE_EVENT_JOB_STATE_CHANGE` case) — if the new dispatch-failure handling bypasses this and frees ad hoc, it risks a double-free or a leak. Mitigation: route dispatch-failure resolution through the existing `XCE_SCHED_OP_JSTATE_CHANGE` → `XCE_CORE_EVENT_JOB_STATE_CHANGE` machinery (same enqueue path `schedStartJob`/`schedExitJob`/`schedFailJob` already use), not a new, parallel mutation path.
+- **Risk**: The bounded-retry counter (Requirement 4 AC3) is new per-job state with no existing analog — if not reset on a fresh successful dispatch, a job that failed twice, succeeded, ran to completion, and was later resubmitted with the same in-memory job object (should not happen given job objects are per-jobid, but worth stating) could inherit a stale count. Mitigation: reset the counter in the same operation that commits a fresh dispatch decision (`sched_commit_decision()`'s DISPATCH branch), not only on success at the end of a retry sequence.
+- **Risk**: `--sim`/simulation mode does not call `sched_commit_decision()` at all (confirmed in the sibling `scheduler-sim-eager-reconciliation` investigation) — no risk of driver divergence from this feature's scheduler-side change, but worth stating explicitly since `scheduler-driver-equivalence.md` is a hard project invariant. Mitigation: none needed; documented as a boundary note in requirements.md and design.md rather than a code change.
+
+## References
+- `.kiro/steering/scheduler-driver-equivalence.md` — state-equivalence invariant between normal-mode and simulation-mode drivers; confirms this feature's scheduler-side change has no simulation-mode counterpart to keep in sync (see Risks).
+- `.kiro/specs/scheduler-sim-decision-parity/design.md` — house style precedent for C-domain adaptation of the design template (Components as C modules/functions, Service Interface blocks as real C signatures with pre/post/invariants).
+- `src/include/wal_types.h` — `wal_jobstate_t`'s `_pad`-discriminated shape, the precedent this feature's new WAL fact type follows.
diff --git a/.kiro/specs/scheduler-dispatch-commit/spec.json b/.kiro/specs/scheduler-dispatch-commit/spec.json
new file mode 100644
index 00000000..3fb40273
--- /dev/null
+++ b/.kiro/specs/scheduler-dispatch-commit/spec.json
@@ -0,0 +1,30 @@
+{
+ "feature_name": "scheduler-dispatch-commit",
+ "created_at": "2026-08-23T03:25:54Z",
+ "updated_at": "2026-08-23T06:45:00Z",
+ "language": "en",
+ "phase": "tasks-generated",
+ "parent_spec": "scheduler-framework",
+ "description": "Move the RUN-state transition to dispatch-decision-commit time on both scheduler and controller, and build the currently-missing dispatch-failure retry/terminal-fail handling this requires -- fixes bug-1624 (resource-in-use round-boundary loss for dispatched-but-unconfirmed jobs) and bug-1625 (scheduler has no handler for NotifyData_data_jobDispatchFail).",
+ "approvals": {
+ "requirements": {
+ "generated": true,
+ "approved": true
+ },
+ "design": {
+ "generated": true,
+ "approved": true
+ },
+ "tasks": {
+ "generated": true,
+ "approved": true
+ }
+ },
+ "ready_for_implementation": true,
+ "task_plan_validation": "2 rounds of independent task-graph sanity review. Round 1: NEEDS_FIXES (task 3.1 missing counter-reset wiring, task 3.2 undersized/overloaded across 6 failure sites with an asymmetric reference-leak fix, ambiguous boundary labels, undocumented non-parallel rationale) -- repaired: 3.1 now resets the counter with explicit _Depends:_, former 3.2 split into 3.2 (terminal, 2 sites) / 3.3 (retryable+bounded escalation, 4 sites, carries the reference-leak fix), boundary labels differentiated, non-parallel rationale stated on task 3's major line. Round 2: NEEDS_FIXES (task 3.2's requirements list incorrectly carried over 4.1/4.4, which belong only to the PEND-return path 3.3 performs) -- fixed directly (single-line correction), and the same leftover was independently found and fixed in task 5.3's mirrored requirements list. Final coverage check: all 18 requirement IDs and all 5 design components mapped, each exactly once except where a requirement genuinely spans tasks. GO.",
+ "sibling_specs": ["scheduler-core", "scheduler-allocation", "scheduler-preemption", "scheduler-controller-msg-gap", "scheduler-sim-eager-reconciliation"],
+ "blocks": ["scheduler-allocation"],
+ "related_bugs": ["bug-1624", "bug-1625", "bug-1626", "bug-1627"],
+ "design_validation": "5 rounds of /kiro-validate-design, all findings fixed in place (no NO-GO carried forward): RTTI_LOCK_WRITE gap (bug-1626), unused scheduler-side field removed, requirements.md AC 2.1 wording reconciled, double-notify race (notify_dispatch fires on success too) fixed by moving classification+notification inside dispatch_a_job_(), rc-value classification replaced with an exhaustive site-identity enum, a 6th failure site (RPC failure) found and added, dispatch_a_job_() reference leak found and fixed (bug-1627, 4 sites), WAL apply logic corrected to cover both apply_wal_entry() and apply_committed_entry(). Final pass found no further issues. GO.",
+ "sequencing_note": "User directive (2026-08-22): this spec must land before scheduler-allocation begins implementation -- scheduler-allocation's iterator-based try_alloc_on_host() reads the same resInUse/sharedInUse totals bug-1624 corrupts, and its planned XCE_ALLOC_PREEMPT borrowing mode interacts with what counts as committed/borrowable. scheduler-allocation's own spec.json should be updated to note this dependency."
+}
diff --git a/.kiro/specs/scheduler-dispatch-commit/tasks.md b/.kiro/specs/scheduler-dispatch-commit/tasks.md
new file mode 100644
index 00000000..79110951
--- /dev/null
+++ b/.kiro/specs/scheduler-dispatch-commit/tasks.md
@@ -0,0 +1,101 @@
+# Implementation Plan
+
+- [ ] 1. Foundation: controller-side dispatch-failure classification and durable transition record
+- [x] 1.1 (P) Define the dispatch-failure classification used to decide whether a failed dispatch attempt should retry or fail permanently
+ - Enumerate each distinct way a dispatch attempt to a worker can fail today (worker lookup, worker unavailable, worker not ready, invalid allocation, queue configuration lookup, the dispatch request itself failing), and classify each as either retryable (transient/environmental) or terminal (a locally invalid decision that retrying will not fix)
+ - The classification is a pure decision with no side effects, independently testable against every known failure case without needing a live dispatch attempt
+ - Observable completion: a standalone test exercises every classified failure case and asserts the expected retryable/terminal outcome
+ - _Requirements: 4.1, 4.2_
+ - _Boundary: Controller Dispatch-Failure Classification (site enum + classify function)_
+
+- [ ] 1.2 (P) Add a durable record of a job's dispatch-time transition to a running state
+ - Introduce a new durable fact type recording that a job has transitioned to running state at dispatch-commit time, reusing the existing shape used for other job-state facts
+ - Wire this new fact type into both the normal live-commit apply path and the recovery/restart replay path, so the transition is recovered identically whether observed live or replayed after a restart
+ - Observable completion: appending this fact and then replaying it (simulating a restart) leaves the job's state as running, without requiring the separate worker-confirmed-start fact to also be present
+ - _Requirements: 2.4_
+ - _Boundary: WAL Dispatch Fact_
+
+- [ ] 1.3 (P) Track how many consecutive times a job's dispatch has failed for a retryable reason
+ - Add an in-memory counter on the controller's job record that increments on each retryable dispatch failure and resets to zero on a successful dispatch
+ - This counter does not need to survive a controller failover — resetting to zero after a failover is an acceptable, safe degradation
+ - Observable completion: the counter can be incremented, read, and reset independently of any actual dispatch attempt
+ - _Requirements: 4.3_
+ - _Boundary: Controller Dispatch-Failure Classification (retry-counter data model)_
+
+- [ ] 2. Core: scheduler transitions a job to running state at dispatch-commit time
+ - No dependency on Task 1 — scheduler-side files only, independent of the controller-side foundation work above.
+- [ ] 2.1 (P) Scheduler marks a job as running in the same operation that commits its dispatch decision
+ - When the scheduler commits a decision to dispatch a job, the job's state becomes running immediately, in the same operation that already records the job's resource allocation — not waiting for any later confirmation
+ - The scheduler's existing resource-in-use accounting, which only counts jobs already in a running state, requires no changes — this task provides the input it was missing
+ - Observable completion: a job dispatched in one round appears in resource-in-use totals starting that same round, and continues to appear in every subsequent round regardless of how many rounds pass before any confirmation arrives
+ - _Requirements: 1.1, 1.2, 1.3_
+ - _Boundary: Scheduler Dispatch Commit_
+
+- [ ] 2.2 (P) Scheduler processes a reported dispatch failure by returning the job to a pending state
+ - When the scheduler is told a job's dispatch failed, it returns that job to a pending state instead of silently ignoring the report as it does today
+ - Returning a job to pending correctly releases the resource-allocation data that was tentatively committed for it, reusing the scheduler's existing behavior for any other transition away from a running state
+ - Observable completion: a job whose dispatch failure is reported to the scheduler is again eligible for the scheduler's own job-selection process in a subsequent round, with no dangling resource-allocation data left behind
+ - _Requirements: 3.1, 3.2, 4.4_
+ - _Boundary: Scheduler Dispatch-Failure Handler_
+
+- [ ] 3. Core: controller transitions a job to running state only once dispatch to the worker succeeds, and resolves failures
+ - Sub-tasks 3.1-3.3 all restructure the same function and share its per-job lock scope, so none of them are marked parallel to each other, regardless of how disjoint their individual concerns look.
+- [ ] 3.1 Controller commits a job's resource allocation and running-state transition together, only on a successful dispatch
+ - The controller's existing (currently unconditional) commit of a job's resource-allocation data becomes conditional on the dispatch request to the worker actually succeeding; the job's state transition to running happens in that same successful outcome, protected against concurrent access to the job record
+ - Record the new durable dispatch-transition fact (Task 1.2) as part of this same successful outcome
+ - Reset the consecutive-retryable-failure counter (Task 1.3) to zero as part of this same successful outcome, so a job that later fails again starts counting from zero rather than retaining count from before its last success
+ - The worker's own later confirmation that the job actually started continues to be recorded exactly as it already is today, unaffected by this change
+ - Observable completion: querying a job's status immediately after a successful dispatch shows it as running, and that status is unaffected by whether the worker has separately confirmed the job started yet; a job that fails, succeeds, then fails again is evaluated against the bound as if it had only failed once
+ - _Requirements: 2.1, 2.2, 2.3, 2.4, 5.1_
+ - _Depends: 1.2, 1.3_
+
+- [ ] 3.2 Controller resolves a terminal (locally-invalid) dispatch failure
+ - When a dispatch attempt fails for a reason the classification (Task 1.1) marks terminal, the controller resolves the job to a permanently failed state immediately, protected against concurrent access to the job record, and releases any resource-allocation data tentatively associated with the attempt
+ - This covers the two failure sites that already release the job reference they hold today — confirm that behavior is preserved, not duplicated, by this new resolution step
+ - Observable completion: a terminally-classified dispatch failure leaves the job permanently failed, with no partial allocation data left behind and the job's reference count unchanged from before the attempt
+ - _Requirements: 4.2, 4.5_
+ - _Depends: 1.1_
+
+- [ ] 3.3 Controller resolves a retryable (transient/environmental) dispatch failure, with bounded escalation
+ - When a dispatch attempt fails for a reason the classification (Task 1.1) marks retryable, the controller increments the consecutive-failure counter (Task 1.3) under the same protected access; if the incremented count is still within a bounded limit, it reports the failure so the job can be retried, otherwise it resolves the job to a permanently failed state exactly as Task 3.2 does for a terminal failure
+ - This covers the four failure sites that do not currently release the job reference they hold — fix each to release it as part of this same resolution step (Task 3.2's two sites already release theirs correctly and are unaffected)
+ - Observable completion: a retryable dispatch failure below the bound leaves the job eligible for another attempt with its reference count restored to the pre-attempt value; a retryable failure that pushes the count past the bound resolves the job to permanently failed instead
+ - _Requirements: 4.1, 4.3, 4.4, 4.5_
+ - _Depends: 1.1, 1.3, 3.2_
+
+- [ ] 4. Integration: caller no longer duplicates dispatch-outcome reporting
+- [ ] 4.1 Remove the caller's own dispatch-outcome reporting now that resolution owns it
+ - The function that invokes a dispatch attempt for each job in a batch no longer separately reports the outcome itself — reporting is now entirely owned by the dispatch resolution added in Task 3, preventing a failed dispatch from ever producing two different, conflicting outcome reports for the same job
+ - Observable completion: a dispatch failure that resolves as permanent produces exactly one outcome report, not two conflicting ones
+ - _Requirements: 3.1, 4.1, 4.2_
+ - _Depends: 3.1, 3.2, 3.3_
+
+- [ ] 5. Validation: end-to-end behavior and regression
+- [ ] 5.1 End-to-end test: resource-in-use accounting survives a multi-round gap between dispatch and confirmation
+ - Reproduce bug-1624 directly: dispatch a job, let several rounds pass before the worker confirms it started, and confirm resource-in-use totals reflect the job continuously from the round it was dispatched, matching what a same-round confirmation would have produced
+ - Observable completion: the regression scenario passes, and fails against the pre-change code path if temporarily reverted
+ - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.3_
+ - _Depends: 2.1, 3.1_
+
+- [ ] 5.2 End-to-end test: retryable and bounded-retry dispatch failures resolve correctly
+ - Simulate a transient dispatch failure and confirm the job becomes eligible for another attempt; simulate enough consecutive transient failures to exceed the bound and confirm the job resolves to permanently failed instead of retrying indefinitely; simulate a failure, a success, then another failure and confirm the bound is evaluated as if only one failure had occurred
+ - Observable completion: all three scenarios produce the documented outcome, with the counter's bound value exercised at exactly the boundary (one under, exactly at, and one over)
+ - _Requirements: 3.1, 3.2, 4.1, 4.3, 4.4, 4.5_
+ - _Depends: 2.2, 3.3_
+
+- [ ] 5.3 End-to-end test: terminal dispatch failure resolves without duplicate reporting or leaked references
+ - Simulate a locally-invalid dispatch decision and confirm the job resolves to permanently failed, that no retry-eligible report is also produced for the same failure, and that the job record's reference count returns to its pre-attempt value
+ - Observable completion: the assertions above all pass in the same test run
+ - _Requirements: 4.2, 4.5_
+ - _Depends: 3.2, 4.1_
+
+- [ ] 5.4 Full regression: existing suites and HA soak
+ - Run the existing scheduler and controller test suites end to end, updating only the assertions that directly depended on the old confirmation-gated timing; run a full sequential and chaotic HA soak
+ - Observable completion: both test suites pass and the soak shows no new job loss, state divergence, or liveness stall attributable to this feature
+ - _Requirements: 5.2, 5.3, 5.4_
+ - _Depends: 5.1, 5.2, 5.3_
+
+## Implementation Notes
+- Task 1.1: design.md's "Controller Dispatch-Failure Classification" Service Interface Preconditions text claims a default-less `switch` over `xce_dispatch_fail_site_t` gives "compiler-enforced exhaustiveness... fails to compile" for an unhandled case — verified false for plain C without `-Werror` (this project sets none, confirmed in `src/meson.build`/`src/controller/meson.build`). Implemented instead with a `default:` arm that ERROR-logs and returns a safe `XCE_DISPATCH_FAIL_TERMINAL`, with `assert(0 && ...)` gated to `#ifdef XCE_TESTING` only — mirrors the established, verified-matching precedent `persist.c`'s `wal_classify()` already uses (`persist.c:6030-6037`) for the same "never risk silent misbehavior on an unrecognized case" concern. Any later task/spec touching this same design.md section should correct its wording rather than re-trusting the "fails to compile" claim.
+- Tasks 3.2/3.3 (retryable/terminal resolution, both call `xce_classify_dispatch_fail()`) should follow the same fail-loud `default:` convention if they add their own exhaustive switches (e.g. over `xce_dispatch_fail_class_t`'s two values).
+- Operational note (not spec-relevant): implementer/reviewer subagents in this run consistently ended their turn mid-build (after starting `xce-build.sh controller`, before it finished) rather than blocking on it, requiring one resume-and-nudge each time. Future task dispatches in this run should explicitly instruct agents to poll/block synchronously until the build genuinely completes before composing their status/verdict block, to save round-trips.
diff --git a/src/controller/job.c b/src/controller/job.c
index ae378d5e..e6d24c67 100644
--- a/src/controller/job.c
+++ b/src/controller/job.c
@@ -1,3 +1,4 @@
+#include
#include
#include
#include
@@ -828,6 +829,96 @@ static int mk_launchinfo(xce_launch_t* launch, xce_job_alloc_t* alloc)
return rc;
}
+/*
+ * scheduler-dispatch-commit task 1.1 (Req 4.1, 4.2): classifies one of
+ * dispatch_a_job_()'s six post-lookup failure points as retryable
+ * (transient/environmental -- return the job to PEND for a later retry) or
+ * terminal (a locally invalid decision that retrying will not fix -- resolve
+ * the job to a terminal failed state). Declared static/file-local: nothing
+ * outside job.c consumes this classification (design.md's own placement-
+ * correction note). dispatch_a_job_() itself is wired to call this at each
+ * of its six failure points by a later task; this task only adds the
+ * disconnected, independently-testable classification step --
+ * dispatch_a_job_() below is unmodified by this task.
+ */
+typedef enum {
+ XCE_DISPATCH_FAIL_SITE_WORKER_LOOKUP = 0, /* workermgr key_find() rc<0 */
+ XCE_DISPATCH_FAIL_SITE_WORKER_NULL = 1, /* worker not found (w==NULL) */
+ XCE_DISPATCH_FAIL_SITE_WORKER_NOT_READY = 2, /* E_XCE_BAD_WORKER */
+ XCE_DISPATCH_FAIL_SITE_INVALID_ALLOC = 3, /* nc <= 0 */
+ XCE_DISPATCH_FAIL_SITE_QUEUE_EXEC = 4, /* get_queue_exec() failure */
+ XCE_DISPATCH_FAIL_SITE_RPC_FAILURE = 5, /* xce_startjob_req() rc<0 */
+} xce_dispatch_fail_site_t;
+
+typedef enum {
+ XCE_DISPATCH_FAIL_RETRYABLE = 0,
+ XCE_DISPATCH_FAIL_TERMINAL = 1,
+} xce_dispatch_fail_class_t;
+
+/* Keyed on WHICH failure point was hit (site identity), not on the raw rc
+ * that point returned: workermgr's key_find() and get_queue_exec() are two
+ * independently-implemented failure sources not guaranteed to return
+ * disjoint error codes (e.g. both could plausibly return -ENOENT for
+ * unrelated reasons), so classifying by rc value alone would be ambiguous
+ * whenever two sites happen to collide.
+ *
+ * Every named site is handled explicitly by its own case. The `default:`
+ * arm below exists for the impossible-by-construction case of a `site` with
+ * no matching enumerator (every current/planned call site passes a literal
+ * enumerator, so this can only be reached by a genuine programming-invariant
+ * violation, not by any operational/environmental condition) -- a plain
+ * default-less switch does NOT get compiler-enforced exhaustiveness in C
+ * (verified directly: GCC emits only a non-fatal `-Wreturn-type` "control
+ * reaches end of non-void function" warning here, and this project sets no
+ * -Werror anywhere), so falling off the end would silently return an
+ * indeterminate value instead. This mirrors persist.c's wal_classify()
+ * default arm exactly: ERROR-log and return a safe-side classification
+ * (TERMINAL -- never risk an unbounded retry loop on an unrecognized site)
+ * unconditionally, and additionally assert() only under XCE_TESTING, so
+ * xce_controller itself (built without -DXCE_TESTING) can never self-
+ * terminate on this path (CLAUDE.md's controller fail-loud/never-self-
+ * terminate discipline: assert()/abort is reserved for exactly this kind of
+ * impossible-state bug, but production must still never exit on it), while
+ * controller_unit_test (built WITH -DXCE_TESTING) crashes loud and
+ * immediately if it's ever hit. */
+static xce_dispatch_fail_class_t
+xce_classify_dispatch_fail(xce_dispatch_fail_site_t site)
+{
+ switch (site) {
+ case XCE_DISPATCH_FAIL_SITE_WORKER_LOOKUP:
+ case XCE_DISPATCH_FAIL_SITE_WORKER_NULL:
+ case XCE_DISPATCH_FAIL_SITE_WORKER_NOT_READY:
+ case XCE_DISPATCH_FAIL_SITE_RPC_FAILURE:
+ return XCE_DISPATCH_FAIL_RETRYABLE;
+ case XCE_DISPATCH_FAIL_SITE_INVALID_ALLOC:
+ case XCE_DISPATCH_FAIL_SITE_QUEUE_EXEC:
+ return XCE_DISPATCH_FAIL_TERMINAL;
+ default:
+ ERROR("xce_classify_dispatch_fail: unrecognized failure site %d -- "
+ "TERMINAL fallback; add it to the classification table",
+ (int)site);
+#ifdef XCE_TESTING
+ assert(0
+ && "unrecognized dispatch-fail site -- add it to "
+ "xce_classify_dispatch_fail");
+#endif
+ return XCE_DISPATCH_FAIL_TERMINAL;
+ }
+}
+
+#ifdef XCE_TESTING
+/* Test-only accessor: xce_classify_dispatch_fail() and its enum types are
+ * intentionally undeclared in any header (nothing outside job.c consumes
+ * them), so the unit test reaches it through this thin, int-typed wrapper
+ * instead -- mirroring job_array_apply_test_set_force_fail_at()'s existing
+ * XCE_TESTING-only-export pattern earlier in this file (xce_controller
+ * itself is built without -DXCE_TESTING, so this never ships). */
+int xce_classify_dispatch_fail_test(int site)
+{
+ return (int)xce_classify_dispatch_fail((xce_dispatch_fail_site_t)site);
+}
+#endif /* XCE_TESTING */
+
static int dispatch_a_job_(cluster_t* c, endpoint_t* ep, xce_job_alloc_t* alloc)
{
int rc = 0;
diff --git a/src/controller/meson.build b/src/controller/meson.build
index cfdcf51d..0e8a28dc 100644
--- a/src/controller/meson.build
+++ b/src/controller/meson.build
@@ -176,6 +176,7 @@ controller_unit_test_src = [
'testsuite/test_job_array_apply.c',
'testsuite/test_job_array_fence.c',
'testsuite/test_job_array_replay_fence.c',
+ 'testsuite/test_job_dispatch.c',
'testsuite/test_upgrade_snapshot_restore.c',
'testsuite/test_upgrade_snapshot_apply.c',
'testsuite/test_bug857_regression.c',
diff --git a/src/controller/testsuite/controller_test.c b/src/controller/testsuite/controller_test.c
index 1b29a38f..b9d14266 100644
--- a/src/controller/testsuite/controller_test.c
+++ b/src/controller/testsuite/controller_test.c
@@ -94,6 +94,7 @@ extern void test_upgrade_snapshot_restore(void);
extern void test_upgrade_snapshot_apply(void);
extern void test_bug857_regression(void);
extern void test_sched_admin_dispatch(void);
+extern void test_job_dispatch(void);
void setUp(void) { return; }
@@ -257,6 +258,7 @@ int main(int argc, char* argv[])
TEST(test_upgrade_snapshot_apply);
TEST(test_bug857_regression);
TEST(test_sched_admin_dispatch);
+ TEST(test_job_dispatch);
return UNITY_END();
}
diff --git a/src/controller/testsuite/test_job_dispatch.c b/src/controller/testsuite/test_job_dispatch.c
new file mode 100644
index 00000000..7af9d22e
--- /dev/null
+++ b/src/controller/testsuite/test_job_dispatch.c
@@ -0,0 +1,114 @@
+/* test_job_dispatch.c -- scheduler-dispatch-commit task 1.1
+ *
+ * Unit tests for the Controller Dispatch-Failure Classification component
+ * (design.md, "Controller Dispatch-Failure Classification"): a pure, static,
+ * file-local function in job.c that maps each of dispatch_a_job_()'s six
+ * post-lookup failure points (xce_dispatch_fail_site_t) to a retryable-vs-
+ * terminal classification (xce_dispatch_fail_class_t), per Requirements
+ * 4.1/4.2.
+ *
+ * xce_classify_dispatch_fail() and its enum types are deliberately NOT
+ * declared in any header (design.md's own placement-correction note: nothing
+ * outside job.c consumes them). This test file reaches the static function
+ * through a thin, int-typed, XCE_TESTING-only wrapper exported from job.c
+ * (xce_classify_dispatch_fail_test), the same pattern job.c already uses for
+ * job_array_apply_test_set_force_fail_at()/friends -- and, like test_job_
+ * array_fence.c's ha_arm_promotion_fence_timer_for_test, the extern
+ * declaration lives directly in this test file rather than in a shared
+ * header.
+ *
+ * No cluster_t/persist/worker fixture is needed: the classification is a
+ * pure decision with no side effects, independently testable against every
+ * known failure case without a live dispatch attempt (task 1.1's own
+ * Observable-completion clause).
+ */
+
+#include
+
+#include
+
+/* Mirrors design.md's Service Interface enum values exactly (job.c's own
+ * xce_dispatch_fail_site_t/xce_dispatch_fail_class_t are file-local and not
+ * shared via a header -- this test drives the wrapper by the same plain-int
+ * values documented there, not by including a private type). */
+#define SITE_WORKER_LOOKUP 0
+#define SITE_WORKER_NULL 1
+#define SITE_WORKER_NOT_READY 2
+#define SITE_INVALID_ALLOC 3
+#define SITE_QUEUE_EXEC 4
+#define SITE_RPC_FAILURE 5
+
+#define CLASS_RETRYABLE 0
+#define CLASS_TERMINAL 1
+
+extern int xce_classify_dispatch_fail_test(int site);
+
+static void test_worker_lookup_is_retryable(void)
+{
+ TEST_ASSERT_EQUAL_INT(CLASS_RETRYABLE,
+ xce_classify_dispatch_fail_test(SITE_WORKER_LOOKUP));
+ printf("test_worker_lookup_is_retryable: PASS\n");
+}
+
+static void test_worker_null_is_retryable(void)
+{
+ TEST_ASSERT_EQUAL_INT(CLASS_RETRYABLE,
+ xce_classify_dispatch_fail_test(SITE_WORKER_NULL));
+ printf("test_worker_null_is_retryable: PASS\n");
+}
+
+static void test_worker_not_ready_is_retryable(void)
+{
+ TEST_ASSERT_EQUAL_INT(CLASS_RETRYABLE, xce_classify_dispatch_fail_test(
+ SITE_WORKER_NOT_READY));
+ printf("test_worker_not_ready_is_retryable: PASS\n");
+}
+
+static void test_rpc_failure_is_retryable(void)
+{
+ TEST_ASSERT_EQUAL_INT(CLASS_RETRYABLE,
+ xce_classify_dispatch_fail_test(SITE_RPC_FAILURE));
+ printf("test_rpc_failure_is_retryable: PASS\n");
+}
+
+static void test_invalid_alloc_is_terminal(void)
+{
+ TEST_ASSERT_EQUAL_INT(CLASS_TERMINAL,
+ xce_classify_dispatch_fail_test(SITE_INVALID_ALLOC));
+ printf("test_invalid_alloc_is_terminal: PASS\n");
+}
+
+static void test_queue_exec_is_terminal(void)
+{
+ TEST_ASSERT_EQUAL_INT(CLASS_TERMINAL,
+ xce_classify_dispatch_fail_test(SITE_QUEUE_EXEC));
+ printf("test_queue_exec_is_terminal: PASS\n");
+}
+
+/* Invariant (design.md Service Interface Postconditions): the same site
+ * always classifies the same way -- no hidden state, no time-dependence. */
+static void test_classification_is_stable_across_repeated_calls(void)
+{
+ int first = xce_classify_dispatch_fail_test(SITE_WORKER_NOT_READY);
+ int second = xce_classify_dispatch_fail_test(SITE_WORKER_NOT_READY);
+ int third = xce_classify_dispatch_fail_test(SITE_QUEUE_EXEC);
+ int fourth = xce_classify_dispatch_fail_test(SITE_QUEUE_EXEC);
+
+ TEST_ASSERT_EQUAL_INT(first, second);
+ TEST_ASSERT_EQUAL_INT(third, fourth);
+ printf("test_classification_is_stable_across_repeated_calls: PASS\n");
+}
+
+void test_job_dispatch(void)
+{
+ UnitySetTestFile(__FILE__);
+ printf("\n>> dispatch-failure classification tests (Req 4.1, 4.2)\n\n");
+
+ RUN_TEST(test_worker_lookup_is_retryable);
+ RUN_TEST(test_worker_null_is_retryable);
+ RUN_TEST(test_worker_not_ready_is_retryable);
+ RUN_TEST(test_rpc_failure_is_retryable);
+ RUN_TEST(test_invalid_alloc_is_terminal);
+ RUN_TEST(test_queue_exec_is_terminal);
+ RUN_TEST(test_classification_is_stable_across_repeated_calls);
+}