What happens
StoreTransaction looks each leg's account up by id alone, with no filter on the ledger the action names:
// examples/ledger/src/models/ledger_model.cpp:694-701
for (const auto& leg : action.legs) {
auto rows = mapper.Query<db::AccountRecord>()
.Where(::Lightweight::FieldNameOf<&db::AccountRecord::id>, "=", *leg.accountId)
.All();
if (rows.empty()) {
throw NotFound{"StoreTransaction: no such account"};
}
legAccounts.push_back(rows.front());
}
Account ids are a table-wide autoincrement, so another book's account id is a perfectly well-formed number naming a real row. The entry is accepted, the journal is filed under the ledger the action named, and the foreign account's balance moves.
Reproduction
Against a real ladder_ledger_server (two seeded ledgers rows):
OpenAccount ledgerId=1 name="B1 wallet" kind=0 currency=1 -> id 1
OpenAccount ledgerId=2 name="B2 spend" kind=1 currency=1 -> id 2
StoreTransaction ledgerId=1 legs=[{accountId:1, -1.00}, {accountId:2, +1.00}]
-> ok {"accounts":[{"id":1,...,"balance":{"num":-100,...}}]}
GetLedger ledgerId=1 -> [{"id":1, balance -100}] # no mention of account 2
GetLedger ledgerId=2 -> [{"id":2, balance 100}] # moved, with no journal here
Book one's reply does not mention the account it just moved, because every read is scoped to its ledger. Book two shows a balance change with no journal of its own to explain it. The two books disagree and neither report says so.
A leg naming an id that exists in no book at all is refused (StoreTransaction: no such account), which is what shows the lookup runs — it simply does not ask which book the row is in.
Why this reads as an inconsistency rather than a policy
UndoTransaction does make the check, at ledger_model.cpp:909:
throw NotFound{"UndoTransaction: journal does not belong to this ledger"};
so the scoping is thought about elsewhere in the same file. Nothing in execute(StoreTransaction) or in transaction_dto.hpp records a decision to omit it.
Same shape, second site
ImportLedgerChunk looks its row account up the same way (ledger_model.cpp:1074-1079), so a chunk whose counterAccountId or whose CSV account_id column names another book's account posts there too.
Where it is pinned
scripts/scenario/scenarios/ledger/two-books-are-isolated.scenario asserts the current behaviour, with a comment naming it, so a fix has to come through that file and change it deliberately.
Suggested fix
Add .Where(FieldNameOf<&db::AccountRecord::ledger>, "=", *action.ledgerId) to both lookups, with a distinct message ("StoreTransaction: account does not belong to this ledger") so it is not confused with the not-found path.
Found by the out-of-process scenario corpus; the in-process tests use one book.
What happens
StoreTransactionlooks each leg's account up by id alone, with no filter on the ledger the action names:Account ids are a table-wide autoincrement, so another book's account id is a perfectly well-formed number naming a real row. The entry is accepted, the journal is filed under the ledger the action named, and the foreign account's balance moves.
Reproduction
Against a real
ladder_ledger_server(two seededledgersrows):Book one's reply does not mention the account it just moved, because every read is scoped to its ledger. Book two shows a balance change with no journal of its own to explain it. The two books disagree and neither report says so.
A leg naming an id that exists in no book at all is refused (
StoreTransaction: no such account), which is what shows the lookup runs — it simply does not ask which book the row is in.Why this reads as an inconsistency rather than a policy
UndoTransactiondoes make the check, atledger_model.cpp:909:so the scoping is thought about elsewhere in the same file. Nothing in
execute(StoreTransaction)or intransaction_dto.hpprecords a decision to omit it.Same shape, second site
ImportLedgerChunklooks its row account up the same way (ledger_model.cpp:1074-1079), so a chunk whosecounterAccountIdor whose CSVaccount_idcolumn names another book's account posts there too.Where it is pinned
scripts/scenario/scenarios/ledger/two-books-are-isolated.scenarioasserts the current behaviour, with a comment naming it, so a fix has to come through that file and change it deliberately.Suggested fix
Add
.Where(FieldNameOf<&db::AccountRecord::ledger>, "=", *action.ledgerId)to both lookups, with a distinct message ("StoreTransaction: account does not belong to this ledger") so it is not confused with the not-found path.Found by the out-of-process scenario corpus; the in-process tests use one book.