# Task 1819 — top-level label allowlist drift

Design, 2026-07-20.

## Problem

Two tests fail on `main`:

- `server/lib/top-level-labels.test.ts` — deep-equals the derived allowlist against the real references dir
- `server/routes/__tests__/graph-default-topLevel.test.ts` — resolves the top-level label set for the default view

Both compare the hardcoded client mirror `FILTER_TOP_LEVEL_LABELS` against the set derived from `plugins/memory/references/`.

## Evidence

Measured set diff, both directions:

| Direction | Labels |
|---|---|
| In `FILTER_TOP_LEVEL_LABELS`, not in derived | `InboundInvoice` |
| In derived, not in `FILTER_TOP_LEVEL_LABELS` | none |

Client mirror 48 entries, derived 47. Contributing files: `schema-construction.md`, `schema-estate-agent.md`. Table-derived 14, static 33.

## Cause

Commit `7b225f814` (2026-07-18, "promote ledger labels to base") moved the `InboundInvoice` row out of the `## Top-level node types` table in `schema-construction.md` and into the `## Node Types` table in `schema-base.md`.

`getTopLevelLabelAllowlist` parses only `## Top-level node types` sections. It deliberately never parses `schema-base.md`, because that table mixes child types (`InvoiceLine`, `InvoicePayment`, `PostalAddress`) in with top-level ones. So the promotion deleted `InboundInvoice` from the derived set.

`STATIC_TOP_LEVEL_LABELS` exists precisely for base-schema labels with no top-level section to derive from. `InboundInvoice` was never added to it — `git log -S` confirms the string has never appeared in `top-level-labels.ts`.

The other three promoted labels (`InvoiceLine`, `InvoicePayment`, `Credit`) are documented children in `graph-labels.ts` and correctly stay off both sets.

Nothing regenerates either side. The rot mechanism is that a schema-doc edit in the memory plugin's references directory silently changes the UI's derived chip set, guarded only by a test that was not run before the promotion landed.

## Fix

`InboundInvoice` is genuinely top-level and the client mirror is correct. The derivation is missing an entry.

1. Add `InboundInvoice` to `STATIC_TOP_LEVEL_LABELS`.
2. Extend the `STATIC_TOP_LEVEL_LABELS` doc comment to cover the reverse migration. It documents only the forward direction today (a vertical gains a top-level section, so its labels leave static). It is silent on the direction that caused this: a label promoted into `schema-base.md` must enter static, because base is never parsed.

Editing either assertion is explicitly not the fix.

## CashEntry

The same commit added `CashEntry`, absent from both sets, so it fails no test but has no `/graph` chip. Operator decision on this sprint: add it as a top-level label.

Justification that it is top-level: the promoting commit describes it as money "not attached to any invoice" with "no parent label". Child labels are reached by drilling down from a parent neighbourhood. `CashEntry` has no parent, so without a chip it is unreachable on `/graph` except by search.

Five surfaces:

| Surface | Change | Gate |
|---|---|---|
| `GRAPH_LABEL_COLOURS` | unique hex | hex uniqueness is test-enforced |
| `LABEL_ICONS` | Lucide glyph | coverage test fails if a colour label has no glyph |
| `FILTER_TOP_LEVEL_LABELS` | add | client chip mirror |
| `STATIC_TOP_LEVEL_LABELS` | add | server derived set |
| `pickShortLabel` | `CashEntry` branch | properties are `entryId`/`direction`/`amount`/`date`/`description`; none match the generic caption keys, so nodes would render captionless |

### Placement

Append `CashEntry` at the end of `GRAPH_LABEL_COLOURS`, not beside the ledger family.

`SHAPE_BY_LABEL` assigns each label a shape by its index position in `ALL_GRAPH_LABELS`, which is the colour table's key order. A mid-table insert shifts every subsequent index and changes the node shape of roughly 40 labels across the whole graph. No test catches that. Appending costs source-file grouping and buys zero collateral churn.

### Build

`platform/lib/graph-style/dist` is a symlink to the parent checkout in this worktree. Replace it with a local build before editing, so the parent checkout's build output is never written to.

## Scope boundaries

**In scope:** the cause of the disagreement, the correct value of the label set, both failing tests, and the five `CashEntry` surfaces.

**Out of scope:** the `/graph` view's rendering; the loop registry and its gate (1810, 1814, 1818); other pre-existing red on `main`; retiring the client mirror in favour of the derived set (already noted as a follow-up in `graph-labels.ts`).

## Testing

The two named tests, passing because the sets agree. Plus the graph-style icon-coverage and hex-uniqueness tests, and the client `graph-labels.test.ts` suite, since the colour table changed.
