Skip to content

scenario: all 16 ledger scenarios are refused by a real server since morph#444 gave the enums glz::meta #460

Description

@Yaraslaut

Rescoped 2026-09-06. Everything above the fold is the corrected framing,
re-verified against origin/master f6a4e464 today. The original report is
preserved at the bottom; where the two disagree, this one wins. The
CI half of the original issue has been split out to
morph#462.

Summary

All 16 files in scripts/scenario/scenarios/ledger/ are refused by a real
ladder_ledger_server, and have been since 2026-09-04.
They spell every
ledger enum as its underlying integer (kind=0, currency=1, trigger=0,
action=0, and the reply assertions that match them). morph#444 gave every
ledger enum a glz::meta/glz::enumerate, so glaze now decodes and encodes
those fields by enumerator name. Every such payload fails to parse, and every
reply assertion written against the integer form is wrong in the same way.

The fix is a per-action rewrite of the enum spellings in those 16 files —
send sites, escaped reply assertions, one inline-JSON site, and the prose that
documents the old wire form. It is not a sed: kind= is two different enums
depending on the action, and one class of assertion goes vacuously true
rather than red if it is converted carelessly. Both traps are spelled out
below, with the evidence.

Verified on master, today

origin/master f6a4e464, no local edits, clang 22.1.8 Release,
build/ladder-srv configured -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_NET=ON -DMORPH_BUILD_QT=ON.

$ python3 scripts/scenario/run_scenarios.py --rung ledger --build-dir build/ladder-srv ; echo $?
  FAIL a-rule-and-the-category-it-sets.scenario
  FAIL a-second-editor-on-the-same-book.scenario
  FAIL a-signed-in-session-is-what-authorises-work.scenario
  FAIL accounts-of-every-kind.scenario
  FAIL an-unbalanced-entry-changes-nothing.scenario
  FAIL bootstrap-a-book-over-the-wire.scenario
  FAIL budget-limit-and-spend-report.scenario
  FAIL categorise-an-account.scenario
  FAIL exactly-once-store-transaction.scenario
  FAIL import-a-statement-chunk.scenario
  FAIL open-account-transact-report-close.scenario
  FAIL submit-a-report-and-poll-it.scenario
  FAIL two-books-are-isolated.scenario
  FAIL undo-needs-a-journal-id-nothing-hands-out.scenario
  FAIL update-rule-version-conflict.scenario
  FAIL zero-sum-holds-per-currency.scenario
FAILED: ledger
1

Whole corpus, all five servers built:

$ python3 scripts/scenario/run_scenarios.py --build-dir build/ladder-srv
73 files: 57 ok, 16 FAIL   →   FAILED: ledger

bookmarks (14/14), kanban (21/21), pastebin (12/12) and polls (10/10) are
fully green. Ledger is the only affected rung
— the original report's
"inferred, not measured" caveat on scope is retired.

Why the original said "1 of 16"

The original measurement was taken on a tree that included
store-list-and-undo-an-entry.scenario, a file that does not exist on
master
— it is added by the unmerged
#461, already written in
the enumerator-name form. The master file it replaces,
undo-needs-a-journal-id-nothing-hands-out.scenario:37, carries kind=0 currency=1 and fails identically:

FAIL step 5 (line 38): do OpenAccount ledgerId=1 name="Undo cash" kind=0 currency=1
  reply: err message="1:41: expected_quote
   {"ledgerId":1,"name":"Undo cash","kind":0,"currency":1}
                                           ^"

So the count is 0 of 16 passing on master, not 1 of 16.

Mechanism, confirmed by a two-call control

One ladder_ledger_server, one session, one action, two spellings, name first
so both actually run:

  ok   do OpenAccount ledgerId=$book name="Name kind" kind="Asset" currency="EUR"
         -> ok body={"id":1,"name":"Name kind","kind":"Asset","currency":"EUR","balance":{"num":0,"den":1,"dp":2}}
  err  do OpenAccount ledgerId=$book name="Int kind"  kind=0       currency=1
         -> err message="1:40: expected_quote
   {"ledgerId":1,"name":"Int kind","kind":0,"currency":1}
                                          ^"

The quoted enumerator name is accepted and the integer refused, on the same
socket, seconds apart. Note the reply too: the server emits
"kind":"Asset","currency":"EUR", so the corpus's escaped reply assertions
(\"kind\":0,\"currency\":1) are wrong in the same way the payloads are.

Provenance (git log -S) names one commit, an ancestor of f6a4e464:

0dc871a9  2026-09-04 14:39:37 +0300  Batch: fix triage:valid issues #435, #392, #393, and part of #396 (#444)

examples/ledger/include/ledger/core/types.hpp:65-92 and
examples/ledger/include/ledger/core/units.hpp:83-87 carry the six metas. The
"since 2026-09-04" date claim holds, and morph#384's "16 files green on both
passes" was true when written — it merged three days before the only commit
that introduced the metas.

morph#444 is correct and is not to be reverted. A metaless enum makes glaze
emit a six-way wildcard type, and the shipped Qt/QML DynamicForm draws that
as a checkbox reporting the form ready for a value nobody chose (morph#392).
The corpus is what was never updated.

The work, sized

Counts are against master (f6a4e464), re-measured for this rescope.
scripts/scenario/scenarios/ledger/, 16 files, all 16 needing the rewrite:

what how to find it count
key=<int> send sites grep -oE '\b(kind|currency|trigger|action)=[0-9]+' 105 (all on do lines)
escaped reply assertions grep -oE '\\"(kind|currency)\\":[0-9]+' 166 (\"currency\":1 ×80, \"kind\":0 ×56, \"kind\":1 ×22, \"kind\":3 ×3, \"currency\":0 ×3, \"kind\":2 ×2)
field <enum> <op> <int> comparisons grep -nE 'field (kind|currency|status) (==|!=) [0-9]+' 18 — 12 with == (go red), 6 with != (go vacuous — see below)
enum inside inline JSON zero-sum-holds-per-currency.scenario:82, "foreignCurrency":0 1
prose blocks asserting the integer wire form comment lines naming AccountKind::/Currency::/… 7 files

Per file:

file send escaped assert field vs int inline JSON prose
accounts-of-every-kind.scenario 8 18 4 0 :15-16
an-unbalanced-entry-changes-nothing.scenario 4 20 0 0
a-rule-and-the-category-it-sets.scenario 8 4 0 0 :31-32
a-second-editor-on-the-same-book.scenario 6 24 0 0
a-signed-in-session-is-what-authorises-work.scenario 12 6 0 0
bootstrap-a-book-over-the-wire.scenario 4 4 0 0 :33-34
budget-limit-and-spend-report.scenario 6 0 4 0 :18
categorise-an-account.scenario 7 2 1 0
exactly-once-store-transaction.scenario 4 18 0 0
import-a-statement-chunk.scenario 4 12 0 0
open-account-transact-report-close.scenario 6 4 2 0 :20-21
submit-a-report-and-poll-it.scenario 6 4 5 0 :39-40
two-books-are-isolated.scenario 11 22 2 0
undo-needs-a-journal-id-nothing-hands-out.scenario 4 12 0 0
update-rule-version-conflict.scenario 7 2 0 0
zero-sum-holds-per-currency.scenario 8 14 0 1 :18-19

If #461 merges first

#461 is open and not
merged. It deletes undo-needs-a-journal-id-nothing-hands-out.scenario, adds
store-list-and-undo-an-entry.scenario (verified: 0 integer-enum sites; it
already spells kind="Asset", currency="EUR", \"kind\":\"Asset\"), and
makes a comment-only edit to
open-account-transact-report-close.scenario:122-135 — which does not move
that file's lines 20, 100 or 116. So on master + #461 the numbers are:

  • 15 files to rewrite, of 16 in the directory
  • send sites 101, escaped reply assertions 154
  • field comparisons 18, inline JSON 1, prose blocks 7 — all unchanged
  • the rung run is 15 FAIL, 1 ok; the whole corpus 73 files, 58 ok, 15 FAIL

Either base is fine to work from. Whichever it is, the acceptance criteria
below are stated as "every file in the directory", so they do not depend on the
count.

Fix: a per-action rewrite

The enumerator table

enum declared in (examples/ledger/include/ledger/) wire spellings, in integer order
AccountKind core/types.hpp:43 0 → "Asset", 1 → "Expense", 2 → "Revenue", 3 → "Liability"
Currency core/units.hpp:17 0 → "USD", 1 → "EUR", 2 → "JPY", 3 → "KRW"
RuleTrigger core/types.hpp:44 0 → "DescriptionContains"
RuleAction core/types.hpp:45 0 → "SetCategory"
ReportKind core/types.hpp:46 0 → "MonthlyStatement", 1 → "BudgetReport"
ReportStatus core/types.hpp:47 0 → "Pending", 1 → "Done", 2 → "Failed"

Which action carries which enum — the kind= overload

This is the correction that makes the rewrite per-action rather than a sed.
kind= names two different enums:

action field enum
OpenAccount kind AccountKind
OpenAccount currency Currency
SubmitReport kind ReportKind
CreateRule trigger RuleTrigger
CreateRule action RuleAction
SetBudgetLimit currency Currency
StoreTransaction legs[].foreignCurrency (inline JSON) Currency

They collide inside a single file, ten lines apart:
submit-a-report-and-poll-it.scenario:55 is OpenAccount … kind=0
"Asset", and :65 is SubmitReport … kind=0"MonthlyStatement". A
global kind=0 → "Asset" substitution silently breaks the second.

Reply-side fields to convert as well

  • field accounts ~ "…\"kind\":N,\"currency\":M…" — the 166 escaped sites. The
    server now emits "kind":"Expense","currency":"EUR", so these become
    \"kind\":\"Expense\",\"currency\":\"EUR\".
  • expect ok field kind == N / field currency == N (12 sites, ==) →
    compare against the quoted name.
  • expect ok field status == 0 / != 2 (ReportStatus) → == "Pending" /
    != "Failed". See the trap below before touching the != ones.

Inline JSON, not just key=value

zero-sum-holds-per-currency.scenario:82 carries the enum inside the
legs=[…] blob:

legs=[{"accountId":$eurCash,…,"foreignCurrency":0},{…}]

A send-site-only pass that greps for currency= leaves this and the file stays
red with … "foreignCurrency":0 … expected_quote. Converted to
"foreignCurrency":"USD", that file passes: 14 steps, 32 assertions, no failures.

Prose: seven blocks, not two

Each of these states the integer wire form and is wrong after the rewrite:

  • accounts-of-every-kind.scenario:15-16"AccountKind::Asset is 0, ::Expense 1, ::Revenue 2, ::Liability 3; Currency::USD is 0 and ::EUR is 1. Both travel as integers."
  • a-rule-and-the-category-it-sets.scenario:31-32"RuleTrigger::DescriptionContains is 0 and RuleAction::SetCategory is 0"
  • bootstrap-a-book-over-the-wire.scenario:33-34"enums are their integer values — AccountKind::Asset is 0 and ::Expense is 1, Currency::EUR is 1"
  • budget-limit-and-spend-report.scenario:18"falls back to Currency::USD -- 0 on the wire"
  • open-account-transact-report-close.scenario:20-21"enums are their integer values, not their names"
  • submit-a-report-and-poll-it.scenario:39-40"ReportKind::MonthlyStatement is 0 and ::BudgetReport is 1; ReportStatus::Pending is 0, ::Done 1, ::Failed 2"
  • zero-sum-holds-per-currency.scenario:18-19"enums are their integer values: AccountKind::Asset is 0 and ::Expense is 1, Currency::USD is 0 and ::EUR is 1"

No prose outside scripts/scenario/scenarios/ledger/ states the integer form
(checked across *.md and *.py), so nothing else needs correcting.

The trap: six assertions go vacuous, not red — and mutation testing does not catch them

This is invariant 7
inside the corpus, and it is the single most dangerous item in this issue.

Runner.compare (scripts/scenario/morph_scenario.py:942, the != branch at :952-953) is plain Python
==/!= on the parsed JSON value. Once status decodes as the string
"Pending", the assertion expect ok field status != 2 reads "Pending" != 2
trivially, permanently true. The file goes green while the check its own
header comment argues for ("a job never reaches Failed (2)") has silently
stopped existing.

Six such sites:

  • submit-a-report-and-poll-it.scenario:69, 73, 80, 84, 99
  • open-account-transact-report-close.scenario:116

The 12 == sites are safe by contrast — "Asset" == 0 is false, so they fail
loudly and cannot be missed.

Demonstrated, not asserted

A scratch rewrite of submit-a-report-and-poll-it.scenario — enums converted
per-action, != 2 deliberately left alone — run against a live server:

submit.scenario: 19 steps, 34 assertions, no failures      # EXIT 0

Two probes on the same server prove the assertion measures nothing:

# replace `status != 2` with an integer no ReportStatus can ever be:
vacuous-probe.scenario (status != 424242): 19 steps, 34 assertions, no failures
# the real value:
realvalue.scenario    (status == "Pending"): 19 steps, 34 assertions, no failures

mutate_scenario.py is blind to this class — do not rely on it

Run on that same scratch file, containing five provably vacuous assertions:

submit.scenario: 28 mutants, 28 caught, 0 survived         # EXIT 0

Zero survivors on a file with five dead assertions. The reason is
structural: mutate_scenario.py mutates the scenario, not the server, and
both mutants of a vacuous line die anyway — the kind flip (expect ok
expect err) dies on the reply's kind, and the operator flip (!===)
dies because "Pending" == 2 is false. Mutation cannot distinguish
"Pending" != 2 (dead) from "Pending" != "Failed" (live).

So the acceptance check for this has to be a static scan, not a mutation
run. --mutate is still worth running for its own sake; it just is not the
control for this defect.

Acceptance criteria

  1. The rung is green, twice. The runner has a flag for it:
    python3 scripts/scenario/run_scenarios.py --rung ledger --build-dir <dir> --twice
    Every file in scripts/scenario/scenarios/ledger/ passes on both passes
    against the same database. Paste the run.
  2. The whole corpus is green. run_scenarios.py --build-dir <dir>
    73 files: 73 ok, 0 FAIL (or 73 ok at whatever the file count is on the
    merge base). This pins that the rewrite did not disturb the four rungs that
    pass today.
  3. No assertion was left vacuous. All three greps must return nothing
    over scripts/scenario/scenarios/ledger/:
    cd scripts/scenario/scenarios/ledger
    grep -nE '\b(kind|currency|foreignCurrency|trigger|action)=[0-9]+' *.scenario
    grep -nE '\\"(kind|currency|status|trigger|action)\\":[0-9]+'      *.scenario
    grep -nE 'field (kind|currency|status|trigger|action) (==|!=) [0-9]+' *.scenario
    The third is the one that matters: it is the only check that catches a
    != comparison of a quoted enumerator name against a bare integer, and
    neither "the corpus is green" nor --mutate will. A rewrite that turns
    those 6 assertions into tautologies passes criteria 1 and 2 and the mutation
    run.
    Also confirm by inspection that each of the 6 sites listed above now
    compares against a quoted ReportStatus name.
  4. The rewritten assertions can still fail. --mutate over the rung,
    0 survivors:
    python3 scripts/scenario/run_scenarios.py --rung ledger --build-dir <dir> --mutate
    (Necessary, not sufficient — see the section above.)
  5. The existing parse-level gates stay green.
    python3 scripts/scenario/scenario_coverage.py → exit 0, ledger actions 18/18 dispatched, workflows 16/16; python3 scripts/scenario/test_morph_scenario.py → OK. Both pass today and must
    still pass; neither is evidence of anything else, since both read text and
    start nothing.
  6. The seven prose blocks say what the wire actually does now — enumerator
    names, not integers.

Out of scope

  • The missing CI gate. That is now
    morph#462

    (triage: valid, area: ci): nothing in CI executes run_scenarios.py, so
    the corpus went 16/16 → 0/16 over five days and a merge with every workflow
    green. ci: nothing runs the scenario corpus, so 16 refused ledger scenarios stayed green for five days #462 depends on this issue landing first, and deliberately so: the
    gate it adds runs the corpus and fails on a red one, so it cannot be merged
    while ledger is 0/16 — it would land red on master on day one. Fix the
    corpus here; add the gate there. (Splitting also means this mechanical,
    reviewable change does not wait on a CI-design argument.)
  • Reverting morph#444's enum metas. They are correct and were added for a
    real defect (morph#392).
  • The other four rungs' corpora. Measured green (57/57); nothing to do.
  • scenario_coverage.py's parse-level checks. They do a different,
    still-useful job.

What would change the verdict

A run_scenarios.py --rung ledger on master that passes without the literals
being rewritten — which would mean the enum wire form is not what the two-call
control above measured.

Original report (superseded by the rescope above)

The "15 of 16 / 1 file passing" figure was an artefact of measuring a tree
that included store-list-and-undo-an-entry.scenario, a file added by the
unmerged #461 and not present on master.
On master the number is 0 of 16.
The original Fix section is also under-specified in four ways — the kind=
overload, inline JSON, the seven (not two) prose blocks, and the vacuous-!=
trap — all corrected above. Kept for provenance.

Summary

15 of the 16 files in scripts/scenario/scenarios/ledger/ are refused by a
real ladder_ledger_server and have been since 2026-09-04.
They send every
ledger enum as its underlying integer (kind=0, currency=1, trigger=0,
action=0); morph#444 gave every ledger enum a glz::meta/glz::enumerate,
which makes glaze decode it by enumerator name, so each of those payloads now
fails to parse.

Nothing reports it. scripts/scenario/scenario_coverage.py — the only scenario
gate in CI (.github/workflows/drift-guard.yml) — parses the corpus and
diffs it against the registered action surface. It never runs it. Running the
corpus needs the five ladder_<rung>_server binaries built, which that
workflow deliberately does not have, so the corpus went from "16 files passing
twice" (morph#384, 2026-09-01) to "1 file passing" with a green CI throughout.

Verification status: reproduced

Measured on Linux, clang 22.1.8, against origin/master f6a4e464 plus one
unrelated new scenario file. python3 scripts/scenario/run_scenarios.py --rung ledger --build-dir <dir> exits 1:

  FAIL a-rule-and-the-category-it-sets.scenario
  FAIL a-second-editor-on-the-same-book.scenario
  FAIL a-signed-in-session-is-what-authorises-work.scenario
  FAIL accounts-of-every-kind.scenario
  FAIL an-unbalanced-entry-changes-nothing.scenario
  FAIL bootstrap-a-book-over-the-wire.scenario
  FAIL budget-limit-and-spend-report.scenario
  FAIL categorise-an-account.scenario
  FAIL exactly-once-store-transaction.scenario
  FAIL import-a-statement-chunk.scenario
  FAIL open-account-transact-report-close.scenario
  ok   store-list-and-undo-an-entry.scenario
  FAIL submit-a-report-and-poll-it.scenario
  FAIL two-books-are-isolated.scenario
  FAIL update-rule-version-conflict.scenario
  FAIL zero-sum-holds-per-currency.scenario
FAILED: ledger

Every failure is the same shape — the first step that carries an enum:

FAIL step 5 (line 42): do OpenAccount ledgerId=1 name="Zero-sum EUR cash" kind=0 currency=1
  expected: @kind == ok
  actual:   @kind == err
  reply:    err message="1:49: expected_quote
   {"ledgerId":1,"name":"Zero-sum EUR cash","kind":0,"currency":1}
                                                   ^"

The caret sits on kind's value: glaze wants a quoted enumerator name there.

Root cause, isolated

Two OpenAccount calls against the same running server, differing only in how
the enums are spelled:

do OpenAccount ledgerId=$book name="Int kind"  kind=0       currency=1
  -> err message="1:40: expected_quote ... {"ledgerId":1,"name":"Int kind","kind":0,"currency":1}"

do OpenAccount ledgerId=$book name="Name kind" kind="Asset" currency="EUR"
  -> ok body={"id":1,"name":"Name kind","kind":"Asset","currency":"EUR","balance":{"num":0,"den":1,"dp":2}}

examples/ledger/include/ledger/core/types.hpp now carries

template <>
struct glz::meta<ledger::AccountKind> {
    using enum ledger::AccountKind;
    static constexpr auto value = glz::enumerate(Asset, Expense, Revenue, Liability);
};

and the same for RuleTrigger, RuleAction, ReportKind, ReportStatus
(plus Currency in units.hpp). Its own doc comment states the consequence
plainly — the enum "travels as its enumerator name, not its underlying
integer". That was the point of morph#392: a metaless enum makes glaze emit a
six-way wildcard type and DynamicForm draws it as a checkbox reporting the
form ready for a value nobody chose. The change is correct; the corpus was
just never updated with it.

git log -S'glz::enumerate(Asset, Expense, Revenue, Liability)' names one
commit: 0dc871a9 (2026-09-04, "Batch: fix triage:valid issues #435, #392,
#393, and part of #396 (#444)"), an ancestor of f6a4e464.

Two files still assert the old shape in prose, and are wrong now:

  • scripts/scenario/scenarios/ledger/bootstrap-a-book-over-the-wire.scenario,
    under "Wire shapes that are not guessable from the DTOs": "enums are their
    integer values — AccountKind::Asset is 0 and ::Expense is 1, Currency::EUR
    is 1"
    .
  • the same claim, implicitly, in every sibling file's payloads.

Scope

ledger only, as far as measured. kanban is the other rung with enum
glz::metas (kanban::Role, kanban::RuleMutationType), and its scenarios
already send role=Member / role=Manager / role=Viewer — names, because
kanban's metas predate its corpus. pastebin, polls and bookmarks were not
run here. Inferred, not measured: if a rung's scenarios spell enums as names,
they are unaffected.

Fix

Mechanical: rewrite the enum literals in the 15 files as their enumerator
names, and correct the two prose claims about the wire shape. AccountKind is
"Asset"/"Expense"/"Revenue"/"Liability", Currency its ISO code,
RuleTrigger "DescriptionContains", RuleAction "SetCategory",
ReportKind "MonthlyStatement"/"BudgetReport", ReportStatus
"Pending"/"Done"/"Failed". Assertions matching reply text
(\"kind\":0,\"currency\":1) move with them.

scripts/scenario/scenarios/ledger/store-list-and-undo-an-entry.scenario
(added by the PR that found this) is already written in the new shape and can
be used as the reference.

The part that matters more than the rewrite

Fixing the literals fixes today and leaves the hole open. The corpus is
five servers' worth of wire-level coverage that nothing in CI executes, so any
change to a payload shape can silently retire the whole of it again, exactly as
this one did, and the only signal is that someone happens to run it by hand.

scripts/scenario/README.md and .github/workflows/drift-guard.yml both say
running the corpus in CI "is its own change". This is the evidence for making
that change: between 2026-09-01 and today the ledger corpus went from 16/16
passing to 1/16, and every gate in the repository stayed green. Whether that is
a new CI job, or a step appended to the existing ladder-tests job that
already builds the rung binaries, is the decision to make.

What would change the verdict

A run of run_scenarios.py --rung ledger that passes without the literals
being rewritten — which would mean the enum wire form is not what the probe
above measured.

Out of scope

  • Reverting morph#444's enum metas. They are correct and were added for a real
    defect (morph#392).
  • The other four rungs' corpora, until someone runs them. If one is broken too,
    it belongs here; if the cause differs, it is its own issue.

🤖 Rescoped with Claude Code

https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: ladderSubsystem: ladderbugSomething isn't workingtriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions