Architecture
Full-stack Rust using Leptos (server-side rendering +
WASM hydration) on Axum, built with
cargo-leptos.
crates/
domain/ Wasm-safe core types (Company, ComplianceCase, Resolution,
Monitor, Sanction, ViolationType, Regulator).
llm/ Pluggable LLM provider abstraction (Ollama + Anthropic),
server-only.
db/ Persistence: CaseRepository + AlertRepository traits,
SqliteCaseRepository/SqliteAlertRepository (share one pool),
server-only.
extraction/ LLM-based structured extraction of a ComplianceCase from raw
filing text, server-only.
crawler/ Scheduled fetch jobs (FilingSource trait + SEC/FCA connectors)
feeding extraction. Standalone `crawl` binary, server-only.
web/
app/ Shared Leptos UI + server functions + fictional seed data.
frontend/ Wasm hydration entry point.
server/ Axum server binary.
style/ Plain CSS.
Why split app / frontend / server
cargo-leptos needs one crate compiled for wasm32-unknown-unknown (the
client) and one compiled natively (the server), sharing UI code. Server-only
dependencies (axum, tokio, sqlx, llm, db, extraction) must never leak
into a crate built for wasm32, or the client build breaks.
crates/domain has no async runtime or HTTP client dependency, so it
compiles for both targets and is shared everywhere. web/app's llm, db,
and extraction dependencies are optional and gated behind the ssr Cargo
feature; the #[server] functions that use them reference them via
fully-qualified paths rather than a top-level use, since the macro only
compiles the function body under ssr — see CLAUDE.md in the repository
root for the full rationale and the pattern to follow when adding new
server-only dependencies.
Persistence
crates/db stores each ComplianceCase as a JSON blob in SQLite, alongside
indexed industry/jurisdiction/company_name columns used for filtering.
web/server connects and runs migrations at startup, seeding the fictional
demo cases only if the database is empty, then makes the repository
available to server functions through Leptos context
(leptos_routes_with_context + provide_context).
CaseRepository::search combines a SQL WHERE on the indexed
industry/jurisdiction columns with an in-memory filter (over that
already-narrowed result set) for violation type and monitor firm, which live
inside the JSON blob rather than their own columns. Still server-side search
from the client's perspective — see Search and filtering
below.
NLP extraction
crates/extraction sends raw filing text to the configured llm::LlmProvider
with a schema-constrained system prompt, then parses and validates the
model's JSON response before converting it into a domain::ComplianceCase
(unrecognized enum values fall back to Other(_); malformed dates, negative
sanction amounts, or an unrecognized status are rejected rather than
guessed). The "Extract a case from filing text" panel in the UI calls this
end-to-end and persists the result via CaseRepository::upsert.
Crawler
crates/crawler fetches real filings so extraction doesn't rely solely on
manual paste. FilingSource is the per-regulator trait; run_crawl fetches,
dedupes by URL against sources already recorded on existing cases, and feeds
new filings through extraction::extract_case + CaseRepository::upsert.
Two connectors exist, both verified against the live sites: SEC (RSS feed +
Drupal body selector, rate-limit aware) and FCA (general news RSS + article
selector). There's deliberately no DoJ connector — justice.gov blocks
automated clients with a bot-management challenge, and defeating that isn't
something this project does. The crawl binary runs one pass and exits; an
external scheduler (cron, a systemd timer) invokes it periodically.
Search and filtering
The UI's SearchPanel filters by industry, jurisdiction, violation type, and
law firm/monitor, calling list_cases with a CaseFilterQuery (an all-None
filter matches everything). CaseList's data resource refetches whenever the
filter changes or an extraction completes.
Alerts
Global watch rules (industry and/or company-name-substring criteria), not
per-user — the app has no auth/user system. db::evaluate_case checks a
newly-persisted case against every rule and records a domain::Alert for
each match; both extract_case and the crawler's ingest_filing call it
right after CaseRepository::upsert, so a case triggers alerts the same way
regardless of how it arrived. The UI's WatchRulesPanel manages rules;
AlertsPanel shows and acknowledges triggered alerts. There's no actual
notification delivery (email/push) — alerts only show up in-app.
Trend analysis
domain::compute_trend_report is a pure function over &[ComplianceCase] —
no database access, fully unit-testable with hand-built fixtures. It
aggregates case counts by industry, resolution counts by regulator/violation
type/kind/status, monitorship rate by industry, and total sanctions summed
per currency (not converted to one currency). The get_trend_report server
function calls CaseRepository::list() (the whole dataset, not the current
search filter) and feeds it straight to compute_trend_report. TrendPanel
renders each section as a simple CSS bar list rather than pulling in a JS
charting library.
Data flow (current state)
Two ways a filing reaches extraction::extract_case:
Browser (WASM) --hydrate--> App component --#[server] fns--> Axum server
SEC/FCA RSS + pages --crawler::run_crawl-------------------> Axum process
Both converge on the same pipeline:
raw filing text --> extraction::extract_case (schema prompt + validate)
| |
llm::provider_from_env() db::CaseRepository::upsert
| |
Ollama (local) or SQLite
Anthropic (frontier)
See Roadmap for what's next.