Destination
Every decision blocking the build of an embedded, daemon-served, client-side web UI for aiusage is resolved: the authentication posture for a publicly reachable surface is settled, the information model the UI exists to serve is chosen, the daemon/UI data contract is specified, the live-update posture is settled, and the release-tag build chain is verified against real mechanics - so execution can start with nothing left to decide.
Notes
Domain: Go daemon + append-only SQLite ledger, already surfaced by a Bubble Tea TUI and a CLI. This map adds a second read-only surface: a browser page served by the daemon itself.
Skills every session should consult: /grilling and /domain-modeling for the HITL tickets; /prototype where "what should it show" is the question.
Stack, already decided (do not re-litigate): React 19.2.8 + TypeScript 7.0.2 + Vite 8.0.9, client-side rendered, responsive, charts via TanStack Charts 0.9.0 on its Canvas renderer. TanStack Charts is pre-alpha and its README says so; that risk is accepted deliberately, pinned exactly (no caret), with all chart construction kept behind one seam - the same containment internal/tui/views/chartstyle.go gives ntcharts in three lines repo-wide.
Coverage floor: everything the TUI shows appears on the web - four surfaces, the hero in all three modes, composition, the drill stack, sort, filter, range and window stepping, freshness, and the resource gauges. Canvas earns the second surface on top of that floor, not instead of it.
State management: TanStack Query (v5, stable) owns server state, which is nearly all of this UI's state - every view is data fetched from the daemon. TanStack Store is alpha and is NOT adopted: one pre-alpha dependency in the critical path is a considered risk, two is a habit. React's own state handles the small amount of genuinely client-owned state (selected range, active tab, theme) until something proves it insufficient.
Build chain, decided and now VERIFIED end to end (see the build chain ticket for the literal commands): the built UI is gitignored and never committed. The npm build runs in release.yml BEFORE GoReleaser - not in before.hooks, which cannot express a working directory and would force npm to run twice. GoReleaser then compiles with tags: [webui], switching on the real embed; a !webui stub compiles otherwise, so a clean checkout always builds and go install never breaks. Consequence accepted: a go install ...@main binary has NO web UI; serve fails fast with an explanation, while the daemon warns and keeps collecting. Whole chain measured at 41.7s for four targets, npm adding ~2.3s.
Four corrections the verification forced, all now binding:
- The gate must re-run
build, vet, staticcheck and test a second time with -tags webui. Compiling the tagged path is NOT enough: vet and staticcheck skip //go:build webui files entirely, and a planted type error plus an unused function were both invisible to the untagged gate. Only gofmt is tag-agnostic.
- The embed pattern is
//go:embed all:dist, not //go:embed dist. Vite can emit a .vite/ directory, which the bare form silently drops. An empty output directory is its own distinct compile error.
- The repo's
.gitignore rule is rooted /dist/ and does not cover the UI's output. New rules are required, and the repo will contain two different dist directories - GoReleaser's and the UI's.
- Nothing links the assets into the binary until
cmd imports the web package, so -tags webui is a no-op on the artifact until then and any "assets present" smoke test gives a false negative.
Deployment, decided and already built: the UI is served on public hostnames behind the box's existing Caddy, not on loopback for the local user only.
aiusage.randomcodespace.dev (prod) -> 127.0.0.1:37800, the release binary serving embedded UI and API from one listener.
aiusage-dev.randomcodespace.dev (dev) -> /api/* and /ws* to 127.0.0.1:37801 (dev daemon, separate config and database), everything else to 127.0.0.1:5173 (Vite dev server and its HMR websocket).
- Config lives at
/etc/caddy/aiusage.caddy, imported from the main Caddyfile, matching the per-project convention already used by oss/buzz/lane. Both certificates are issued and both hosts answer 502 until an upstream exists.
This overturns an earlier decision and redraws the destination. Charting settled on a hard-coded loopback listener with no bind knob, and put remote access in Out of scope. The user has since required public hostnames, so remote access is IN scope and the loopback-only decision is void. The security consequence is not: usage data names local project paths, session ids, models and spend, and these hostnames are public. Authentication has since been decided against for now (see Decisions so far), which makes ONE constraint load-bearing: raw must never cross the boundary. 45,615 rows predate the usage-object-only allow-list and hold whole transcript lines, so an event endpoint returning raw would publish conversation content to the open internet. Explicit column projection is the condition under which an unauthenticated surface is survivable, not a later optimisation.
Invariants that scope every decision: adapters strictly read-only; usage_events append-only (the UI never writes - though note the store has NO read-only open today, so that is currently true only by trigger; see store: add a read-only open, a hard precondition); CGO disabled; OSI-licensed deps only; the runtime is offline and air-gapped - assets are embedded, never fetched from a CDN. Layering puts the server at internal/web, a peer of report and tui: it may import store and model, never tui, and cmd wires it. Serving uses its own read-only connection so a request never contends with the daemon's writes. The listener binds loopback only - Caddy is the sole route in, never 0.0.0.0.
Decisions so far
-
Research: TanStack Charts 0.9.0 canvas API and its churn surface - MIT throughout, 36 transitive packages all OSI-permissive, React 19 peer matches, tree-shaking claim mechanically gated rather than asserted. 21 releases in 12 days with three breaking minors in four, but breakage arrived through import specifiers (0.9.0), option renames (0.8.0) and callback signatures (0.7.0) - never through component props, so a props-only wrapper is not a sufficient seam. The seam must own every import specifier (no file outside it may name @tanstack/charts, lint-enforced), renderer selection, our own props and point type, our own accessor convention, and it must consume the store's already-formatted bucket keys rather than re-deriving buckets from a Date (the localtime/time.Local trap the project already fixed once). Canvas costs SSR entirely - no server-painted pixels - and its bundle size is unpublished and ungated upstream, so we are the only ones who will measure it.
-
Research: what the store can already answer for a web UI - five read methods, nine group dimensions, four filter dimensions. Every time-bucketed query is a full scan costing ~3us/row through the pure-Go driver: a daily timeline over the real 360,250-row ledger is 1.17s and a cold Overview reload is 3.33s. Counter-intuitively a 30-day range is SLOWER than all-time (4.33s vs 3.33s) because the bounded range switches the plan from sequential scan to per-row rowid indirection - and 30 days is exactly a web UI's likely default. A covering index was built and measured and made it worse; an hourly rollup was measured 118-187x faster. Gaps a superset UI hits immediately: no ordering/Top-N/LIMIT, no pagination (and model.UsageEvent has no id field to page on), ListEvents always drags 18MB of raw transcript content, no server-side search, no facet listing, kind (usage vs adjustment) neither filterable nor groupable, and no incremental-sync watermark.
-
Research: verify the release-tag build chain end to end - the design holds mechanically, with eight corrections. The load-bearing one: the untagged gate is BLIND to build-tagged files, so vet/staticcheck/test/build must all run twice. GoReleaser's dirty-tree check counts untracked files and runs before hooks, so hook output escapes it in both directions; before.hooks entries are plain strings with no shell and no dir:. {{ .Version }} strips the leading v, so release and go install binaries already report versions in two different formats. npm cost is negligible (~2.3s, 87MB node_modules, 272KB dist, 84KB gzip bundle with the canvas chart actually imported).
-
Decide: authentication posture for a publicly reachable UI - none for now, matching the manage precedent on this box rather than the kb token precedent. Recorded with its consequence: aggregate data (tokens, models, providers, spend, local project paths, session ids) is public on both hostnames. The hard condition it creates is that no endpoint may ever return raw; the commented basic_auth stanza stays in the Caddy config so the edge can be closed with one uncomment and a reload.
-
Decide: live-update posture - WebSocket, carrying a change NOTIFICATION rather than data: the server cannot answer "what changed since X" (no exposed id, observed_time_unix not filterable) and a cold reload is 3.33s, so pushing computed aggregates would cost that per client per cycle. A message becomes a TanStack Query invalidation and the client refetches only the view it is on. This makes the rollup decision more urgent, not less - a broadcast wakes every client at once, so a push manufactures a thundering herd that polling would have staggered. Reconnect with backoff is mandatory because the daemon re-executes itself into a replaced binary and drops every connection without a close frame.
-
Decide: derived rollup table, or live within the full-scan cost - adopted. Hourly, keyed on UTC hours and folded to local on read, derived-only and rebuildable, written from the collector's existing transaction, with its own schema version and migration step. CLAUDE.md's mutable-tables invariant is amended to three. Sub-hour buckets and per-event listing are explicitly out of its reach. Unmeasured risk carried: incremental maintenance cost alongside live collection.
-
Decide: how a binary advertises whether it can serve the UI - folded into buildinfo.Identity(). doctor prints embed status, serve exits 1 with the release link, the daemon warns and keeps collecting, and the v-prefix inconsistency between GoReleaser and module build info gets normalised on the way through.
-
Decide: what the web UI shows that the TUI cannot - single page, full TUI coverage, drill narrows every panel at once rather than navigating. Selection is a drawer, the rail stays, ring mode caps at top-8 with a stated "other" band. Committed light theme on the ANSI 16 in Tango values, so a series that is cyan in the terminal is cyan in the browser. Granularities hour/day/week/month, no sub-hour; landing range 7d; the brush and the presets are one range concept, not two.
Not yet specified
- Canvas accessibility. (Theming is settled: committed light, ANSI/Tango palette.) The library places the same
role="img" and description on the Canvas root as on SVG, so nothing AT-traversable is lost relative to SVG - but the exact-value alternative (linked table, role="status", visible units) is application-owned in both renderers, and the TUI's palette and elevation ladder have no automatic counterpart in a browser. Sharpens once the information model exists.
- How the JavaScript side is tested and what gates it in CI, including a bundle-size gate we will have to own ourselves for the Canvas lane.
- Whether any of this changes the CLI's
--json surfaces. The data contract ticket may or may not reuse them; if it diverges, the divergence needs its own decision rather than drifting.
- Whether the dev deployment needs its own collected data, or points at a copy of the real ledger. It has a separate database by construction; what goes in it is unsettled.
- Whether the TUI's cost figure gets corrected alongside this work. The TUI is
Summarize-only and never calls UnpricedGroups, so its cost KPI is the stamped floor while the CLI display-prices the same window - two surfaces, two numbers, no store change needed to fix it.
Out of scope
- Cloud sync or any network egress of usage data (inherited from the perf/graphs/harness map, still binding). Serving a read-only UI to the operator over their own reverse proxy is not egress; shipping usage data to a third party remains ruled out.
- Any notion of the web UI replacing the TUI. (Parity itself is NO LONGER out of scope: the owner has directed that everything the TUI shows must appear on the web. Parity is the floor the superset is built on, and canvas is what is added above it. Prototype to react to: https://claude.ai/code/artifact/03160e07-446b-4ebd-8f45-a1dd4f328f46)
- Writing to the ledger from the browser. The UI is read-only; the append-only invariant is not negotiable.
- Multi-user accounts, roles or tenancy. Authentication is about keeping the public internet out, not about modelling users.
- Windows support (flock/statfs/cgroup are Unix; deliberate per .goreleaser.yaml).
Destination
Every decision blocking the build of an embedded, daemon-served, client-side web UI for aiusage is resolved: the authentication posture for a publicly reachable surface is settled, the information model the UI exists to serve is chosen, the daemon/UI data contract is specified, the live-update posture is settled, and the release-tag build chain is verified against real mechanics - so execution can start with nothing left to decide.
Notes
Domain: Go daemon + append-only SQLite ledger, already surfaced by a Bubble Tea TUI and a CLI. This map adds a second read-only surface: a browser page served by the daemon itself.
Skills every session should consult:
/grillingand/domain-modelingfor the HITL tickets;/prototypewhere "what should it show" is the question.Stack, already decided (do not re-litigate): React 19.2.8 + TypeScript 7.0.2 + Vite 8.0.9, client-side rendered, responsive, charts via TanStack Charts 0.9.0 on its Canvas renderer. TanStack Charts is pre-alpha and its README says so; that risk is accepted deliberately, pinned exactly (no caret), with all chart construction kept behind one seam - the same containment
internal/tui/views/chartstyle.gogives ntcharts in three lines repo-wide.Coverage floor: everything the TUI shows appears on the web - four surfaces, the hero in all three modes, composition, the drill stack, sort, filter, range and window stepping, freshness, and the resource gauges. Canvas earns the second surface on top of that floor, not instead of it.
State management: TanStack Query (v5, stable) owns server state, which is nearly all of this UI's state - every view is data fetched from the daemon. TanStack Store is alpha and is NOT adopted: one pre-alpha dependency in the critical path is a considered risk, two is a habit. React's own state handles the small amount of genuinely client-owned state (selected range, active tab, theme) until something proves it insufficient.
Build chain, decided and now VERIFIED end to end (see the build chain ticket for the literal commands): the built UI is gitignored and never committed. The npm build runs in
release.ymlBEFORE GoReleaser - not inbefore.hooks, which cannot express a working directory and would force npm to run twice. GoReleaser then compiles withtags: [webui], switching on the real embed; a!webuistub compiles otherwise, so a clean checkout always builds andgo installnever breaks. Consequence accepted: ago install ...@mainbinary has NO web UI;servefails fast with an explanation, while the daemon warns and keeps collecting. Whole chain measured at 41.7s for four targets, npm adding ~2.3s.Four corrections the verification forced, all now binding:
build,vet,staticcheckandtesta second time with-tags webui. Compiling the tagged path is NOT enough:vetandstaticcheckskip//go:build webuifiles entirely, and a planted type error plus an unused function were both invisible to the untagged gate. Onlygofmtis tag-agnostic.//go:embed all:dist, not//go:embed dist. Vite can emit a.vite/directory, which the bare form silently drops. An empty output directory is its own distinct compile error..gitignorerule is rooted/dist/and does not cover the UI's output. New rules are required, and the repo will contain two differentdistdirectories - GoReleaser's and the UI's.cmdimports the web package, so-tags webuiis a no-op on the artifact until then and any "assets present" smoke test gives a false negative.Deployment, decided and already built: the UI is served on public hostnames behind the box's existing Caddy, not on loopback for the local user only.
aiusage.randomcodespace.dev(prod) ->127.0.0.1:37800, the release binary serving embedded UI and API from one listener.aiusage-dev.randomcodespace.dev(dev) ->/api/*and/ws*to127.0.0.1:37801(dev daemon, separate config and database), everything else to127.0.0.1:5173(Vite dev server and its HMR websocket)./etc/caddy/aiusage.caddy, imported from the main Caddyfile, matching the per-project convention already used by oss/buzz/lane. Both certificates are issued and both hosts answer 502 until an upstream exists.This overturns an earlier decision and redraws the destination. Charting settled on a hard-coded loopback listener with no bind knob, and put remote access in Out of scope. The user has since required public hostnames, so remote access is IN scope and the loopback-only decision is void. The security consequence is not: usage data names local project paths, session ids, models and spend, and these hostnames are public. Authentication has since been decided against for now (see Decisions so far), which makes ONE constraint load-bearing: raw must never cross the boundary. 45,615 rows predate the usage-object-only allow-list and hold whole transcript lines, so an event endpoint returning
rawwould publish conversation content to the open internet. Explicit column projection is the condition under which an unauthenticated surface is survivable, not a later optimisation.Invariants that scope every decision: adapters strictly read-only;
usage_eventsappend-only (the UI never writes - though note the store has NO read-only open today, so that is currently true only by trigger; see store: add a read-only open, a hard precondition); CGO disabled; OSI-licensed deps only; the runtime is offline and air-gapped - assets are embedded, never fetched from a CDN. Layering puts the server atinternal/web, a peer ofreportandtui: it may importstoreandmodel, nevertui, andcmdwires it. Serving uses its own read-only connection so a request never contends with the daemon's writes. The listener binds loopback only - Caddy is the sole route in, never0.0.0.0.Decisions so far
Research: TanStack Charts 0.9.0 canvas API and its churn surface - MIT throughout, 36 transitive packages all OSI-permissive, React 19 peer matches, tree-shaking claim mechanically gated rather than asserted. 21 releases in 12 days with three breaking minors in four, but breakage arrived through import specifiers (0.9.0), option renames (0.8.0) and callback signatures (0.7.0) - never through component props, so a props-only wrapper is not a sufficient seam. The seam must own every import specifier (no file outside it may name
@tanstack/charts, lint-enforced), renderer selection, our own props and point type, our own accessor convention, and it must consume the store's already-formatted bucket keys rather than re-deriving buckets from aDate(the localtime/time.Localtrap the project already fixed once). Canvas costs SSR entirely - no server-painted pixels - and its bundle size is unpublished and ungated upstream, so we are the only ones who will measure it.Research: what the store can already answer for a web UI - five read methods, nine group dimensions, four filter dimensions. Every time-bucketed query is a full scan costing ~3us/row through the pure-Go driver: a daily timeline over the real 360,250-row ledger is 1.17s and a cold Overview reload is 3.33s. Counter-intuitively a 30-day range is SLOWER than all-time (4.33s vs 3.33s) because the bounded range switches the plan from sequential scan to per-row rowid indirection - and 30 days is exactly a web UI's likely default. A covering index was built and measured and made it worse; an hourly rollup was measured 118-187x faster. Gaps a superset UI hits immediately: no ordering/Top-N/LIMIT, no pagination (and
model.UsageEventhas no id field to page on),ListEventsalways drags 18MB ofrawtranscript content, no server-side search, no facet listing,kind(usage vs adjustment) neither filterable nor groupable, and no incremental-sync watermark.Research: verify the release-tag build chain end to end - the design holds mechanically, with eight corrections. The load-bearing one: the untagged gate is BLIND to build-tagged files, so
vet/staticcheck/test/buildmust all run twice. GoReleaser's dirty-tree check counts untracked files and runs before hooks, so hook output escapes it in both directions;before.hooksentries are plain strings with no shell and nodir:.{{ .Version }}strips the leadingv, so release andgo installbinaries already report versions in two different formats. npm cost is negligible (~2.3s, 87MB node_modules, 272KB dist, 84KB gzip bundle with the canvas chart actually imported).Decide: authentication posture for a publicly reachable UI - none for now, matching the
manageprecedent on this box rather than thekbtoken precedent. Recorded with its consequence: aggregate data (tokens, models, providers, spend, local project paths, session ids) is public on both hostnames. The hard condition it creates is that no endpoint may ever returnraw; the commentedbasic_authstanza stays in the Caddy config so the edge can be closed with one uncomment and a reload.Decide: live-update posture - WebSocket, carrying a change NOTIFICATION rather than data: the server cannot answer "what changed since X" (no exposed id,
observed_time_unixnot filterable) and a cold reload is 3.33s, so pushing computed aggregates would cost that per client per cycle. A message becomes a TanStack Query invalidation and the client refetches only the view it is on. This makes the rollup decision more urgent, not less - a broadcast wakes every client at once, so a push manufactures a thundering herd that polling would have staggered. Reconnect with backoff is mandatory because the daemon re-executes itself into a replaced binary and drops every connection without a close frame.Decide: derived rollup table, or live within the full-scan cost - adopted. Hourly, keyed on UTC hours and folded to local on read, derived-only and rebuildable, written from the collector's existing transaction, with its own schema version and migration step. CLAUDE.md's mutable-tables invariant is amended to three. Sub-hour buckets and per-event listing are explicitly out of its reach. Unmeasured risk carried: incremental maintenance cost alongside live collection.
Decide: how a binary advertises whether it can serve the UI - folded into
buildinfo.Identity().doctorprints embed status,serveexits 1 with the release link, the daemon warns and keeps collecting, and thev-prefix inconsistency between GoReleaser and module build info gets normalised on the way through.Decide: what the web UI shows that the TUI cannot - single page, full TUI coverage, drill narrows every panel at once rather than navigating. Selection is a drawer, the rail stays, ring mode caps at top-8 with a stated "other" band. Committed light theme on the ANSI 16 in Tango values, so a series that is cyan in the terminal is cyan in the browser. Granularities hour/day/week/month, no sub-hour; landing range 7d; the brush and the presets are one range concept, not two.
Not yet specified
role="img"and description on the Canvas root as on SVG, so nothing AT-traversable is lost relative to SVG - but the exact-value alternative (linked table,role="status", visible units) is application-owned in both renderers, and the TUI's palette and elevation ladder have no automatic counterpart in a browser. Sharpens once the information model exists.--jsonsurfaces. The data contract ticket may or may not reuse them; if it diverges, the divergence needs its own decision rather than drifting.Summarize-only and never callsUnpricedGroups, so its cost KPI is the stamped floor while the CLI display-prices the same window - two surfaces, two numbers, no store change needed to fix it.Out of scope