core+journal: a journal sink that refuses a success no longer makes the framework report — and record — a committed write as rejected (fixes #796) - #798
Merged
Yaraslaut merged 4 commits intoSep 25, 2026
Conversation
Yaraslaut
force-pushed
the
fix/796-recording-failure-is-not-execution-failure
branch
from
September 24, 2026 00:44
392d2a7 to
afd3193
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Yaraslaut
force-pushed
the
fix/796-recording-failure-is-not-execution-failure
branch
2 times, most recently
from
September 25, 2026 07:51
c256ab0 to
8f60e2a
Compare
…k report — and record — a committed write as rejected (fixes #796) Both sites that actually run `Model::execute` called `recordActionSuccess` from inside the `try` whose `catch (const std::exception&)` exists to report *execution* failures: `ActionDispatcher::registerAction`'s runner and `Bridge::executeVia`'s `localOp`. `ActionTraits<Action>::resultToJson` sat inside that same `try`. `IActionLog::append` is required to throw when the entry did not reach its backend — the return type is `void`, so it is the only channel the interface gives an implementation, and `file_action_log.hpp` throws from 18 sites. A journal file on a full disk therefore produced three wrong answers at once, after the model's mutation had already committed: dispatch threw: journal sink unavailable model.committed = 1, model.balance = 10 journal entries = 1 outcome=Failed error=journal sink unavailable result= The caller was told a durable write was rejected and could retry it, the audit trail gained an `Outcome::Failed` entry for a mutation that committed, and that entry's `error` field blamed the action for an infrastructure fault. `Model::execute` is now the only call inside that `try` — it is the only one whose failure means the action was rejected. Serialising the result and appending the entry run after it, and a throw from either surfaces as the new `morph::model::ActionRecordingError`: `what()` is "action executed but was not recorded: <cause>", `cause()` is the underlying message, `result()` is the committed action's result JSON. The caller still learns the recording failed; what changed is what it is told, which is now true. Deriving from `std::runtime_error` leaves every existing `catch (const std::exception&)` path working — `RemoteServer` still replies `err`, `LocalBackend` still rejects the `Completion` through `onError`. `resultToJson` moved out too: a result type whose serialisation throws produced the identical three symptoms with no journal involved. When it is what threw, no entry is written at all — a `Succeeded` entry carries the result by definition and there is none to carry, so the caller is told and the audit trail is left with a gap rather than an assertion that the action failed. `OutboxRelay::relay()` is untouched and still depends on `append`/`flush` throwing: it calls them directly, and marks a row relayed only after the sink returned normally. Tests (`tests/test_action_log.cpp`): a `SuccessRefusingLog` that throws on the `Succeeded` append and accepts the `Failed` one, plus a hand-written `ActionTraits` whose `resultToJson` throws. Five cases across both dispatch paths. Each assertion was reddened by mutating the fix back: - with `resultToJson`+`recordActionSuccess` returned to the execution `try`, the two refusing-sink cases fail on `seen.recordingError`, `seen.what == "action executed but was not recorded: journal sink unavailable"`, `seen.cause`, `seen.result == "10"`, `log->entries().empty()` (false — the `Failed` entry is there), `log->offered().size() == 1` (2) and `entry.outcome != Outcome::Failed` (1 != 1); the unserialisable-result case fails on `seen.recordingError`, `seen.cause` and `log->entries().empty()`; - with the execution `catch` changed to throw `ActionRecordingError` instead of rethrowing, the two regression-guard cases fail on `CHECK_FALSE(seen.recordingError)` and `seen.what == "insufficient funds"` (got "action executed but was not recorded: insufficient funds"). Full `ctest` on `clang-debug` (clang 22.1.8, Linux): 1782/1782 passed. Specs updated in the same commit: `docs/spec/journal/journal.md` gains "A refused recording is not an execution failure"; `docs/spec/core/registry.md` and `docs/spec/core/bridge.md` no longer describe the success record as living inside the execution `try`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
… returns Moving the journal append out of the execution `try` put the model call behind `return model.execute(action);` inside an immediately-invoked lambda. Two test translation units register an action whose handler body is a bare `throw`, so for those instantiations the `return` really is unreachable and MSVC raises C4702 -- fatal under WarningsAsErrors. gcc and clang do not warn. The warning is correct, so it is suppressed rather than argued with, and suppressed at the two translation units that instantiate the never-returning handler rather than in the public headers that contain the statement: MSVC reports C4702 at the first instantiation point in the TU, not at the line inside the header, which is the same reason and the same placement the pastebin paste-model test already uses. Nothing about the dispatcher changes. Before this fix `execute` was called in statement context, so there was no `return` for MSVC to judge; the warning is new because the structure is, not because the behaviour is. Not verified locally: no MSVC is available here, so the placement follows the existing precedent and the compiler's own reported line rather than a reproduction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…per translation unit The previous attempt put the C4702 suppression in the two test files the compiler happened to name. That was the wrong placement and CI said so: the next run reported the same warning from `test_flows_apps.cpp` and `test_sections.cpp` instead. The warning is about a statement in the header, so it fires from whichever translation unit instantiates a handler that never returns, and suppressing per consumer is an open-ended obligation -- eleven test files already register an `execute` overload whose body is a bare `throw`, and every future one would join them. So the suppression now sits around the statement it is about, in `registry.hpp` and `bridge.hpp`, guarded on `_MSC_VER` and scoped with push/pop so it disables nothing else. gcc and clang do not warn here and are unaffected; a syntax-only compile of a test translation unit under clang 22 is clean. The warning is correct for the instantiation that provokes it -- when `Model::execute` never returns, the `return` really is unreachable -- and wrong as a verdict on the statement, which every other instantiation reaches. The comment says that rather than claiming the compiler is mistaken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…dability-use-concise-preprocessor-directives requires Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y4eif7wQNNhSkHUKYqq5Xq
Yaraslaut
force-pushed
the
fix/796-recording-failure-is-not-execution-failure
branch
from
September 25, 2026 07:54
8f60e2a to
6214c06
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #796.
What was wrong
Both sites that actually run
Model::executecalledrecordActionSuccessfrominside the
trywhosecatch (const std::exception&)exists to reportexecution failures —
ActionDispatcher::registerAction's runner(
registry.hpp) andBridge::executeVia'slocalOp(bridge.hpp).ActionTraits<Action>::resultToJsonsat inside that sametry.IActionLog::appendis required to throw when the entry did not reach itsbackend:
append/flushreturnvoid, so it is the only channel the interfacegives an implementation (
action_log.hpp:185), andfile_action_log.hppthrowsfrom 18 sites. So a journal file on a full disk produced three wrong answers at
once, after the model's mutation had already committed:
landed;
Outcome::Failedentry for a mutation thatcommitted;
errorcarries the sink's message, permanently blaming theaction for an infrastructure fault.
What the caller is now told, and why that shape
Model::executeis now the only call inside thattry— it is the only onewhose failure means the action was rejected. Serialising the result and
appending the entry run after it, outside.
A throw from either surfaces as a new
morph::model::ActionRecordingError:what()—"action executed but was not recorded: <cause>"cause()— the underlying message, unprefixedresult()— the committed action's result JSON (""when serialising it iswhat failed)
Why this and not the obvious alternatives. Catching and discarding was
ruled out by the issue and by
OutboxRelay::relay(), which callssink->append()/sink->flush()directly and marks a row relayed only afterthey return normally — a fix that swallowed would break at-least-once relay.
(
OutboxRelayis untouched here; it does not go through either dispatch path.)Returning normally and logging is the other silent failure: the caller would
believe the action is in the audit trail when it is not, which is exactly the
asymmetry the journal exists to prevent. Letting the sink's exception propagate
raw would keep symptom 1 — the caller cannot tell "the model refused this" from
"the model accepted it and the disk is full", and those call for opposite
actions (retry vs. do not retry).
So the caller still learns the recording failed; what changed is what it is
told, and it is now true: the write happened, the record of it did not.
Deriving from
std::runtime_errormeans no existing handler changes behaviour—
RemoteServer::dispatchExecute's strand catch still turns it into anerrreply,
LocalBackendstill rejects theCompletionthroughonError— whilea caller that cares can catch the type and read
result().resultToJsonmoved out tooYes — it is in the same position and produces the identical three symptoms with
no journal involved (a
ParseErrorfrom a glaze write error, raised after themutation committed).
When
resultToJsonis what threw, no journal entry is written at all. ASucceededentry carries the result by definition (LogEntry::resultindocs/spec/journal/journal.md), and there is none to carry. A committedmutation with no entry is a real gap, and it is stated in the spec — but it is a
smaller one than an entry asserting the action failed, which is the thing
nothing downstream can distinguish from a genuine rejection.
Both paths
Dispatcher and bridge, each fixed and each tested. The issue recorded the
bridge path as read-but-not-executed; it is now driven by two tests.
Proof, both ways
tests/test_action_log.cppgains aSuccessRefusingLog— throws on theSucceededappend, accepts theFailedone, and records every entry it wasoffered as well as every one it stored — plus a hand-written
ActionTraitswhose
resultToJsonthrows. Five cases.Each assertion was reddened by mutating the fix back. Real output:
Mutation A —
resultToJson+recordActionSuccessreturned to the executiontry(master's shape, withActionRecordingErrorleft declared so the testsstill build).
REQUIRE→CHECKfor this run so every assertion reports:The
balance == 10/balance == 7assertions passed under the mutation,which is the point: the mutation is durable either way, and only what the caller
and the log are told changes.
Mutation B — the regression this fix could cause. The execution
catchchanged to
throw ActionRecordingError{...}instead of rethrowing:So the three required assertions all fail against the old code, and the
regression guard fails against a plausible over-broad fix.
Gates run locally
clang-format --dry-run -Werror(clang-format 22.1.8) on all three changedC++ files — clean.
clang-tidy-diff.py(clang-tidy 22.1.8) againstorigin/master...HEAD, threefiles analysed (the two changed headers pinned into the compile database with
a real command, as CI's filter does, rather than left to
InterpolatingCompilationDatabase) — clean, exit 0. Verified non-vacuous:a deliberate
auto* tidyBait = (const void*)&result;on a changed line inregistry.hppwas reported as three errors(
readability-qualified-auto,modernize-avoid-c-style-cast,clang-diagnostic-old-style-cast), and removing it returned the run to clean.--target docwithMORPH_BUILD_DOCUMENTATION=ONandWARN_AS_ERROR=FAIL_ON_WARNINGS— exit 0;morph::model::ActionRecordingErrorgenerates.ctestonclang-debug(clang 22.1.8, Linux, tests + net,examples off): 1782/1782 passed, 76 s.
Not run locally:
scripts/coverage.sh— master's coverage leg is red for anunrelated reason (the comment cleanup moved lines that
scripts/branch_partial_allowlist.jsonpins; the repair is in #797). That fileis not touched here.
Spec
AGENTS.mdmakes the spec authoritative, so it moves in the same commit:docs/spec/journal/journal.md— new section "A refused recording is not anexecution failure", plus an invariant bullet stating that a
Failedentrymeans the model rejected the action and never that the framework could not
record a success.
docs/spec/core/registry.md—registerAction's description no longer placesthe success record inside the execution
try;ActionRecordingErroradded tothe type list.
docs/spec/core/bridge.md— the same correction forlocalOp, and theexecuteViatable row.Found and left
Nothing filed — nothing found that clears
AGENTS.md's bar. One adjacentobservation, recorded here rather than as an issue because it is a property of
this change and not a separate defect: the failure path's own
appendcan stillthrow (the issue lists this as unverified). It propagates from inside the
execution
catch, replacing the model's exception with the sink's — the sameclass of confusion, on the path where the action genuinely was rejected, so no
committed write is misreported. Narrowing it would mean deciding which of two
real failures the caller hears about, which is a different design question from
this one and is deliberately left alone here.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW