jcp §4.11: an orchestrator's spend died with its process, so a restart was a fresh budget #189
Merged
dcharlot
merged 2 commits from 2026-10-09 08:46:22 -04:00
feat/orchestrate-journal into main 2026-10-09
jouledesk: an interrupted tag is not a failed one, and is not sealed as waste
All checks were successful
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m28s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 1m8s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 1m13s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m15s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m13s
standards workspaces build + test / sense-rs (pull_request) Successful in 1m6s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 11m7s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m36s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 6m34s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 17s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 12m58s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Successful in 8s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 24s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 46s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 1m46s
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Successful in 1m18s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 1m6s
wai-pack / wai-pack · does this change touch it (pull_request) Successful in 4s
wai-pack / wai-pack · linux (pull_request) Has been skipped
wai-pack / wai-pack · linux-arm64 (pull_request) Has been skipped
wai-pack / wai-pack · macos (pull_request) Has been skipped
wai-pack / wai-pack · independent verifier (Python) (pull_request) Has been skipped
wai-pack / wai-pack · the floor through a media prober (pull_request) Has been skipped
wai-pack / wai-pack · dependency licences (pull_request) Has been skipped
WAI mbt2018_mean integer decode (regenerated vectors) / regenerate mbt2018_mean integer vectors → Rust decode, required (pull_request) Successful in 4m21s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 2m51s
workflow hygiene / every workflow supersedes its own queue (pull_request) Successful in 4s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 2h2m13s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 23m45s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 2h1m9s
This completes #189 at its only consumer, and closes a hole that PR opened. `jouledesk` has a path dependency on `jcp-orchestrate` and matches on `WorkState`. Adding `WorkState::Interrupted` did not break the build — the match has a catch-all — so an interrupted task fell through it and came out `TagOutcome::Failed("no terminal state")`. Then the next branch sealed it: // a `Failed` is spend that bought no result (waste) if matches!(outcome, TagOutcome::Done | TagOutcome::Failed(_)) { So a task whose work may well have completed, whose joules were debited, and whose outcome nobody knows, would have been reported to a person as failed and sealed in an `efficiency/1` receipt as waste. That is exactly what §4.11 — which #189 writes — forbids: an implementation MUST NOT report such an action as completed or as failed. A compiler could not catch it, and nor could CI: the catch-all made it compile, and the still-pending `jouledesk-rs` job would have passed. `TagOutcome::Interrupted { harness }` now carries the harness that held the task, because that is who can settle it. The sealing branch excludes it alongside `Denied` — a denied tag ran nothing, an interrupted one may have run everything, and sealing it either way states as fact something unknown. Its joules stay debited on the grant regardless; the dispatch decision that incurred them is sealed in the orchestrator's own ledger. Five consumers updated. Four were exhaustive matches the compiler found, including `jouledesk-github`'s `seal`, which now reports `interrupted (held by <harness>)` rather than `failed`. Every surface renders it, so no binding tells a different story about the same outcome. **Falsifying it caught my own test being worthless.** The first version asserted `!matches!(interrupted, Done | Failed(_))` — a property of the *enum*, not of the code — so changing the sealing branch, or deleting the match arm entirely, left it green. The same vacuousness I had just caught in #190. The obstacle was real: `WorkState::Interrupted` could not arise through `Teammate`'s API, because `tag` submits and runs to a terminal state in one process. So the fix was to make it reachable rather than to assert around it. `Teammate` gains the minimum resume surface over #189's journal — `with_journal`, `replay`, and `outcome_of` — which is the composition that journal exists for, and jouledesk's whole purpose is composing `jcp-orchestrate`. The mapping and the sealing rule are now named functions (`outcome_for`, `seals_efficiency`) used by the real code paths, so a test reads the rule instead of restating it. The test is now end to end: tag once with a journal, truncate it after the dispatch — the crash this models — replay into a fresh teammate, and assert the tag reports `Interrupted { harness }` and seals nothing. Both earlier falsifications now fail it: sealing an interrupted tag as waste, and removing the match arm so it falls back to the catch-all. jouledesk-rs workspace: 40 passed, clippy clean. Co-Authored-By: Claude Opus 5 <[email protected]>
jcp §4.11: an orchestrator's spend died with its process, so a restart was a fresh budget
`jcp-orchestrate`'s module doc claims "the whole run is an ordered ledger of
sealed decisions — the audit a bare meta-harness has no way to produce." That
was true for as long as the process lived. Item states, the receipts, and — the
part that matters — each harness's spend were held in memory: spend lives in
`self.runtime`, and a fresh `Runtime` starts at zero. A crash loop could spend a
harness's joule budget over and over, and a task already paid for and run would
be dispatched again.
A run now emits a `JournalEntry` for every transition through a `Journal` the
caller supplies. `MemoryJournal` is the default and keeps exactly the old
behaviour; a deployment that wants the run to survive its process writes the
entries somewhere that does. `Orchestrator::replay` rebuilds a run from them:
items, attempts, the receipt ledger, and the spend.
**Spend is re-derived, not asserted.** Each allowed dispatch goes back through
`Runtime::decide`, so the budgets land where the original run left them. Two
reasons for replaying rather than restoring a total: a replay cannot produce a
spend the gate would not have produced, and a stored total is one more figure
that can be wrong. The grants reproduce exactly because `register` mints them
with a fixed nonce, so an identical registration yields identical grant ids.
`Runtime` has no spend setter and did not need one.
The test that matters is not that the number comes back: it is that the resumed
budget still **binds**. A harness with 1 000 µJ that has spent 900 must refuse
the next 300 µJ dispatch, and does.
**The interrupted case is left unresolved on purpose.** The dispatch is
journalled *before* the harness runs, because the decision has already debited
the grant and recording it afterwards would lose the debit for anything
interrupted between the two. So a `Dispatched` with no `Settled` means the
process died while a harness held the task, and whether the work happened is not
knowable from the journal — only that harness knows. Such an item resumes as
`WorkState::Interrupted { harness, attempt }`, is counted separately in the
report, keeps its joules debited, and is **not** re-dispatched: the loop only
offers `Pending` items. Retrying risks paying twice for work already done,
discarding risks losing it, and choosing between those without asking the
harness is not a decision this crate can make.
A journal naming an unregistered harness, or a dispatch for a task no
`Submitted` entry introduced, is refused rather than guessed — the grant the
decision was made against cannot be reproduced.
SPEC §4.11 makes it normative beyond this crate, since the hole is in any
enforcement point that can restart: record every allowed decision durably,
re-establish the spend before deciding anything new (replay RECOMMENDED over a
stored total), record the decision before the action, and never silently retry
or mis-report an interrupted one.
jcp-rs workspace: 409 passed, clippy clean on jcp-orchestrate.
Co-Authored-By: Claude Opus 5 <[email protected]>