Scrape-and-Analyze Constitution
Core Principles
I. Domain-Driven Design (NON-NEGOTIABLE)
The src/ service MUST follow hexagonal/DDD architecture with strict layer separation:
- Domain layer (
src/modules/*/domain/): Entities, value objects, repository interfaces, and domain service interfaces. Zero dependency on infrastructure or application code.- Entities and value objects MUST default to stdlib
@dataclass. PydanticBaseModelMUST NOT be used in the domain layer unless a genuinely Pydantic-specific capability is required and a@dataclasscannot reasonably provide it (documented exception:ScraperKeywordVOinsrc/modules/collection/domain/value_objects/scraper_keyword.py, which needsField(discriminator="type")for polymorphic deserialization). Pydantic remains reserved for API/config boundaries per Principle VII, not as a default choice for domain modeling.
- Entities and value objects MUST default to stdlib
- Application layer (
src/modules/*/application/): Use cases, event handlers, DTOs, and application events. Depends only on domain layer interfaces. - Infrastructure layer (
src/infrastructure/): Concrete implementations of scrapers, LLM providers, repositories, parsers, notifications, and observability. Implements domain interfaces. - Composition root (
src/bootstrap.py): Manual dependency wiring; no DI container. All cross-layer assembly happens here.
Rationale: DDD prevents domain logic leakage into infrastructure and keeps the scraper pipeline testable, replaceable, and resilient to provider changes.
II. Atomic Frontend Architecture
Frontend components MUST follow a modified atomic design hierarchy:
components/ui/— Shadcn/UI primitives (atomic): button, card, dialog, table, etc. Never import domain logic.components/common/— Shared molecules: date-filter, error-boundary, multi-select-popover. Compose fromui/atoms.components/features/— Domain organisms organized by feature (articles/,graph/,monitoring/,tags/, etc.). May consume common molecules and ui atoms.components/providers/— Context providers (Session, Topic, I18n, ErrorBoundary). Wrap at layout level only.
Component reuse and documentation rules:
- Reuse first: Before creating a new component, existing components in
components/ui/,components/common/, andcomponents/features/MUST be evaluated for reuse or composition. New components MUST only be introduced when no existing component can reasonably satisfy the requirement. - Storybook story required: Every new component added to
components/common/orcomponents/features/MUST ship with a corresponding Storybook story (.stories.tsx) in the same directory. Stories MUST cover at minimum the default state and any significant variants or interactive states. Shadcn/UI primitives incomponents/ui/are exempt from this rule.
Rationale: Clear component boundaries prevent feature coupling and keep UI primitives reusable across admin and public routes. Mandatory Storybook stories ensure new shared components are discoverable and visually verified before integration.
III. Test Discipline
- Python: pytest with
@pytest.mark.integrationfor DB-dependent tests. Unit tests MUST NOT require a running database. Integration tests MUST use isolated schemas (test_integration,backend_test) with per-test rollback via savepoints. - Frontend: Vitest for unit tests (exclude
components/ui/Shadcn primitives from coverage). Playwright for E2E (chromium). Storybook for component visual testing. - Test isolation: Integration test conftest MUST create and tear down isolated PostgreSQL schemas. Backend integration conftest MUST use savepoint-based transaction wrapping so endpoint
db.commit()does not escape the outer rollback. - Docker-only test execution: All test runs MUST be executed inside Docker containers via Makefile targets (
make testfor unit,make test-integrationfor integration). Running pytest directly on the host (uv run pytest) is permitted only for IDE test discovery; CI and all acceptance runs MUST use Docker. - CI gates: Unit tests run first; integration/E2E only after unit pass. Coverage uploaded to Codecov with carryforward.
- Mandatory test tasks in every tasks.md: Every feature implementation MUST include at least one dedicated test phase in
tasks.md. Tests are NOT optional and MUST NOT be omitted even if not explicitly requested in the spec. The test phase MUST use the project's established test directories and frameworks:- Frontend unit tests →
frontend/tests/unit/(Vitest) - Frontend E2E tests →
frontend/tests/integration/(Playwright) - Backend unit tests →
backend/tests/(pytest) - Scraper unit tests →
src/tests/unit/(pytest) - Scraper integration tests →
src/tests/integration/(pytest,@pytest.mark.integration) The tasks template instruction "Tests are OPTIONAL" does NOT apply to this project.speckit-tasksMUST always generate test tasks.
- Frontend unit tests →
Rationale: Isolated, deterministic tests prevent flaky CI and ensure fast feedback loops. Schema isolation avoids cross-test contamination. Mandatory test tasks in every feature prevent the recurring gap where implementation is complete but automated coverage is absent.
IV. Docker-First Local Development
- All local development MUST use
docker compose upwithDockerfile.devconfigurations that bind-mount source for live-reload. - No bare-metal runs: Backend, frontend, and scraper services run exclusively inside Docker containers. PostgreSQL MUST use the
pgvector/pgvector:pg15image with the pgvector extension. - Makefile as interface: All developer-facing operations (migrate, test, scrape, dump/sync, backfill) MUST be accessible via Makefile targets that execute inside the appropriate Docker service.
- Service architecture: 10 services across two tiers. Always-on (
docker compose up):postgres(:5432),redis(:6379),pgadmin(:80),backend(FastAPI :8000),frontend(Next.js :3000),fastembed(ONNX embedding server :8080),chatbot_plugin(RAG chat API :8001). One-off tools (started viadocker compose run, never viadocker compose up):app(scraper runner),test_service(pytest),job_service(migrations, dump/sync, backfill). - Docker Compose profiles: One-off services MUST carry
profiles: ["tools"].docker compose upMUST NOT start tool containers.docker compose run --rm <service> <cmd>works without--profile tools.
Rationale: Docker-first eliminates "works on my machine" issues and ensures parity between developer environments and CI service containers.
V. Explicit CI/CD Deployment Boundary
- GitHub Actions performs both CI and explicit CD via the Railway CLI (
railway up --detach --service <id>) — it is not CI-only. Deploys are gated on tests passing and only fire on two specific triggers, never on a bare merge tomaster:- Staging (
.github/workflows/ci.yml,deploy-staging-*jobs): fires only onpull_requestevents, after the relevant test jobs succeed. Deploys the PR's checked-out commit to each service's Railway staging environment. - Production (
.github/workflows/release.yml): fires only on pushing av*tag, after stamping the version and runningalembic upgrade headagainst production. Deploys every service to Railway production. - A plain push to
master(post-merge) runs migration + tests + therollbacksafety net only — it does not deploy anything.
- Staging (
- Exception —
chatbot-plugin: this service has its own standalone GitHub repo (github.com/s091648/chatbot-plugin), own CI (chatbot-plugin/.github/workflows/ci.yml, gated by that repo's own branch protection onmaster), and own semverv*release tags (chatbot-plugin/.github/workflows/release.yml). Railway's native Git integration for this service is intentionally NOT connected — it is deployed only via this monorepo'srailway upcalls, using whatever commit thechatbot-pluginsubmodule pointer has checked out. The two monorepo triggers apply asymmetrically to it:- Staging deploys the submodule pointer's current commit as-is, tagged or not — staging is a preview environment, not a release.
- Production deploys it only if that exact commit carries a
v*tag inchatbot-plugin's own repo (verified byrelease.ymlbefore therailway upcall); an untagged commit is skipped with a loud warning rather than silently deployed. This mirrors a build-once/tag-then-promote pattern (thechatbot-pluginrepo is the "build once, test, tag" stage; this monorepo just pins and promotes an already-tagged reference to production) without needing a container registry.
- Migration safety: On push to master, CI runs
alembic upgrade headagainst the production DB. If any downstream test stage fails, therollbackjob runsalembic downgrade -1on production automatically. - Data migrations run alongside schema migrations: Immediately after each
alembic upgrade headstep inci.yml'smigratejob (staging) andrelease.yml(production), a second step runsscripts/run_data_migrations.pyto apply any pending standalone data migration fromscripts/data/versions/(thedata_migrations-table- tracked, Alembic-analogous framework for one-off data fixes that aren't tied to a schema change). It is deliberately not run against the three ephemeral-per-job test databases.requires_api=Truemigrations are always skipped by these automatic runs (manual-only, viamake data-migrate --include-api). A migration'sup()runs in its own transaction; on failure it rolls back, is not recorded, halts the rest of that run's chain, and fails the containing CI job — without reversing an already-successful schema migration from the same run. - No direct production access: Migrations and retries against production MUST go through Makefile targets (
make migrate-remote,make retry-failed-remote) that useREMOTE_RAILWAY_DB_URL.
Rationale: Gating deploys on explicit CI triggers (PR review for staging, version tags for production) instead of Railway's own branch-watching auto-deploy prevents unreviewed or untested commits from reaching a live environment. chatbot-plugin previously also had Railway's own Git integration watching its standalone repo directly, deploying on every push independent of (and untested by) either repo's CI — that auto-deploy has been removed in favor of the tag-gated promotion described above, so a production deploy of that service now always traces back to a commit that passed its own CI and was deliberately released.
VI. Observability as a First-Class Concern
- Structured logging: All services MUST emit structured JSON logs to stdout and optionally ship to Loki. The scraper (
src/) uses structlog; FastAPI microservices (backend/,chatbot-plugin/,services/fastembed/) use stdlibloggingwith a_JsonFormatterproducing{"event", "level", "logger", "service", "timestamp"}output compatible with scraper's structlog format.print()and unformattedlogging.basicConfig()are forbidden in all services. - Tracing: OpenTelemetry traces to Grafana Cloud. New HTTP endpoints and scraper pipeline steps MUST include span creation.
- Metrics: OpenTelemetry metrics for scrape volume, LLM usage, pipeline timing.
RequestLoggingMiddlewareMUST remain active on backend. - Error tracking: Sentry integration MUST be active in production. Unhandled exceptions MUST propagate to Sentry; silent swallowing is forbidden.
- Graceful degradation: All observability components MUST fail silently with no-op fallbacks. Missing Sentry/Loki/OTel config MUST NOT crash the application.
Rationale: Production debugging depends on traces, metrics, and logs. No-op fallbacks ensure local development works without observability infrastructure.
VIII. UML Architecture Diagram Conventions
The pipeline and class diagram at site/guide/architecture/uml is auto-generated by scripts/generate_uml.py. The script infers topology purely from code structure — no manual configuration is required. The following conventions MUST be followed for auto-generation to remain correct.
Directory Structure (drives layer and context classification):
- Business modules MUST live under
src/modules/<module_name>/. Adding a new directory here automatically adds a context tab in the UML viewer. - Infrastructure implementations MUST live under
src/infrastructure/<module_name>/. - Composition root MUST be
src/bootstrap.py. This file is scanned to infer the entire pipeline flow. - Module sub-directories MUST follow standard DDD layers:
domain/entities/,domain/repositories/,domain/value_objects/,domain/services/,domain/events/,application/use_cases/,application/event_handlers/,application/dtos/,application/ports/.
Event Naming:
- All domain event classes MUST end in
Event(e.g.,ArticleScrapedEvent). The DI tree excludes classes ending inEventto avoid polluting dependency graphs. - Failure/error events MUST contain
Failedin the class name (e.g.,AnalysisFailedEvent). The pipeline viewer uses this keyword to identify branch/error paths and render them separately from the main chain.
Handler Interface:
- Handler classes MUST be
UpperCamelCase. - Handlers MUST expose a
handle()method — this is the method scanned for*.publish(...)calls to infer which events each handler emits. - Handlers MUST be wired in
bootstrap.pyvia:event_bus.subscribe(SomeEvent, handler.handle). The pipeline builder function is auto-detected as the function inbootstrap.pywith the mostsubscribe()calls — no function name needs to be hardcoded.
Event Publishing:
- Events MUST be published via
*.publish(SomeEvent(...))insidehandle()methods. - The topology inference classifies entry events as "main chain" (handlers that publish further events) vs "terminal" (handlers that only notify/log with no further publishing). This drives the two-phase BFS ordering of the pipeline diagram.
Pipeline and Repository Naming:
- The primary pipeline class MUST contain
Pipelinein its name but NOTStats(e.g.,CollectionPipelineis fine;PipelineStatsis excluded). - Repository implementation classes MUST have filenames ending in
_repo_impl(e.g.,article_repo_impl.py) for correctinfrastructure-persistencelayer classification.
Running make uml-backend regenerates the diagram after any code change. No configuration files need to be edited when adding new handlers, events, or modules — as long as these conventions are followed.
VII. Code Style & Quality Standards
- Python: Follow PEP 8 conventions. Use
uvfor all dependency management (uv sync,uv run,uv.lockcommitted). Pydantic schemas for API input/output validation. Alembic migrations numbered with descriptive prefixes. - TypeScript/React: Strict mode enabled. ESLint with
core-web-vitals+typescriptconfigs. Prettier with 100-char width, double quotes, trailing commas, 2-space indent. Shadcn/UI primitives MUST NOT be modified directly; extend via composition. - ORM conventions: UUID primary keys on all models.
metadata_Python attribute maps tometadataDB column.Usermodel lives inauthPostgreSQL schema.configure_mappers()called intag.pyfor circular dependency resolution. - i18n: All user-facing strings MUST use the I18nProvider with locale files in
frontend/i18n/. Server-side translation viaTranslateArticleUseCaseandTranslateTagsUseCase. - No TODO comments in production code: Either implement the feature or create a tracked issue. No placeholder implementations.
- VitePress-compatible Markdown: All spec and documentation markdown files rendered via VitePress MUST avoid bare angle-bracket syntax outside of fenced code blocks. Generic type expressions (e.g.,
Array<T>,Record<K, V>) and placeholder tokens (e.g.,<ISO_8601>,<UUID>) MUST be wrapped in backticks or escaped as</>. Vue's production compiler (npm run build) is stricter than the dev-server runtime — bare<…>outside code blocks is treated as an unclosed HTML element and fails the build even ifnpm run devrenders correctly.
Rationale: Consistent style reduces review friction. Committed uv.lock and strict TypeScript prevent dependency drift and runtime type errors.
IX. FastAPI Microservice Structure
Each Python microservice (backend/, chatbot-plugin/, services/fastembed/) MUST follow this layout:
config.py— Allos.environ.get()reads in one place. Pure reads only; no side effects, no imports from the rest of the package. Every other module imports from here — noos.environcalls elsewhere in the service.observability.py— Exportsconfigure_logging(service, loki_url, loki_user, loki_api_key, app_env). Installs a JSON stdout handler and optionally a Loki handler. Called once at module top-level inmain.py, before any logger is used.routers/__init__.py— Imports and re-exports router objects to a single name (e.g.api_router,embed_router).routers/<name>.py— Route handlers only. Reads services fromrequest.app.state; imports config fromconfig. Zero business logic.services/__init__.py— Empty.services/<name>.py— Service class with injected dependencies; async/sync business logic methods. No knowledge of HTTP or config.main.py— Thin entry point: callsconfigure_logging(), defineslifespan(builds dependencies → assigns toapp.state→ yields → teardown), createsFastAPI(lifespan=lifespan), callsapp.include_router(...).
Environment variable discipline: All env vars MUST appear in .env.example (the Railway shared-variable source of truth). Hardcoded values in docker-compose.yml environment: blocks are forbidden; always use env_file: .env and declare defaults only in config.py.
Log format (all microservices, compatible with scraper structlog):
{"event": "...", "level": "info", "logger": "...", "service": "...", "timestamp": "..."}Rationale: Consistent structure across services reduces onboarding friction and ensures Loki/Grafana queries work identically whether targeting the scraper, backend, or embedding service.
Technology Stack
| Layer | Technology | Version |
|---|---|---|
| Language (Backend) | Python | >=3.11 |
| Language (Frontend) | TypeScript + React | 19.x / strict |
| Package Manager (Python) | uv | 0.10.x |
| Package Manager (Frontend) | npm | lockfile committed |
| Web Framework | FastAPI | >=0.111 |
| Frontend Framework | Next.js | 16.x (App Router) |
| ORM | SQLAlchemy | >=2.0 |
| Database | PostgreSQL + pgvector | 15 |
| Migrations | Alembic | >=1.13 |
| Auth (Frontend) | NextAuth v4 | JWT strategy |
| Auth (Backend) | python-jose | HS256 JWT |
| UI Components | Shadcn/UI + Radix UI + Tailwind CSS v4 | — |
| LLM Providers | Gemini, Claude, OpenRouter | via providers.toml |
| Observability | OpenTelemetry, Sentry, Loki, structlog | — |
| Testing (Python) | pytest + pytest-cov + pytest-asyncio | — |
| Testing (Frontend) | Vitest + Playwright + Storybook | — |
| CI | GitHub Actions | — |
| CD | Railway | — |
Development Workflow
Branch & PR Conventions
- All work MUST happen on feature branches (
feat/,fix/,chore/). Never commit directly tomaster. - PRs targeting
mastertrigger the full CI pipeline plus CodeRabbit AI review. - Merge commits on
mastertrigger Railway auto-deploy.
Database Changes
- All schema changes MUST be delivered as Alembic migrations.
- Migrations MUST be tested locally via
make migratebefore push. - Auto-migration on CI runs against production; rollback job guards against downstream failures.
LLM Provider Configuration
- Provider priority and rate limits are configured in
providers.tomlat project root. New providers MUST be added there, not hardcoded. ResilientLLMServicewalks providers in priority order withSlidingWindowStrategyrate limiting. Falls back onRateLimitExhaustedor any exception.
Frontend API Access
- Frontend MUST NOT call the backend directly. All API requests go through the Next.js catch-all reverse proxy at
app/api/proxy/[...path]/route.ts. - Client code MUST use
apiFetch()fromlib/api-fetch.tswhich prefixes with/api/proxyand appendslangfrom localStorage.
Scraper Pipeline
- Pipeline follows: Discover → Pre-dedup → Fetch → Publish → Process → Analyze → Translate → Notify.
- Scheduled runner adds 0-180s random startup jitter (disable with
RUN_IMMEDIATELY=1), has a 50-min hard timeout, and handles SIGTERM/SIGINT for graceful shutdown. - Failed tasks are retried via
make retry-failedwith configurableHOURSandLIMIT.
Governance
- This constitution supersedes all other development practices and conventions for the scrape-and-analyze project.
- Amendments MUST be documented with a version bump, rationale, and migration plan if the change affects existing code.
- All PRs and code reviews MUST verify compliance with these principles. Complexity that violates principles MUST be justified in the Complexity Tracking section of the implementation plan.
- Use
CLAUDE.mdat project root for AI assistant runtime guidance; this constitution provides the authoritative principles that CLAUDE.md references.
Version: 1.8.0 | Ratified: 2026-05-28 | Last Amended: 2026-07-26