Version: 1.4.0
Status: Draft (reverse-engineered from the live codebase plus the historical CLAUDE.md at the repo root; a private MEMORY.md that lived outside the repo was also consulted during the initial pass but is not part of the OSS tree).
Last synced with code: 2026-05-04
Scope: Backend (openarg_backend). Frontend has its own constitution.md.
This document codifies the non-negotiable principles that govern OpenArg development. Every plan.md of any feature must pass the "Constitution Check" declared here before moving forward. Known deviations are listed in section 9 of each plan.md with explicit justification and a debt ticket.
The constitution is immutable by convention: modifying it requires a semver version bump, written justification, and a review of the impact on existing specs (Sync Impact Report).
Simplicity beats cleverness, always. This is the principal axiom — every other article in this constitution must be read in its light.
Concretely, when designing or reviewing a change:
- Prefer the obvious solution. If a junior engineer can read the spec and plan and understand why the code does what it does, the design is good. If it takes a seasoned engineer 20 minutes to follow the dance, the design is wrong even if it is technically correct.
- Add abstractions only when they pay rent. Never introduce an interface, a pattern, or a layer for a hypothetical second caller. Three similar lines beat a premature factory. A second caller earns the abstraction; a first caller does not.
- Small files beat small functions in big files. Prefer splitting by responsibility over creating 30-line helpers inside 600-line modules.
- Data flow over control flow. A pipeline of pure transforms is easier to reason about than a mesh of callbacks, events, and mutable shared state. Prefer the former even if it takes more keystrokes.
- One way to do something. If two code paths converge on the same outcome, delete one. Duplicate code is cheaper than duplicate semantics.
- Delete before you add. When a
spec.mdorplan.mdgrows, ask what can be removed. If a section can go without losing information, remove it. - When in doubt, write the dumbest version that works, ship it, and let usage teach you what to generalize.
A spec that violates this axiom for a good reason must cite the reason inline ("complexity justified because X") and open a debt item to revisit once the assumption is validated. Complexity without justification is a bug in the design.
Every change follows a three-step cadence, in strict order. This is the second principal axiom alongside §0 Keep It Simple.
-
Modify the spec first. Before writing any production code, update the relevant
spec.md/plan.mdunderspecs/to describe the new behavior in WHAT/WHY terms. Add or edit FRs, update the output contract, append or strike tech-debt entries. If no spec section needs to change, the code probably doesn't need to change either — or the spec is incomplete, in which case complete it first. -
Then write the code. Make it match the spec as-written, not the other way around. If you discover during coding that the spec is wrong, STOP, fix the spec, then return to the code. Do not diverge and reconcile later.
-
Then verify. Run
make code.test, runruffandmypy, grep the diff against the spec's FRs to check for contradictions. Bump the spec'sLast synced with codefield. If verification reveals a mismatch, repair whichever side is wrong — usually it is the spec that under-specified a detail you had to invent while coding.
- Writing the spec first forces you to think in the domain language before you are tempted by implementation details. Implementation-first thinking produces specs that are thinly-disguised code commentary.
- The spec is the contract reviewers check the code against. If the spec arrives after the code, reviewers have nothing to check, and specs quietly rot into fiction.
- The first caller of a new abstraction is the spec itself: if you cannot defend the abstraction in FR form, you probably shouldn't add the code.
- Specs that move after code always lag. Specs that move before code stay current by construction.
- Pure typo / rename / autoformat — no spec change needed.
- Test-only changes that cover existing behavior — the spec already describes what's being tested.
- Reverting a recent regression to restore a previously-specified state — both sides can revert in the same commit.
- You are adding a new
FR-NNNorSC-NNN. - You are changing an existing FR's acceptance criteria.
- You are closing a
[DEBT-NNN]or[CL-NNN]entry — the strikethrough +FIXED YYYY-MM-DDmarker lives in the spec and must land in the same commit as the code fix. - You are touching a file that already has a sibling
spec.md/plan.md.
Any PR that modifies src/ without touching the corresponding specs/ will be asked by reviewers to fix the drift before merging. The contributor contract in README.md#spec-driven-design-is-the-contract is the enforcement hook. This axiom exists because even the original author of these specs caught himself skipping the cadence twice in a single session: the cadence is cheap, the drift is expensive, and the only way not to skip it is to make it non-negotiable.
Any non-trivial change is proposed as a plan, approved by the operator, and only then implemented. This complements §0.5 — §0.5 says "spec changes before code", §0.6 says "plan changes before spec changes for anything beyond a typo".
- Any change that touches more than one file.
- Any change that introduces a new module, migration, Celery task, env var, feature flag, or API surface.
- Any change that modifies an existing FR or SC in a
spec.md. - Any destructive or hard-to-reverse operation (DROP, TRUNCATE, force-push, schema rewrite, deletion of
cache_*tables). - Any "while I'm here, let me also fix this" detour — those especially require an approved plan, because they are exactly the path by which scope creeps.
- Surface the diagnosis first. State the problem, the suspected root cause, and 1–3 candidate approaches. Do not jump to a fix.
- Propose a concrete plan. Files to touch, new files to create, migrations to add, tests to write, rollout steps. Show the shape, not the contents.
- Wait for approval. A green light from the operator (
ok,dale,yes,approved,ejecutá, etc.) is the signal to start coding. Silence is not approval. - Implement strictly within the approved plan. If during coding you discover something the plan didn't anticipate, STOP, surface it, and get a new approval. Do not "patch and explain after".
- Report back. When done, summarise what changed against the approved plan, including any deviations.
- Trivial edits — typos,
ruff --fix, single-line bug fixes that match a clearly-broken FR. - Read-only diagnostics — running tests,
git status,grep,mypy, generating reports. - Adding tests for an already-specified behavior — the spec is the plan.
- Reverting your own unapproved code — restoring the pre-detour state needs no further approval.
- The user asked you to "ejecutar todo / hacelo / dale" but the task list itself spans multiple workstreams that haven't each been approved individually. The blanket approval covers the direction, not each constituent decision.
- A code-review surfaces issues that need fixing — surface them first, propose fixes, wait for approval, then fix.
- You're tempted to "just clean up while you're at it". That is exactly when the rule matters.
The rule was added in v1.3.0 (2026-04-25) after the operator pointed out that the agent had jumped straight from a code-review to applying fixes without proposing them first. The fixes were correct, but the cadence was wrong: the operator did not get the chance to redirect, deprioritise, or split the work. Doing the right thing the wrong way still costs the operator's review time.
The cost of pausing to propose is low. The cost of an unwanted change — even a correct one — is the operator's mental budget, which is the project's scarcest resource.
Added 2026-05-04 (v1.4.0). Formalises FR-009 of spec 014.
Rule: Every code path that writes a terminal cached_datasets.status (permanently_failed, error, schema_mismatch) MUST classify error_message via _classify_error_category and write error_category in the same SQL statement. No path may set status to a terminal value without also writing error_category.
In April 2026 production showed 26 datasets in permanently_failed with error_category='unknown' despite the classifier having matching rules for every one of their error_message strings. The cause: _set_error_status (deterministic-error fast path) wrote status directly without invoking the classifier, so error_category stayed at the column's previous value or default unknown. The classifier was correct; the architecture allowed an end-run around it.
- Single helper. There is one canonical helper that writes terminal states. New code that needs to mark a row terminal MUST call that helper, not
UPDATE cached_datasets SET status='permanently_failed'directly. - Linter rule (deferred). A future ruff/AST check should fail PRs that contain
SET status = 'followed by a terminal value withouterror_category =in the same string. - No backdoor in migrations. Alembic data-migrations that flip statuses MUST also set
error_category(to a meaningful bucket or the explicit'unknown'literal — never leave it at default).
You may not. The rule exists because every backdoor produced a real production bug.
The code is organized strictly into the following layers:
src/app/
├── domain/ # Ubiquitous language, entities, ports (ABC), exceptions
├── application/ # Use cases, orchestration, pipeline steps
├── infrastructure/ # Adapters, persistence, celery, resilience, monitoring
├── presentation/ # HTTP controllers, routers, middleware
└── setup/ # IoC (Dishka), config, app factory
Rules:
domain/does not import anything frominfrastructure/. Only from itself.application/imports fromdomain/(entities, ports) but NOT frominfrastructure/directly. It receives implementations via DI.infrastructure/implementsdomain/ports/. Adapter classes type ports as dependencies.presentation/imports fromapplication/and fromdomain/for DTOs. Routers don't know SQL or external HTTP.setup/ioc/provider_registry.pyis the ONLY place where implementations are wired to ports.
Known violations: see individual specs. The most notable case is the BCRA connector which has no port ([002a-bcra/DEBT-001]).
The tech stack is part of the constitution. Changes require a major bump.
| Layer | Technology | Version |
|---|---|---|
| Runtime | Python | 3.12 |
| Web framework | FastAPI | 0.115 |
| ASGI server | Uvicorn + UVLoop | — |
| ORM | SQLAlchemy async | 2.0 |
| Migrations | Alembic | — |
| Database | PostgreSQL + pgvector | 16 + HNSW |
| DI container | Dishka | 1.6 |
| Task queue | Celery + Redis | 5.4 + 7 |
| Primary AI | AWS Bedrock Claude Haiku | 4.5 |
| Fallback AI | Google Gemini Flash | 2.5 |
| Embeddings | AWS Bedrock Cohere Embed Multilingual | v3 (1024-dim) |
| Pipeline | LangGraph | — |
| HTTP client | HTTPX async | — |
| Auth | PyJWT + bcrypt | — |
| Rate limiting | SlowAPI | — |
| Config | TOML + Pydantic settings | — |
| Logging | structlog | — |
| Lint | Ruff (100 chars) | — |
| Type check | mypy strict | — |
| Test | pytest | — |
- All singletons use
Scope.APP(settings, engines, AWS clients). - All per-request objects use
Scope.REQUEST(DB sessions, user context). Scope.SESSIONis not used — there are no persistent sessions beyond the request.- Celery workers use a dedicated provider — workers are not async-native and request-scope does not apply.
- Instantiating adapters with inline
importinside business code is forbidden. Everything goes through the container.
Known violation: bcra_tasks.py instantiates BCRAAdapter directly ([002a-bcra/DEBT-004]).
- All I/O uses
async/awaitin the HTTP and application layers. - SQLAlchemy sessions are async on the request-path.
- Celery workers are sync because Celery 5 does not support native async. Inside the worker,
asyncio.run()is allowed as a bridge, but it is considered structural debt and a ticket is opened every time it is used. - HTTP clients are
httpx.AsyncClient.requestsis not used.
- Pydantic v2 for: HTTP API schemas, config validation, cross-process contracts.
- Dataclasses for: domain entities.
- SQLAlchemy 2.0 mapped classes in
infrastructure/persistence_sqla/mappings/— explicit table ↔ entity mapping, never the other way around. - Using Pydantic in
domain/is forbidden. It contaminates the domain with HTTP adapter knowledge. - TypedDict or Protocol for lightweight structural contracts that don't need a full class.
All accesses to external services MUST implement:
- Retry with the
@with_retrydecorator (infrastructure/resilience/retry.py)- Exponential backoff + jitter
- Configurable max_retries (default: 2)
- Retryable HTTP statuses:
429, 500, 502, 503, 504
- Circuit breaker per connector (
infrastructure/resilience/circuit_breaker.py)- States:
CLOSED→OPEN→HALF_OPEN failure_threshold=5,recovery_timeout=60s
- States:
- Explicit timeouts in all HTTP clients. Never
timeout=None. - Graceful degradation: when an external source fails, the system responds with partial data or cache, never fails entirely.
- Errors are not silenced without structured logging and an incremented metric.
Recurring known violation: silent degradation without metrics in several connectors. See debt items in each spec.
- Logging:
structlogin infrastructure and presentation. Standard logger accepted in domain/application for now. - Metrics:
MetricsCollectorsingleton (infrastructure/monitoring/metrics.py) tracks requests, connectors, cache hits, tokens used. Exposed viaGET /api/v1/metrics. - Health checks:
HealthCheckService(infrastructure/monitoring/health.py) with per-component checks. Endpoints:GET /health,GET /health/ready. - Audit trail:
infrastructure/audit/records sensitive actions (API key creation, admin actions). - Sentry: configured conditionally via
setup_sentry()insetup/logging_config.py. It is a runtime no-op whenSENTRY_DSNis unset.
- All schema modifications go through Alembic. Migrations in
infrastructure/persistence_sqla/alembic/versions/namedYYYY_MM_DD_NNNN_description.py. - No tables are created on-the-fly in production except for dynamic
cache_*tables generated by connector snapshots (see violation[002a-bcra/DEBT-006]). - The core schema (
datasets,dataset_chunks,cached_datasets,user_queries,query_cache,api_keys,api_usage,table_catalog,successful_queries,agent_tasks,query_dataset_links) is considered stable. Changes require justification inplan.md. - pgvector with HNSW indexing, 1024-dimensional embeddings (Cohere Embed Multilingual v3).
- Pool config:
pool_pre_ping=True,pool_recycleconfigured (seepersistence_sqla/provider.py). - Explicit transactions with
async with session.begin()in request-scope.
pytestis the only test runner.- Hundreds of maintained tests. A local default-suite run on 2026-04-12 produced 885 passed, 1 skipped, 140 deselected.
- Levels:
- Unit: domain + application, without real DB.
- Integration: with real PostgreSQL + Redis (Docker services in CI).
- The DB is not mocked in integration tests. (Principle analogous to Spec Kit's advice: "Integration-First".)
- Coverage target: no hard percentage established, but CI fails if it drops significantly.
- Type checking: CI runs
mypy src/. The tracked config enablescheck_untyped_defs = true, but the repo is not currently on full--strict. - Lint:
ruffwith 100-character max line length. - CI workflow:
.github/workflows/test.ymlruns unit + integration + mypy on every PR.
- Domain comments/docstrings in Spanish (because the domain is Argentina-specific).
- Infrastructure code in English (international standard).
- End-user messages in Spanish (friendly pipeline status messages, HTTP errors).
- Logs in English for compatibility with observability tools.
- Commits: do not add
Co-Authored-Byat the end of the message (house convention). - PRs: go to
staging, notmain/master.
- Primary: AWS Bedrock Claude Haiku 4.5. All LLM calls default to this model.
- Fallback: Google Gemini 2.5 Flash. Activated when Bedrock fails or is rate-limited.
- Anthropic adapter exists in the codebase but is not part of the active Dishka-wired fallback chain today.
- Embeddings: AWS Bedrock Cohere Embed Multilingual v3 (1024-dim). OpenAI is not used.
- Prompts live in
application/pipeline/prompts/or similar — never hardcoded in business logic. - Tokens counted and logged per request (see
MetricsCollector). - Pipeline with LangGraph: stateful graph with checkpointing. Do not use ad-hoc chains outside the graph.
- Authentication: JWT (PyJWT) + bcrypt for passwords. Google OAuth via NextAuth in the frontend.
- API Keys: hashed with SHA-256 before persisting. Shown to the user only once when created.
- Rate limiting: SlowAPI with per-plan policies (free, paid, admin).
- SQL sandbox: read-only, statement timeout, table allowlist via
table_catalog. - Secrets:
.secrets.tomlor environment variables. Never commit. - Email allowlist for alpha access (currently 25 emails).
- Auditing of sensitive actions (see principle VII.4).
- Docker Compose is the production runtime (13 services on EC2).
- Images on GHCR (GitHub Container Registry), builds via GitHub Actions (
.github/workflows/build.yml). - Caddy 2 as reverse proxy / TLS terminator.
- PGBouncer between the app and PostgreSQL RDS.
- Celery workers segmented by queue (scraper, embedding, collector, analyst, transparency, ingest, s3) with configurable concurrencies.
- Celery beat a single process.
- Per-environment configuration:
config/local/,config/prod/with TOML.
Every plan.md under specs/ MUST have a "Deviations from Constitution" section that:
- Explicitly lists which principles are violated.
- For each violation: justification + debt ticket +
[DEBT-NNN]. - Indicates whether the violation is temporary (fix plan) or accepted (conscious trade-off).
The Constitution Check is the gate prior to forward SDD: before implementing a new feature, the plan is validated to ensure it does not introduce new undocumented violations.
End of constitution.md