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/<run-id>; 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://<control-plane LAN IP>: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 (<root>/runs/<run-id>/<stage>/<path> + .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 <root>/public/<paper-id>/v<n>/ 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-<year>-<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-<year>-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).