jcp §4.11: an orchestrator's spend died with its process, so a restart was a fresh budget #189
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/orchestrate-journal"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
jcp-orchestrate's module doc claims: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 freshRuntimestarts at zero.So 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. An audit trail that
dies with the process is not an audit trail, and a ceiling that resets on restart
bounds one process lifetime rather than the work.
The journal
A run emits a
JournalEntryfor every transition through aJournalthe callersupplies.
MemoryJournalis the default and keeps exactly the old behaviour; adeployment that wants the run to survive its process writes the entries
somewhere that does.
Orchestrator::replayrebuilds 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 landwhere the original run left them. Two reasons to replay rather than restore a
total:
The grants reproduce exactly because
registermints them with a fixed nonce, soan identical registration yields identical grant ids.
Runtimehas no spendsetter 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; recording it afterwards would lose the debit for
anything interrupted between the two.
That ordering leaves one state the record cannot resolve. A
Dispatchedwith noSettledmeans the process died while a harness held the task, and whether thework happened is not knowable from the journal — only that harness knows. Such
an item resumes as
WorkState::Interrupted { harness, attempt }, is countedseparately in the report, keeps its joules debited, and is not re-dispatched
(the loop only offers
Pendingitems).Retrying risks paying twice for work already done; discarding risks losing it.
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
Submittedentry introduced, is refused rather than guessed — the grant the decision was
made against cannot be reproduced.
Spec
Numbered §4.11, not §4.10: six places in
jcp-runtimealready cite"SPEC §4.10" for the flow/taint gate, and no such section exists — the gate is
implemented and its normative text was never written. I took §4.11 rather than
claim a number the code has already spoken for; writing §4.10 for the flow gate
is a separate piece of work.
§4.11 Durable spend: a restart is not a fresh budget (REQUIRED where a run can
restart) — the hole is in any enforcement point that can restart, not just this
crate. 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; never silently retry an interrupted one, nor report
it as completed or failed, and keep its energy debited.
Verification
jcp-rsworkspace 409 passed, clippy clean onjcp-orchestrate.Pendinga_dispatch_with_no_settlement_resumes_unresolved_and_is_not_re_runthe_dispatch_is_journalled_before_the_harness_runsa_journal_naming_something_unregistered_is_refused_not_guessedThe third of those initially had no test: my first falsification of it only
failed to compile, which is not a pass. The ordering is only observable by
crashing between the decision and the harness run, so there is now a test that
does exactly that — a harness that panics where a process would — and asserts
the debited decision is on the record with no settlement after it.
Merge order with the sibling PR
#190 adds §4.10 (the flow gate) and this one adds §4.11. They conflict with
each other, though each merges cleanly against main: both insert a new §4.x at
the same anchor, after §4.9 and before §5. Nothing else overlaps —
jcp-orchestrateandjcp-runtimeare disjoint, and neither references theother's symbols.
Merge #190 first. Then rebase this one; the resolution is to keep both
sections in numeric order, which is also the order they must read in: §4.10
Flow, §4.11 Durable spend. I checked the landing by merging the whole set onto
main in a scratch branch — the sequence comes out §4.9, §4.10, §4.11, §5, and
afterwards §4.10 has 13 code references against 1 spec section and §4.11 has 0
against 1.
(A
git merge-treebetween the two branches reported no conflict and I believedit; a real merge disagrees. The textual check was wrong, not the branches.)
Its only consumer would have mishandled the new state
jouledeskhas a path dependency onjcp-orchestrateand matches onWorkState. AddingInterrupteddid not break the build — the match has acatch-all — so an interrupted task fell through and came out
TagOutcome::Failed("no terminal state"). Then the next branch sealed it: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/1receipt as waste — exactly what §4.11, written inthis same PR, forbids. No compiler could catch it, and nor could CI: the
catch-all made it compile and the
jouledesk-rsjob would have passed.TagOutcome::Interrupted { harness }now names it, carrying who can settle it.The sealing branch excludes it alongside
Denied— a denied tag ran nothing, aninterrupted one may have run everything. Five consumers updated; four were
exhaustive matches the compiler found, including
jouledesk-github'sseal.Falsifying it caught my own test being worthless
The first version asserted
!matches!(interrupted, Done | Failed(_))— aproperty of the enum, not of the code — so changing the sealing branch, or
deleting the match arm entirely, left it green.
The obstacle was real:
Interruptedcould not arise throughTeammate's API,since
tagruns to a terminal state in one process. So the fix was to make itreachable rather than assert around it.
Teammategains the minimum resumesurface over this PR's journal —
with_journal,replay,outcome_of— whichis the composition the journal exists for. The mapping and the sealing rule are
now named functions used by the real code paths, so the test reads the rule
instead of restating it.
The test is end to end: tag once with a journal, truncate it after the dispatch
(the crash this models), replay into a fresh teammate, assert the tag reports
Interrupted { harness }and seals nothing. Both earlier falsifications nowfail it.
jouledesk-rsworkspace: 40 passed, clippy clean.🤖 Generated with Claude Code
`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.10 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]>e000df1fca59af9a6b6ajcp §4.10: an orchestrator's spend died with its process, so a restart was a fresh budgetto jcp §4.11: an orchestrator's spend died with its process, so a restart was a fresh budgetpaths:matches the push delta #1910c4b724f572bfde7fb19