Architecture¶
Lingo is a single Python service (FastAPI + asyncpg + PostgreSQL) with three client surfaces: a web SPA, a Slack bot, and a CLI. A FastMCP endpoint at /mcp exposes the glossary to AI agents.
Component overview¶
┌─────────────────────────────────────────────────────────┐
│ Lingo Server (FastAPI) │
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌──────────┐ │
│ │ REST API │ │ FastMCP /mcp │ │ Static │ │
│ │ /api/v1/* │ │ (bearer auth) │ │ SPA │ │
│ └───────┬────────┘ └───────┬────────┘ └──────────┘ │
│ │ │ │
│ ┌───────▼───────────────────▼───────────────────────┐ │
│ │ Services (TermService, VoteService) │ │
│ └───────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────────────▼───────────────────────────┐ │
│ │ SQLAlchemy async models + Alembic │ │
│ └───────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────────────▼───────────────────────────┐ │
│ │ PostgreSQL │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ APScheduler (in-process, AsyncIOScheduler) │ │
│ │ DiscoveryJob (daily 2am) | StalenessJob (Mon 3am)│ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
▲ ▲ ▲
Slack Bot CLI (httpx) Web Browser
(Socket Mode) LINGO_APP_URL React SPA
Source structure¶
src/lingo/
├── main.py # FastAPI app, lifespan, route wiring
├── config.py # Settings (pydantic-settings, LINGO_ prefix)
├── api/
│ ├── deps.py # FastAPI dependencies: CurrentUser, SessionDep, EditorUser
│ ├── schemas.py # Pydantic request/response schemas
│ └── routes/
│ ├── terms.py # /api/v1/terms — all term CRUD + actions
│ ├── export.py # /api/v1/export
│ ├── users.py # /api/v1/users
│ ├── tokens.py # /api/v1/tokens
│ ├── admin.py # /api/v1/admin
│ └── features.py # /api/v1/features
├── auth/
│ └── oidc.py # Slack OAuth + JWT middleware
├── mcp/
│ ├── app.py # FastMCP tools: get_term, search_terms, list_terms
│ └── auth.py # MCPBearerAuthMiddleware
├── slack/
│ └── app.py # Slack-bolt AsyncApp, command handlers, DM helpers
├── cli/
│ └── main.py # Typer CLI: define, add, list, export
├── scheduler/
│ ├── setup.py # APScheduler wiring
│ └── jobs/
│ ├── discovery.py # Daily Slack acronym scan
│ └── staleness.py # Weekly stale-term DMs
├── services/
│ ├── term_service.py # Business logic: create, update, vote thresholds, history
│ └── vote_service.py # Vote dedup, status promotion (CAS-based)
├── models/ # SQLAlchemy ORM models (7 tables)
└── db/
└── session.py # AsyncSession factory (asyncpg driver)
frontend/ # React + Vite SPA (compiled → src/lingo/static/)
alembic/ # Database migrations
tests/
├── unit/ # pytest (async, aiosqlite in-memory DB — no Docker)
└── integration/ # pytest (async, real Postgres — requires Docker)
Data model¶
Seven tables. The core is terms — everything else supports it.
| Table | Purpose |
|---|---|
users |
Accounts; role: viewer | editor | admin |
terms |
The glossary; status: suggested | pending | community | official |
votes |
One vote per (user, term); drives status transitions |
term_history |
Append-only log of every term edit |
term_relationships |
Synonym / antonym / related links between terms |
tokens |
API bearer tokens for programmatic access |
jobs |
Scheduler job run log (status, progress_json, error) |
Term status lifecycle¶
stateDiagram-v2
[*] --> suggested: User submits term
suggested --> pending: Maintainer approves
pending --> community: Reaches vote threshold
community --> official: Maintainer promotes
community --> disputed: Dispute flag raised
disputed --> community: Dispute resolved
official --> stale: Weekly staleness job (Mon 3am)
stale --> official: Re-confirmed by votes
stale --> [*]: Archived after no activity
VoteService checks both thresholds on every vote and triggers the transition automatically. Editors can also manually promote or mark_official via the API.
Authentication¶
Three modes, evaluated in order by the CurrentUser dependency:
- Slack OAuth — the web UI uses Sign in with Slack (
openid,email,profilescopes). The callback at/auth/slack/callbackexchanges the code for tokens, creates or updates the user record, and issues a session JWT signed withLINGO_SECRET_KEY. RequiresLINGO_SLACK_CLIENT_IDandLINGO_SLACK_CLIENT_SECRET. - JWT Bearer — HS256 token signed with
LINGO_SECRET_KEY. Used by the CLI, MCP, and direct API clients after obtaining a token via Slack OAuth or an admin-issued API token. - Dev mode —
LINGO_DEV_MODE=trueonly. Visit/auth/dev/login?email=you@example.comto get a session without Slack.X-User-Id: <uuid>header is also accepted for CLI use in dev. Both are rejected with401in production.
The MCP endpoint has its own auth middleware (MCPBearerAuthMiddleware) validating against LINGO_MCP_BEARER_TOKEN.
CORS is restricted to LINGO_APP_URL (default: http://localhost:8000).
Scheduler¶
APScheduler's AsyncIOScheduler starts in the FastAPI lifespan and shares the same event loop:
- DiscoveryJob — runs daily at 2 AM. Scans public Slack channels over a 90-day window, extracts uppercase acronyms (
[A-Z]{2,6}), and createssuggestedterms for any not already in the glossary. - StalenessJob — runs weekly Monday at 3 AM. Finds terms where
last_confirmed_atis older thanLINGO_STALE_THRESHOLD_DAYS, setsis_stale=true, and DMs owners via Slack Block Kit.
Both jobs write a Job row with status, a progress_json blob, and any error message.
--workers 1 is required. Multiple workers would each start their own scheduler and run jobs multiple times.
MCP server¶
FastMCP is mounted as a sub-ASGI app at /mcp. Three read-only tools:
get_term(name)— exact case-insensitive lookupsearch_terms(query, status?, limit?)— full-text searchlist_terms(category?, status?, limit?, offset?)— paginated list
Read-only by design. Write operations go through the REST API or Slack bot where user identity and governance are well-defined.
Key design decisions¶
Single process, async throughout. FastAPI + asyncpg + APScheduler share one asyncio event loop. Keeps deployment simple at the cost of requiring --workers 1.
Dev mode is a hard gate. X-User-Id header auth is rejected with 401 unless LINGO_DEV_MODE=true. Enforced in the CurrentUser dependency, not just documented.
Term status is append-only via history. Every edit creates a TermHistory row. The revert endpoint restores a previous version by creating a new edit, not by modifying history.
Optimistic concurrency on updates. PUT /api/v1/terms/{id} requires a version field matching the current row. A mismatch returns 409. This prevents silent overwrites when two editors update the same term concurrently.
CAS on vote status transitions. VoteService.vote() uses a compare-and-swap UPDATE terms SET status=?, version=version+1 WHERE id=? AND status=? AND version=?. Two concurrent votes both hitting the threshold race; exactly one wins (rowcount=1) and fires the transition. Both votes still succeed — the transition fires once.
MCP is read-only. Write access via AI agents would bypass the community vote-based governance model. Keeping MCP read-only is intentional.
API-first design. The REST API is the source of truth. The Slack bot, web UI, and CLI are thin clients over it.