You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Rescoped 2026-09-06. Everything above the fold is the corrected framing,
re-verified against origin/masterf6a4e464 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/masterf6a4e464, 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.
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:
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:
#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.
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:
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.
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:
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
The rung is green, twice. The runner has a flag for it:
Every file in scripts/scenario/scenarios/ledger/ passes on both passes
against the same database. Paste the run.
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.
No assertion was left vacuous. All three greps must return nothing
over scripts/scenario/scenarios/ledger/:
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.
The rewritten assertions can still fail.--mutate over the rung,
0 survivors:
(Necessary, not sufficient — see the section above.)
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.
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/masterf6a4e464 plus one
unrelated new scenario file. python3 scripts/scenario/run_scenarios.py --rung ledger --build-dir <dir> exits 1:
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.
Summary
All 16 files in
scripts/scenario/scenarios/ledger/are refused by a realladder_ledger_server, and have been since 2026-09-04. They spell everyledger enum as its underlying integer (
kind=0,currency=1,trigger=0,action=0, and the reply assertions that match them). morph#444 gave everyledger enum a
glz::meta/glz::enumerate, so glaze now decodes and encodesthose 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 enumsdepending 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/masterf6a4e464, no local edits, clang 22.1.8 Release,build/ladder-srvconfigured-DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all -DMORPH_BUILD_NET=ON -DMORPH_BUILD_QT=ON.Whole corpus, all five servers built:
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 onmaster — 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, carrieskind=0 currency=1and fails identically: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 firstso both actually run:
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 off6a4e464:examples/ledger/include/ledger/core/types.hpp:65-92andexamples/ledger/include/ledger/core/units.hpp:83-87carry 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
DynamicFormdraws thatas a checkbox reporting the form
readyfor 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:key=<int>send sitesgrep -oE '\b(kind|currency|trigger|action)=[0-9]+'dolines)grep -oE '\\"(kind|currency)\\":[0-9]+'\"currency\":1×80,\"kind\":0×56,\"kind\":1×22,\"kind\":3×3,\"currency\":0×3,\"kind\":2×2)field <enum> <op> <int>comparisonsgrep -nE 'field (kind|currency|status) (==|!=) [0-9]+'==(go red), 6 with!=(go vacuous — see below)zero-sum-holds-per-currency.scenario:82,"foreignCurrency":0AccountKind::/Currency::/…Per file:
fieldvs intaccounts-of-every-kind.scenarioan-unbalanced-entry-changes-nothing.scenarioa-rule-and-the-category-it-sets.scenarioa-second-editor-on-the-same-book.scenarioa-signed-in-session-is-what-authorises-work.scenariobootstrap-a-book-over-the-wire.scenariobudget-limit-and-spend-report.scenariocategorise-an-account.scenarioexactly-once-store-transaction.scenarioimport-a-statement-chunk.scenarioopen-account-transact-report-close.scenariosubmit-a-report-and-poll-it.scenariotwo-books-are-isolated.scenarioundo-needs-a-journal-id-nothing-hands-out.scenarioupdate-rule-version-conflict.scenariozero-sum-holds-per-currency.scenarioIf #461 merges first
#461 is open and not
merged. It deletes
undo-needs-a-journal-id-nothing-hands-out.scenario, addsstore-list-and-undo-an-entry.scenario(verified: 0 integer-enum sites; italready spells
kind="Asset",currency="EUR",\"kind\":\"Asset\"), andmakes a comment-only edit to
open-account-transact-report-close.scenario:122-135— which does not movethat file's lines 20, 100 or 116. So on master + #461 the numbers are:
fieldcomparisons 18, inline JSON 1, prose blocks 7 — all unchanged15 FAIL, 1 ok; the whole corpus73 files, 58 ok, 15 FAILEither 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
examples/ledger/include/ledger/)AccountKindcore/types.hpp:430 → "Asset",1 → "Expense",2 → "Revenue",3 → "Liability"Currencycore/units.hpp:170 → "USD",1 → "EUR",2 → "JPY",3 → "KRW"RuleTriggercore/types.hpp:440 → "DescriptionContains"RuleActioncore/types.hpp:450 → "SetCategory"ReportKindcore/types.hpp:460 → "MonthlyStatement",1 → "BudgetReport"ReportStatuscore/types.hpp:470 → "Pending",1 → "Done",2 → "Failed"Which action carries which enum — the
kind=overloadThis is the correction that makes the rewrite per-action rather than a
sed.kind=names two different enums:OpenAccountkindAccountKindOpenAccountcurrencyCurrencySubmitReportkindReportKindCreateRuletriggerRuleTriggerCreateRuleactionRuleActionSetBudgetLimitcurrencyCurrencyStoreTransactionlegs[].foreignCurrency(inline JSON)CurrencyThey collide inside a single file, ten lines apart:
submit-a-report-and-poll-it.scenario:55isOpenAccount … kind=0→"Asset", and:65isSubmitReport … kind=0→"MonthlyStatement". Aglobal
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. Theserver 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=valuezero-sum-holds-per-currency.scenario:82carries the enum inside thelegs=[…]blob:A send-site-only pass that greps for
currency=leaves this and the file staysred 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
*.mdand*.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. Oncestatusdecodes as the string"Pending", the assertionexpect ok field status != 2reads"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 silentlystopped existing.
Six such sites:
submit-a-report-and-poll-it.scenario:69, 73, 80, 84, 99open-account-transact-report-close.scenario:116The 12
==sites are safe by contrast —"Asset" == 0is false, so they failloudly and cannot be missed.
Demonstrated, not asserted
A scratch rewrite of
submit-a-report-and-poll-it.scenario— enums convertedper-action,
!= 2deliberately left alone — run against a live server:Two probes on the same server prove the assertion measures nothing:
mutate_scenario.pyis blind to this class — do not rely on itRun on that same scratch file, containing five provably vacuous assertions:
Zero survivors on a file with five dead assertions. The reason is
structural:
mutate_scenario.pymutates the scenario, not the server, andboth 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" == 2is 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.
--mutateis still worth running for its own sake; it just is not thecontrol for this defect.
Acceptance criteria
scripts/scenario/scenarios/ledger/passes on both passesagainst the same database. Paste the run.
run_scenarios.py --build-dir <dir>→73 files: 73 ok, 0 FAIL(or73 okat whatever the file count is on themerge base). This pins that the rewrite did not disturb the four rungs that
pass today.
over
scripts/scenario/scenarios/ledger/:!=comparison of a quoted enumerator name against a bare integer, andneither "the corpus is green" nor
--mutatewill. A rewrite that turnsthose 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
ReportStatusname.--mutateover the rung,0 survivors:
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 muststill pass; neither is evidence of anything else, since both read text and
start nothing.
names, not integers.
Out of scope
morph#462
(
triage: valid,area: ci): nothing in CI executesrun_scenarios.py, sothe 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
masteron day one. Fix thecorpus here; add the gate there. (Splitting also means this mechanical,
reviewable change does not wait on a CI-design argument.)
real defect (morph#392).
scenario_coverage.py's parse-level checks. They do a different,still-useful job.
What would change the verdict
A
run_scenarios.py --rung ledgeron master that passes without the literalsbeing rewritten — which would mean the enum wire form is not what the two-call
control above measured.
Original report (superseded by the rescope above)
Summary
15 of the 16 files in
scripts/scenario/scenarios/ledger/are refused by areal
ladder_ledger_serverand have been since 2026-09-04. They send everyledger enum as its underlying integer (
kind=0,currency=1,trigger=0,action=0); morph#444 gave every ledger enum aglz::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 scenariogate in CI (
.github/workflows/drift-guard.yml) — parses the corpus anddiffs it against the registered action surface. It never runs it. Running the
corpus needs the five
ladder_<rung>_serverbinaries built, which thatworkflow 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/masterf6a4e464plus oneunrelated new scenario file.
python3 scripts/scenario/run_scenarios.py --rung ledger --build-dir <dir>exits 1:Every failure is the same shape — the first step that carries an enum:
The caret sits on
kind's value: glaze wants a quoted enumerator name there.Root cause, isolated
Two
OpenAccountcalls against the same running server, differing only in howthe enums are spelled:
examples/ledger/include/ledger/core/types.hppnow carriesand the same for
RuleTrigger,RuleAction,ReportKind,ReportStatus(plus
Currencyinunits.hpp). Its own doc comment states the consequenceplainly — 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
DynamicFormdraws it as a checkbox reporting theform
readyfor a value nobody chose. The change is correct; the corpus wasjust never updated with it.
git log -S'glz::enumerate(Asset, Expense, Revenue, Liability)'names onecommit:
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".
Scope
ledger only, as far as measured.
kanbanis the other rung with enumglz::metas (kanban::Role,kanban::RuleMutationType), and its scenariosalready send
role=Member/role=Manager/role=Viewer— names, becausekanban's metas predate its corpus.
pastebin,pollsandbookmarkswere notrun 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.
AccountKindis"Asset"/"Expense"/"Revenue"/"Liability",Currencyits 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.mdand.github/workflows/drift-guard.ymlboth sayrunning 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-testsjob thatalready builds the rung binaries, is the decision to make.
What would change the verdict
A run of
run_scenarios.py --rung ledgerthat passes without the literalsbeing rewritten — which would mean the enum wire form is not what the probe
above measured.
Out of scope
defect (morph#392).
it belongs here; if the cause differs, it is its own issue.
🤖 Rescoped with Claude Code
https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF