# Architecture decisions — AutoScholar.ai Each decision records what was verified vs assumed. Deviations from the master build instructions are explicit. ## D-01 Monorepo and language pnpm workspace, TypeScript 5 (strict), Node ≥ 22 (nodes run 25/26), Biome for lint/format, Vitest for unit/integration, Playwright for e2e. Rationale: single toolchain on macOS Apple Silicon nodes that already have Node + pnpm (mld runtime detection). ## D-02 Web and API in one Next.js app (deviation from `apps/api`) `apps/web` (Next.js 16, React 19, Tailwind 4) serves the public library, the admin panel and **all HTTP APIs** as route handlers (`/api/public/*`, `/api/admin/*`, `/api/internal/workers/*`). The suggested separate `apps/api` service is folded into `apps/web` to keep one public port behind the tunnel. Business logic lives in packages (`db`, `auth`, `schemas`, `storage`, `research-contract`, `maclustr-adapter`) so the API surface stays thin and testable. Deviation documented here; subsystem responsibilities are unchanged. ## D-03 Durable queue = PostgreSQL (`FOR UPDATE SKIP LOCKED`) No Redis dependency. Runs/attempts/leases live in Postgres with lease expiry, fencing tokens (monotonic per run), idempotency keys (unique constraints) and an append-only event ledger. Redis exists on M3U96b but adds nothing at this scale. ## D-04 Control plane host = M3U96b:8470 Verified: 96 GB RAM, Postgres 17 + pm2 present, wg1 peer, free ports. Two pm2 processes: `autoscholar` (Next.js) and `autoscholar-scheduler` (orchestration loop). Deployed through `mld` with `tunnel.domain = autoscholar.ai`. ## D-05 Worker host = M4M64c, dedicated user `asworker` Verified: empty node, 64 GB, macOS 26.4, Seatbelt + pf + launchd available. The worker daemon runs as a non-admin user via a LaunchDaemon (`io.autoscholar.worker`), not under the operator session; workspaces in `/var/autoscholar/runs/`; env file `/etc/autoscholar/worker.env` (root:asworker 0640) holds the Anthropic key and the worker token. The operator installs it once with `services/worker/install/install-macos.sh` (sudo). The platform never uses SSH or sudo at runtime. ## D-06 Worker transport = outbound LAN HTTP with scoped token Workers poll `http://:8470/api/internal/workers/*` (lease, heartbeat, events, checkpoints, artifact upload). Public tunnel traffic to `/api/internal/*` is rejected. Enrollment: one-time enrollment secret → long-lived per-worker token (hashed in DB, revocable, rotated by `POST …/rotate`). mTLS not used (no PKI in cluster); the LAN is private and tokens are scoped. Documented limitation. ## D-07 Artifact store = versioned filesystem store with SHA-256 (deviation from S3) No S3-compatible service exists in the cluster and no new paid product is introduced. `packages/storage` implements an immutable content-addressed store (`/runs///` + `.sha256` sidecars, write-once, verified on read) with a `StorageBackend` interface so S3 can be added later. Public artifacts are promoted (copied, re-hashed) into `/public//v/` and served by the web app with immutable cache headers. Backups: nightly `pg_dump` + rsync of the store to M3U96a (docs/OPERATIONS.md). ## D-08 Research engine = Claude Agent SDK (TypeScript) with API key `query()` with `settingSources: ['project']` so the frozen `CLAUDE.md` in the run workspace is the project instruction file (verified by integration test in Phase 5), `permissionMode: 'dontAsk'`, explicit `allowedTools`, `disallowedTools` scoped rules, `PreToolUse` hook policy (platform-side), SDK `sandbox` (Seatbelt + hostname-allowlisted egress proxy, `failIfUnavailable: true`, `allowUnsandboxedCommands: false`), `maxBudgetUsd`/`maxTurns`/wall-clock abort from the manifest, `persistSession: false`, isolated `CLAUDE_CONFIG_DIR` and `HOME` per run. Auth: `ANTHROPIC_API_KEY` only (subscription login is not allowed for third-party products per official docs). ## D-09 Stage execution split Stages 0–8 run on the worker as independently checkpointed SDK sessions (one session per stage, fresh context; the reviewer stage uses a separate session that cannot edit the manuscript directory — enforced by hook + filesystem `denyWrite`). Stage 9 (quality decision) runs **on the control plane** (`services/qa`) over the uploaded artifacts, never from text returned by the model. Stage 10 (publication) runs on the control plane (`services/publisher`) with credentials the worker does not have. ## D-10 Password hashing Argon2id via `@node-rs/argon2` (prebuilt darwin-arm64). Bootstrap password `admin123` is read from `ADMIN_BOOTSTRAP_PASSWORD` **only when the `admin_users` table is empty**, hashed immediately, flagged `must_change_password`, and the env value is never logged. After rotation the old hash is kept in `password_changes` for audit and rejected on login. ## D-11 Search = PostgreSQL full-text (`tsvector` + GIN) + trigram for titles No search engine. Re-evaluate only with measurements. ## D-12 Paper identifiers `AS-WP--<4-digit>` from a Postgres sequence per year inside the publication transaction; slugs derived from the title + ID; DOIs not issued (no agency membership) — UI shows "No DOI". ## D-13 LaTeX toolchain = tectonic Installed on the worker via Homebrew (no LaTeX exists on any node). Proprietary class `research/templates/latex/autoscholar.cls` + `autoscholar-plots.mplstyle`. PDF checks: `pdfinfo`-equivalent via `pdf-lib`/`pdfjs` in Node (page count, fonts embedded, text extractable) plus rendered page previews with `pdftoppm` if available (poppler installed with tectonic step) else `mutool`. ## D-14 Local development without MacLustr `pnpm dev` runs web + scheduler against local Postgres (laptop has Postgres 17) with `WORKER_MODE=simulated` for tests only; simulated results are marked `synthetic: true` and can never be published (hard gate). ## D-15 Deviation: no separate `apps/api`, `services/qa` and `services/publisher` are libraries invoked by the scheduler process They keep their own directories and tests but execute inside `autoscholar-scheduler` to avoid three extra pm2 processes with identical DB access. Responsibilities unchanged. ## D-16 Greenfield development exception to remote-first Until the first deployment the repository was authored on the operator laptop (fast iteration, local Postgres, Playwright). First deployment: `mld stage` → `mld deploy --node M3U96b` → `mld adopt` (node directory = git clone, origin spbgit `autoscholar.git`, commit e709d44). From then on changes are committed and pushed through spbgit and pulled on the node (`git pull && pnpm install && build && pm2 restart`); the worker is a separate bundle (`services/worker/install/bundle.sh`) rsynced to `/var/autoscholar/bundle` on M4M64c. After launch the standard remote-first rule applies (edit on the node). ## D-17 Internal API source check `next start` sets `X-Forwarded-For` to the socket address for direct connections; the gateway sets it to the public client address. The internal worker API therefore trusts the *first* XFF hop: it must be inside `INTERNAL_API_ALLOWED_CIDRS` (LAN/loopback), which excludes any request that came through the tunnel. ## D-18 Paper numbers reserved at launch `AS-WP--NNNN` is drawn from the per-year sequence when a run is launched and stored in the immutable manifest (`paperNumber`), so the manuscript carries its final number. Numbers of runs that never publish remain unused (gaps are acceptable, as in most working-paper series); the publisher consumes the reserved number and only allocates a fresh one when the manifest has none (legacy runs).