TagService.resolveRootTags(tags) maps each tag to its root ancestor as a
RootTag (id, name, color token), keyed by the input tag id. A root maps to
itself; a child is walked to the parentless ancestor via the existing
recursive-CTE findAncestorIds — one CTE per distinct non-root tag (memoized),
plus a single batched findAllById — so a timeline of many letters sharing few
tags costs O(distinct tags) queries, never O(letters). The color is read from
the resolved root's stored token (null when the root has none).
This is the shared enrichment the /zeitstrahl tag chip (#835) and, later, the
Thema buckets (#827) both consume. Unit-tested in TagServiceTest; the
DB-dependent ancestry walk is pinned against real Postgres in
TagServiceIntegrationTest.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>