From bec0ba92c4c3bcc9ce607aff39c6c16ac8e38e35 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 22 Apr 2026 11:45:00 +0300 Subject: [PATCH] chore: init --- generic.md | 345 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 generic.md diff --git a/generic.md b/generic.md new file mode 100644 index 0000000..e78f9ab --- /dev/null +++ b/generic.md @@ -0,0 +1,345 @@ +# Project Architecture Preferences + +Baseline architectural conventions for new projects. Claude Code should follow these defaults when scaffolding, implementing, or refactoring unless the project explicitly overrides them. When in doubt, ask before deviating. + +These preferences are opinionated and have evolved from running real multi-site infrastructure. They optimise for: local-first operation, reproducible deployments, minimal release-time churn, and keeping secrets out of source control. + +--- + +## 1. Workspace Layout + +Projects are Rust cargo workspaces. The repository root contains: + +``` +/ +├── Cargo.toml # workspace manifest (workspace-level deps + version) +├── crates/ # all Rust crates live here +│ ├── -entities/ # domain types, DTOs, error enums — no I/O +│ ├── -core/ # business logic, pure where practical +│ ├── -data/ # data access: postgres, turso, filesystem, etc. +│ ├── -crypto/ # shared cryptography (only if needed across bins) +│ ├── -os-utils/ # shared OS/process/path helpers (only if needed) +│ ├── -api/ # binary: REST / JSON / WebSocket daemon +│ ├── -worker/ # binary: long-running processor / queue consumer +│ └── -cli/ # binary: operator / admin CLI +├── / # Vite + React + SWC + TS frontend (when applicable) +├── asset/ # deployment artifacts (see §6) +├── script/ # deploy.sh and related operational scripts +└── README.md +``` + +### Crate naming +Use `-` throughout. The `` prefix makes grep, cargo output, and systemd unit naming unambiguous across a multi-project host. + +### Separation of concerns (strict) +- **entities** — types only. No I/O, no async runtime deps. Serde, thiserror, chrono/time are fine. Everything downstream depends on this. +- **core** — business logic. Consumes entities. May define traits for data access (ports) that the `data` crate implements (adapters). No direct DB or network calls. +- **data** — implements the traits defined in `core`. Owns all sqlx/turso/reqwest usage relevant to persistence and external services. +- **binaries** — thin. Wire up config, logging, signal handling, and the appropriate core/data stack. Binaries should contain no business logic that could live in a library crate. + +### Shared utility crates +Only create `-crypto`, `-os-utils`, etc. when genuinely shared by two or more binaries. Premature extraction is worse than inlining — extract when the second consumer appears. + +--- + +## 2. Cargo Workspace Conventions + +### Workspace-level versioning +Every crate in the workspace shares a single version, defined once in the root `Cargo.toml`: + +```toml +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "GPL-3.0-or-later" # adjust per project +authors = ["Rob Thijssen "] + +[workspace] +resolver = "3" +members = ["crates/*"] +``` + +Each crate's `Cargo.toml` inherits: + +```toml +[package] +name = "-entities" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +``` + +**Rationale:** release tagging scripts only need to rewrite one version string. CI stamps and changelogs stay consistent across artifacts from the same tag. + +### Workspace-level dependencies +Declare every external dependency once under `[workspace.dependencies]` in the root manifest. Crates reference them via `dep.workspace = true`. This prevents version drift between crates in the same workspace. + +```toml +[workspace.dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +sqlx = { version = "0.8", default-features = false, features = ["postgres", "runtime-tokio-rustls", "macros", "migrate"] } +thiserror = "2" +tracing = "0.1" +# ... +``` + +### Internal crate dependencies +Internal crates depend on each other via path + version: + +```toml +-entities = { path = "../-entities", version = "=0.1.0" } +``` + +Use `=` pinning on the internal version to guarantee in-workspace coherence after publishing or vendoring. + +### Edition and toolchain +- Rust edition **2024**. +- Commit a `rust-toolchain.toml` at the repo root pinning the stable channel to match CI. + +--- + +## 3. Binaries and Runtime + +### Daemons run under systemd +API and worker binaries are managed by systemd unit files shipped from `asset/systemd/`. Binaries should: + +- Log to stdout/stderr using `tracing` with structured JSON output when `JOURNAL_STREAM` is set (journald will ingest cleanly). +- Read config from `/etc//config.toml` by default, overridable via `--config` and env vars (figment-style layering: file → env → CLI). +- Handle `SIGTERM` gracefully: stop accepting new work, drain in-flight tasks with a bounded timeout, exit 0. +- Never daemonise themselves. systemd owns the lifecycle. +- Expose a health endpoint (for api) or emit periodic heartbeat logs (for workers) so `systemd` and monitoring can assess liveness. + +### API crate +- Axum is the default web framework unless there's a reason otherwise. +- Serves REST + JSON over TCP, with WebSocket upgrades where streaming is needed. +- TLS terminates at the site nginx reverse proxy (see §7) unless the binary itself is the ingress (e.g., Cichlid-style self-serving nodes), in which case use rustls with post-quantum-capable cipher suites. +- API surface versioned under `/v1/` from day one. +- Request/response types live in `-entities` so clients (including the desktop app) can depend on them. + +### Worker binaries +- Long-running processors (ingestion, indexing, queue consumers) use Postgres `FOR UPDATE SKIP LOCKED` for work-claiming where a central DB is already in play. +- Idempotent by design — crashes and restarts should never double-process. +- Backoff with jitter on transient failures; escalate to DLQ semantics on repeated failure. + +--- + +## 4. Frontends + +### Web (default) +Vite + React + SWC + TypeScript, in `/web/`: + +``` +web/ +├── package.json +├── vite.config.ts +├── tsconfig.json +├── index.html +└── src/ + ├── main.tsx + ├── App.tsx + ├── api/ # generated or hand-written client for the -api + ├── components/ + ├── routes/ + └── lib/ +``` + +- Build output is static. Deployed to an nginx CDN endpoint — no Node.js in production. +- API base URL is configured at build time (Vite `import.meta.env.VITE_API_BASE_URL`) and stamped per environment during deploy. +- Prefer React Query or equivalent for server state. Keep business logic server-side; the frontend is a rendering and interaction layer. + +### Web (Rust framework exception) +Use a Rust web framework (Axum + templating, or a fullstack framework) **only when** the deployment model requires a single self-contained binary with no external web server — e.g., distributed orchestration nodes that each serve their own UI over TLS. The Cichlid pattern. Default is still Vite + nginx. + +### Desktop +Tauri. Consumes the same `-api` as the web client. Shares types via the `-entities` crate (exposed to the Tauri frontend via generated TypeScript bindings — `ts-rs` or `specta`). + +### Mobile +**Preferences TBD.** When a project targets mobile, the goal is a framework that consumes the existing backend API (keeping business logic server-side) and produces responsive native-quality UIs for both Android and iOS from a shared codebase. Revisit this section once there's real-world experience to draw on. + +--- + +## 5. Data + +### Central database: Postgres +Default for any app with a central data store. + +- Connection is **mTLS with passwordless auth**. Host-level client certificates issued by the internal step-ca, with cert CN → pg role mapping via `pg_ident.conf`. +- No passwords in config files, ever. Connection strings reference cert paths. +- Migrations via `sqlx-cli` or `refinery`; migration files live in `crates/-data/migrations/`. +- Schema changes are forward-only in production. Destructive migrations require a dedicated maintenance window and an explicit plan. +- Use `sqlx` with compile-time query checking (`sqlx prepare`) and commit the generated `.sqlx/` offline query cache so CI builds don't need a live database. + +### Distributed database: Turso +When the app's data model is distributed (edge replicas, per-site local copies with sync), use Turso. Auth via Turso-issued tokens stored in the per-host secret store, not in `manifest.yml`. + +### Caching / ephemeral state +Prefer in-process (moka, quick-cache) over introducing Redis. Only add Redis when multiple processes genuinely need to share ephemeral state and Postgres LISTEN/NOTIFY won't do. + +--- + +## 6. Deployment Assets (`asset/`) + +`asset/` is the single source of truth for what gets deployed where. **No secrets in this directory, ever** — it's in source control. + +``` +asset/ +├── manifest.yml # environments → components → hosts +├── systemd/ +│ ├── -api.service +│ ├── -api.socket # if socket-activated +│ ├── -worker.service +│ ├── -indexer.timer +│ └── -indexer.service +├── nginx/ +│ └── ..conf # per server_name configs +├── config/ +│ ├── config.toml.tmpl # templated; {{SECRET_NAME}} placeholders +│ └── ... +└── sql/ + └── bootstrap.sql # idempotent role/db creation +``` + +### `manifest.yml` structure + +```yaml +app: +environments: + prod: + components: + api: + hosts: [oolon.hanzalova.internal] + config: + bind: 0.0.0.0:8080 + log_level: info + worker: + hosts: [gramathea.kosherinata.internal, oolon.hanzalova.internal] + config: + concurrency: 4 + web: + hosts: [cdn.hanzalova.internal] + root: /var/www/ + dev: + components: + api: + hosts: [quadbrat.hanzalova.internal] + config: + bind: 0.0.0.0:8080 + log_level: debug + # ... +``` + +Top-level keys: `app`, `environments`. Each environment defines `components`, each component defines `hosts` (one or many) and `config` (non-secret values only). Secret references are placeholders resolved by `deploy.sh` at deploy time. + +### Templated config +Config file templates use a simple `{{VAR_NAME}}` syntax. `deploy.sh` substitutes values from the host's `pass` store (or equivalent) into the template before shipping to the target. The unrendered template is committed; the rendered file never is. + +--- + +## 7. Deployment Script (`script/deploy.sh`) + +A bash script with a stable CLI: + +``` +./script/deploy.sh [component...] + +./script/deploy.sh prod api worker +./script/deploy.sh dev all +./script/deploy.sh prod default +``` + +### Contract +- First positional arg is the environment name, matched against `manifest.yml` `environments.*`. +- Subsequent args name components, or `all` (every component in the environment), or `default` (a project-defined sensible subset — often everything except disruptive migrations). +- Parses `manifest.yml` with `yq`. +- For each `(component, host)` pair: + 1. Build the artifact locally (cargo build release, vite build, etc.) if not already built for the current commit. + 2. Resolve secrets from `pass` (or the project's configured secret backend) and render config templates. + 3. `rsync` binary + rendered config + systemd units to the target host over ssh (quantum-safe key exchange). + 4. `ssh` to the target to `systemctl daemon-reload` and restart the unit(s). + 5. Verify health (HTTP probe for api, `systemctl is-active` for all). +- Exit non-zero on any failure. Report per-host status at the end. + +### Requirements +- Idempotent. Running twice with no changes is a no-op beyond file copies. +- Quiet on success, loud on failure. +- Supports `--dry-run` to print what would happen. +- Never writes secrets to disk on the build host outside of the rendered template being rsynced. + +--- + +## 8. Infrastructure Context + +This is the environment these apps deploy into. Implementation should assume it. + +### Network +- Multi-site WireGuard mesh. Sites are numbered; host IPs follow `10..0.0/16` (currently `10.3.0.0/16` and `10.6.0.0/16`, but the second octet encodes the site and is the stable part). +- Per-site OPNsense router handles WAN/LAN and the WireGuard endpoints. +- Internal DNS split-horizon via `.internal` domains (`hanzalova.internal`, `kosherinata.internal`, etc.). + +### TLS / PKI +- Internal PKI via Smallstep `step-ca` at `ca.internal`. +- Host certs renewed via systemd timers. +- mTLS everywhere internal services talk to each other. +- **Quantum-safe** SSH (sntrup761x25519 KEX) and TLS (X25519MLKEM768 where peers support it) are the default. External peers that don't support PQ fall back to classical curves — document the fallback explicitly in nginx config. + +### Ingress +- Per-site nginx reverse proxy terminates all WAN inbound 443. +- Public DNS via Cloudflare, **unproxied by default** (CF's mTLS origin-pull has been unreliable). Revisit if/when that changes. +- nginx serves static frontends directly from `/var/www/` and reverse-proxies API traffic to the internal host:port from `manifest.yml`. + +### Hosts +- Workstations and servers run the latest Fedora. +- Services run as dedicated non-root users created by RPM scaffolding or `sysusers.d` drop-ins shipped in `asset/systemd/`. +- SELinux enforcing. Any non-standard file paths or ports require a policy module shipped with the app. +- Podman quadlets for containerised workloads; bare-metal systemd units for native Rust binaries (preferred where feasible). + +--- + +## 9. Code Quality and Tooling + +### Formatting and linting +- `cargo fmt` on commit (pre-commit hook or CI gate). +- `cargo clippy --all-targets --all-features -- -D warnings` must pass. +- Frontend: `eslint` + `prettier`, configured to match the team style. Type errors fail the build. + +### Testing +- Unit tests live alongside the code they test (`#[cfg(test)] mod tests`). +- Integration tests under `crates//tests/`. +- End-to-end tests that require a database use a dedicated test DB per run, created and torn down by the test harness. +- Target: core business logic has meaningful test coverage. Binaries have smoke tests. + +### Observability +- `tracing` with `tracing-subscriber`. JSON output in production, pretty output when stdout is a TTY. +- Structured log fields for request IDs, user IDs (where applicable), and operation names. +- No `println!` or `eprintln!` in committed code outside of CLI binaries' user-facing output. + +### Error handling +- Library crates use `thiserror` for typed errors. +- Binaries use `anyhow` at the outermost layer, with `.context(...)` at call boundaries. +- Never `unwrap()` in production code paths. `expect("...")` with a clear message is acceptable for invariants that are genuinely impossible to violate. + +### Documentation +- Every public item in library crates has a doc comment. +- Each crate has a `README.md` or top-level module doc explaining its role in the workspace. +- The repo `README.md` covers: what the project does, how to build, how to run locally, how to deploy. Point readers to this document for architectural conventions. + +--- + +## 10. Conventions Summary for Scaffolding and Implementation + +When scaffolding or extending a project: + +1. Default to the workspace layout in §1. Ask before deviating. +2. Put new types in `entities`, new logic in `core`, new I/O in `data`. Binaries stay thin. +3. Add dependencies to the workspace root first, then reference with `dep.workspace = true`. +4. Version strings live in exactly one place — the workspace root. +5. Any new deployable component gets an entry in `asset/manifest.yml` and a systemd unit in `asset/systemd/` in the same change. +6. Config templates go in `asset/config/` with `{{PLACEHOLDER}}` secrets. Never commit a rendered config. +7. Postgres connections are mTLS, passwordless. If writing connection code that accepts a password, stop and ask. +8. Frontend is Vite + React + SWC + TS, served as static assets from nginx. Rust web frameworks require a stated reason. +9. Prefer fewer dependencies. Prefer bare-metal systemd over containers unless there's a reason. +10. When unsure, ask — these preferences are defaults, not mandates, but deviations should be deliberate. \ No newline at end of file