Skip to content

forms(qml) optional enum closed set (#839); core: promote runPostCommitTail (#789) - #845

Open
Yaraslaut wants to merge 2 commits into
masterfrom
lane/forms-postcommit
Open

Yaraslaut wants to merge 2 commits into
masterfrom
lane/forms-postcommit

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Closes #839
Closes #789

Two tickets, one commit each.

#839: DynamicForm reads an optional enum's closed set one level down

Changed. src/qt/forms/qml/DynamicForm.qml: enumChoices() now delegates to a new constBranchRows(p, nested). If a non-null branch, after resolveRef, has no const but is itself an all-const oneOf/anyOf, its alternatives count as the property's set. This is read exactly one level deep, and anything deeper still falls back. This is glaze's own output for std::optional<E>: {"anyOf": [{"$ref": "#/$defs/Grade"}, {"type": "null"}]}. docs/spec/forms/forms.md ("Closed sets") now describes the optional shape and the one-level bound.

Pinned in tst_DynamicFormEnumChoice.qml:

  • glaze's generated shape, copied verbatim from the triage comment (grade optional via $ref, req required via bare $ref)
  • the issue's inlined nested shape
  • a two-level nesting that must stay "not a closed set"

The assertions check kind == "enum" (not only that choices exist), that a ComboBox is drawn, that choosing "High" puts "grade":"High" on the wire, that a blank optional field is omitted while ready stays true, and that "Medium" leaves the form not ready.

Verified (measured, macOS, Qt 6.11.1):

  • ctest -j 4 -R forms_qml_logic → 100% tests passed out of 1. The enum file on its own: Totals: 25 passed, 0 failed.
  • Mutation 1: call constBranchRows(p, false), i.e. no flattening. Result: Totals: 20 passed, 5 failed. All five new optional/inlined cases fail, e.g. test_glazes_optional_enum_is_a_closed_set_of_kind_enum() ... Actual (): false Expected (): true.
  • Mutation 2: allow unbounded nesting. Result: Totals: 24 passed, 1 failed (test_a_set_nested_two_levels_down_is_not_a_closed_set).

Not verified: I did not regenerate the glaze schema myself. The test uses the triage comment's literal JSON, which was produced against the pinned glaze.

#789: morph::model::runPostCommitTail

This follows the 2026-09-26 design decision on the issue: promote kanban's helper and do not add a dispatch-layer seam.

Changed.

  • include/morph/core/model.hpp: two overloads in morph::model (non-detail), taken from kanban's helper:
    • void runPostCommitTail(Tail&&, std::string_view what) noexcept. It requires a void-returning tail.
    • [[nodiscard]] Result runPostCommitTail(Tail&&, Result committed, std::string_view what). Result is deduced from committed, the tail's result only has to convert to it, and the overload is noexcept when Result is nothrow-move-constructible.
    • Both catch std::exception and ..., log through morph::log::logError (the noexcept formatting overload), and never rethrow.
    • Why model.hpp and not a new header: a new public header would have to be added to the FILE_SET HEADERS list in the top-level CMakeLists.txt, which is outside this lane.
  • kanban: the local copy is deleted and the ten call sites use ::morph::model::runPostCommitTail. The labels now carry the model tag ("[kanban::BoardModel] CreateColumn"), so the log line is unchanged.
  • ledger: every logAction that runs after a durable write now goes through the helper. That covers the two named sites (StoreTransaction together with its rule-cascade entries in one tail, and SetCategory) plus five more with the same shape after autocommit writes: CreateLedger, OpenAccount, UndoTransaction, ImportLedgerChunk and RunReportJob. Ledger catches only LedgerError, so a sink throw at any of these sites used to reach the caller after the write had committed.
  • Tests:
    • tests/test_post_commit_tail.cpp (10 cases): both overloads, std and non-std exceptions, the exact log text, conversion, noexcept, and a model-shaped handler that journals to a refusing IActionLog. It also checks that a pre-commit throw still reaches the caller.
    • examples/ledger/tests/test_ledger_post_commit_tail.cpp: OpenAccount ×2 and StoreTransaction against a refusing sink return normally, the sink was called 3 times, and a fresh GetLedger shows the balances.
    • kanban's test_board_post_commit_tail.cpp stays as it is, as the rung's integration proof.
  • Spec: new docs/spec/core/post_commit_tail.md, linked from docs/spec/README.md.

Verified (measured):

  • ./tests/morph_tests "[post_commit_tail]" → All tests passed (36 assertions in 10 test cases).
  • Mutation: add throw; after the std::exception log line and drop noexcept. The build fails on the two STATIC_REQUIRE(noexcept(...)). With those disabled, the run gives test cases: 10 | 6 passed | 4 failed: every std::exception containment case fails. The non-std case was not mutated.
  • ladder_ledger_tests "[post_commit_tail]" → All tests passed (10 assertions in 1 test case). Against master's ledger_model.cpp: FAILED: due to unexpected exception with message: RefusingActionLog: append refused.
  • ctest -j 4 -R "^(kanban|ledger)\." → 100% tests passed out of 344.
  • ctest -j 4 -E "^(kanban|ledger)\.": all tests that were built passed. The 14 failures are all targets this lane did not build (Not Run / *_NOT_BUILT, or forms_html_math hitting ENOENT on morph_forms_demo).
  • The Doxygen doc target, configured with the flags from AGENTS.md, built with no warnings.

Not verified:

  • Other rungs: bank, bookmarks, crm, lims, pastebin and polls still carry the shape and were not audited or retrofitted. The issue's closing condition asks for one rung other than kanban.
  • Windows/Linux builds, which are left to CI.
  • The ledger cascade tail was exercised by the full ledger suite with a working sink. No test drives a failing sink through a rule cascade.

The design comment's revisit condition: it asked to revisit if a rung needs post-commit work in a different execution context. Neither kanban nor ledger does: every tail runs synchronously before execute() returns. The spec's "Why a utility" section records that limit.

Review notes (inline)

  • model.hpp now includes logger.hpp. That adds <format>/<print>/<mutex> to every TU that includes model.hpp. It is small, and logger was likely already reachable transitively, but I did not measure the compile-time cost.
  • The value overload takes committed by value, so kanban's MoveTaskPosition copies one GetBoardResult, exactly as the old local helper did.
  • The helper's channel is the error log, not the caller. This complements the dispatcher's ActionRecordingError (which covers the framework's own auto-append) and does not duplicate it. The spec states this.
  • constBranchRows accepts a mix of const branches and one-level nested sets as the union of their values. No generator produces that mix. I chose not to reject it, because every value in the union is still a declared const.

🤖 Generated with Claude Code

Yaraslaut and others added 2 commits September 29, 2026 15:43
glaze emits std::optional<E> for a glz::enumerate'd E as
{"anyOf": [{"$ref": "#/$defs/E"}, {"type": "null"}]}, and $defs.E is the
oneOf of const alternatives. enumChoices() gave up on the first non-null
branch without a const, so every optional enum rendered as a free-text
field.

A non-null branch that, after resolveRef, is itself an all-const
oneOf/anyOf now contributes its alternatives. Exactly one level; a set
nested deeper still falls back. The field's kind becomes "enum", the value
encodes as the JSON string, blank is omitted.

tst_DynamicFormEnumChoice pins glaze's generated $ref shape verbatim, the
issue's inlined nested shape, and the depth bound.

Closes #839

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every rung that journals from inside execute() commits and then keeps
working, and a throw from that post-commit work told the caller a durable
write had failed. kanban hand-rolled the containment; no other rung had it.

morph::model::runPostCommitTail (include/morph/core/model.hpp) is kanban's
helper promoted as-is, with the value-returning overload's result type made
a template parameter deduced from `committed`: run the tail, log a failure
through morph::log::logError, never rethrow. The void overload is noexcept
and rejects a value-returning tail at compile time.

- kanban: local copy deleted; the ten call sites use the framework helper.
- ledger: every logAction after a durable write (StoreTransaction and its
  rule cascade, SetCategory, CreateLedger, OpenAccount, UndoTransaction,
  ImportLedgerChunk, RunReportJob) now runs through it.
- tests/test_post_commit_tail.cpp: framework-level coverage, including a
  model-shaped handler journalling to a refusing sink.
- examples/ledger/tests/test_ledger_post_commit_tail.cpp: ledger shown
  using it; fails on master's unshielded code.
- docs/spec/core/post_commit_tail.md: new spec.

Closes #789

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.72727% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
include/morph/core/model.hpp 93.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant