Architecture Decision Records
Numbered ADRs with Context / Decision / Consequences. D1-D19. Each captures a fork in the road and which way we went, so future sessions do not reopen settled questions.
D1 — Claude Code + DigitalOcean over Lovable + Vercel
Date: 2026-05-02 Status: Active (build-tool half); hosting/deploy half revised by D20 — production now serves from Vercel, not the droplet
Context: The dashboard holds personal bank balances, investment data, and business cash positions. Needed to decide between Lovable (fast scaffolding, Supabase Cloud hosting) and Claude Code (full repo control, self-hosted).
Decision: Build with Claude Code, deploy to existing DigitalOcean droplet. Lovable overwrites ~30% of a design system's intent with defaults. Claude Code reads the design handoff as structure-and-intent and applies tokens as constraints. The droplet is already paid for and sitting idle.
Consequences: Full control over auth, RLS, secrets, infrastructure. No cold starts (PM2/systemd vs serverless). No usage-based billing surprises. Trade-off: no preview deploy URLs — test locally or use staging branch.
D2 — Caddy (existing) over Nginx
Date: 2026-05-03 Status: Active
Context: HLD specified Nginx + Certbot. Droplet already has Caddy installed and running for OpenClaw.
Decision: Use Caddy instead of Nginx. Caddy auto-provisions SSL without Certbot, and adding a second site is a 5-line config block. Dashboard runs alongside OpenClaw on a different port — Caddy routes by domain.
Consequences: No Certbot management. Simpler config. OpenClaw continues to run on the same droplet without conflict.
D3 — systemd over PM2
Date: 2026-05-03 Status: Active
Context: HLD specified PM2 for process management. Droplet already uses systemd for OpenClaw.
Decision: Use systemd service for the dashboard. Consistent with existing droplet patterns. PM2 is not installed and not needed.
Consequences: Process management via systemctl start/stop/restart aj-dashboard. Logs via journalctl -u aj-dashboard. Auto-start on reboot via systemd enable.
D4 — Single-user Microsoft 365 SSO, MFA at Entra ID level
Date: 2026-04 (HLD v1.1) Status: Active
Context: Dashboard is single-user (AJ only). Needed auth that doesn't require building a login UI.
Decision: Microsoft 365 SSO (aj@finchmax.com) via Supabase Auth. MFA enforced at Entra ID level with Microsoft Authenticator. 14-day device trust configured in Conditional Access. No app-level auth UI.
Consequences: Hardcoded allowlist (aj@finchmax.com only). No RBAC needed. Session managed by Supabase Auth.
D5 — Three Supabase orgs as shared infrastructure
Date: 2026-04 (HLD v1.1) Status: Active
Context: Three businesses share some data patterns but have different tools and users. Needed to decide between one big database or separate orgs.
Decision: Three Supabase orgs: Personal, Bright Connect, Solar Market (TQC+TCE shared). Each org is shared infrastructure used by other apps beyond the dashboard. Personal org accessed via client-side Supabase. BC/SM accessed via API routes with service-role keys.
Consequences: Tasks table lives in Personal org for cross-entity querying (Covey quadrant, Today view, delegation cockpit, Cmd+K). Major events queried real-time from BC/SM orgs via API routes. Cross-org latency is 3 parallel queries — acceptable for now, sync later if needed.
D6 — HubSpot CRM Search API (iframe ruled out)
Date: 2026-04 (HLD v1.1) Status: Active
Context: Needed HubSpot dashboard data in the app. Two options: embed HubSpot dashboards via iframe, or query via API.
Decision: API-driven via CRM Search API. HubSpot sets X-Frame-Options headers that prevent iframe embedding.
Consequences: More work per dashboard (need to spec metrics, properties, filters) but full control over presentation. TCE Revenue query proven in POC. 4 remaining dashboards need speccing.
D7 — HubSpot closed-won is multi-stage
Date: 2026-04 (POC) Status: Active
Context: Querying TCE closed-won deals returned incomplete data when filtering only on dealstage = closedwon.
Decision: Filter on multiple stage IDs: closedwon (default Sales Pipeline), 4745402615 and 4745402614 (Installation Pipeline 3468486848). Pipeline IDs discovered by inspecting deal records, not the properties UI.
Consequences: All HubSpot deal queries must account for multi-pipeline stage IDs. The properties UI does not surface this — inspect actual records.
D8 — AWST-to-UTC boundary translation pattern
Date: 2026-04 (POC) Status: Active
Context: HubSpot stores closedate in UTC. Dashboard operates in AWST (UTC+8). Monday 00:00 AWST = Sunday 16:00:00Z.
Decision: Always use GTE and LT operators on closedate with UTC-converted AWST boundaries. Never use inclusive end dates.
Consequences: Applied consistently across all HubSpot date filters. The getDateRange() helper in /api/hubspot/tce-revenue/route.ts implements this pattern.
D9 — HubSpot amount property is amount_in_home_currency
Date: 2026-04 (POC) Status: Active
Context: Needed the ex-GST deal amount. hs_closed_amount_in_home_currency does not exist. get_properties does not surface system-calculated properties.
Decision: Use amount_in_home_currency as the correct property for closed deal revenue.
Consequences: Don't trust the HubSpot properties API for system-calculated fields — test with actual deal data.
D10 — LMS Daily Snapshot via token-based iframe auth
Date: 2026-04 (HLD v1.1) Status: Active
Context: LMS (Lead Management System) needs to display a daily snapshot in the TQC tab. LMS has a login screen that would show inside an iframe.
Decision: Token-based iframe auth using HMAC-SHA256 signed URLs with 5-minute lifetime. Steven Miles adds ~20 lines PHP token validation on the LMS side. Dashboard API route at /api/lms/token generates the signed URL.
Consequences: Requires coordination with Steven Miles. URL expires after 5 minutes — iframe must request a fresh token on each load.
D11 — Basiq for Open Banking, screen scrape for Amex
Date: 2026-04 (HLD v1.1) Status: Active
Context: Need bank balances from Westpac, ANZ, Macquarie (all CDR-ready) and Amex (not CDR-ready until mid-2026).
Decision: Basiq as primary provider. CDR for Westpac/ANZ/Macquarie. Screen scrape for Amex via Basiq. Daily sync via system cron at 06:00 AWST (22:00 UTC).
Consequences: CDR consents expire annually — need re-consent flow. Amex screen scrape requires re-auth more frequently. Monitor Amex CDR timeline for transition.
D12 — Vanguard manual entry initially
Date: 2026-04 (HLD v1.1) Status: Active
Context: Two Vanguard accounts (Family Trust + SMSF). No API or CDR path available.
Decision: Manual entry initially. Investigate Basiq screen scrape coverage. CSV import as fallback.
Consequences: Investment balance snapshots table exists but is manually populated until automation is found.
D13 — Direction A ("The Operator's Sheet") as the design direction
Date: 2026-05-02 Status: Active
Context: Claude Design produced three directions: A (dense operator sheet), B (calm editorial), C (dark cockpit). Needed to pick one for build.
Decision: Ship Direction A as the daily driver. Forest sidebar, stone content area, six concurrent panels visible without scrolling at 1440px. Direction C reserved as future evening theme toggle.
Consequences: Design handoff bundle in design/handoff/option-a.jsx is the pixel-level visual contract. All brand primitives defined in design/handoff/shared.jsx.
D15 — next/font replaces Google Fonts @import for the three font families
Date: 2026-05-04 Status: Active
Context: globals.css originally loaded Newsreader, Inter, and IBM Plex Mono via a Google Fonts @import url(...). The previous root layout also instantiated next/font/google for the same families with their own variable names (--font-newsreader etc.) — so fonts were loaded twice and the next/font output was effectively unused (the CSS variables in :root like --ar-font-display: 'Newsreader', ... only matched the @import-loaded font).
Decision: Use next/font/google as the sole font loader. Configure each face with variable: '--ar-font-display' | '--ar-font-body' | '--ar-font-mono' so the variables it sets on <html> shadow the fallback chain still declared in :root. Removed the @import url(...) line from globals.css. The :root token block is unchanged — the font-family declarations there now act as fallback specs only.
Consequences: No external runtime call to fonts.googleapis.com (better LCP, no third-party DNS, no privacy footprint). Single source of font loading. The :root font tokens remain the documented fallback chain. If next/font ever fails, fallback fonts (Iowan Old Style / system-ui / SFMono-Regular) take over per the existing chain.
D16 — Vitest as the test runner; node env by default, jsdom on demand
Date: 2026-05-05 Status: Active
Context: Two recent issues argued for proper unit tests: (1) the formatter divergence GOTCHA — fmtAUD had two implementations under the same name in mock-data.ts vs lib/fmt.ts and rendered "$X" or "A$X" depending on which module a page imported from; (2) the brand-compliance audit caught half a dozen ways pages had bypassed lib/fmt.ts with inline formatting. Both classes of bug are easy to prevent with a couple of expect(fmtX(input)).toBe(output) assertions, and impossible to prevent without them.
Decision: Use Vitest 4 as the runner. vitest.config.ts at repo root with '@' aliased to the project root so tests can import { fmtAUD } from '@/lib/fmt' exactly like the production code does. Default environment: 'node' — lib/ is pure functions and doesn't need a DOM. When a component test eventually lands, either flip the global env to 'jsdom' or scope it via the /* @vitest-environment jsdom */ pragma. jsdom is already installed for that future. No @testing-library/react yet — defer until there's an actual component test that justifies the dependency. Test files live in lib/__tests__/*.test.ts and components/**/*.test.{ts,tsx} (config-included paths). npm test runs once (vitest run); npm run test:watch for the watch loop; npm run typecheck exposes the existing tsc --noEmit as a script.
Consequences: First test file is lib/__tests__/fmt.test.ts — 33 assertions covering every helper, including the locale-edge cases that bit us (fmtDateLong had to be rewritten to compose the comma manually because Node's en-AU ICU omits it; the test would have caught that immediately on first run had it existed). Future bugs in formatters fail loudly, not silently. New developers can run the suite to verify their environment. CI can run npm test as a gate on PRs once we have a CI pipeline.
D17 — Adopt @testing-library/react for component tests (refines D16)
Date: 2026-05-06 Status: Active
Context: D16 set up Vitest with the explicit deferral "No @testing-library/react yet — defer until there's an actual component test that justifies the dependency". The Cmd+K palette and entity-tab refactor (MED-010, MED-012) and now the mobile pass (MED-013) all introduced components with non-trivial logic — period-toggle state, conditional caption colour, OKR list rendering, mobile/desktop tree visibility — that benefit from real DOM-level coverage. Continuing to defer means accumulating untested behaviour exactly where regressions are most visible.
Decision: Add @testing-library/react and @testing-library/jest-dom to devDependencies. Switch vitest.config.ts to environment: 'jsdom' globally (pure-function tests run fine in jsdom; the speed delta on this codebase is negligible). Add vitest.setup.ts that imports @testing-library/jest-dom/vitest (extends matchers) and registers afterEach(cleanup) from RTL — necessary because globals: false means RTL's auto-cleanup hook doesn't fire on its own. Also add @vitejs/plugin-react so JSX/TSX files compile under the test pipeline.
Consequences: The first component-test pass covers the four components/entity/* shells (24 assertions across hero, projection-chart-card, okrs-card, events-card) and the lib/search.ts index builder (15 assertions). Total suite is now 77 assertions across 6 files; runs in under 2 s. RTL gives screen.getByRole, getByText, fireEvent.click, etc., which are a much better fit for component contracts than checking inline-style strings. We don't add @testing-library/user-event yet — fireEvent covers the click cases we have so far; can add user-event if/when we need keyboard interaction or paste/drag tests.
D18 — A11y co-design with brand: copper-2 / fg-3 for small captions, bold LogoMark
Date: 2026-05-06 Status: Active
Context: BRAND.md §10 lists "Lighthouse a11y ≥ 95" as an acceptance criterion for the Today PR. An axe-core scan against a fresh build surfaced 69 nodes of WCAG AA violations across the 9 routes — almost all color-contrast. The brand's signature small captions (Plex Mono UC at 8-10px in copper #B87333 or fg-4 #7A8485 on bg-paper #FBF8F2) sat at 3.5-4.0:1 — below the 4.5:1 small-text threshold. The LogoMark monogram tiles on copper backgrounds were even worse — no combination of ivory or forest-deep text hit 4.5:1 at 12px. The brand spec is explicit; WCAG AA is non-negotiable.
Decision: Co-design rather than choose. The brand owns the colour family; a11y owns the exact shade.
- Small-caption copper accents (≤14px): switch to
text-copper-2(#8F5422 — the brand's own darker copper, originally the "hover" variant). Clears 4.5:1 on bg-paper. Same hue family, slightly darker. - "Quiet meta" captions: bulk-replace
text-fg-4(#7A8485) withtext-fg-3(#4A5657). Brand intent of "muted" preserved at the next gray step in the existing token scale. - Sidebar sub-brand monogram row: replace lettered LogoMarks with plain colour-coded squares. The section nav below carries the semantic navigation; the row is decorative.
- Entity-hero LogoMarks:
LogoMarknow defaults tofontWeight: 700. At the 56px hero size, the 23.5px bold monogram qualifies as WCAG "large text" (3:1 threshold), giving copper backgrounds a path to compliance. TCE explicitly usesforest-deeptext on copper; TQC uses ivory on copper-2. - Skip-to-main link added in
app/layout.tsx.<main id="main">so it has a target.
Component primitives carry the new defaults so future contributors don't have to think about it. Per-page captions that bypassed the primitives were updated explicitly. Documented in GOTCHAS.
Consequences: All 8 user-facing routes scan at zero WCAG AA violations under axe-core 4.10. /brand-demo retains 5 expected violations (it intentionally renders all variants including the borderline copper combinations). The visual diff vs the original brand is small — the brand identity reads identically; it's a half-shade darker in places. New rules: never text-copper at ≤14px on light bg (use text-copper-2); never text-fg-4 for small captions (use text-fg-3); never lettered LogoMark at ≤32px on copper backgrounds (decorative-only). Tests for the entity shells were updated to reflect the new class names.
D14 — Living documentation system
Date: 2026-05-03 Status: Active
Context: Project spans multiple AI surfaces (Claude Chat for coordination, Claude Design for visuals, Claude Code for implementation). Context loss between surfaces is the primary risk.
Decision: Adopt living docs system: CLAUDE.md as orientation primer, docs/ directory with STATUS, DECISIONS, GOTCHAS, CHANGELOG, PIPELINE. Maintenance contract: doc updates in same commit as code changes.
Consequences: CLAUDE.md is no longer the brand spec — brand spec moved to docs/BRAND.md. Every Claude Code session reads CLAUDE.md first and updates docs as part of the work.
D19 — Husky + lint-staged for pre-commit; project-wide checks at pre-push
Date: 2026-05-09 Status: Active
Context: The autonomous-chunk pattern means a session can land tens of files in a single commit, and Aidan often isn't watching live. We were relying on the test suite + tsc --noEmit being run manually before each commit. That's worked so far (every shipped commit has been clean) but it's procedural — one missed step and a typed error or unused import lands in main. With ESLint 9 / Next 15, next lint is also deprecated and prompts interactively on first run, which would block any future autonomous lint pass.
Decision: Wire husky + lint-staged.
pre-commitrunsnpx lint-stagedonly — fast, scoped to staged TS/TSX. ESLint with--fix --max-warnings=0so warnings can't slip in. Keeps the inner loop quick (sub-second on small commits).pre-pushrunsnpm run typecheck && npm test— project-wide, comprehensive, fires once per push (typically ≤2 s typecheck + ≤3 s test on this codebase). Catches cross-file type errors that file-scoped lint can't see.- ESLint flat config (
eslint.config.mjs) usesFlatCompatto consumeeslint-config-next/core-web-vitals+eslint-config-next/typescript, plus a tightened@typescript-eslint/no-unused-vars(allows_-prefixed args).design/handoff/**ignored — that's vendor design code we don't own. package.jsonlintscript switched from deprecatednext linttoeslint ..
Consequences: Every commit is guaranteed lint-clean; every push is guaranteed typecheck-clean and test-green. If the autonomous chunk pattern continues to scale (multiple sessions, multiple machines), the hooks are the floor that prevents drift. Trade-off: pre-push adds ~5 s before push — acceptable. Husky uses core.hooksPath = .husky/_, so installing dependencies on a new clone (npm install runs prepare: husky) auto-wires the hooks. If a hook ever becomes friction during exploratory work, git commit --no-verify is the escape hatch — but that's discouraged per CLAUDE.md's git safety rules and should never appear in committed code or scripts.
D20 — Dashboard hosting moves to Vercel; droplet auto-deploy disabled
Date: 2026-06-26 Status: Active (revises the hosting/deploy half of D1)
Context: D1 chose to self-host on the existing DigitalOcean droplet (Node + Caddy + systemd) and D-none/SML-002 wired a GitHub Actions workflow (.github/workflows/deploy.yml) that SSHes into the droplet on every push to main and rebuilds. The dashboard has since moved to Vercel for hosting — production is now served from Vercel, not the droplet. With both the droplet deploy and Vercel live, every push triggered a redundant (and now divergent) droplet build.
Decision: Disable the droplet auto-deploy. The on: trigger block in deploy.yml (both push: branches:[main] and workflow_dispatch) is commented out so the workflow never fires; the job body is kept intact behind a header comment documenting how to re-enable. The file is retained rather than deleted so the droplet path can be restored if Vercel is ever abandoned. The droplet itself stays up — it still runs OpenClaw — it simply no longer serves aj-dashboard.
Consequences: Pushes to main now deploy only via Vercel. No more redundant droplet builds or failure emails. Vercel provides preview deploys (the trade-off D1 accepted the absence of) but introduces serverless cold starts and usage-based billing — the concerns D1 originally cited against Vercel; accepted now that the project is past initial build-out. The Caddy dashboard.finchmax.com block and systemd service on the droplet are now dormant for the dashboard; cleaning them up is deferred (out of scope here). The "GitHub Actions deploy" rows in STATUS.md and the 2026-05-23 CHANGELOG re-enable entry are superseded by this.