Skip to content

Monorepo Architecture Review — 2026-06-25

Section titled “Monorepo Architecture Review — 2026-06-25”

The monorepo is in materially better architectural shape than the 2026-05-14 baseline. The single biggest prior finding — the “phantom” components package consumed by Vite alias instead of as a real workspace dep (prior #1) — is fully resolved: packages/components is now @monorepo/components with "workspace:*" declared by all four sites and imported via the package specifier (@monorepo/components/ui/*, /layout/*, /brand/*). The exports contract is now real, not fiction. Dependency direction is clean (no cross-site imports), the Astro-first/Alpine/Svelte split is genuinely honored (0 framework hydration directives and 0 .svelte files in sites/), brand CSS now has a true SSOT (342db93), and the new Tooltip lives correctly in the canonical library. The remaining issues are incomplete-migration debt and SSOT drift, not structural rot: a half-finished package-naming convention (@monorepo/* vs @smart-debt/*), a fully orphaned @smart-debt/i18n package, ~250 lines of duplicated focus-page glossary/markdown engine copy-pasted between mbr and sdc, residual @components alias config that one file still depends on, and committed build artifacts / Obsidian state. None are release-blocking; most are 1–3h cleanups that prevent future silent breakage.

Severity counts: Critical 0 · High 3 · Medium 5 · Low 4

Prior-finding status: #1 RESOLVED · #2 RESOLVED (sdc now uses workspace dep, no alias needed) · #3 UNRESOLVED (sd-app↔sd-api still unwired, now documented as roadmap item) · #4 RESOLVED (nested sites/template/sites/ gone) · #5 LIKELY RESOLVED · #6 RESOLVED (sd-math cursor docs gone) · #7 PARTIAL (.obsidian/ still tracked) · #8 not re-audited in depth · #9 UNRESOLVED (archive/ still in tree) · #10 RESOLVED (mbr now declares design-tokens) · #11/#12 informational.


[SEVERITY: high] [effort: 2h] Focus-page glossary/markdown engine is copy-pasted between mbr and sdc (SSOT violation)

Section titled “[SEVERITY: high] [effort: 2h] Focus-page glossary/markdown engine is copy-pasted between mbr and sdc (SSOT violation)”
  • Where: sites/mbr/src/utils/load-focus-content.ts (271 lines) and sites/sdc/src/utils/load-focus-content.ts (272 lines).
  • What: A diff shows the two files share substantial generic infrastructure verbatim: injectGlossaryTooltips() (Glossary(term)→tooltip-span regex), loadGlossaryTerms() (parses Core/Misc/Glossary.md into a Map), the markdown-bold→<b> pipeline, the YAML frontmatter parser, and the FocusContent / FocusSection / FocusCard / FocusPlan / PlanItem type definitions. Only the brand-injection function (injectBrands + the mbr-entity/smart-debt spans), the KB paths (MBR/_WorkingOn/Focus.md vs SDC/_WorkingOn/Focus.md), and the defaultContent() fallback are genuinely site-specific.
  • Why it matters: This is the exact SSOT failure pattern the prior review flagged for components. The glossary engine reads from one canonical source (Core/Misc/Glossary.md) but the parser that reads it is forked. A fix to the glossary regex, a new tooltip ARIA pattern, or a frontmatter-schema change must be made in two files and will silently drift (sdc already added a FocusProminent section type that mbr lacks — drift has begun).
  • Recommendation: Extract the generic engine into the canonical library — e.g. packages/components/src/lib/focus-content.ts exporting loadGlossaryTerms(), injectGlossaryTooltips(), the frontmatter parser, and the shared types. Each site keeps only its injectBrands() + KB path + defaultContent() and passes them in (or composes). Both sites already depend on @monorepo/components via workspace:*, so the wiring cost is near zero.

[SEVERITY: high] [effort: 1h] @smart-debt/i18n is a fully orphaned package

Section titled “[SEVERITY: high] [effort: 1h] @smart-debt/i18n is a fully orphaned package”
  • Where: packages/i18n/ (package.json, messages/en.json, messages/fr.json).
  • What: Grep across the entire repo (json/ts/astro/svelte/mjs, excluding the package itself) finds zero consumers — no site or app declares @smart-debt/i18n as a dependency and nothing imports it. apps/sd-app does its i18n via its own Paraglide setup (apps/sd-app/src/lib/i18n.ts, src/paraglide-types.d.ts), not this package.
  • Why it matters: Dead workspace package with a real package.json implies an active contract that doesn’t exist (same class of problem as the prior phantom-components finding). It also carries the stale @smart-debt/ prefix after the components rename, compounding the naming-convention confusion (next finding). New contributors will assume i18n flows through this package; it doesn’t.
  • Recommendation: Decide its fate explicitly. Either (a) delete packages/i18n/ if sd-app’s Paraglide catalogue is the real SSOT, or (b) if these en.json/fr.json are meant to be the shared catalogue, wire sd-app (and any i18n-bound site) to consume it and make Paraglide compile from it. Do not leave it as untyped, unconsumed dead metadata.

[SEVERITY: high] [effort: 1.5h] Package-rename convention is half-finished — @monorepo/* vs @smart-debt/* split

Section titled “[SEVERITY: high] [effort: 1.5h] Package-rename convention is half-finished — @monorepo/* vs @smart-debt/* split”
  • Where: packages/components/package.json (@monorepo/components) vs packages/design-tokens/package.json (@smart-debt/design-tokens), packages/i18n/package.json (@smart-debt/i18n), apps/sd-app/package.json (@smart-debt/sd-app).
  • What: Commit a741aa2 renamed @smart-debt/components@monorepo/components across 50+ imports (cleanly — no dangling @smart-debt/components refs remain except one stale line in CLAUDE.md:29). But the other three packages kept the @smart-debt/ scope. The workspace now has two competing org scopes with no documented rationale.
  • Why it matters: Two scopes is a coin-flip for every future import and every new package — contributors won’t know which to use. The @smart-debt/ scope is also semantically wrong now that the repo brands itself as a multi-brand @monorepo (sd, mbr, ts). Mixed scopes are a classic source of “which one is canonical?” churn.
  • Recommendation: Pick one scope and complete the migration. @monorepo/* matches the repo’s own framing and the just-renamed components package — rename design-tokens, i18n (if kept), and sd-app to @monorepo/*, update the workspace:* consumers, and document the scope in CLAUDE.md/README.md. Also fix the stale @smart-debt/components reference at CLAUDE.md:29.

[SEVERITY: medium] [effort: 0.5h] Residual @components Vite/tsconfig alias — ts still mixes two consumption models

Section titled “[SEVERITY: medium] [effort: 0.5h] Residual @components Vite/tsconfig alias — ts still mixes two consumption models”
  • Where: alias defined in sites/template/astro.config.mjs:19 and sites/ts/astro.config.mjs:19 + sites/ts/tsconfig.json:8; the only live consumer is sites/ts/src/layouts/BaseLayout.astro:3 (import Search from '@components/ui/Search.astro').
  • What: Now that all sites consume components via the @monorepo/components workspace specifier, the legacy @components path alias is dead config — except ts still has one import using it, so ts uses BOTH models at once. sdc and mbr have already dropped the alias cleanly.
  • Why it matters: Two ways to import the same package is config drift that re-introduces the original phantom-package coupling for one file. If the @components alias is ever removed (it should be), ts’s build breaks silently.
  • Recommendation: Change sites/ts/src/layouts/BaseLayout.astro:3 to import Search from '@monorepo/components/ui/Search.astro', then delete the @components alias from sites/ts/astro.config.mjs, sites/ts/tsconfig.json, and sites/template/astro.config.mjs (template no longer uses it).

[SEVERITY: medium] [effort: 0.5h] Committed build artifact: rates.generated.json is tracked, and the real rates SSOT lives in a different project

Section titled “[SEVERITY: medium] [effort: 0.5h] Committed build artifact: rates.generated.json is tracked, and the real rates SSOT lives in a different project”
  • Where: sites/mbr/src/data/rates.generated.json (git-tracked), generated by sites/mbr/scripts/sync-rates.mjs from SOURCE_PATH = /home/ta/projects/mbr/rate-scanner/data/rates.json (outside this repo), with in-repo sites/mbr/src/data/rates.json as a fallback.
  • What: A file named *.generated.json is committed to source control, and the canonical rate data lives in a separate sibling project (projects/mbr/rate-scanner). The in-repo rates.json is only a CI/offline fallback.
  • Why it matters: Committing generated output invites merge noise and “edit the artifact instead of the source” mistakes. The cross-project SSOT is also fragile — the monorepo build depends on a hardcoded absolute path to another repo that isn’t a declared dependency. (It degrades gracefully to the fallback, which is the right call, but the coupling is undocumented at the monorepo level.)
  • Recommendation: Either gitignore rates.generated.json and generate it in a prebuild step (sync-rates.mjs already exists; wire it into the build), or — if it must be committed for offline/CI builds — document explicitly in sites/mbr/ that it is a generated artifact whose SSOT is projects/mbr/rate-scanner. Note the cross-project dependency in STATUS/README so it isn’t a surprise.

[SEVERITY: medium] [effort: 2h] rates.astro hand-rolls 9 inline <table> blocks instead of reusing RateTable.astro

Section titled “[SEVERITY: medium] [effort: 2h] rates.astro hand-rolls 9 inline <table> blocks instead of reusing RateTable.astro”
  • Where: sites/mbr/src/pages/rates.astro (575 lines, contains 9 inline <table>, 0 <RateTable>); component sites/mbr/src/components/RateTable.astro (46 lines) is used only on sites/mbr/src/pages/index.astro.
  • What: The main rates dashboard renders all its tables as inline markup rather than via the existing RateTable component. The inline tables do carry extra/variable columns (Term, Type, lender-specific) that the current RateTable (full boolean, fixed columns) doesn’t model, so it isn’t a drop-in — but the result is a 575-line page with 9 near-parallel table structures and a 46-line component that the dashboard ignores.
  • Why it matters: Table markup, accessibility attributes (role="region", tabindex, aria-label), and the footnote pattern are duplicated 9× inline and will drift from RateTable. A11y or styling fixes must be applied in many places.
  • Recommendation: Generalize RateTable.astro to accept a column schema (header defs + row-cell mapping) so the variable-column tables can flow through one component, then refactor rates.astro to use it. This is brand-local (correctly not shared cross-site), so it stays in sites/mbr/.

[SEVERITY: medium] [effort: 0.5h] sd-app ↔ sd-api polyglot boundary still undefined (prior #3, unresolved)

Section titled “[SEVERITY: medium] [effort: 0.5h] sd-app ↔ sd-api polyglot boundary still undefined (prior #3, unresolved)”
  • Where: apps/sd-app/src/ (no reference to sd-api, localhost:8000, or /api/), packages/sd-api/ (FastAPI, isolated).
  • What: The SvelteKit PWA still does not call the Python FastAPI service; there’s no generated client, shared schema, or fetch wrapper. This is the prior review’s #3 and remains open. It is now at least acknowledged in STATUS.md/ROADMAP.md (“sd-app production deploy” / mini-apps workstream) but the contract is still implicit.
  • Why it matters: When either side moves, the other breaks with no compile-time signal. Pre-production this is the highest strategic coupling risk even though it’s currently inert.
  • Recommendation: Before sd-app prod deploy, decide the boundary: generate a TS client from FastAPI’s OpenAPI into packages/sd-api-client/, or mark packages/sd-api/ explicitly not-yet-wired in its README with an integration smoke test that fails loudly on drift.

[SEVERITY: medium] [effort: 0.5h] design-tokens declared by all sites but barely consumed as a package

Section titled “[SEVERITY: medium] [effort: 0.5h] design-tokens declared by all sites but barely consumed as a package”
  • Where: @smart-debt/design-tokens is workspace:* in all four sites’ package.json, but the only importers are sites/template/scripts/generate-color-system.mjs and apps/sd-app/src/design-tokens.d.ts. Brand sites consume tokens as generated CSS/JSON (sites/{site}/src/design-tokens/colors.oklch.json), not via the package API.
  • What: The dependency is declared four times but provides runtime value to only the color-gen script. This isn’t wrong (declaring it documents intent and enables the generator), but the gap between “declared everywhere” and “imported almost nowhere” obscures how tokens actually flow.
  • Why it matters: Low risk, but it muddies the token architecture — a reader can’t tell from the deps whether tokens are a build-time generator input or a runtime import. Combined with the per-site committed colors.oklch.json, the SSOT chain (brand contract → generator → per-site JSON → CSS) is implicit.
  • Recommendation: Document the token flow in one place (likely CLAUDE.md’s Design Tokens section): contract in src/brand/{brand}/index.tsgenerate-color-system.mjs → per-site colors.oklch.json → Tailwind/CSS. Keep the workspace:* deps (they’re harmless and correct), but make the generator-vs-runtime distinction explicit.

[SEVERITY: low] [effort: 0h] apps/CLAUDE.md is NOT stray — it is a correct layer-level context file

Section titled “[SEVERITY: low] [effort: 0h] apps/CLAUDE.md is NOT stray — it is a correct layer-level context file”
  • Where: apps/CLAUDE.md (40 lines, git-tracked).
  • What: The review brief flagged this as possibly misplaced. It is not: it documents the apps/-layer SvelteKit/PWA conventions (adapter-static, shadcn-svelte, Tailwind v3, Paraglide) that apply to every app under apps/, distinct from the app-specific apps/sd-app/CLAUDE.md. This is good layered-context hygiene.
  • Recommendation: No action. Noting it as healthy so it isn’t “cleaned up” by mistake.

[SEVERITY: low] [effort: 1h] archive/brand-assets-2025-12-22/ still committed in the working tree (prior #9, unresolved)

Section titled “[SEVERITY: low] [effort: 1h] archive/brand-assets-2025-12-22/ still committed in the working tree (prior #9, unresolved)”
  • Where: archive/brand-assets-2025-12-22/ (tracked: logo-*.svg, README.md, etc.).
  • What: Old brand assets remain in the repo tree, as flagged in the prior review.
  • Recommendation: Move to KB or rely on git history; delete from the working tree. Repo is the SSOT for live assets, not an archive.

[SEVERITY: low] [effort: 1h] .obsidian/ state committed at repo root and docs/.obsidian/ (prior #7, partial)

Section titled “[SEVERITY: low] [effort: 1h] .obsidian/ state committed at repo root and docs/.obsidian/ (prior #7, partial)”
  • Where: tracked: .obsidian/workspace.json, .obsidian/app.json, docs/.obsidian/*, sites/template/.gemini/GEMINI.md.
  • What: Obsidian vault state — including workspace.json, which mutates on every vault open — is git-tracked at two levels. This guarantees recurring noise commits and treats the code repo partly as an Obsidian vault.
  • Recommendation: Gitignore **/.obsidian/workspace.json (and ideally the whole .obsidian/ dirs) and git rm --cached what’s tracked. Decide deliberately which AI-tool config (.gemini/, etc.) should be committed vs ignored.

[SEVERITY: low] [effort: 0.25h] MyBetterRatesEntity.astro is a redundant thin wrapper

Section titled “[SEVERITY: low] [effort: 0.25h] MyBetterRatesEntity.astro is a redundant thin wrapper”
  • Where: sites/mbr/src/components/brand/MyBetterRatesEntity.astro (13 lines) → delegates to @monorepo/components/brand/MyBetterRates.astro.
  • What: A site-local component that just re-exports the canonical brand component with the same class/entity props. No added behavior. (Good news: it is NOT a duplicate of the brand markup — the prior brand-placement concern is resolved; the canonical MyBetterRates.astro is the SSOT and this only forwards to it.)
  • Why it matters: Minor indirection. Call sites could import the canonical component directly; the wrapper buys nothing and is one more thing to keep in sync if the props change.
  • Recommendation: Either drop the wrapper and import @monorepo/components/brand/MyBetterRates.astro directly at the call sites, or keep it only if a brand-local default (preset class) is intended — in which case give it that default so it earns its existence.

[SEVERITY: low] [effort: 0.1h] Minor astro script drift across sites

Section titled “[SEVERITY: low] [effort: 0.1h] Minor astro script drift across sites”
  • Where: sites/sdc/package.json uses "astro": "astro" where template/mbr/ts use "astro": "pnpm exec astro".
  • What: sdc’s astro script differs from its siblings. Cosmetic, but config drift.
  • Recommendation: Align sdc to "pnpm exec astro" to match the other three.

Architecturally healthy (no action — noted for honesty)

Section titled “Architecturally healthy (no action — noted for honesty)”
  • Prior #1 RESOLVED: packages/components is a real workspace package (@monorepo/components, workspace:* in all 4 sites), imported via the package specifier; exports (./ui/*, ./layout/*, ./brand/*) now match real usage. The phantom-package problem is gone.
  • Dependency direction is clean: no site imports from another site; sites depend on packages/* only. Correct one-way flow.
  • Astro-first / Alpine / Svelte split is honored: 0 client: framework directives and 0 .svelte files in sites/; Alpine (x-data) used for interactivity. Matches the stated 90/10/last-resort policy. (Svelte correctly confined to apps/sd-app.)
  • Brand CSS SSOT (342db93): shared structural + animation CSS extracted to packages/components/src/brand/MyBetterRates.css, imported by both MyBetterRates.astro and focus.astro — eliminated the prior frozen inline copy in the PWA page. Genuine SSOT win.
  • Tooltip in the canonical library: new Tooltip lives at packages/components/src/ui/Tooltip.astro with a showcase in sites/template — correct placement, not duplicated per-site.
  • Glossary content SSOT: tooltip definitions read from one source (Core/Misc/Glossary.md) at build time. (Only the parser is duplicated — see High #1.)
  • Rates feature scoping: rates logic/data/components are mbr-only and not copy-pasted across sites — correct, since this is a brand-specific feature, not shared infra.
  • Astro version alignment: ^5.15.3 across all four sites (prior strength sustained).
  • Prior #4 & #6 RESOLVED: nested sites/template/sites/ artifact gone; packages/sd-math stale cursor-task-*.md planning docs gone.
  • Prior #10 RESOLVED: all four sites now declare @smart-debt/design-tokens.
  • Polyglot boundary respected: pnpm-workspace.yaml globs packages/*; Python packages (sd-api, sd-math) coexist via pyproject/uv and are silently skipped by pnpm — works correctly.