Skip to content

Add an ordered, coalescing, identity-aware action log#5

Merged
Yaraslaut merged 6 commits into
masterfrom
feature/issue-3-action-log
Jul 3, 2026
Merged

Add an ordered, coalescing, identity-aware action log#5
Yaraslaut merged 6 commits into
masterfrom
feature/issue-3-action-log

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Summary

Implements issue #3 ("Message queue"): an ordered log of actions executed against a model, with a pluggable durable sink, so state can be reproduced by replay and undone by replaying a shorter prefix.

  • Records every executed action, automatically, at the two places Model::execute() is ever actually invoked in the codebase — so recording is always server-side wherever a client/server split exists (local vs. remote/Qt topologies), with no extra plumbing required.
  • Coalesces repeats into their net effect at an app-controlled checkpoint (e.g. a "Save" action) while keeping full history in memory for undo — reusing the existing ActionDispatcher/ModelRegistryFactory as the replay engine rather than building a new one.
  • Tags every entry with the model instance's own stable identity (entityKey), attached once via the same HandlerBinding custom-factory seam already used for other injected dependencies.
  • Loggable rides on the existing BRIDGE_REGISTER_ACTION macro as an optional, strongly-typed 4th argument (default Yes) — no new registration macro.
  • Ships a file sink (FileActionLog, append-only NDJSON, real fsync) and closes the remote-identity gap (wire::Envelope::contextKey + RemoteServer::setLogProvider, via a non-breaking IBackend::registerModelWithContext default method — LocalBackend/QtWebSocketBackend need no changes).
  • Ships a Kafka-shaped sink (KafkaActionLog + FakeProducer) as an interface + in-memory fake broker — no librdkafka or live cluster dependency. Its key scheme lets one compacted Kafka topic serve both coalescing policies for free once a real producer is plugged into the IProducer seam.
  • A Kafka Streams read-model (phase 4) is left as documented future work in docs/ARCHITECTURE.md — different tech stack, no live deployment to build it against in this environment.

