Virtual assertions **Stacked on #5.** Base is `lot-prices`, so this diff is only the virtual assertions. Merge #5 first and this retargets to `master` on its own. Implements `VIRTUAL-ASSERTIONS-PLAN.md` from this branch, which is deleted by the last commit now that the work exists. ## The problem `+ assert` cannot see virtual postings. That is deliberate and consistent — `balanceTransaction` feeds only real postings into `balancesForAssertions`, `checkEntryAssertions` reads the without-virtual map, and `balance/balanced/virtual-assertion.cent` pins it — but it leaves an account declared `virtual-only` with **no assertable balance at all**. The failure mode was worse than a refusal. Asserting such an account reported ``` The actual balance has no amount in the asserted currency. Maybe there is a mistake with the currency? ``` which reads as a typo in the ledger rather than as a form that can never work there. ## What this adds ```centjes 2025-01-27 | I bought a coffee. * assets:cash -5.00 USD * expenses:coffee 5.00 USD ! expenses:vat 0.50 USD + assert expenses:coffee = 5.00 USD + assert virtual expenses:vat = 0.50 USD ``` Plain `+ assert` keeps meaning exactly what it meant. `--virtual` still changes what you see, not what an assertion means. ## Worth a look **The scope is lexed as one compound token** in the `<extra>` start code, not as a `virtual` keyword inside `<assertion>`. The `<assertion>` start code lexes account names with `@var`, and `@var` matches `virtual`, so a keyword rule there would tie on that exact input and make an account called `virtual` unassertable through a rule that has nothing to do with it. It also leaves the `assertion` production untouched, so this composes with #5 for free: ```centjes + assert virtual assets:broker = +1 SWDA lot @ 500 EUR ``` The scope sits on the extra, the lot sits on the commodity, and neither grammar production knows about the other. **`AssertionScope` is a two-constructor type, not a `Bool`**, and it sits on `Assertion` in both `Module` and `Ledger` rather than on `ExtraAssertion` where the keyword syntactically lives, so `Compile` copies a field instead of moving one between types. **The behavioural change is one map selection** in `checkEntryAssertions`. `checkAssertion` already took the balances as an argument. **It is legal on any account**, whatever its virtual posting policy. The keyword names the view, not the kind of account. On an account that forbids virtual postings the two forms are equivalent, which makes `assert virtual` redundant there, not wrong. ## Two silent holes closed - **`CE_REAL_ASSERTION_NOT_ALLOWED`** — a plain assertion on a `virtual-only` account. Its real balance is empty by construction, so the assertion is either trivially true at zero or always false, and carries no information either way. This is what used to produce the misleading message above. - **`CE_UNDECLARED_ACCOUNT` now covers assertions.** `compileTransactionAssertion` took only `currencies`, so it never looked at the account name, and `checkAssertion` defaulted a missing account to zero. An assertion naming an account that does not exist **passed silently**: a ledger declaring only `assets:cash` and asserting `assets:typoo = +0.00 CHF` reported `Valid`. Threading the accounts map in for the first error made the second a one-line fix, which is why they are in one change. Both have a fixture and a rendered `.err` golden. Both fixtures start life *passing*, which is their red state, since `CompileSpec` requires failure. ## Deliberately unchanged - Account-type assertions stay real-only. A `virtual-only` liability holding a positive virtual balance stays legal, as today. - `balance/balanced/virtual-assertion.cent` is untouched. It belongs to the old behaviour and is the regression test for the decision that plain `assert` keeps meaning what it means — which is why the new fixture is `assert-virtual.cent` and not the other way round. - Out of scope: virtual-aware account-type assertions, virtual-aware `assert currency`, and `net-worth` counting virtual postings. ## Checks `nix flake check` passes. All five suites pass: 777 in `centjes-gen` (up from 775 on `lot-prices`), 28 in `centjes-switzerland`, 3 + 13 + 3 elsewhere.