f9404100

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.

Suite timing

Time to Start Worker time Duration Time to finish Idle
Config 0s 1s 1s 1s 0s
Eval 1s 16s 16s 17s 0s
Build 15s 2m35s 1m24s 1m40s 0s
Suite 0s 2m53s 1m40s 1m40s 0s

Timeline

0s10s20s30s40s50s1m1m10s1m20s1m30s1m40s