Test plan

  • ctest — 280/280 tests pass, zero compiler warnings
  • Coverage: precise per-line analysis (de-duplicated across template instantiations, matching scripts/aggregate_lcov_branches.py's own approach for this codebase) shows 100% of the new code is exercised; the only untested lines in the whole diff are pre-existing, unrelated code
  • VERIFY_INTERFACE_HEADER_SETS passes (every new header compiles standalone)
  • End-to-end integration test: a Save action's completion checkpoints SessionLog to a real file on disk, and journal::replay() from that file reproduces the live model's state
  • End-to-end test proving the client/server placement claim: a client-side factory (and any log it would attach) is never invoked when the active backend is SimulatedRemoteBackend

Closes #3.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GnLrDn28LXHHvJStTXHRfV

Records every executed action against a stateful model instance, coalesces
repeats into their net effect at an app-controlled checkpoint (e.g. a "Save"
action) while keeping full history for undo, tags every entry with the
model instance's own identity, and replays a log to reconstruct state —
reusing the existing ActionDispatcher/ModelRegistryFactory as the replay
engine rather than inventing a new one.

Core (registry.hpp, model.hpp, bridge.hpp, action_log.hpp, journal.hpp):
- Loggable{No,Yes} rides on the existing BRIDGE_REGISTER_ACTION macro as an
  optional 4th argument (default Yes); ActionLogPolicy<A>::coalesce (default
  false) is a lightweight, separately-specialised trait.
- IModelHolder::attachActionLog/recordIfAttached automatically records at the
  two places Model::execute() is ever actually invoked (ActionDispatcher's
  runner for every remote/Qt topology, Bridge::executeVia's localOp for local
  mode) — recording is therefore always server-side wherever a client/server
  split exists, with no extra plumbing.
- SessionLog keeps full-fidelity history for undo (via journal::replay() over
  a shorter prefix — no inverse operations needed) and checkpoint()s a
  coalesced view to a durable IActionLog sink.

Phase 2 (file_action_log.hpp, wire.hpp, backend.hpp, remote.hpp):
- FileActionLog: append-only NDJSON, real fsync on flush().
- Closes the remote-identity gap: wire::Envelope::contextKey +
  HandlerBinding::contextKey + a non-breaking IBackend::registerModelWithContext
  (default-implemented, so LocalBackend/QtWebSocketBackend need no changes) +
  RemoteServer::setLogProvider, so a server-created instance behind
  SimulatedRemoteBackend can get a real log attached with a real identity.

Phase 3 (kafka_action_log.hpp), per the "interface + fake broker" approach
(no librdkafka or live cluster in this environment):
- IProducer is the seam a real librdkafka-backed implementation plugs into
  later; FakeProducer is the in-memory reference impl with a compactedView()
  helper reproducing Kafka's own log-compaction semantics.
- KafkaActionLog's key scheme lets one compacted topic serve both coalescing
  policies for free: coalesce==true omits a uniquifier (last-write-wins via
  compaction); coalesce==false folds in `seq` so compaction never merges
  distinct events.

Phase 4 (a Kafka Streams read-model) is left as documented future work in
docs/ARCHITECTURE.md — different tech stack, no live deployment to build it
against here.

280 tests pass. Precise per-line coverage analysis (de-duplicating across
template instantiations, the same approach scripts/aggregate_lcov_branches.py
already uses for this codebase) shows 100% of the new code is exercised.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnLrDn28LXHHvJStTXHRfV
@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Yaraslaut and others added 5 commits July 3, 2026 14:05
CI's "Build documentation" job runs Doxygen with WARN_NO_PARAMDOC/
WARN_AS_ERROR — several new public methods (IActionLog/InMemoryActionLog/
FileActionLog append+entries, SessionLog entries/undoLast, the Kafka
IProducer/FakeProducer/KafkaActionLog surface, FileActionLog's constructor)
were missing @param/@return tags. Verified locally with the same doxygen
target (`cmake --build --target doc`) before pushing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnLrDn28LXHHvJStTXHRfV
TransactionModel's handler in bank_cli gets an audited HandlerBinding whose
contextKey is the demo principal: on LocalBackend the factory attaches a
FileActionLog directly; on SimulatedRemoteBackend the same contextKey
travels through the register wire envelope and RemoteServer::setLogProvider
attaches the identical log server-side. Same application code, same audit
file, either backend — this is a runnable demonstration of the client/server
placement guarantee the action log design relies on, not just a unit test of
it.

Deposit/Withdraw/Transfer are audited by the Loggable default; History (a
pure read) opts out via the macro's 4th argument.

Verified: `bank_cli` runs both scenarios and prints the resulting audit
trail — each entry correctly attributed to its principal, with the real
account id visible in the recorded payload. `bank_tests` (131 assertions)
still passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnLrDn28LXHHvJStTXHRfV
FileActionLog::~FileActionLog's `_file != nullptr` check was dead code: the
constructor either finishes with a valid handle or throws before completing
(destructor never runs on a failed construction), and copy/move are deleted,
so _file can never be null when the destructor runs. Removed the check
instead of writing an unreachable-by-design test for it.

Added a real test for entries()'s line.empty() skip: a hand-appended blank
line in the NDJSON file (not something FileActionLog itself would ever
produce, but plausible from external editing) must be skipped, not passed to
fromJson().

The third gap codecov flagged — action_log.hpp's toJson() throwing
SerializationError when glz::write_json fails — is left uncovered: it has no
realistic trigger for LogEntry's plain string/int fields, matching the same
already-accepted pattern in BRIDGE_REGISTER_ACTION's own untested
write-failure throw.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnLrDn28LXHHvJStTXHRfV
Kafka (IProducer/FakeProducer/KafkaActionLog and its tests) is removed —
dropped for now per request. The IActionLog interface is unchanged, so a
real sink can be added back later without touching call sites.

The bigger change: morph::journal::setActionLog(log) installs a process-wide
default action log. ModelFactory::create<Model>() — the single construction
path behind every ordinary model registration, local *or* remote — attaches
that default to each new instance automatically. Set it once in main(); no
per-model, per-handler, or per-backend wiring is needed, and it reaches
RemoteServer-owned instances the same way since they're built through the
same factory. defaultActionLog() reads it back; ScopedActionLog (RAII,
mirrors morph::log::ScopedLoggerOverride) installs one temporarily and
restores the previous one on scope exit.

IModelHolder::attachActionLog remains available for callers that need a
specific instance identity (contextKey) — an explicit call always overrides
the auto-attached default — so HandlerBinding::contextKey and
RemoteServer::setLogProvider from the previous commits still work as the
advanced per-instance escape hatch.

examples/bank/src/cli/main.cpp now demonstrates the simplified path: one
setActionLog() call in main() replaces the previous per-handler
HandlerBinding/contextKey/setLogProvider wiring, and every one of the app's
nine models is audited automatically, for both the local and simulated-
remote scenarios. Read-only actions (ListAccounts, GetAccount, GetLoan,
ListLoans, LoanScheduleRequest, ListBudgets, SpendingByKind, ListCards,
WhoAmI, ListPayments, ListPayees, ListNotifications, GenerateStatement, plus
the already-opted-out History) are marked Loggable::No so the resulting
trail stays meaningful now that logging applies everywhere by default.

282 core tests pass (down from 287 after removing 5 Kafka-only tests, up 6
for the new default-action-log coverage — net -5, +6 relative to before this
change). bank_tests (131 assertions) passes. Verified bank_cli end to end:
the printed audit trail correctly attributes every mutation across all nine
models to its principal, identically for both backends.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnLrDn28LXHHvJStTXHRfV
toJson()'s write-failure throw was structurally unreachable: I verified
against Glaze's own source that dump_int_error (the only write-relevant
error code that could apply to a flat string/int struct like LogEntry) is
declared in the error_code enum but never actually raised anywhere in
Glaze's write path, and the other write-relevant code (recursion-depth
limit) doesn't apply to a non-recursive struct. No amount of crafted input
could reach that branch through the public API.

Rather than leave it as a documented exception, factored the Glaze-error-to-
SerializationError conversion out of toJson/fromJson into one shared,
non-template function (detail::throwOnGlazeError). Both call sites now route
through the exact same compiled branch, and fromJson's failure path already
has a real, easy-to-trigger test (malformed JSON) — so that branch is now
genuinely exercised, not faked. action_log.hpp is 100% line/branch covered
as a result, no new test needed; the existing fromJson test now proves the
shared logic works for both directions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnLrDn28LXHHvJStTXHRfV
@Yaraslaut Yaraslaut merged commit bed488a into master Jul 3, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Message queue

1 participant