docs/architecture.md
Rama main · réplica de lectura del repositorio original.
# Architecture
Four independent Node processes, one Postgres database, and a background worker. They share no
in-process code and talk to each other only over HTTP, using URLs each is handed at build time.
```
┌──────────────┐
public visitor ──│ apps/landing │──┐
│ :3001 │ │
└──────────────┘ │
┌──────────────┐ │ HTTP ┌──────────────┐
member ──────────│ apps/web │──┼──────────────▶│ apps/api │──▶ Postgres
│ :53000 │ │ │ :4000 │
└──────────────┘ │ └──────┬───────┘
┌──────────────┐ │ │ JobQueue table
operator ────────│ apps/admin │──┘ ▼
│ :3002 │ ┌────────────────────┐
└──────────────┘ │ background work │
│ (in the API process)│
agent / MCP client ───────────────────────────▶│ 23 job types │
:4000/mcp └────────────────────┘
```
Ports shown are each app's `npm run dev` default.
## The four apps
| App | Stack | Responsibility |
|---|---|---|
| `apps/api` | Fastify · Prisma · Postgres | REST API, MCP server, validation pipeline, background worker. 37 route modules, ~73 services, and a Prisma schema of ~2,960 lines across 10 migrations. |
| `apps/web` | Next.js | The logged-in product. 28 pages: contributor workspace, sponsor requests, validator audit queue, karma, profile, developer/MCP setup. |
| `apps/admin` | Next.js | Operator console. 31 pages: request review, dataset-type and harness authoring, moderation, settings, health. |
| `apps/landing` | Next.js | Public site. 14 pages: pool catalog, public profiles at `/{handle}`, domains, changelog, legal. |
### Why there is no shared package
The apps genuinely share no code — verified: zero cross-app imports. There is no root `package.json`, no
npm workspace, and no monorepo build tool. Each app installs and builds from its own directory with its
own lockfile.
This is deliberate but unconventional (current practice favours a single root lockfile with `apps/` +
`packages/`), so it is called out in [CONTRIBUTING.md](../CONTRIBUTING.md) as the first thing a
newcomer needs to know: **`npm install` at the repository root does nothing.**
### How they find each other
Each frontend reads `NEXT_PUBLIC_API_URL` to reach the API, and `NEXT_PUBLIC_DASHBOARD_URL` /
`NEXT_PUBLIC_ADMIN_URL` / `NEXT_PUBLIC_LANDING_URL` to link to each other. The API reads `APP_URL` and
`ADMIN_URL` server-side to build links inside emails and notifications, and `CORS_ORIGINS` to decide
which frontend origins may call it.
`apps/web` is the public MCP gateway: it proxies `/mcp/*` and the OAuth discovery documents through to
the API. MCP clients use the web origin; `MCP_PUBLIC_URL` makes the API advertise that same origin.
## Identity: roles vs personas
This is the easiest thing to get wrong.
**Sponsor, contributor and validator are not roles.** They are personas derived from real work rows —
you are a sponsor because you own a dataset request, a validator because you hold an audit window.
Nothing grants them; they cannot be assigned or revoked. Those three values still exist in the `Role`
enum marked deprecated, granted to nobody and checked by nothing, so historical rows still parse.
The only real roles are three operator roles, none self-assignable:
| Role | Access |
|---|---|
| `admin` | Full admin console, including settings and security-sensitive routes. |
| `member` | Broad operational writes — review, moderate, decide. Not settings, not security. |
| `support` | Read-only, plus benign self-service actions. |
The admin console resolves its session **client-side** and redirects unauthenticated visitors to
`/login`. That redirect is UX only — the real boundary is `requireRole()` on every API call. Bypassing
the redirect yields a console shell that cannot fetch anything.
## The validation pipeline
Six stages, in a fixed order hardcoded in `services/validation.ts`. There is **no** per-dataset-type
pipeline array and no admin toggle for stage order — every stage runs on every submission. Each writes
an immutable `ValidationResult` row.
| Stage | What it does |
|---|---|
| `duplicate_check` | Exact-hash plus near-duplicate detection via MinHash + LSH banding (`SubmissionLshBand`), scoped per pool. Rejection is **per item** — one duplicate does not sink a batch. |
| `ai_attribution` | Deterministic, dependency-free scan for **explicit** model or co-author disclosures. Deliberately does *not* infer authorship from writing style, because general-purpose AI-text detectors are not reliable enough to base a penalty on. Neither sponsor nor admin can configure it in or out. |
| `execution` | Runs the item's own tests in a sandbox. With no sandbox key it records `no_provider_configured` and routes to human review — it never passes an item it could not run. |
| `llm` | Quality review against a rubric. Unconfigured it records `pending_llm_review` and routes to human audit. **Off by default**, so the honest path is the default path. |
| `pool_capacity` | Claims one of the pool's finite acceptance slots with a single atomic `UPDATE`, guarded against replay so a retried job cannot flip an already-accepted item to rejected. |
| `human_audit` | A person decides. |
> **In a default deployment no automated stage can grant final acceptance.** Contamination screening was
> removed and LLM review is off, so a clean automated pass lands on `accepted_pending_sample` — a
> non-terminal state — and a human resolves it.
External-corpus **contamination screening was deleted rather than left as a stub**. The old stage
reported "not attempted" for every input regardless, which is worse than no stage. Its
`contamination_check` status survives in the enum only because dropping an enum value is a migration
nobody has needed; no live row holds it.
## Pool lifecycle
There is **no claiming**. While a pool has room, any eligible member may submit. Higher karma tiers get
an early-access head start before a pool opens to everyone.
```
DatasetRequest ──admin approves──▶ Bounty (kind: community) ← the pool
│
contributor submits ──────┤
▼
pipeline runs (as a job)
│
┌────────────────┴────────────────┐
clean pass failed / rejected
│ │
accepted_pending_sample slot released back
(holds a slot, NOT terminal)
│
pool target reached → close-out + sampling
│
┌──────────────┴──────────────┐
sampled not sampled
│ │
in_audit ──validator──▶ accepted accepted
│
(or in_sponsor_review, when audit coverage is 0)
│
▼
karma awarded → held through dispute window → released
│
▼
DatasetPublication → export + push, with contributor attribution
```
`BountyKind` has exactly one value, `community`. There is no paid variant in the enum.
Validators claim a `HumanAuditWindow` of 50–100 items (admin-set, hard-bounded) **exclusively for 24
hours** (`CLAIM_SLA_MS`). Conflict-of-interest blocking guarantees none of their own submissions are in
the batch, and an abandoned claim auto-releases.
`SubmissionStatus` has **16 values**, two of which exist only for this model:
`accepted_pending_sample` (clean automated pass, pool still open) and `in_sponsor_review` (pools with
zero audit coverage, where the sponsor decides in place of a validator). One of the 16, `contamination_check`,
is a retired value no live row holds — so 15 are reachable. See the note on contamination screening above.
## The worker
Anything heavy runs as a job claimed from the `JobQueue` table. Jobs are idempotent, with a visibility
lease (a row whose worker dies mid-handler is reclaimed once the lease expires), exponential backoff,
bounded retries and a terminal dead-letter state.
**Concurrency limits.** A job is claimed with a `findFirst` followed by an optimistic guarded
`updateMany` — safe (two workers cannot claim the same row; the loser simply gets nothing) but not
`FOR UPDATE SKIP LOCKED`. Every worker races for the same oldest row, so a second process mostly idles
rather than adding throughput. The queue also has no fairness partitioning: `workspaceId` is written on
every job and nothing reads it, so one tenant's bulk upload occupies the claim slot until its backlog
drains. Run a single worker until these are addressed.
**26 job types** (the full `JobType` union in `services/jobs.ts`, which a
`Record<JobType, number>` in `worker.ts` makes impossible to extend without a
handler and a lease):
```
validation.run pool.sampling leaderboard.rank_check
artifact.scan artifact.parse artifact.preview
artifact.similarity_check artifact.purge_expired_uploads
bulk_source.parse upload_draft.submit sponsor_reference.review
community.publish community.unpublish
user.provision profile_source.verify
benchmark.version_build benchmark.run_evaluation
notifications.fanout_watchers waitlist.notify_domain_live
harness.proof_run agent_issue.escalate agent_issue.escalation_sweep
agent_issue.purge_expired agent_issue.notify_filed
agent_issue.duplicate_candidates agent_issue.notify_canonical_outcome
```
`user.provision` is worth calling out because of where it is enqueued: inside
the same transaction as the `User` row it provisions. Account setup (personal
workspace, email notification channel, default watch preferences) is therefore
atomic with account creation — a rolled-back signup leaves no job — while
staying off the request path. It is also re-armed as an idempotent repair
whenever an existing account is seen without an email channel, at sign-in,
invite acceptance and email verification.
**Time-based sweeps.** Not everything can be triggered by an event, so the worker also runs a small set
of periodic loops. Each writes a heartbeat, and `services/watchdog.ts` raises an alert when one stops
ticking:
| Sweep | Default cadence | What it does |
|---|---|---|
| `job-dispatch` | continuous poll | Drains the job queue itself. |
| `dispatch` / `digest` | 15 s / 60 s | Notification delivery and digest flushing. |
| `pool-reconcile-and-settle` | 10 min | Recounts open-pool capacity and settles pools that close after reaching their verified-item target. Community pools are open-ended and never close because time elapsed. |
| `artifact-sweeps` | 5 min | Expired upload purge, and re-arms format stages whose handler version moved on. |
| `rate-bucket-prune` | 5 min | Deletes expired one-minute rate-limit buckets. |
| `agent-issue-sweeps` | 5 min | Aging escalation and retention. |
| `leaderboard-rank-sweep` | 10 min | Coalesced rank-movement checks. |
| `waitlist-notify-sweep` | 10 min | "Your domain is live" mail. |
| `profile-source-revalidation` | 10 min | Re-verifies connected credentials so a revoked grant stops crediting reputation. |
| `audit-reaper` / `karma-hold-release` | — | Expired audit claims and matured karma holds. |
| `watchdog` / `admin-metrics-snapshot` | — | Alerting and rolling metric snapshots. |
> **Background work runs inside the API process** — there is no separate worker to start or deploy.
> One process cannot be half-deployed, which rules out the failure mode of a queue that nothing drains.
>
> **The rule that comes with it:** run a single API instance. The job claim is safe under concurrency
> (a guarded compare-and-swap — two workers cannot take one row), but the sweeps above are not
> partitioned, so two instances would both run them. Split the worker back out before scaling
> horizontally; `startBackgroundWorkers()` in `apps/api/src/worker.ts` is the only seam needed.
## Notifications
Six channels: email, Telegram, Discord, Slack, Google Chat, Microsoft Teams.
A **transactional outbox** — the notification row is written inside the same transaction as the thing
that caused it, so an event cannot be lost to a crash between the two. A polling dispatch worker
delivers; a digest flusher batches. Idempotency is keyed on `(userId, eventKey)`, so a retried fan-out
does not double-notify.
An unconfigured channel **dead-letters with the actual reason**. It is never recorded as sent.
## Dataset types
50 checked-in type definitions under `apps/api/prisma/dataset-catalog/`, seeded idempotently. A type
declares its own field contract — keys, roles, which are files, what each accepts — and the submission
form is generated from it. That is how one form serves all 50 types.
Nothing is hardcoded to today's catalog or to JSON/CSV. Submission, preview, validation, similarity and
export go through a versioned format registry with per-type allowlists and pluggable parsers; unknown
types fail closed to quarantine or human review.
Admins author new types and harnesses in the console, so adding a type is data, not a deploy.
## Data model
~2,960 lines of Prisma across 10 migrations (counts as of this writing — the schema is under active
development, so treat them as a scale indicator, not a fixed fact; `npx prisma migrate status` is the
authority). The spine:
```
User → DatasetRequest → Bounty (pool) → Submission → ValidationResult
│
├──▶ HumanAuditWindow → AuditItem
├──▶ KarmaEvent / PendingKarmaAward
└──▶ Artifact
Bounty ──▶ DatasetPublication
```
Around it: OAuth and MCP token tables, sessions, API keys with rate buckets, artifacts with modality and
scan status, an LSH band table for near-duplicate lookup, LLM audit/cache/quota, notification channels
and deliveries, badges, benchmarks, agent issues, the job queue, and operational telemetry including
worker heartbeats and an append-only admin audit log with chain-integrity verification.
## Where to read next
- Wiring it up: [configuration.md](configuration.md)
- Running it: [self-hosting.md](self-hosting.md)
- Calling it: [api.md](api.md) · [mcp.md](mcp.md)