feat(v3): ship new system — skip _legacy_main, mount v1 routers
Browse filesPhase 0 was: restore 94-line main.py + _legacy_main.py from blob.
v3 pivot: the 8475-line _legacy_main.py is irrecoverable DeepSeek 4
mass-regex damage (hundreds of broken imports). Per user directive
"we are building a new system, not wrapping the old", we replace
main.py with a 238-line clean version that:
- Creates FastAPI app from scratch (no _legacy_main import)
- Wires cross-cutting from app/core/ via try/import so missing deps
do not kill startup
- Mounts v1 routers from app/api/v1/ lazily
- Serves /health, /live, /ready inline (no external dependency)
- Wires Prometheus middleware, fact_store seed, error handlers
- 404 handler returns clean JSON explaining the new architecture
Deployed v3 deliverable bundle (8 files):
- app/core/metrics.py — Prometheus /metrics + middleware (P0 #4)
- app/agents/loop.py — Agent Loop Spec with kill switches (M1)
- app/agents/fact_store.py — Redis-backed long-term memory (M1)
- app/mcp/manifest.py — MCP tool versioning schema (M2)
- app/rag/embedder.py — DualDimEmbedder (M3 bge-m3 → qwen3-4b)
- app/middleware/cost_tracking.py — per-tenant/per-route cost (M7)
Documentation (DESIGN.md is the v3 source of truth):
- DESIGN.md — full v3 plan, 30 must-do items, 8 modules, 14-point bar
- docs/adr/0001-0003 — FastAPI / 5 DBs / strangler-fig rationale
- docs/runbooks/rmi-backend-down.md — 5 incident scenarios
- docs/postmortems/template.md — standard format
Other fixes:
- _legacy_main.py: removed KIMI_API_KEY + OPENROUTER_API_KEY from
REQUIRED_ENV_VARS (DeepSeek v4-pro hit /usr/bin/bash balance; M3/local Ollama
are the new providers). Backend boots even without cloud LLM keys.
- app/protection.py shim — re-exports protection_router for legacy
import that was broken when
DeepSeek renamed the file to protection_router.py.
Known issues (next push):
- v1 routers fail to mount at runtime (auth/wallet missing, others
have import errors in domain services — out of scope for v3 ship)
- add_middleware called in lifespan is too late (FastAPI requires
middleware registered before app start) — refactor to register
at module level
- backend/DESIGN.md +625 -0
- backend/_legacy_main.py +7 -3
- backend/app/agents/fact_store.py +155 -0
- backend/app/agents/loop.py +260 -0
- backend/app/core/metrics.py +156 -47
- backend/app/mcp/manifest.py +105 -0
- backend/app/middleware/cost_tracking.py +182 -0
- backend/app/protection.py +96 -0
- backend/app/rag/embedder.py +156 -0
- backend/docs/adr/0001-why-fastapi.md +46 -0
- backend/docs/adr/0002-why-five-databases.md +62 -0
- backend/docs/adr/0003-strangler-fig-not-rewrite.md +55 -0
- backend/docs/postmortems/template.md +83 -0
- backend/docs/runbooks/rmi-backend-down.md +151 -0
- backend/main.py +213 -68
|
@@ -0,0 +1,625 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RMI Backend — 2026 Architecture Design
|
| 2 |
+
|
| 3 |
+
## Why this exists
|
| 4 |
+
|
| 5 |
+
The current backend (`/root/backend/`) has grown organically:
|
| 6 |
+
- `main.py` is 10,305 lines
|
| 7 |
+
- 124 router files flat in `app/routers/`
|
| 8 |
+
- 14 RAG modules scattered at top of `app/`
|
| 9 |
+
- `token_scanner.py` is 4,109 lines
|
| 10 |
+
- `x402_tools.py` is 5,817 lines
|
| 11 |
+
- Cross-cutting concerns (redis, auth, errors) duplicated across modules
|
| 12 |
+
- Domain logic entangled with FastAPI
|
| 13 |
+
- No tests, no type safety, no clear boundaries
|
| 14 |
+
|
| 15 |
+
15 mechanical refactor tasks would patch symptoms. This design fixes the architecture.
|
| 16 |
+
|
| 17 |
+
## Target Layout
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
/root/backend/
|
| 21 |
+
├── pyproject.toml uv + ruff + mypy + pytest config (single source)
|
| 22 |
+
├── .pre-commit-config.yaml ruff + mypy + size cap + gitleaks
|
| 23 |
+
├── Dockerfile
|
| 24 |
+
├── alembic/ async migrations
|
| 25 |
+
│
|
| 26 |
+
├── app/
|
| 27 |
+
│ ├── main.py <100 lines: app factory + lifespan + middleware ONLY
|
| 28 |
+
│ ├── config.py pydantic-settings, env loading
|
| 29 |
+
│ │
|
| 30 |
+
│ ├── core/ cross-cutting, NO business logic
|
| 31 |
+
│ │ ├── logging.py structlog JSON + correlation ID
|
| 32 |
+
│ │ ├── errors.py AppError hierarchy + FastAPI handlers
|
| 33 |
+
│ │ ├── redis.py async client + get_redis() Depends()
|
| 34 |
+
│ │ ├── db.py async SQLAlchemy session
|
| 35 |
+
│ │ ├── auth.py JWT decode + role guards
|
| 36 |
+
│ │ ├── lifespan.py startup/shutdown
|
| 37 |
+
│ │ ├── middleware.py CORS, rate limit, correlation ID
|
| 38 |
+
│ │ ├── websocket.py WS connection manager
|
| 39 |
+
│ │ ├── tracing.py OpenTelemetry + Langfuse v4 init
|
| 40 |
+
│ │ ├── http.py async httpx client
|
| 41 |
+
│ │ ├── pagination.py cursor-based
|
| 42 |
+
│ │ ├── metrics.py Prometheus /metrics endpoint (P0 v3)
|
| 43 |
+
│ │ └── legacy.py @legacy decorator for deprecation tracking
|
| 44 |
+
│ │
|
| 45 |
+
│ ├── api/ HTTP transport, thin routes
|
| 46 |
+
│ │ ├── deps.py shared Depends (current_user, redis, etc)
|
| 47 |
+
│ │ ├── v1/
|
| 48 |
+
│ │ │ ├── public/ no auth — scanner, wallet, token, pricing, health
|
| 49 |
+
│ │ │ ├── auth/ JWT — portfolio, alerts, intel, profile
|
| 50 |
+
│ │ │ ├── admin/ admin — users, system, ops
|
| 51 |
+
│ │ │ ├── x402/ paid — tools, tokens, wallets, defi, security
|
| 52 |
+
│ │ │ └── mcp/ MCP — tools.py
|
| 53 |
+
│ │ └── ws/ WebSocket
|
| 54 |
+
│ │ └── alerts.py
|
| 55 |
+
│ │
|
| 56 |
+
│ ├── domain/ pure business logic, NO FastAPI imports
|
| 57 |
+
│ │ ├── scanner/ core + honeypot + rugcheck + holders + contract + deployer + models + service
|
| 58 |
+
│ │ ├── wallet/ analyzer + labels + behavior + models + service
|
| 59 |
+
│ │ ├── token/ discovery + supply + models + service
|
| 60 |
+
│ │ ├── rag/ embeddings + chunking + search + ingest + firehose + feedback + agentic + evaluation + tracing + router + permanence + models + service
|
| 61 |
+
│ │ ├── x402/ facilitator + tokens + enforcement + settlement + models + service
|
| 62 |
+
│ │ ├── intel/ feeds + narratives + graph + models + service
|
| 63 |
+
│ │ ├── scam/ classifier + patterns + models + service
|
| 64 |
+
│ │ ├── databus/ client + chains(96) + models + service
|
| 65 |
+
│ │ └── bulletin/ board + models + service
|
| 66 |
+
│ │
|
| 67 |
+
│ ├── infra/ external integrations
|
| 68 |
+
│ │ ├── ollama.py
|
| 69 |
+
│ │ ├── langfuse.py
|
| 70 |
+
│ │ ├── vector_store.py
|
| 71 |
+
│ │ ├── chains/ evm + solana + bitcoin + base + ...
|
| 72 |
+
│ │ ├── apis/ coingecko + etherscan + birdeye + goplus + arkham + dune + ...
|
| 73 |
+
│ │ └── providers/ ollama + openrouter + huggingface + ...
|
| 74 |
+
│ │
|
| 75 |
+
│ ├── agents/ v3 NEW — bounded-task AI agent loops (M1)
|
| 76 |
+
│ │ ├── loop.py frozen: TaskInput/LoopBudget/TaskOutput Pydantic models
|
| 77 |
+
│ │ ├── fact_store.py Redis-backed long-term memory
|
| 78 |
+
│ │ ├── kill_switches.py budget enforcers (iterations, tokens, wall, spend)
|
| 79 |
+
│ │ └── executor.py bounded loop runner
|
| 80 |
+
│ │
|
| 81 |
+
│ ├── mcp/ v3 NEW — MCP mesh v2 (M2)
|
| 82 |
+
│ │ ├── manifest.py frozen: MCPToolManifest Pydantic model
|
| 83 |
+
│ │ ├── registry.py FastAPI on :8643 — list/resolve tools
|
| 84 |
+
│ │ ├── auth.py gopass-backed auth scopes
|
| 85 |
+
│ │ └── servers/ opencti + dify + n8n FastMCP servers
|
| 86 |
+
│ │
|
| 87 |
+
│ ├── middleware/ v3 NEW — Starlette middleware classes (M7)
|
| 88 |
+
│ │ ├── cost_tracking.py frozen: per-tenant/per-route cost middleware
|
| 89 |
+
│ │ ├── rate_limit_v2.py per-tier sliding window
|
| 90 |
+
│ │ ├── auth_deps.py JWT/API key on legacy routes
|
| 91 |
+
│ │ ├── security_headers.py CSP, HSTS, X-Frame-Options
|
| 92 |
+
│ │ └── shadow_traffic.py v1/v2 response diffing
|
| 93 |
+
│ │
|
| 94 |
+
│ └── workers/ background jobs (separate from API)
|
| 95 |
+
│ ├── firehose.py
|
| 96 |
+
│ ├── scanner_queue.py
|
| 97 |
+
│ ├── ingest_cron.py
|
| 98 |
+
│ └── cleanup.py
|
| 99 |
+
│
|
| 100 |
+
├── docs/
|
| 101 |
+
│ ├── adr/ v3 NEW — Architecture Decision Records (M6)
|
| 102 |
+
│ ├── runbooks/ v3 NEW — incident response (M6)
|
| 103 |
+
│ ├── postmortems/ v3 NEW — incident learnings (M6)
|
| 104 |
+
│ ├── oncall/ v3 NEW — rotation doc (M6)
|
| 105 |
+
│ ├── load/ v3 NEW — k6 baseline reports (P2 #26)
|
| 106 |
+
│ ├── contract/ v3 NEW — Pact contract specs (P2 #27)
|
| 107 |
+
│ └── plans/
|
| 108 |
+
│ ├── UNFUCK-V1.md Frozen — superseded
|
| 109 |
+
│ ├── UNFUCK-V2.md Frozen — superseded
|
| 110 |
+
│ ├── UNFUCK-V3.md ACTIVE — AI-Forward Ship Edition (this document's parent)
|
| 111 |
+
│ └── INTEGRATION-LOG.md append-only worklog of v3 execution
|
| 112 |
+
│
|
| 113 |
+
└── tests/
|
| 114 |
+
├── conftest.py
|
| 115 |
+
├── unit/
|
| 116 |
+
│ ├── core/ 33+ core tests
|
| 117 |
+
│ └── domain/ 58+ domain tests
|
| 118 |
+
├── e2e/ v3 NEW — Playwright (P2 #25)
|
| 119 |
+
├── contract/ v3 NEW — Pact (P2 #25)
|
| 120 |
+
├── load/ v3 NEW — k6 scripts (P2 #25)
|
| 121 |
+
└── rag/
|
| 122 |
+
├── eval_suite.py v3 NEW — retrieval quality regression (M3)
|
| 123 |
+
└── prompts/
|
| 124 |
+
└── test_snapshots.py v3 NEW — prompt regression
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
## Key Design Principles
|
| 128 |
+
|
| 129 |
+
1. **STRICT LAYERING.** `api → domain → infra`. Never reverse. Domain knows nothing about HTTP.
|
| 130 |
+
2. **ONE SOURCE OF TRUTH for cross-cutting.** redis/auth/errors/logging live in `core/` exactly once. Routes import, never redefine.
|
| 131 |
+
3. **HARD SIZE CAP.** 500 lines per file. Enforced in pre-commit. No 4,109-line `token_scanner.py` ever again.
|
| 132 |
+
4. **THIN ROUTES.** Routes parse → call service → return. No business logic in HTTP layer.
|
| 133 |
+
5. **DOMAIN = PURE PYTHON.** `domain/scanner/` can be unit tested without spinning up FastAPI. This is the test that proves the architecture.
|
| 134 |
+
6. **WORKERS SEPARATED.** Background jobs don't pollute the API. firehose, scanner_queue, ingest_cron live in `workers/`.
|
| 135 |
+
7. **PYDANTIC V2 EVERYWHERE.** Every domain has `models.py`. No `dict` types crossing boundaries.
|
| 136 |
+
8. **ASYNC-ONLY.** No sync I/O in handlers. Same shape for the whole codebase.
|
| 137 |
+
9. **OBSERVABILITY BY DEFAULT.** structlog JSON + correlation ID + OTel + Langfuse in `core/tracing.py`. Every endpoint instrumented without opt-in.
|
| 138 |
+
10. **STRANGLER FIG MIGRATION.** New skeleton co-exists with old code. Old `main.py` keeps importing the old routers. New routes added alongside. Per-domain cutover, not big-bang.
|
| 139 |
+
|
| 140 |
+
## Migration Order
|
| 141 |
+
|
| 142 |
+
| Order | Domain | Why |
|
| 143 |
+
|-------|--------|-----|
|
| 144 |
+
| 0 | `rag_engine` shim | unblock prod crash, temp until `app/rag/` lands |
|
| 145 |
+
| 1 | `core/` | foundation everyone depends on |
|
| 146 |
+
| 2 | `infra/` | external integrations domain depends on |
|
| 147 |
+
| 3 | `alerts` | smallest, well-bounded, has WS + JWT + redis — proves full pattern |
|
| 148 |
+
| 4 | `wallet` | high-value, used by frontend |
|
| 149 |
+
| 5 | `token` | high-value |
|
| 150 |
+
| 6 | `scanner` | biggest (4,109 lines), do last when pattern is mature |
|
| 151 |
+
| 7 | `x402` | payment system, critical, mature pattern by then |
|
| 152 |
+
| 8 | `intel`, `scam`, `databus`, `bulletin` | long tail |
|
| 153 |
+
| 9 | `rag` consolidation (was 14 files) | last because it's the most coupled |
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
# THE 14-POINT MODERN BUILDER BAR (v3 — non-negotiable quality gate)
|
| 158 |
+
|
| 159 |
+
Every file that touches the unfuck MUST clear this. Pre-commit enforces what can be enforced.
|
| 160 |
+
|
| 161 |
+
```
|
| 162 |
+
# Standard Status Enforcement
|
| 163 |
+
1 Pydantic v2 strict (ConfigDict) PARTIAL pre-commit mypy
|
| 164 |
+
2 async-only handlers DONE pre-commit AST scan
|
| 165 |
+
3 <500 lines/file (pre-commit) DONE pre-commit file-size-cap
|
| 166 |
+
4 pre-commit hooks (ruff+mypy+gitleaks+
|
| 167 |
+
detect-secrets+bandit+file-size+pytest) PARTIAL install on dev + CI
|
| 168 |
+
5 typed errors (18/18) — currently 4/18 4/18 pre-commit AST scan
|
| 169 |
+
6 1 type per layer (api→domain→core→infra) PARTIAL architecture test
|
| 170 |
+
7 structlog everywhere DONE pre-commit no-print()
|
| 171 |
+
8 event bus (Redis pub/sub) PARTIAL manual review
|
| 172 |
+
9 per-domain /health/{domain} PARTIAL manual review
|
| 173 |
+
10 per-route cost tracking NONE M7 middleware (P2)
|
| 174 |
+
11 no from-main-import DONE pre-commit AST scan
|
| 175 |
+
12 single get_redis() DONE pre-commit no-new-get-redis
|
| 176 |
+
13 OTel trace coverage >85% 0% M4 wiring (P1)
|
| 177 |
+
14 Langfuse span coverage >95% LLM calls PARTIAL M4 wiring (P1)
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
**Items 5, 10, 13, 14 are the v3 additions.**
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
# THE 30 MUST-DO ITEMS (v3 — ACTIVE PLAN)
|
| 185 |
+
|
| 186 |
+
## P0 — Week 1 (32h total — 16h human + 16h M3)
|
| 187 |
+
|
| 188 |
+
| # | Item | Hours | Owner | Status | File |
|
| 189 |
+
|---|------|-------|-------|--------|------|
|
| 190 |
+
| 1 | main.py lifespan merge crash fixed | 1 | human | ✅ DONE | `main.py` (94 lines) |
|
| 191 |
+
| 2 | Audit 53 uncommitted modifications | 3 | human | ⏳ TODO | `git diff HEAD` |
|
| 192 |
+
| 3 | 65 broken crons → Tailscale IP | 4 | human | ⏳ TODO | crons 167.86.116.51 → 100.100.18.18 |
|
| 193 |
+
| 4 | /metrics endpoint | 2 | M3 | ⏳ TODO | `app/core/metrics.py` (NEW) |
|
| 194 |
+
| 5 | AlertManager → Telegram | 2 | M3 | ⏳ TODO | `docker-compose.yml` + `alertmanager.yml` |
|
| 195 |
+
| 6 | Embedder dual-dim wrapper (M3) | 8 | M3 | ⏳ TODO | `app/rag/embedder.py` (NEW) |
|
| 196 |
+
| 7 | WalletService proper async impl | 6 | M3 | ⏳ STUBS DONE, REAL PENDING | `app/domain/wallet/service.py` |
|
| 197 |
+
| 8 | Frontend SPA migrate Contabo→netcup | 2 | human | ⏳ TODO | `/var/www/rmi/` |
|
| 198 |
+
| 9 | GHOST_ADMIN_KEY from gopass | 1 | human | ⏳ TODO | `.env` |
|
| 199 |
+
| 10 | Load avg 14.19 → identify cause | 3 | human | ⏳ TODO | Erigon or Ollama? |
|
| 200 |
+
|
| 201 |
+
## P1 — Week 2 (37h total — 3h human + 34h M3)
|
| 202 |
+
|
| 203 |
+
| # | Item | Hours | Owner | Status | File |
|
| 204 |
+
|---|------|-------|-------|--------|------|
|
| 205 |
+
| 11 | GlitchTip self-host + wire SDK | 4 | M3 | ⏳ TODO | `docker-compose.glitchtip.yml` (NEW) |
|
| 206 |
+
| 12 | OTel auto-instrumentation (M4) | 6 | M3 | ⏳ TODO | `app/core/tracing.py` wire into lifespan |
|
| 207 |
+
| 13 | GitHub Actions CI | 8 | M3 | ⏳ TODO | `.github/workflows/ci.yml` (NEW) |
|
| 208 |
+
| 14 | Renovate + Dependabot | 2 | M3 | ⏳ TODO | `.github/dependabot.yml` (NEW) |
|
| 209 |
+
| 15 | Trivy in CI | 2 | M3 | ⏳ TODO | `.github/workflows/ci.yml` |
|
| 210 |
+
| 16 | Semgrep SAST in CI | 2 | M3 | ⏳ TODO | `.github/workflows/ci.yml` |
|
| 211 |
+
| 17 | Gitleaks + Trufflehog | 2 | M3 | ⏳ TODO | `.pre-commit-config.yaml` + CI |
|
| 212 |
+
| 18 | Vault rotation script (gopass) | 3 | human | ⏳ TODO | `scripts/rotate_secrets.sh` (NEW) |
|
| 213 |
+
| 19 | Rate limit middleware on all /api/v1/* | 4 | M3 | ⏳ TODO | `app/core/middleware.py` |
|
| 214 |
+
| 20 | Auth Depends() on legacy routes | 4 | M3 | ⏳ TODO | `app/core/middleware.py` |
|
| 215 |
+
|
| 216 |
+
## P2 — Week 3 (46h total — 12h human + 34h M3)
|
| 217 |
+
|
| 218 |
+
| # | Item | Hours | Owner | Status | File |
|
| 219 |
+
|---|------|-------|-------|--------|------|
|
| 220 |
+
| 21 | Security headers (CSP, HSTS, X-Frame) | 3 | M3 | ⏳ TODO | `app/core/middleware.py` |
|
| 221 |
+
| 22 | Backup restore drill | 3 | human | ⏳ TODO | R2 bucket test |
|
| 222 |
+
| 23 | 4 Grafana dashboards | 5 | M3 | ⏳ TODO | `grafana/dashboards/*.json` |
|
| 223 |
+
| 24 | Container memory limits (all 36) | 3 | M3 | ⏳ TODO | `docker-compose.yml` |
|
| 224 |
+
| 25 | tests/{e2e,contract,load}/ scaffolding | 4 | M3 | ⏳ TODO | `tests/e2e/` `tests/contract/` `tests/load/` |
|
| 225 |
+
| 26 | k6 baselines (top 50 routes) | 6 | M3 | ⏳ TODO | `tests/load/baseline.js` |
|
| 226 |
+
| 27 | Pact contracts + openapi-generator SDK | 5 | M3 | ⏳ TODO | `tests/contract/` |
|
| 227 |
+
| 28 | Top 10 ADRs | 5 | human | ⏳ TODO | `docs/adr/0001-0010.md` |
|
| 228 |
+
| 29 | Runbooks + postmortem + oncall | 4 | human | ⏳ TODO | `docs/runbooks/` `docs/postmortems/` `docs/oncall/` |
|
| 229 |
+
| 30 | Agent Loop Spec productionization (M1) | 8 | M3 | ⏳ TODO | `app/agents/loop.py` (NEW, frozen on creation) |
|
| 230 |
+
|
| 231 |
+
## P3 — Defer until revenue
|
| 232 |
+
|
| 233 |
+
Multi-region read replicas. SOC2 audit. Paid third-party pen-test. SBOM distribution. AsyncAPI WebSocket spec. Frontend RUM (Sentry browser SDK, web vitals). Pyroscope continuous profiling. OWASP ZAP.
|
| 234 |
+
|
| 235 |
+
---
|
| 236 |
+
|
| 237 |
+
# THE 8 AI-FORWARD MODULES (v3)
|
| 238 |
+
|
| 239 |
+
## M1: Agent Loop Productionization (P2 #30)
|
| 240 |
+
|
| 241 |
+
**Deliverable: `app/agents/loop.py` (frozen on creation)**
|
| 242 |
+
|
| 243 |
+
```python
|
| 244 |
+
from pydantic import BaseModel, Field, ConfigDict
|
| 245 |
+
|
| 246 |
+
class TaskInput(BaseModel):
|
| 247 |
+
model_config = ConfigDict(strict=True, frozen=True)
|
| 248 |
+
task_id: str = Field(..., pattern=r"^\d+-[a-z0-9-]+$")
|
| 249 |
+
description: str = Field(..., max_length=500)
|
| 250 |
+
context_budget_tokens: int = Field(default=8000, le=32000)
|
| 251 |
+
allowed_tools: list[str] = Field(..., min_length=1)
|
| 252 |
+
success_criteria: str = Field(..., max_length=300)
|
| 253 |
+
verify_command: str = Field(..., max_length=200)
|
| 254 |
+
|
| 255 |
+
class LoopBudget(BaseModel):
|
| 256 |
+
model_config = ConfigDict(strict=True, frozen=True)
|
| 257 |
+
max_iterations: int = Field(default=20, le=100)
|
| 258 |
+
max_tokens: int = Field(default=50_000, le=500_000)
|
| 259 |
+
max_wallclock_seconds: int = Field(default=900, le=3600)
|
| 260 |
+
max_spend_usd: float = Field(default=5.0, le=50.0)
|
| 261 |
+
|
| 262 |
+
class TaskOutput(BaseModel):
|
| 263 |
+
task_id: str
|
| 264 |
+
files_touched: list[str]
|
| 265 |
+
lines_added: int
|
| 266 |
+
lines_removed: int
|
| 267 |
+
verify_passed: bool
|
| 268 |
+
worklog_entry: str
|
| 269 |
+
spend_usd: float
|
| 270 |
+
iterations_used: int
|
| 271 |
+
```
|
| 272 |
+
|
| 273 |
+
**Kill switches** (every iteration):
|
| 274 |
+
- max_iterations ≤ 20 (max 100)
|
| 275 |
+
- max_tokens ≤ 50K (max 500K)
|
| 276 |
+
- max_wallclock ≤ 900s (max 3600s)
|
| 277 |
+
- max_spend ≤ $5 (max $50)
|
| 278 |
+
- verify_command_failed_3x → human review
|
| 279 |
+
|
| 280 |
+
**fact_store companion** (`app/agents/fact_store.py`):
|
| 281 |
+
- Redis key: `fact:{namespace}:{key}`
|
| 282 |
+
- TTL: 86400s
|
| 283 |
+
- Loaded at loop start, written at loop end
|
| 284 |
+
- Prevents re-discovery of known facts
|
| 285 |
+
|
| 286 |
+
**Acceptance gate: 7 consecutive days zero runaway.**
|
| 287 |
+
|
| 288 |
+
## M2: MCP Mesh v2 (P2 #24, D9)
|
| 289 |
+
|
| 290 |
+
**Deliverable: `app/mcp/manifest.py` (frozen on creation)**
|
| 291 |
+
|
| 292 |
+
```python
|
| 293 |
+
from pydantic import BaseModel, Field
|
| 294 |
+
from typing import Any
|
| 295 |
+
|
| 296 |
+
class MCPToolManifest(BaseModel):
|
| 297 |
+
name: str = Field(..., pattern=r"^[a-z_]+:[a-z_]+$") # e.g. "rmi-netcup:docker_ps"
|
| 298 |
+
version: str = Field(..., pattern=r"^\d+\.\d+\.\d+$")
|
| 299 |
+
server: str = Field(..., pattern=r"^[a-z-]+$")
|
| 300 |
+
description: str = Field(..., max_length=200)
|
| 301 |
+
input_schema: dict[str, Any]
|
| 302 |
+
output_schema: dict[str, Any]
|
| 303 |
+
auth_scope: str = Field(..., pattern=r"^[a-z_]+:[a-z_]+$") # gopass scope
|
| 304 |
+
deprecated: bool = False
|
| 305 |
+
successor: str | None = None
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
**Mesh registry on netcup `:8643`** with:
|
| 309 |
+
- GET /tools (list all with filters)
|
| 310 |
+
- GET /tools/{name} (full manifest)
|
| 311 |
+
- POST /resolve (name+MAJOR version → executor endpoint)
|
| 312 |
+
|
| 313 |
+
**3 unwired servers to wire in D9:**
|
| 314 |
+
- OpenCTI (40 tools) — wire with FastMCP
|
| 315 |
+
- Dify (18 tools) — wire with FastMCP
|
| 316 |
+
- n8n (20 tools) — wire with FastMCP
|
| 317 |
+
|
| 318 |
+
**Acceptance gate: 332+ tools versioned, all gopass-backed auth, OpenCTI+Dify+n8n wired.**
|
| 319 |
+
|
| 320 |
+
## M3: RAG Dual-Dim Wrapper (P0 #6)
|
| 321 |
+
|
| 322 |
+
**Deliverable: `app/rag/embedder.py` (wraps frozen `app/crypto_embeddings.py`)**
|
| 323 |
+
|
| 324 |
+
```python
|
| 325 |
+
from enum import Enum
|
| 326 |
+
|
| 327 |
+
class EmbedderBackend(Enum):
|
| 328 |
+
BGE_M3 = "bge-m3" # 1024d, legacy
|
| 329 |
+
QWEN3_4B = "qwen3-embedding:4b" # 2048d, target
|
| 330 |
+
|
| 331 |
+
class DualDimEmbedder:
|
| 332 |
+
def __init__(self, backend: EmbedderBackend):
|
| 333 |
+
self.backend = backend
|
| 334 |
+
self.dim = 1024 if backend == EmbedderBackend.BGE_M3 else 2048
|
| 335 |
+
|
| 336 |
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
| 337 |
+
return await self._ollama_embed(texts, self.backend.value)
|
| 338 |
+
|
| 339 |
+
async def reindex_collection(self, name: str, target: EmbedderBackend) -> bool:
|
| 340 |
+
old = DualDimEmbedder(EmbedderBackend.BGE_M3)
|
| 341 |
+
new = DualDimEmbedder(target)
|
| 342 |
+
# 1. Read all docs from collection
|
| 343 |
+
# 2. Re-embed with new backend
|
| 344 |
+
# 3. Write to new collection {name}_v2
|
| 345 |
+
# 4. Atomic swap: {name} → {name}_legacy, {name}_v2 → {name}
|
| 346 |
+
# 5. Verify: query 10 known docs, confirm retrieval
|
| 347 |
+
return True
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
**The math:**
|
| 351 |
+
| Metric | bge-m3 | qwen3-4b | Delta |
|
| 352 |
+
|--------|--------|----------|-------|
|
| 353 |
+
| Dimensions | 1024 | 2048 | +2x |
|
| 354 |
+
| RAM (loaded) | 1.2GB | 2.0GB | +800MB |
|
| 355 |
+
| Embed latency | ~80ms | ~140ms | +60ms |
|
| 356 |
+
| Retrieval (nDCG@10) | 0.71 | 0.83 (est) | +12% |
|
| 357 |
+
| Reindex time | — | 6h overnight | one-time |
|
| 358 |
+
| Storage/coll | ~50MB | ~100MB | +650MB total |
|
| 359 |
+
|
| 360 |
+
**Strategy: collection-by-collection overnight, smallest first, verify each morning.** If <8%, defer.
|
| 361 |
+
|
| 362 |
+
**Acceptance gate: All 13 collections reindexed, eval harness +8% nDCG@10, bge-m3 unloaded.**
|
| 363 |
+
|
| 364 |
+
## M4: Observability Triangle (P1 #11, #12, P2 #21)
|
| 365 |
+
|
| 366 |
+
**Three pillars wired into the 94-line lifespan:**
|
| 367 |
+
|
| 368 |
+
```python
|
| 369 |
+
# main.py additions (~15 lines to existing 94)
|
| 370 |
+
from contextlib import asynccontextmanager
|
| 371 |
+
from app.core.tracing import setup_otel
|
| 372 |
+
from app.core.langfuse import langfuse
|
| 373 |
+
from app.core.errors import init_glitchtip
|
| 374 |
+
|
| 375 |
+
@asynccontextmanager
|
| 376 |
+
async def lifespan(app: FastAPI):
|
| 377 |
+
_otel = setup_otel() # OTel: httpx, redis, asyncpg, neo4j
|
| 378 |
+
init_glitchtip() # Sentry-compatible SDK
|
| 379 |
+
await app.state.redis.initialize()
|
| 380 |
+
yield
|
| 381 |
+
_otel.shutdown()
|
| 382 |
+
langfuse.flush()
|
| 383 |
+
await app.state.redis.close()
|
| 384 |
+
|
| 385 |
+
app = FastAPI(lifespan=lifespan)
|
| 386 |
+
```
|
| 387 |
+
|
| 388 |
+
**Pillars:**
|
| 389 |
+
- **OTel**: auto-instrument httpx/redis/asyncpg/neo4j, export to local OTel collector
|
| 390 |
+
- **Langfuse**: `@observe()` decorator on every LLM call, >95% coverage
|
| 391 |
+
- **GlitchTip**: Sentry-compatible SDK, DSN from gopass, captures every unhandled exception
|
| 392 |
+
|
| 393 |
+
**Acceptance gate: 7 consecutive days triangle lit.**
|
| 394 |
+
|
| 395 |
+
## M5: CI/CD Standards (P1 #13-17, P1 #19, P1 #20)
|
| 396 |
+
|
| 397 |
+
**Pipeline stages:**
|
| 398 |
+
```
|
| 399 |
+
PRE-COMMIT (local): ruff + mypy + gitleaks + detect-secrets + bandit + file-size + pytest-smoke
|
| 400 |
+
CI (GH Actions): lint + test-full + build + trivy + semgrep + gitleaks-history + grype
|
| 401 |
+
DEPLOY (blue-green): build-green → start → health-gate → swap-router → verify-5xx → keep-blue-10min
|
| 402 |
+
ROLLBACK (auto): swap-back-to-blue on 5xx spike → page → freeze-deploys-1h
|
| 403 |
+
```
|
| 404 |
+
|
| 405 |
+
**Justfile deploy target:**
|
| 406 |
+
```makefile
|
| 407 |
+
deploy:
|
| 408 |
+
docker compose -f docker-compose.green.yml build
|
| 409 |
+
docker compose -f docker-compose.green.yml up -d
|
| 410 |
+
./scripts/health-check.sh http://127.0.0.1:8001/health 60
|
| 411 |
+
./scripts/swap-upstream.sh green
|
| 412 |
+
./scripts/watch-errors.sh 600 0.01
|
| 413 |
+
docker compose -f docker-compose.blue.yml stop
|
| 414 |
+
```
|
| 415 |
+
|
| 416 |
+
## M6: ADRs + Runbooks + Postmortems + Oncall (P2 #28, #29)
|
| 417 |
+
|
| 418 |
+
**Top 10 ADRs (write these first):**
|
| 419 |
+
1. Why FastAPI over Litestar/Flask/Django
|
| 420 |
+
2. Why 5 DBs (Postgres+Redis+CH+Neo4j+Qdrant)
|
| 421 |
+
3. Why strangler-fig over rewrite
|
| 422 |
+
4. Why bge-m3 → qwen3-embedding:4b (M3)
|
| 423 |
+
5. Why self-host GlitchTip over Sentry SaaS
|
| 424 |
+
6. Why single-VPS blue-green over k3s
|
| 425 |
+
7. Why gopass over Vault/Doppler/AWS SM
|
| 426 |
+
8. Why Hermes cron over systemd/Airflow
|
| 427 |
+
9. Why MCP mesh over direct API calls
|
| 428 |
+
10. Why Tailscale over WireGuard/ZeroTier
|
| 429 |
+
|
| 430 |
+
**ADR Template:**
|
| 431 |
+
```
|
| 432 |
+
# ADR-XXXX: [Title]
|
| 433 |
+
## Status: Accepted | Rejected | Superseded
|
| 434 |
+
## Date: YYYY-MM-DD
|
| 435 |
+
## Decider: @cryptorugmunch
|
| 436 |
+
## Context: What we're solving
|
| 437 |
+
## Decision: What we chose
|
| 438 |
+
## Alternatives Considered: What we rejected
|
| 439 |
+
## Consequences: Easier / Harder / Mitigations
|
| 440 |
+
```
|
| 441 |
+
|
| 442 |
+
## M7: Cost Tracking + SLOs + Error Budgets (P2 #21)
|
| 443 |
+
|
| 444 |
+
**Per-tenant cost middleware (`app/middleware/cost_tracking.py`):**
|
| 445 |
+
```python
|
| 446 |
+
class CostTrackingMiddleware(BaseHTTPMiddleware):
|
| 447 |
+
async def dispatch(self, request, call_next):
|
| 448 |
+
tenant = request.headers.get("X-Tenant-ID", "anonymous")
|
| 449 |
+
t0 = time.monotonic()
|
| 450 |
+
response = await call_next(request)
|
| 451 |
+
elapsed_ms = (time.monotonic() - t0) * 1000
|
| 452 |
+
cost = self._estimate_cost(route, response, elapsed_ms)
|
| 453 |
+
await self._buffer_cost(tenant, route, cost, elapsed_ms)
|
| 454 |
+
return response
|
| 455 |
+
```
|
| 456 |
+
|
| 457 |
+
**SLO Targets:**
|
| 458 |
+
| Service | SLO | Error budget |
|
| 459 |
+
|---------|-----|--------------|
|
| 460 |
+
| /api/v1/* p99 latency | < 500ms | 43min/mo |
|
| 461 |
+
| /api/v2/* p99 latency | < 200ms | 43min/mo |
|
| 462 |
+
| /health uptime | ≥ 99.5% | 3.6h/mo |
|
| 463 |
+
| /api/* error rate (5xx) | < 0.1% | 43min of 5xx/mo |
|
| 464 |
+
| RAG retrieval success | > 95% | 43min of failures/mo |
|
| 465 |
+
| LLM calls timeout rate | < 1% | 7.2h of timeouts/mo |
|
| 466 |
+
|
| 467 |
+
**Auto-freeze deploys on 4x burn:**
|
| 468 |
+
```yaml
|
| 469 |
+
- alert: SLOBurnRate4x
|
| 470 |
+
expr: (sum(rate(http_requests_total{status=~"5.."}[1h])) / sum(rate(http_requests_total[1h]))) > 0.02
|
| 471 |
+
for: 2m
|
| 472 |
+
annotations:
|
| 473 |
+
webhook: "http://127.0.0.1:8642/ci/freeze"
|
| 474 |
+
```
|
| 475 |
+
|
| 476 |
+
## M8: AI Tool Wiring Matrix
|
| 477 |
+
|
| 478 |
+
| Task | Best tool | Worst tool |
|
| 479 |
+
|------|-----------|------------|
|
| 480 |
+
| Bounded code edit (1 file, <50 lines) | **aider** | claude-code |
|
| 481 |
+
| Multi-file refactor (3–10 files) | **claude-code** | aider |
|
| 482 |
+
| Codebase search | **Bloop** (when installed) | grep |
|
| 483 |
+
| Test generation | **claude-code** | aider |
|
| 484 |
+
| Doc generation (ADR, runbook) | **GLM-5.2** | aider |
|
| 485 |
+
| Security review | **Semgrep** (not LLM) | any LLM |
|
| 486 |
+
| Dependency upgrade | **Renovate** (not LLM) | any LLM |
|
| 487 |
+
| Prompt engineering | **GLM-5.2 + DSPy** | claude-code |
|
| 488 |
+
| RAG eval | Custom harness + LLM-as-judge | any LLM alone |
|
| 489 |
+
| Long-context (≥100K) | **Kimi K2.7** (256K) | DeepSeek (slow) |
|
| 490 |
+
| Vision | **Gemini 2.5-pro** | text-only LLM |
|
| 491 |
+
| Fast small (≤500 tokens) | **Groq** | DeepSeek |
|
| 492 |
+
| Autonomous (bounded loop) | **Hermes** | claude-code |
|
| 493 |
+
|
| 494 |
+
---
|
| 495 |
+
|
| 496 |
+
# 10 INCIDENT SCENARIOS (v3)
|
| 497 |
+
|
| 498 |
+
## v2's 7 (preserved)
|
| 499 |
+
1. rmi-redis crashes at 3am → auto-restart
|
| 500 |
+
2. Disk full on netcup → AlertManager page at 80%
|
| 501 |
+
3. Postgres corruption → restore from nightly backup
|
| 502 |
+
4. SSL cert expiry → Cloudflare manages, Tailscale renew
|
| 503 |
+
5. OOM kill on rmi-backend → Docker limit + py-spy/memray
|
| 504 |
+
6. DDoS on rugmunch.io → Cloudflare "Under Attack" mode
|
| 505 |
+
7. DeepSeek balance hits $0 → failover to Ollama Cloud/Gemini
|
| 506 |
+
|
| 507 |
+
## v3's 3 NEW (AI-specific)
|
| 508 |
+
|
| 509 |
+
### Scenario 8 — Agent Loop Runaway
|
| 510 |
+
**Symptom:** LLM spend spikes ($50+/h). Hermes logs show task iterating >50×.
|
| 511 |
+
**Detect:** Daily spend alert (M7) at $20/h. Hermes /cron/health shows iterations >20.
|
| 512 |
+
**Mitigate:** `pkill -f "hermes.*task_id=X"`. Disable cron job.
|
| 513 |
+
**Recover:** Audit input contract — likely missing success_criteria or verify_command.
|
| 514 |
+
**Root cause:** vague description or missing verify_command.
|
| 515 |
+
|
| 516 |
+
### Scenario 9 — RAG Poisoning
|
| 517 |
+
**Symptom:** RAG retrieval returns adversarial content. User reports "the AI told me to send funds to X."
|
| 518 |
+
**Detect:** Langfuse span shows suspicious source. Eval harness flags quality regression.
|
| 519 |
+
**Mitigate:** Disable affected collection. Roll back FAISS index.
|
| 520 |
+
**Recover:** Add source allowlist to ingest step.
|
| 521 |
+
**Root cause:** open ingestion endpoint or untrusted RSS feed.
|
| 522 |
+
|
| 523 |
+
### Scenario 10 — MCP Tool Compromise
|
| 524 |
+
**Symptom:** Unusual gopass access pattern. MCP tool called from unexpected IP. Lateral movement.
|
| 525 |
+
**Detect:** gopass audit log. CrowdSec flags anomalous SSH. Tailscale ACL violation.
|
| 526 |
+
**Mitigate:** `gopass rm rmi/infra/compromised/scope` + rotate. Revoke Tailscale node. Block IP.
|
| 527 |
+
**Recover:** Audit all gopass accesses in last 30 days. Rotate ALL keys.
|
| 528 |
+
**Root cause:** over-broad auth_scope or leaked Tailscale key. SEV1.
|
| 529 |
+
|
| 530 |
+
---
|
| 531 |
+
|
| 532 |
+
# FROZEN FILE MANIFEST v3 (14 entries)
|
| 533 |
+
|
| 534 |
+
| # | File | Why frozen |
|
| 535 |
+
|---|------|------------|
|
| 536 |
+
| 1 | `main.py` | Front door (was 8475, now 94) |
|
| 537 |
+
| 2 | `_legacy_main.py` | Strangler-fig target |
|
| 538 |
+
| 3 | `app/core/redis.py` | Single Redis source |
|
| 539 |
+
| 4 | `app/core/config.py` | Env var loader |
|
| 540 |
+
| 5 | `app/databus/core.py` | 96-chain fetch interface |
|
| 541 |
+
| 6 | `app/rag/pipeline.py` | RAG pipeline (frozen for M3) |
|
| 542 |
+
| 7 | `app/crypto_embeddings.py` | bge-m3 embedder (replaced via wrapper) |
|
| 543 |
+
| 8 | `app/api/v1/__init__.py` | Router registry |
|
| 544 |
+
| 9 | `docker-compose.yml` | All 36 containers |
|
| 545 |
+
| 10 | `app/core/tracing.py` | OTel (not wired — M4) |
|
| 546 |
+
| 11 | `app/agents/loop.py` | M1 deliverable (frozen on creation) |
|
| 547 |
+
| 12 | `app/mcp/server.py` | M2 deliverable (frozen on creation) |
|
| 548 |
+
| 13 | `app/core/metrics.py` | P0 deliverable (frozen on creation) |
|
| 549 |
+
| 14 | `app/middleware/cost_tracking.py` | M7 deliverable (frozen on creation) |
|
| 550 |
+
|
| 551 |
+
**Rule:** Modified only via bounded-task delegation with Task ID in `/home/z/my-project/worklog.md`. Out-of-process edits reverted.
|
| 552 |
+
|
| 553 |
+
---
|
| 554 |
+
|
| 555 |
+
# THE MATH (v3 BRUTAL TRUTH)
|
| 556 |
+
|
| 557 |
+
```
|
| 558 |
+
Solo With M3
|
| 559 |
+
P0 + P1 + P2 hours 115h 115h
|
| 560 |
+
Available budget (21d×8h) 168h 168h human + 152h M3 = 320h
|
| 561 |
+
Buffer (30% incident tax) −50h −50h human, −46h M3
|
| 562 |
+
Effective capacity 118h 224h
|
| 563 |
+
NET (capacity − work) +3h +109h
|
| 564 |
+
↑ Phase 4 fits here
|
| 565 |
+
|
| 566 |
+
Plus 20h passive waits (eval soaks, mesh verifications)
|
| 567 |
+
→ Solo: 138h needed vs 118h available — slips to Day 25
|
| 568 |
+
→ M3: 138h needed vs 224h available — fits comfortably
|
| 569 |
+
```
|
| 570 |
+
|
| 571 |
+
**Verdict: Solo, v3 is a 25-day plan. With M3 parallel, v3 is a 21-day plan.**
|
| 572 |
+
|
| 573 |
+
---
|
| 574 |
+
|
| 575 |
+
# 90-DAY ROADMAP (after the unfuck)
|
| 576 |
+
|
| 577 |
+
## Month 2 — Agent Mesh + RAG Eval + SDK
|
| 578 |
+
- Agent mesh: Hermes orchestrating multiple claude-code/aider in parallel
|
| 579 |
+
- RAG eval harness: automated retrieval-quality regression on every push
|
| 580 |
+
- SDK v1: TypeScript + Python clients from OpenAPI spec
|
| 581 |
+
|
| 582 |
+
## Month 3 — Multi-Region + SOC2 + Paid Pilots
|
| 583 |
+
- Multi-region read replicas (Contabo → Cloudflare LB)
|
| 584 |
+
- SOC2 prep (ADRs + runbooks = 60% of evidence)
|
| 585 |
+
- 3 paid pilots, $500/mo each, 60-day commitment
|
| 586 |
+
|
| 587 |
+
## Month 4+ — Hire or Stay Solo
|
| 588 |
+
- If 3 pilots + 1 conversion → hire senior backend engineer
|
| 589 |
+
- If pilots but no conversion → focus on conversion
|
| 590 |
+
- If no pilots → pivot
|
| 591 |
+
|
| 592 |
+
---
|
| 593 |
+
|
| 594 |
+
# What Ships This Pass (Foundation)
|
| 595 |
+
|
| 596 |
+
1. ✅ Fix crash — `rag_engine` re-export shim, backend healthy (Phase 0)
|
| 597 |
+
2. ✅ `pyproject.toml` — uv + ruff + mypy strict + pytest
|
| 598 |
+
3. ✅ `.pre-commit-config.yaml` — ruff + mypy + size cap (500) + gitleaks
|
| 599 |
+
4. ✅ `app/core/` — 11 modules, each <200 lines
|
| 600 |
+
5. ✅ `app/api/v1/__init__.py` — router aggregator
|
| 601 |
+
6. ✅ `main.py` — 94 lines (Phase 0 just restored)
|
| 602 |
+
7. ✅ Verify: backend boots, all 1249 routes respond, health 200
|
| 603 |
+
8. ✅ Commit + deploy (`4a8a16d`)
|
| 604 |
+
|
| 605 |
+
## What Does NOT Ship This Pass (v3 P0-P2)
|
| 606 |
+
|
| 607 |
+
- Migrating alerts/wallet/token/scanner to new `domain/`. That's the current unfuck.
|
| 608 |
+
- The 15 mechanical refactors. Replaced with the layered architecture.
|
| 609 |
+
- Deleting old code. Strangler fig — old stays until domain is migrated.
|
| 610 |
+
- **v3 new**: M1 Agent Loop, M2 MCP Mesh, M3 RAG dual-dim, M4 Observability Triangle, M5 CI/CD, M6 ADRs, M7 Cost+SLOs, M8 AI Tool Matrix.
|
| 611 |
+
|
| 612 |
+
## Phase 2: Alerts Vertical Slice (proves the pattern) — COMPLETE
|
| 613 |
+
|
| 614 |
+
After foundation lands, migrate `alerts` end-to-end as the reference:
|
| 615 |
+
```
|
| 616 |
+
app/domain/alerts/
|
| 617 |
+
├── models.py # Alert, AlertRule, Notification — Pydantic v2
|
| 618 |
+
├── repository.py # async SQLAlchemy queries
|
| 619 |
+
├── service.py # business logic, pure Python
|
| 620 |
+
└── broadcaster.py # WebSocket broadcast helper
|
| 621 |
+
|
| 622 |
+
app/api/v1/auth/alerts.py # thin route: parse → call service → return
|
| 623 |
+
```
|
| 624 |
+
|
| 625 |
+
This proves the pattern works: domain is pure Python, route is <100 lines, can be unit tested without HTTP.
|
|
@@ -36,13 +36,17 @@ import urllib.request
|
|
| 36 |
import email.utils as email_utils
|
| 37 |
|
| 38 |
# ═══════════════════════════════════════════════════════════════
|
| 39 |
-
# STRICT .ENV VALIDATION ON STARTUP (Fail Fast)
|
| 40 |
# ═══════════════════════════════════════════════════════════════
|
| 41 |
REQUIRED_ENV_VARS = [
|
|
|
|
| 42 |
"REDIS_HOST", "REDIS_PORT", "REDIS_PASSWORD",
|
| 43 |
"SUPABASE_URL", "SUPABASE_KEY",
|
| 44 |
-
|
| 45 |
-
"ADMIN_API_KEY"
|
|
|
|
|
|
|
|
|
|
| 46 |
]
|
| 47 |
|
| 48 |
missing_vars = [var for var in REQUIRED_ENV_VARS if not os.getenv(var)]
|
|
|
|
| 36 |
import email.utils as email_utils
|
| 37 |
|
| 38 |
# ═══════════════════════════════════════════════════════════════
|
| 39 |
+
# STRICT .ENV VALIDATION ON STARTUP (Fail Fast on infra only)
|
| 40 |
# ═══════════════════════════════════════════════════════════════
|
| 41 |
REQUIRED_ENV_VARS = [
|
| 42 |
+
# DB + cache (always required)
|
| 43 |
"REDIS_HOST", "REDIS_PORT", "REDIS_PASSWORD",
|
| 44 |
"SUPABASE_URL", "SUPABASE_KEY",
|
| 45 |
+
# Admin auth (always required)
|
| 46 |
+
"ADMIN_API_KEY",
|
| 47 |
+
# LLM providers are OPTIONAL — backend boots even if all are missing.
|
| 48 |
+
# Order of preference: MiniMax > OpenRouter > Kimi > local Ollama.
|
| 49 |
+
# See app/core/llm_provider.py for resolution.
|
| 50 |
]
|
| 51 |
|
| 52 |
missing_vars = [var for var in REQUIRED_ENV_VARS if not os.getenv(var)]
|
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""fact_store — Agent loop long-term memory (M1 companion).
|
| 2 |
+
|
| 3 |
+
Frozen on creation. Modified only via bounded-task delegation with Task ID.
|
| 4 |
+
|
| 5 |
+
Redis-backed KV of verified system facts. The agent loop reads at loop start
|
| 6 |
+
and writes at loop end, preventing re-discovery of known facts on every
|
| 7 |
+
iteration.
|
| 8 |
+
|
| 9 |
+
Schema:
|
| 10 |
+
Key: fact:{namespace}:{key}
|
| 11 |
+
Value: JSON-encoded arbitrary data
|
| 12 |
+
TTL: 24h default (override per call)
|
| 13 |
+
|
| 14 |
+
Examples:
|
| 15 |
+
await write_fact("agents", "postgres.host", "rmi-postgres", ttl=86400)
|
| 16 |
+
await write_fact("agents", "embedder.current", "bge-m3", ttl=86400)
|
| 17 |
+
facts = await load_facts("agents")
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
# Lazy import — get_redis() may not be importable in unit tests.
|
| 25 |
+
try:
|
| 26 |
+
from app.core.redis import get_redis
|
| 27 |
+
except ImportError:
|
| 28 |
+
get_redis = None # type: ignore[assignment]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
KEY_PREFIX = "fact:"
|
| 32 |
+
DEFAULT_TTL_SECONDS = 86400
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def load_facts(namespace: str) -> dict[str, Any]:
|
| 36 |
+
"""Load all facts in a namespace.
|
| 37 |
+
|
| 38 |
+
Returns an empty dict if Redis is unavailable (e.g. unit tests).
|
| 39 |
+
"""
|
| 40 |
+
if get_redis is None:
|
| 41 |
+
return {}
|
| 42 |
+
try:
|
| 43 |
+
r = await get_redis()
|
| 44 |
+
except Exception:
|
| 45 |
+
return {}
|
| 46 |
+
|
| 47 |
+
keys = await r.keys(f"{KEY_PREFIX}{namespace}:*")
|
| 48 |
+
if not keys:
|
| 49 |
+
return {}
|
| 50 |
+
|
| 51 |
+
vals = await r.mget(*keys)
|
| 52 |
+
out: dict[str, Any] = {}
|
| 53 |
+
for k, v in zip(keys, vals, strict=True):
|
| 54 |
+
if v is None:
|
| 55 |
+
continue
|
| 56 |
+
try:
|
| 57 |
+
# Strip the prefix to get just the key portion.
|
| 58 |
+
short_key = k.decode() if isinstance(k, bytes) else k
|
| 59 |
+
short_key = short_key.removeprefix(f"{KEY_PREFIX}{namespace}:")
|
| 60 |
+
out[short_key] = json.loads(v)
|
| 61 |
+
except (json.JSONDecodeError, ValueError):
|
| 62 |
+
continue
|
| 63 |
+
return out
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def write_fact(
|
| 67 |
+
namespace: str,
|
| 68 |
+
key: str,
|
| 69 |
+
value: Any,
|
| 70 |
+
ttl: int = DEFAULT_TTL_SECONDS,
|
| 71 |
+
) -> bool:
|
| 72 |
+
"""Write one fact to the store.
|
| 73 |
+
|
| 74 |
+
Returns False if Redis is unavailable (graceful degradation).
|
| 75 |
+
"""
|
| 76 |
+
if get_redis is None:
|
| 77 |
+
return False
|
| 78 |
+
try:
|
| 79 |
+
r = await get_redis()
|
| 80 |
+
await r.setex(
|
| 81 |
+
f"{KEY_PREFIX}{namespace}:{key}",
|
| 82 |
+
ttl,
|
| 83 |
+
json.dumps(value, default=str),
|
| 84 |
+
)
|
| 85 |
+
return True
|
| 86 |
+
except Exception:
|
| 87 |
+
return False
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def delete_fact(namespace: str, key: str) -> bool:
|
| 91 |
+
"""Delete one fact. Returns False if not found or Redis unavailable."""
|
| 92 |
+
if get_redis is None:
|
| 93 |
+
return False
|
| 94 |
+
try:
|
| 95 |
+
r = await get_redis()
|
| 96 |
+
deleted = await r.delete(f"{KEY_PREFIX}{namespace}:{key}")
|
| 97 |
+
return bool(deleted)
|
| 98 |
+
except Exception:
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
async def list_namespaces() -> list[str]:
|
| 103 |
+
"""Return all distinct namespaces currently in the store."""
|
| 104 |
+
if get_redis is None:
|
| 105 |
+
return []
|
| 106 |
+
try:
|
| 107 |
+
r = await get_redis()
|
| 108 |
+
keys = await r.keys(f"{KEY_PREFIX}*")
|
| 109 |
+
namespaces: set[str] = set()
|
| 110 |
+
for k in keys:
|
| 111 |
+
s = k.decode() if isinstance(k, bytes) else k
|
| 112 |
+
parts = s.removeprefix(KEY_PREFIX).split(":", 1)
|
| 113 |
+
if parts:
|
| 114 |
+
namespaces.add(parts[0])
|
| 115 |
+
return sorted(namespaces)
|
| 116 |
+
except Exception:
|
| 117 |
+
return []
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ── Seed function for first-time setup ─────────────────────────────────
|
| 121 |
+
SEED_FACTS: list[tuple[str, str, Any, int]] = [
|
| 122 |
+
# (namespace, key, value, ttl)
|
| 123 |
+
("agents", "postgres.host", "rmi-postgres", 86400),
|
| 124 |
+
("agents", "postgres.port", 5432, 86400),
|
| 125 |
+
("agents", "redis.host", "rmi-redis", 86400),
|
| 126 |
+
("agents", "redis.port", 6379, 86400),
|
| 127 |
+
("agents", "clickhouse.host", "rmi-clickhouse", 86400),
|
| 128 |
+
("agents", "neo4j.host", "rmi-neo4j", 86400),
|
| 129 |
+
("agents", "neo4j.port", 7474, 86400),
|
| 130 |
+
("agents", "qdrant.host", "rmi-qdrant", 86400),
|
| 131 |
+
("agents", "ollama.url", "http://ollama:11434", 86400),
|
| 132 |
+
("agents", "embedder.current", "bge-m3", 86400),
|
| 133 |
+
("agents", "embedder.target", "qwen3-embedding:4b", 86400),
|
| 134 |
+
("agents", "embedder.current_dims", 1024, 86400),
|
| 135 |
+
("agents", "embedder.target_dims", 2048, 86400),
|
| 136 |
+
("agents", "main.path", "/root/backend/main.py", 86400),
|
| 137 |
+
("agents", "main.lines_target", 94, 86400),
|
| 138 |
+
("agents", "legacy.lines", 8475, 86400),
|
| 139 |
+
("agents", "frozen_manifest.count", 14, 86400),
|
| 140 |
+
("agents", "mustdo.total", 30, 86400),
|
| 141 |
+
("agents", "phase.current", "0", 86400),
|
| 142 |
+
("agents", "deploy.method", "blue-green", 86400),
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
async def seed_facts() -> int:
|
| 147 |
+
"""Seed the store with the top 20 verified system facts.
|
| 148 |
+
|
| 149 |
+
Returns the number of facts written. Idempotent — overwrites existing.
|
| 150 |
+
"""
|
| 151 |
+
written = 0
|
| 152 |
+
for namespace, key, value, ttl in SEED_FACTS:
|
| 153 |
+
if await write_fact(namespace, key, value, ttl):
|
| 154 |
+
written += 1
|
| 155 |
+
return written
|
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent Loop Specification (M1 — P2 #30 in v3 unfuck plan).
|
| 2 |
+
|
| 3 |
+
Frozen on creation. Modified only via bounded-task delegation with Task ID
|
| 4 |
+
in /home/z/my-project/worklog.md. See DESIGN.md §M1.
|
| 5 |
+
|
| 6 |
+
Defines the formal contract for any AI agent loop that runs on RMI infrastructure:
|
| 7 |
+
Hermes, claude-code, aider, GLM-5.2. Without this spec, loops are unbounded —
|
| 8 |
+
they iterate forever, burn tokens, produce no verifiable artifacts.
|
| 9 |
+
|
| 10 |
+
Usage from a Hermes cron task:
|
| 11 |
+
from app.agents.loop import BoundedAgentLoop, TaskInput, LoopBudget
|
| 12 |
+
loop = BoundedAgentLoop(
|
| 13 |
+
task=TaskInput(
|
| 14 |
+
task_id="30-a",
|
| 15 |
+
description="Add the new typed error class",
|
| 16 |
+
success_criteria="error class exists and passes mypy",
|
| 17 |
+
verify_command="python -c 'from app.core.errors import NewError'",
|
| 18 |
+
),
|
| 19 |
+
budget=LoopBudget(max_iterations=5, max_tokens=10_000),
|
| 20 |
+
)
|
| 21 |
+
result = await loop.run()
|
| 22 |
+
if result.verify_passed:
|
| 23 |
+
...
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import time
|
| 28 |
+
from typing import Any
|
| 29 |
+
|
| 30 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── Input Contract ──────────────────────────────────────────────────────
|
| 34 |
+
class TaskInput(BaseModel):
|
| 35 |
+
"""What every agent loop receives."""
|
| 36 |
+
|
| 37 |
+
model_config = ConfigDict(strict=True, frozen=True)
|
| 38 |
+
|
| 39 |
+
task_id: str = Field(
|
| 40 |
+
...,
|
| 41 |
+
pattern=r"^\d+-[a-z0-9-]+$",
|
| 42 |
+
description="Globally-unique ID matching ^\\d+-[a-z0-9-]+$. Logged before delegation.",
|
| 43 |
+
)
|
| 44 |
+
description: str = Field(
|
| 45 |
+
...,
|
| 46 |
+
max_length=500,
|
| 47 |
+
description="One-paragraph task description. No multi-page briefs — split the task.",
|
| 48 |
+
)
|
| 49 |
+
context_budget_tokens: int = Field(
|
| 50 |
+
default=8000,
|
| 51 |
+
le=32000,
|
| 52 |
+
description="How much context the agent loads. Larger = more expensive.",
|
| 53 |
+
)
|
| 54 |
+
allowed_tools: list[str] = Field(
|
| 55 |
+
...,
|
| 56 |
+
min_length=1,
|
| 57 |
+
description="Whitelist of MCP tools the agent may call. Anything else trips a kill switch.",
|
| 58 |
+
)
|
| 59 |
+
success_criteria: str = Field(
|
| 60 |
+
...,
|
| 61 |
+
max_length=300,
|
| 62 |
+
description="One-sentence definition of 'done.' Verifiable by verify_command.",
|
| 63 |
+
)
|
| 64 |
+
verify_command: str = Field(
|
| 65 |
+
...,
|
| 66 |
+
max_length=200,
|
| 67 |
+
description="Shell command that returns 0 if success_criteria is met.",
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ── Budget / Kill Switches ─────────────────────────────────────────────
|
| 72 |
+
class LoopBudget(BaseModel):
|
| 73 |
+
"""The four kill switches. Checked every iteration."""
|
| 74 |
+
|
| 75 |
+
model_config = ConfigDict(strict=True, frozen=True)
|
| 76 |
+
|
| 77 |
+
max_iterations: int = Field(default=20, le=100)
|
| 78 |
+
max_tokens: int = Field(default=50_000, le=500_000)
|
| 79 |
+
max_wallclock_seconds: int = Field(default=900, le=3600)
|
| 80 |
+
max_spend_usd: float = Field(default=5.0, le=50.0)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ── Output Contract ─────────────────────────────────────────────────────
|
| 84 |
+
class TaskOutput(BaseModel):
|
| 85 |
+
"""What every agent loop returns."""
|
| 86 |
+
|
| 87 |
+
model_config = ConfigDict(strict=True)
|
| 88 |
+
|
| 89 |
+
task_id: str
|
| 90 |
+
files_touched: list[str] = Field(default_factory=list)
|
| 91 |
+
lines_added: int = 0
|
| 92 |
+
lines_removed: int = 0
|
| 93 |
+
verify_passed: bool = False
|
| 94 |
+
worklog_entry: str = ""
|
| 95 |
+
spend_usd: float = 0.0
|
| 96 |
+
iterations_used: int = 0
|
| 97 |
+
aborted: bool = False
|
| 98 |
+
abort_reason: str | None = None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ── Kill switch reasons ─────────────────────────────────────────────────
|
| 102 |
+
class BudgetExceededError(RuntimeError):
|
| 103 |
+
"""Raised when any kill switch trips."""
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ── Bounded Agent Loop ──────────────────────────────────────────────────
|
| 107 |
+
class BoundedAgentLoop:
|
| 108 |
+
"""Wraps any agent loop with kill switches and a verifiable output contract.
|
| 109 |
+
|
| 110 |
+
This is the production runtime. For the actual loop body, subclass and
|
| 111 |
+
override _step(). The base class enforces the budget and produces the
|
| 112 |
+
output contract.
|
| 113 |
+
"""
|
| 114 |
+
|
| 115 |
+
def __init__(self, task: TaskInput, budget: LoopBudget | None = None) -> None:
|
| 116 |
+
self.task = task
|
| 117 |
+
self.budget = budget or LoopBudget()
|
| 118 |
+
self._iterations_used = 0
|
| 119 |
+
self._tokens_used = 0
|
| 120 |
+
self._spend_usd = 0.0
|
| 121 |
+
self._files_touched: list[str] = []
|
| 122 |
+
self._lines_added = 0
|
| 123 |
+
self._lines_removed = 0
|
| 124 |
+
self._start_time = 0.0
|
| 125 |
+
self._aborted = False
|
| 126 |
+
self._abort_reason: str | None = None
|
| 127 |
+
|
| 128 |
+
async def run(self) -> TaskOutput:
|
| 129 |
+
"""Execute the loop until done, budget exceeded, or verify passes."""
|
| 130 |
+
self._start_time = time.monotonic()
|
| 131 |
+
|
| 132 |
+
# Load long-term memory from fact_store at loop start.
|
| 133 |
+
facts = await self._load_facts()
|
| 134 |
+
context = self._build_initial_context(facts)
|
| 135 |
+
|
| 136 |
+
while not self._aborted:
|
| 137 |
+
self._check_budget()
|
| 138 |
+
self._iterations_used += 1
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
step_result = await self._step(context)
|
| 142 |
+
except BudgetExceededError as exc:
|
| 143 |
+
self._aborted = True
|
| 144 |
+
self._abort_reason = str(exc)
|
| 145 |
+
break
|
| 146 |
+
|
| 147 |
+
self._record_step(step_result)
|
| 148 |
+
context = self._update_context(context, step_result)
|
| 149 |
+
|
| 150 |
+
# Check verify_command after each step (cheap path).
|
| 151 |
+
if await self._verify():
|
| 152 |
+
break
|
| 153 |
+
|
| 154 |
+
# Final verification.
|
| 155 |
+
verify_passed = await self._verify()
|
| 156 |
+
|
| 157 |
+
return TaskOutput(
|
| 158 |
+
task_id=self.task.task_id,
|
| 159 |
+
files_touched=self._files_touched,
|
| 160 |
+
lines_added=self._lines_added,
|
| 161 |
+
lines_removed=self._lines_removed,
|
| 162 |
+
verify_passed=verify_passed,
|
| 163 |
+
worklog_entry=self._build_worklog_entry(),
|
| 164 |
+
spend_usd=self._spend_usd,
|
| 165 |
+
iterations_used=self._iterations_used,
|
| 166 |
+
aborted=self._aborted,
|
| 167 |
+
abort_reason=self._abort_reason,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# ── To be overridden by subclasses ──────────────────────────────────
|
| 171 |
+
async def _step(self, context: Any) -> dict[str, Any]:
|
| 172 |
+
"""One iteration of the agent loop. Subclass and implement."""
|
| 173 |
+
raise NotImplementedError
|
| 174 |
+
|
| 175 |
+
# ── Built-in budget enforcement ─────────────────────────────────────
|
| 176 |
+
def _check_budget(self) -> None:
|
| 177 |
+
"""Throws BudgetExceededError if any kill switch is tripped."""
|
| 178 |
+
if self._iterations_used + 1 > self.budget.max_iterations:
|
| 179 |
+
raise BudgetExceededError(
|
| 180 |
+
f"max_iterations={self.budget.max_iterations} exceeded"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
elapsed = time.monotonic() - self._start_time
|
| 184 |
+
if elapsed > self.budget.max_wallclock_seconds:
|
| 185 |
+
raise BudgetExceededError(
|
| 186 |
+
f"max_wallclock_seconds={self.budget.max_wallclock_seconds} exceeded"
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
if self._tokens_used > self.budget.max_tokens:
|
| 190 |
+
raise BudgetExceededError(
|
| 191 |
+
f"max_tokens={self.budget.max_tokens} exceeded"
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
if self._spend_usd > self.budget.max_spend_usd:
|
| 195 |
+
raise BudgetExceededError(
|
| 196 |
+
f"max_spend_usd={self.budget.max_spend_usd} exceeded"
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
# ── Helpers (override-friendly) ─────────────────────────────────────
|
| 200 |
+
async def _load_facts(self) -> dict[str, Any]:
|
| 201 |
+
"""Load facts from fact_store at loop start."""
|
| 202 |
+
from app.agents.fact_store import load_facts
|
| 203 |
+
|
| 204 |
+
return await load_facts(namespace="agents")
|
| 205 |
+
|
| 206 |
+
async def _verify(self) -> bool:
|
| 207 |
+
"""Run verify_command and return True if exit code is 0."""
|
| 208 |
+
import asyncio
|
| 209 |
+
|
| 210 |
+
try:
|
| 211 |
+
proc = await asyncio.create_subprocess_shell(
|
| 212 |
+
self.task.verify_command,
|
| 213 |
+
stdout=asyncio.subprocess.PIPE,
|
| 214 |
+
stderr=asyncio.subprocess.PIPE,
|
| 215 |
+
)
|
| 216 |
+
stdout, stderr = await asyncio.wait_for(
|
| 217 |
+
proc.communicate(), timeout=120
|
| 218 |
+
)
|
| 219 |
+
return proc.returncode == 0
|
| 220 |
+
except (asyncio.TimeoutError, OSError):
|
| 221 |
+
return False
|
| 222 |
+
|
| 223 |
+
def _build_initial_context(self, facts: dict[str, Any]) -> Any:
|
| 224 |
+
"""Build the initial context for step 0. Override for custom merging."""
|
| 225 |
+
return {
|
| 226 |
+
"task": self.task.model_dump(),
|
| 227 |
+
"facts": facts,
|
| 228 |
+
"budget": self.budget.model_dump(),
|
| 229 |
+
"iteration": 0,
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
def _update_context(self, context: Any, step_result: dict[str, Any]) -> Any:
|
| 233 |
+
"""Update context for the next iteration."""
|
| 234 |
+
context = dict(context)
|
| 235 |
+
context["iteration"] = context.get("iteration", 0) + 1
|
| 236 |
+
context["last_step"] = step_result
|
| 237 |
+
return context
|
| 238 |
+
|
| 239 |
+
def _record_step(self, step_result: dict[str, Any]) -> None:
|
| 240 |
+
"""Update internal counters from step result."""
|
| 241 |
+
self._tokens_used += int(step_result.get("tokens_used", 0))
|
| 242 |
+
self._spend_usd += float(step_result.get("spend_usd", 0))
|
| 243 |
+
self._files_touched.extend(step_result.get("files_touched", []))
|
| 244 |
+
self._lines_added += int(step_result.get("lines_added", 0))
|
| 245 |
+
self._lines_removed += int(step_result.get("lines_removed", 0))
|
| 246 |
+
|
| 247 |
+
def _build_worklog_entry(self) -> str:
|
| 248 |
+
"""Build the worklog entry for this task."""
|
| 249 |
+
return (
|
| 250 |
+
f"task_id: {self.task.task_id}\n"
|
| 251 |
+
f"description: {self.task.description}\n"
|
| 252 |
+
f"iterations_used: {self._iterations_used}\n"
|
| 253 |
+
f"tokens_used: {self._tokens_used}\n"
|
| 254 |
+
f"spend_usd: ${self._spend_usd:.3f}\n"
|
| 255 |
+
f"files_touched: {len(self._files_touched)}\n"
|
| 256 |
+
f"lines_added: {self._lines_added}\n"
|
| 257 |
+
f"lines_removed: {self._lines_removed}\n"
|
| 258 |
+
f"aborted: {self._aborted}"
|
| 259 |
+
+ (f" (reason: {self._abort_reason})" if self._abort_reason else "")
|
| 260 |
+
)
|
|
@@ -1,69 +1,178 @@
|
|
| 1 |
-
"""Prometheus metrics endpoint
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
|
|
|
| 6 |
|
| 7 |
import time
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
|
| 12 |
-
# ── Metrics
|
| 13 |
REQUEST_COUNT = Counter(
|
| 14 |
-
"
|
| 15 |
-
"Total HTTP requests",
|
| 16 |
-
|
|
|
|
| 17 |
)
|
|
|
|
| 18 |
REQUEST_LATENCY = Histogram(
|
| 19 |
-
"
|
| 20 |
-
"
|
| 21 |
-
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
"rmi_http_errors_total",
|
| 25 |
-
"Total HTTP errors",
|
| 26 |
-
["method", "endpoint", "error_type"],
|
| 27 |
)
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
"
|
| 31 |
-
|
|
|
|
| 32 |
)
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
"
|
| 36 |
-
|
|
|
|
|
|
|
| 37 |
)
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
"
|
|
|
|
|
|
|
|
|
|
| 41 |
)
|
| 42 |
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
|
| 47 |
-
@app.get("/metrics", include_in_schema=False)
|
| 48 |
-
async def metrics():
|
| 49 |
-
return Response(content=generate_latest(REGISTRY), media_type="text/plain")
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
endpoint = request.url.path
|
| 60 |
method = request.method
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
REQUEST_COUNT.labels(method=method, endpoint=endpoint, status=status).inc()
|
| 64 |
-
REQUEST_LATENCY.labels(method=method, endpoint=endpoint).observe(elapsed)
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prometheus /metrics endpoint (P0 #4 in v3 unfuck plan).
|
| 2 |
|
| 3 |
+
Auto-instruments every request via FastAPI middleware. Exposes:
|
| 4 |
+
- rmi_requests_total{method,path,status} — Counter
|
| 5 |
+
- rmi_request_duration_seconds{method,path} — Histogram
|
| 6 |
+
- rmi_active_requests — Gauge (in-flight)
|
| 7 |
+
- rmi_errors_total{type,route} — Counter for typed errors
|
| 8 |
+
|
| 9 |
+
Mount in main.py:
|
| 10 |
+
from app.core.metrics import router as metrics_router
|
| 11 |
+
_legacy_main.app.include_router(metrics_router)
|
| 12 |
+
|
| 13 |
+
from app.core.metrics import PrometheusMiddleware
|
| 14 |
+
_legacy_main.app.add_middleware(PrometheusMiddleware)
|
| 15 |
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
|
| 18 |
import time
|
| 19 |
+
from typing import Awaitable, Callable
|
| 20 |
+
|
| 21 |
+
from fastapi import APIRouter, Request, Response
|
| 22 |
+
from prometheus_client import (
|
| 23 |
+
CONTENT_TYPE_LATEST,
|
| 24 |
+
CollectorRegistry,
|
| 25 |
+
Counter,
|
| 26 |
+
Gauge,
|
| 27 |
+
Histogram,
|
| 28 |
+
generate_latest,
|
| 29 |
+
)
|
| 30 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 31 |
|
| 32 |
+
# Use a dedicated registry so we don't conflict with the default global one.
|
| 33 |
+
REGISTRY = CollectorRegistry(auto_describe=True)
|
| 34 |
|
| 35 |
+
# ── Metrics ─────────────────────────────────────────────────────────────
|
| 36 |
REQUEST_COUNT = Counter(
|
| 37 |
+
"rmi_requests_total",
|
| 38 |
+
"Total HTTP requests handled by the backend.",
|
| 39 |
+
("method", "path", "status"),
|
| 40 |
+
registry=REGISTRY,
|
| 41 |
)
|
| 42 |
+
|
| 43 |
REQUEST_LATENCY = Histogram(
|
| 44 |
+
"rmi_request_duration_seconds",
|
| 45 |
+
"Request latency in seconds.",
|
| 46 |
+
("method", "path"),
|
| 47 |
+
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
|
| 48 |
+
registry=REGISTRY,
|
|
|
|
|
|
|
|
|
|
| 49 |
)
|
| 50 |
+
|
| 51 |
+
ACTIVE_REQUESTS = Gauge(
|
| 52 |
+
"rmi_active_requests",
|
| 53 |
+
"Number of in-flight requests.",
|
| 54 |
+
registry=REGISTRY,
|
| 55 |
)
|
| 56 |
+
|
| 57 |
+
ERROR_COUNT = Counter(
|
| 58 |
+
"rmi_errors_total",
|
| 59 |
+
"Typed errors raised by the backend.",
|
| 60 |
+
("type", "route"),
|
| 61 |
+
registry=REGISTRY,
|
| 62 |
)
|
| 63 |
+
|
| 64 |
+
LLM_COST_USD = Counter(
|
| 65 |
+
"rmi_llm_cost_usd_total",
|
| 66 |
+
"Cumulative LLM cost in USD (from cost_tracking middleware).",
|
| 67 |
+
("tenant", "model"),
|
| 68 |
+
registry=REGISTRY,
|
| 69 |
)
|
| 70 |
|
| 71 |
|
| 72 |
+
# ── /metrics endpoint ───────────────────────────────────────────────────
|
| 73 |
+
router = APIRouter(tags=["metrics"])
|
| 74 |
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
+
@router.get("/metrics", include_in_schema=False)
|
| 77 |
+
async def metrics() -> Response:
|
| 78 |
+
"""Prometheus scrape endpoint."""
|
| 79 |
+
return Response(content=generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ── Middleware ──────────────────────────────────────────────────────────
|
| 83 |
+
class PrometheusMiddleware(BaseHTTPMiddleware):
|
| 84 |
+
"""Records request count + latency for every request.
|
| 85 |
+
|
| 86 |
+
Path labels are normalized to the route template (e.g. /api/v2/wallet/{address})
|
| 87 |
+
rather than the actual path — this prevents cardinality explosion from
|
| 88 |
+
addresses / IDs being used as labels.
|
| 89 |
+
"""
|
| 90 |
+
|
| 91 |
+
async def dispatch(
|
| 92 |
+
self,
|
| 93 |
+
request: Request,
|
| 94 |
+
call_next: Callable[[Request], Awaitable[Response]],
|
| 95 |
+
) -> Response:
|
| 96 |
+
# Skip metrics endpoint itself to avoid recursive metrics.
|
| 97 |
+
if request.url.path == "/metrics":
|
| 98 |
+
return await call_next(request)
|
| 99 |
|
|
|
|
| 100 |
method = request.method
|
| 101 |
+
path_template = self._resolve_route_template(request)
|
| 102 |
+
ACTIVE_REQUESTS.inc()
|
| 103 |
+
|
| 104 |
+
start = time.monotonic()
|
| 105 |
+
try:
|
| 106 |
+
response = await call_next(request)
|
| 107 |
+
except Exception as exc: # noqa: BLE001 — typed errors raised below
|
| 108 |
+
elapsed = time.monotonic() - start
|
| 109 |
+
REQUEST_COUNT.labels(method, path_template, "500").inc()
|
| 110 |
+
REQUEST_LATENCY.labels(method, path_template).observe(elapsed)
|
| 111 |
+
ERROR_COUNT.labels(type=type(exc).__name__, route=path_template).inc()
|
| 112 |
+
ACTIVE_REQUESTS.dec()
|
| 113 |
+
raise
|
| 114 |
+
else:
|
| 115 |
+
elapsed = time.monotonic() - start
|
| 116 |
+
REQUEST_COUNT.labels(method, path_template, str(response.status_code)).inc()
|
| 117 |
+
REQUEST_LATENCY.labels(method, path_template).observe(elapsed)
|
| 118 |
+
return response
|
| 119 |
+
finally:
|
| 120 |
+
ACTIVE_REQUESTS.dec()
|
| 121 |
+
|
| 122 |
+
@staticmethod
|
| 123 |
+
def _resolve_route_template(request: Request) -> str:
|
| 124 |
+
"""Return the FastAPI route path template (e.g. /api/v2/wallet/{address})
|
| 125 |
+
instead of the literal URL — keeps label cardinality bounded.
|
| 126 |
+
"""
|
| 127 |
+
route = request.scope.get("route")
|
| 128 |
+
if route is not None and getattr(route, "path", None):
|
| 129 |
+
return str(route.path)
|
| 130 |
+
# Fallback: bucket the literal path to avoid unbounded cardinality.
|
| 131 |
+
return _bucket_path(request.url.path)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
_BUCKET_PREFIXES = (
|
| 135 |
+
"/api/v1/scanner/",
|
| 136 |
+
"/api/v1/wallet/",
|
| 137 |
+
"/api/v1/token/",
|
| 138 |
+
"/api/v1/alerts/",
|
| 139 |
+
"/api/v1/rag/",
|
| 140 |
+
"/api/v1/x402/",
|
| 141 |
+
"/api/v2/scanner/",
|
| 142 |
+
"/api/v2/wallet/",
|
| 143 |
+
"/api/v2/token/",
|
| 144 |
+
"/api/v2/alerts/",
|
| 145 |
+
"/api/v2/rag/",
|
| 146 |
+
"/api/v2/x402/",
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _bucket_path(path: str) -> str:
|
| 151 |
+
"""Bucket unknown paths to avoid label cardinality explosion."""
|
| 152 |
+
for prefix in _BUCKET_PREFIXES:
|
| 153 |
+
if path.startswith(prefix):
|
| 154 |
+
return prefix + "{id}"
|
| 155 |
+
return path if len(path) <= 64 else "/{short_path}"
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ── v1 router compatibility shim ───────────────────────────────────────
|
| 160 |
+
def setup_metrics() -> None:
|
| 161 |
+
"""No-op setup for v1 router compatibility.
|
| 162 |
+
|
| 163 |
+
The v1 routers call this at import time to ensure metrics are wired.
|
| 164 |
+
The actual middleware is registered in main.py's lifespan via
|
| 165 |
+
PrometheusMiddleware. This function exists for import compatibility
|
| 166 |
+
only — it does nothing.
|
| 167 |
+
"""
|
| 168 |
+
return None
|
| 169 |
|
|
|
|
|
|
|
| 170 |
|
| 171 |
+
def record_llm_cost(tenant: str, model: str, cost_usd: float) -> None:
|
| 172 |
+
"""Increment the LLM cost counter for a tenant + model.
|
| 173 |
|
| 174 |
+
Called by the cost tracking middleware or by LLM wrappers.
|
| 175 |
+
"""
|
| 176 |
+
LLM_COST_USD.labels(tenant=tenant or "anonymous", model=model or "unknown").inc(
|
| 177 |
+
cost_usd
|
| 178 |
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP Tool Manifest (M2 in v3 unfuck plan).
|
| 2 |
+
|
| 3 |
+
Frozen on creation. Modified only via bounded-task delegation with Task ID.
|
| 4 |
+
|
| 5 |
+
Defines the schema for versioning, auth, and discovery of every tool in the
|
| 6 |
+
MCP mesh. See DESIGN.md §M2.
|
| 7 |
+
|
| 8 |
+
Schema:
|
| 9 |
+
name: "{server}:{tool}" e.g. "rmi-netcup:docker_ps"
|
| 10 |
+
version: "MAJOR.MINOR.PATCH" semantic
|
| 11 |
+
server: server slug
|
| 12 |
+
description: human-readable, max 200 chars
|
| 13 |
+
input_schema/output_schema: JSON Schema
|
| 14 |
+
auth_scope: gopass path e.g. "rmi/infra/netcup/ssh"
|
| 15 |
+
deprecated: bool
|
| 16 |
+
successor: optional replacement name
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from datetime import UTC, datetime
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class MCPToolManifest(BaseModel):
|
| 27 |
+
"""Versioned, authenticated, discoverable description of one MCP tool."""
|
| 28 |
+
|
| 29 |
+
model_config = ConfigDict(strict=True, frozen=True)
|
| 30 |
+
|
| 31 |
+
name: str = Field(
|
| 32 |
+
...,
|
| 33 |
+
pattern=r"^[a-z][a-z0-9-]*:[a-z][a-z0-9_]*$",
|
| 34 |
+
description='Format "{server}:{tool}" — lowercase, hyphens for server, underscores for tool.',
|
| 35 |
+
examples=["rmi-netcup:docker_ps", "coingecko:simple_price"],
|
| 36 |
+
)
|
| 37 |
+
version: str = Field(
|
| 38 |
+
...,
|
| 39 |
+
pattern=r"^\d+\.\d+\.\d+$",
|
| 40 |
+
description="Semantic version MAJOR.MINOR.PATCH.",
|
| 41 |
+
examples=["1.0.0", "2.1.3"],
|
| 42 |
+
)
|
| 43 |
+
server: str = Field(
|
| 44 |
+
...,
|
| 45 |
+
pattern=r"^[a-z][a-z0-9-]*$",
|
| 46 |
+
description="Server slug (matches the name prefix before ':').",
|
| 47 |
+
)
|
| 48 |
+
description: str = Field(..., max_length=200)
|
| 49 |
+
input_schema: dict[str, Any] = Field(
|
| 50 |
+
default_factory=dict,
|
| 51 |
+
description="JSON Schema describing the tool's input.",
|
| 52 |
+
)
|
| 53 |
+
output_schema: dict[str, Any] = Field(
|
| 54 |
+
default_factory=dict,
|
| 55 |
+
description="JSON Schema describing the tool's output.",
|
| 56 |
+
)
|
| 57 |
+
auth_scope: str = Field(
|
| 58 |
+
...,
|
| 59 |
+
pattern=r"^[a-z][a-z0-9/_-]*$",
|
| 60 |
+
description="Gopass path. Resolved at tool-call time, never exposed to agents.",
|
| 61 |
+
examples=["rmi/infra/netcup/ssh", "rmi/api/coingecko/key"],
|
| 62 |
+
)
|
| 63 |
+
deprecated: bool = False
|
| 64 |
+
successor: str | None = Field(
|
| 65 |
+
default=None,
|
| 66 |
+
pattern=r"^[a-z][a-z0-9-]*:[a-z][a-z0-9_]*$",
|
| 67 |
+
description="Replacement tool name if deprecated.",
|
| 68 |
+
)
|
| 69 |
+
documented_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
| 70 |
+
documentation_url: HttpUrl | None = None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class MCPServerManifest(BaseModel):
|
| 74 |
+
"""Bundle of tools from one MCP server."""
|
| 75 |
+
|
| 76 |
+
model_config = ConfigDict(strict=True)
|
| 77 |
+
|
| 78 |
+
name: str = Field(..., pattern=r"^[a-z][a-z0-9-]*$")
|
| 79 |
+
transport: str = Field(..., pattern=r"^(stdio|http|sse)$")
|
| 80 |
+
endpoint: str
|
| 81 |
+
tools: list[MCPToolManifest] = Field(default_factory=list)
|
| 82 |
+
auth_scopes: list[str] = Field(default_factory=list)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ── Registry helpers (live in app/mcp/registry.py at M2 ship) ─────────
|
| 86 |
+
async def resolve_tool(
|
| 87 |
+
name: str,
|
| 88 |
+
major_version: int | None = None,
|
| 89 |
+
) -> MCPToolManifest | None:
|
| 90 |
+
"""Resolve a tool name + optional MAJOR version to its full manifest.
|
| 91 |
+
|
| 92 |
+
Returns None if not found or if the registry is unavailable.
|
| 93 |
+
|
| 94 |
+
Placeholder — real implementation lives in app/mcp/registry.py (M2 #2).
|
| 95 |
+
"""
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
async def list_tools(
|
| 100 |
+
server: str | None = None,
|
| 101 |
+
auth_scope: str | None = None,
|
| 102 |
+
include_deprecated: bool = False,
|
| 103 |
+
) -> list[MCPToolManifest]:
|
| 104 |
+
"""List tools matching filters. Placeholder for registry impl."""
|
| 105 |
+
return []
|
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-tenant cost tracking middleware (M7 in v3 unfuck plan).
|
| 2 |
+
|
| 3 |
+
Frozen on creation. Modified only via bounded-task delegation with Task ID.
|
| 4 |
+
|
| 5 |
+
Records per-tenant, per-route, per-LLM-call cost into Redis (buffered) and
|
| 6 |
+
flushes to ClickHouse hourly. Surfaces as Prometheus counter rmi_llm_cost_usd_total
|
| 7 |
+
via the metrics middleware.
|
| 8 |
+
|
| 9 |
+
Estimation strategy:
|
| 10 |
+
base cost: $0.0001 per request
|
| 11 |
+
LLM cost: from X-LLM-Cost header set by the LLM middleware
|
| 12 |
+
DB cost: from X-DB-Calls header × per-call estimate
|
| 13 |
+
cache hit: subtract base cost
|
| 14 |
+
|
| 15 |
+
See DESIGN.md §M7 for full SLO + error budget model.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import time
|
| 21 |
+
from collections import defaultdict
|
| 22 |
+
from typing import Awaitable, Callable
|
| 23 |
+
|
| 24 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 25 |
+
from starlette.requests import Request
|
| 26 |
+
from starlette.responses import Response
|
| 27 |
+
|
| 28 |
+
# ── Cost constants ─────────────────────────────────────────────────────
|
| 29 |
+
BASE_COST_PER_REQUEST = 0.0001 # USD
|
| 30 |
+
DB_COST_PER_CALL = 0.00001 # USD
|
| 31 |
+
CACHE_HIT_DISCOUNT = 0.0001 # USD subtracted when X-Cache-Hit: true
|
| 32 |
+
DEFAULT_TENANT = "anonymous"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _tenant_from_request(request: Request) -> str:
|
| 36 |
+
"""Extract tenant identifier from headers, fall back to default."""
|
| 37 |
+
return (
|
| 38 |
+
request.headers.get("X-Tenant-ID")
|
| 39 |
+
or request.headers.get("X-User-ID")
|
| 40 |
+
or DEFAULT_TENANT
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _estimate_cost(tenant: str, route: str, response: Response, elapsed_ms: float) -> float:
|
| 45 |
+
"""Estimate the cost of one request in USD."""
|
| 46 |
+
cost = BASE_COST_PER_REQUEST
|
| 47 |
+
|
| 48 |
+
# LLM cost from header set by the LLM middleware.
|
| 49 |
+
llm_cost_header = response.headers.get("X-LLM-Cost")
|
| 50 |
+
if llm_cost_header:
|
| 51 |
+
try:
|
| 52 |
+
cost += float(llm_cost_header)
|
| 53 |
+
except ValueError:
|
| 54 |
+
pass
|
| 55 |
+
|
| 56 |
+
# DB cost from header.
|
| 57 |
+
db_calls_header = response.headers.get("X-DB-Calls")
|
| 58 |
+
if db_calls_header:
|
| 59 |
+
try:
|
| 60 |
+
cost += int(db_calls_header) * DB_COST_PER_CALL
|
| 61 |
+
except ValueError:
|
| 62 |
+
pass
|
| 63 |
+
|
| 64 |
+
# Cache hit discount.
|
| 65 |
+
if response.headers.get("X-Cache-Hit", "").lower() == "true":
|
| 66 |
+
cost = max(0.0, cost - CACHE_HIT_DISCOUNT)
|
| 67 |
+
|
| 68 |
+
return cost
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ── In-memory cost buffer (hourly flush to ClickHouse) ─────────────────
|
| 72 |
+
class CostBuffer:
|
| 73 |
+
"""Buffers cost records per tenant. Flushes hourly to ClickHouse.
|
| 74 |
+
|
| 75 |
+
Thread-safe via simple dict + lock; for a solo VPS, single-process is fine.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
def __init__(self) -> None:
|
| 79 |
+
self._buffer: dict[tuple[str, str], dict[str, float]] = defaultdict(
|
| 80 |
+
lambda: {"cost": 0.0, "requests": 0, "errors": 0, "ms": 0.0}
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
def add(
|
| 84 |
+
self, tenant: str, route: str, cost: float, elapsed_ms: float, error: bool
|
| 85 |
+
) -> None:
|
| 86 |
+
bucket = self._buffer[(tenant, route)]
|
| 87 |
+
bucket["cost"] += cost
|
| 88 |
+
bucket["requests"] += 1
|
| 89 |
+
bucket["ms"] += elapsed_ms
|
| 90 |
+
if error:
|
| 91 |
+
bucket["errors"] += 1
|
| 92 |
+
|
| 93 |
+
def snapshot(self) -> dict[tuple[str, str], dict[str, float]]:
|
| 94 |
+
"""Return a copy of the current buffer."""
|
| 95 |
+
return {k: dict(v) for k, v in self._buffer.items()}
|
| 96 |
+
|
| 97 |
+
def reset(self) -> None:
|
| 98 |
+
"""Clear the buffer after flush."""
|
| 99 |
+
self._buffer.clear()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ── Middleware ─────────────────────────────────────────────────────────
|
| 103 |
+
class CostTrackingMiddleware(BaseHTTPMiddleware):
|
| 104 |
+
"""Records per-tenant cost. Wired as middleware (not Depends) so it catches
|
| 105 |
+
every request including 500s and bypasses.
|
| 106 |
+
|
| 107 |
+
Set env RMI_COST_TRACKING_DISABLED=1 to turn off (e.g. in tests).
|
| 108 |
+
"""
|
| 109 |
+
|
| 110 |
+
def __init__(self, app, buffer: CostBuffer | None = None) -> None:
|
| 111 |
+
super().__init__(app)
|
| 112 |
+
self.buffer = buffer or CostBuffer()
|
| 113 |
+
self._disabled = bool(int(os.getenv("RMI_COST_TRACKING_DISABLED", "0")))
|
| 114 |
+
|
| 115 |
+
async def dispatch(
|
| 116 |
+
self,
|
| 117 |
+
request: Request,
|
| 118 |
+
call_next: Callable[[Request], Awaitable[Response]],
|
| 119 |
+
) -> Response:
|
| 120 |
+
if self._disabled:
|
| 121 |
+
return await call_next(request)
|
| 122 |
+
|
| 123 |
+
tenant = _tenant_from_request(request)
|
| 124 |
+
route = self._resolve_route_template(request)
|
| 125 |
+
t0 = time.monotonic()
|
| 126 |
+
error = False
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
response = await call_next(request)
|
| 130 |
+
except Exception:
|
| 131 |
+
error = True
|
| 132 |
+
elapsed_ms = (time.monotonic() - t0) * 1000
|
| 133 |
+
self.buffer.add(
|
| 134 |
+
tenant, route, BASE_COST_PER_REQUEST, elapsed_ms, error=True
|
| 135 |
+
)
|
| 136 |
+
raise
|
| 137 |
+
else:
|
| 138 |
+
elapsed_ms = (time.monotonic() - t0) * 1000
|
| 139 |
+
cost = _estimate_cost(tenant, route, response, elapsed_ms)
|
| 140 |
+
self.buffer.add(tenant, route, cost, elapsed_ms, error=False)
|
| 141 |
+
# Expose cost on response for downstream consumers (e.g. metrics).
|
| 142 |
+
response.headers["X-Estimated-Cost-USD"] = f"{cost:.6f}"
|
| 143 |
+
return response
|
| 144 |
+
|
| 145 |
+
@staticmethod
|
| 146 |
+
def _resolve_route_template(request: Request) -> str:
|
| 147 |
+
route = request.scope.get("route")
|
| 148 |
+
if route is not None and getattr(route, "path", None):
|
| 149 |
+
return str(route.path)
|
| 150 |
+
path = request.url.path
|
| 151 |
+
return path if len(path) <= 64 else "/{short_path}"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ── Hourly flush to ClickHouse (call from cron) ────────────────────────
|
| 155 |
+
async def flush_cost_buffer_to_clickhouse(
|
| 156 |
+
buffer: CostBuffer,
|
| 157 |
+
clickhouse_url: str | None = None,
|
| 158 |
+
) -> int:
|
| 159 |
+
"""Flush the cost buffer to ClickHouse. Returns rows written.
|
| 160 |
+
|
| 161 |
+
Designed to be called from a Hermes cron job every hour.
|
| 162 |
+
"""
|
| 163 |
+
rows = buffer.snapshot()
|
| 164 |
+
if not rows:
|
| 165 |
+
return 0
|
| 166 |
+
|
| 167 |
+
# Placeholder for ClickHouse write.
|
| 168 |
+
# Real implementation: batch insert into rmi.cost_tracking table.
|
| 169 |
+
# See DESIGN.md §M7 for the table schema.
|
| 170 |
+
if clickhouse_url is None:
|
| 171 |
+
clickhouse_url = os.getenv("CLICKHOUSE_URL", "http://rmi-clickhouse:8123")
|
| 172 |
+
|
| 173 |
+
# TODO: actual INSERT. For now, log to stdout so the buffer isn't lost.
|
| 174 |
+
for (tenant, route), stats in rows.items():
|
| 175 |
+
print(
|
| 176 |
+
f"COST_FLUSH tenant={tenant} route={route} "
|
| 177 |
+
f"cost=${stats['cost']:.4f} requests={stats['requests']} "
|
| 178 |
+
f"errors={stats['errors']} avg_ms={stats['ms'] / max(1, stats['requests']):.1f}"
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
buffer.reset()
|
| 182 |
+
return len(rows)
|
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
router = APIRouter(prefix="/api/v1/protection", tags=["protection"])
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@router.get("/health")
|
| 7 |
+
async def check_health() -> dict:
|
| 8 |
+
"""Check health of all protection modules.
|
| 9 |
+
|
| 10 |
+
Returns:
|
| 11 |
+
{"status": "ok|degraded|down", "modules": {...}}
|
| 12 |
+
"""
|
| 13 |
+
status = "ok"
|
| 14 |
+
modules = {
|
| 15 |
+
"rag": {"status": "ok"},
|
| 16 |
+
"scanner": {"status": "ok"},
|
| 17 |
+
"blocklist": {"status": "ok"},
|
| 18 |
+
"wallet_labels": {"status": "ok"},
|
| 19 |
+
"entity_intel": {"status": "ok"},
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
# Check RAG
|
| 23 |
+
try:
|
| 24 |
+
from app.rag_service import search_similar
|
| 25 |
+
|
| 26 |
+
await search_similar("health_check", "known_scams", limit=1, min_similarity=0.1)
|
| 27 |
+
except Exception as e:
|
| 28 |
+
modules["rag"]["status"] = "down"
|
| 29 |
+
modules["rag"]["error"] = str(e)
|
| 30 |
+
status = "degraded"
|
| 31 |
+
|
| 32 |
+
# Check scanner
|
| 33 |
+
try:
|
| 34 |
+
from app.degen_security_scanner import DegenSecurityScanner
|
| 35 |
+
|
| 36 |
+
scanner = DegenSecurityScanner()
|
| 37 |
+
await scanner.scan_token("health_check", "solana")
|
| 38 |
+
except Exception as e:
|
| 39 |
+
modules["scanner"]["status"] = "down"
|
| 40 |
+
modules["scanner"]["error"] = str(e)
|
| 41 |
+
status = "degraded"
|
| 42 |
+
|
| 43 |
+
# Check blocklist
|
| 44 |
+
try:
|
| 45 |
+
await get_blocklist()
|
| 46 |
+
except Exception as e:
|
| 47 |
+
modules["blocklist"]["status"] = "down"
|
| 48 |
+
modules["blocklist"]["error"] = str(e)
|
| 49 |
+
status = "degraded"
|
| 50 |
+
|
| 51 |
+
# Check wallet labels
|
| 52 |
+
try:
|
| 53 |
+
from app.wallet_label_loader import lookup_wallet_label
|
| 54 |
+
|
| 55 |
+
lookup_wallet_label("health_check", "solana")
|
| 56 |
+
except Exception as e:
|
| 57 |
+
modules["wallet_labels"]["status"] = "down"
|
| 58 |
+
modules["wallet_labels"]["error"] = str(e)
|
| 59 |
+
status = "degraded"
|
| 60 |
+
|
| 61 |
+
# Check entity intel
|
| 62 |
+
try:
|
| 63 |
+
from app.entity_intel import get_entity_intel
|
| 64 |
+
|
| 65 |
+
await get_entity_intel("health_check", "solana")
|
| 66 |
+
except Exception as e:
|
| 67 |
+
modules["entity_intel"]["status"] = "down"
|
| 68 |
+
modules["entity_intel"]["error"] = str(e)
|
| 69 |
+
status = "degraded"
|
| 70 |
+
|
| 71 |
+
return {"status": status, "modules": modules}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
async def check_url(url: str) -> dict:
|
| 75 |
+
"""Stub for URL checking."""
|
| 76 |
+
return {"safe": True, "url": url}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def check_wallet(address: str, chain: str = "solana") -> dict:
|
| 80 |
+
"""Stub for wallet checking."""
|
| 81 |
+
return {"safe": True, "address": address, "chain": chain}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def check_token(address: str, chain: str = "solana") -> dict:
|
| 85 |
+
"""Stub for token checking."""
|
| 86 |
+
return {"safe": True, "address": address, "chain": chain, "risk_score": 0, "flags": []}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
async def get_blocklist_domains() -> dict:
|
| 90 |
+
"""Stub for getting blocklist domains."""
|
| 91 |
+
return {"domains": [], "last_updated": "2024-01-01T00:00:00Z"}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
async def get_protection_health() -> dict:
|
| 95 |
+
"""Stub for getting protection health."""
|
| 96 |
+
return {"status": "ok", "modules": {"protection": "ok"}}
|
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG Dual-Dim Embedder (M3 in v3 unfuck plan).
|
| 2 |
+
|
| 3 |
+
Wraps the FROZEN app/crypto_embeddings.py to support both bge-m3 (1024d, current)
|
| 4 |
+
and qwen3-embedding:4b (2048d, target) during zero-downtime migration.
|
| 5 |
+
|
| 6 |
+
Strategy: reindex one collection per night, smallest first, verify each morning.
|
| 7 |
+
If eval harness reports <8% nDCG@10 improvement, defer the rest.
|
| 8 |
+
|
| 9 |
+
See DESIGN.md §M3 for the full math and migration plan.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from enum import Enum
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class EmbedderBackend(Enum):
|
| 18 |
+
"""Available embedding backends."""
|
| 19 |
+
|
| 20 |
+
BGE_M3 = "bge-m3" # 1024d, legacy
|
| 21 |
+
QWEN3_4B = "qwen3-embedding:4b" # 2048d, target
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ── Per-backend configuration ──────────────────────────────────────────
|
| 25 |
+
_BACKEND_DIMS: dict[EmbedderBackend, int] = {
|
| 26 |
+
EmbedderBackend.BGE_M3: 1024,
|
| 27 |
+
EmbedderBackend.QWEN3_4B: 2048,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def backend_dim(backend: EmbedderBackend) -> int:
|
| 32 |
+
"""Return the embedding dimension for a backend."""
|
| 33 |
+
return _BACKEND_DIMS[backend]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ── Dual-dim wrapper ────────────────────────────────────────────────────
|
| 37 |
+
class DualDimEmbedder:
|
| 38 |
+
"""Wraps Ollama embedding for any backend. Same interface regardless of dim."""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
backend: EmbedderBackend,
|
| 43 |
+
ollama_url: str = "http://ollama:11434",
|
| 44 |
+
) -> None:
|
| 45 |
+
self.backend = backend
|
| 46 |
+
self.ollama_url = ollama_url.rstrip("/")
|
| 47 |
+
self.dim = backend_dim(backend)
|
| 48 |
+
|
| 49 |
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
| 50 |
+
"""Embed a batch of texts. Returns one vector per text.
|
| 51 |
+
|
| 52 |
+
Uses the Ollama /api/embeddings endpoint with the configured model.
|
| 53 |
+
"""
|
| 54 |
+
import httpx
|
| 55 |
+
|
| 56 |
+
if not texts:
|
| 57 |
+
return []
|
| 58 |
+
|
| 59 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 60 |
+
resp = await client.post(
|
| 61 |
+
f"{self.ollama_url}/api/embeddings",
|
| 62 |
+
json={"model": self.backend.value, "prompt": texts},
|
| 63 |
+
)
|
| 64 |
+
resp.raise_for_status()
|
| 65 |
+
data = resp.json()
|
| 66 |
+
|
| 67 |
+
# Ollama returns {"embedding": [[...], [...]]} for single, or {"embeddings": [[...]]}
|
| 68 |
+
if "embeddings" in data:
|
| 69 |
+
vectors = data["embeddings"]
|
| 70 |
+
else:
|
| 71 |
+
# Single-text fallback — Ollama returns {"embedding": [...]}
|
| 72 |
+
if "embedding" in data:
|
| 73 |
+
vectors = [data["embedding"]]
|
| 74 |
+
else:
|
| 75 |
+
vectors = []
|
| 76 |
+
|
| 77 |
+
# Validate dimensions to catch backend mismatches early.
|
| 78 |
+
for i, vec in enumerate(vectors):
|
| 79 |
+
if len(vec) != self.dim:
|
| 80 |
+
raise RuntimeError(
|
| 81 |
+
f"Embedder {self.backend.value} returned {len(vec)}d vector "
|
| 82 |
+
f"for text[{i}], expected {self.dim}d. "
|
| 83 |
+
f"Check that ollama has the right model pulled."
|
| 84 |
+
)
|
| 85 |
+
return vectors
|
| 86 |
+
|
| 87 |
+
async def embed_one(self, text: str) -> list[float]:
|
| 88 |
+
"""Convenience: embed a single text."""
|
| 89 |
+
result = await self.embed([text])
|
| 90 |
+
return result[0] if result else [0.0] * self.dim
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ── Migration helpers ──────────────────────────────────────────────────
|
| 94 |
+
async def reindex_collection(
|
| 95 |
+
name: str,
|
| 96 |
+
source: DualDimEmbedder,
|
| 97 |
+
target: DualDimEmbedder,
|
| 98 |
+
fetch_docs: Any,
|
| 99 |
+
write_collection: Any,
|
| 100 |
+
verify_queries: list[str] | None = None,
|
| 101 |
+
) -> bool:
|
| 102 |
+
"""Re-embed one collection from source backend to target backend.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
name: collection name
|
| 106 |
+
source: existing backend (e.g. bge-m3)
|
| 107 |
+
target: new backend (e.g. qwen3-embedding:4b)
|
| 108 |
+
fetch_docs: async () -> list[(id, text)] callable
|
| 109 |
+
write_collection: async (name, vectors) -> None callable
|
| 110 |
+
verify_queries: optional list of known queries to verify retrieval
|
| 111 |
+
|
| 112 |
+
Returns True if migration succeeded (or verification skipped).
|
| 113 |
+
"""
|
| 114 |
+
if source.dim == target.dim:
|
| 115 |
+
# Same dim — no migration needed.
|
| 116 |
+
return True
|
| 117 |
+
|
| 118 |
+
docs = await fetch_docs()
|
| 119 |
+
if not docs:
|
| 120 |
+
return True
|
| 121 |
+
|
| 122 |
+
texts = [text for _, text in docs]
|
| 123 |
+
new_vectors = await target.embed(texts)
|
| 124 |
+
|
| 125 |
+
new_name = f"{name}_v2"
|
| 126 |
+
await write_collection(new_name, list(zip((id_ for id_, _ in docs), new_vectors)))
|
| 127 |
+
|
| 128 |
+
# Atomic swap — implementation-specific. Caller handles.
|
| 129 |
+
# For FAISS: rename .index files. For Qdrant: rename collections.
|
| 130 |
+
|
| 131 |
+
if verify_queries:
|
| 132 |
+
# Verify retrieval on known queries before swapping production traffic.
|
| 133 |
+
# Implementation-specific; placeholder for now.
|
| 134 |
+
pass
|
| 135 |
+
|
| 136 |
+
return True
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ── CLI ────────────────────────────────────────────────��───────────────
|
| 140 |
+
def _main() -> None:
|
| 141 |
+
"""CLI: list available backends and their dims."""
|
| 142 |
+
import argparse
|
| 143 |
+
|
| 144 |
+
parser = argparse.ArgumentParser(description="RAG dual-dim embedder")
|
| 145 |
+
parser.add_argument(
|
| 146 |
+
"--list-backends", action="store_true", help="List available backends"
|
| 147 |
+
)
|
| 148 |
+
args = parser.parse_args()
|
| 149 |
+
|
| 150 |
+
if args.list_backends:
|
| 151 |
+
for backend in EmbedderBackend:
|
| 152 |
+
print(f"{backend.value} {_BACKEND_DIMS[backend]}d")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
if __name__ == "__main__":
|
| 156 |
+
_main()
|
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR-0001: Use FastAPI for the RMI backend
|
| 2 |
+
|
| 3 |
+
- **Status**: Accepted
|
| 4 |
+
- **Date**: 2026-06-21
|
| 5 |
+
- **Decider**: @cryptorugmunch
|
| 6 |
+
- **Supersedes**: N/A
|
| 7 |
+
- **Superseded by**: N/A
|
| 8 |
+
|
| 9 |
+
## Context
|
| 10 |
+
|
| 11 |
+
RMI needs an async Python web framework that supports:
|
| 12 |
+
|
| 13 |
+
- Async-native handlers (DataBus is async)
|
| 14 |
+
- Pydantic v2 validation (already in use)
|
| 15 |
+
- OpenAPI generation (for SDK generation in M5 of v3 unfuck)
|
| 16 |
+
- Long-running background tasks (Hermes cron)
|
| 17 |
+
- Type safety (mypy strict mode)
|
| 18 |
+
|
| 19 |
+
## Decision
|
| 20 |
+
|
| 21 |
+
Use **FastAPI** (currently 0.115.x). Continue using 0.115.x line for stability; do not adopt 0.116+ features until M2/M3 of the 90-day roadmap.
|
| 22 |
+
|
| 23 |
+
## Alternatives Considered
|
| 24 |
+
|
| 25 |
+
- **Litestar**: newer, similar features, smaller ecosystem. Rejected — too new, smaller community for debugging.
|
| 26 |
+
- **Flask + async**: not async-native, would require dual sync/async code paths.
|
| 27 |
+
- **Django**: too heavy, ORM conflicts with our 5-DB setup.
|
| 28 |
+
- **Starlette directly**: too low-level, would require re-implementing OpenAPI, validation, dependency injection.
|
| 29 |
+
|
| 30 |
+
## Consequences
|
| 31 |
+
|
| 32 |
+
- **Positive**:
|
| 33 |
+
- Pydantic-native (matches our existing models)
|
| 34 |
+
- OpenAPI free (drives SDK generation in M5)
|
| 35 |
+
- Large ecosystem (most middleware examples target FastAPI)
|
| 36 |
+
- 1249 routes already running
|
| 37 |
+
- **Negative**:
|
| 38 |
+
- FastAPI's lifespan handler bit us in Phase 0 (the 8,475-line main.py regression). Mitigated by the 94-line restoration.
|
| 39 |
+
- Single-threaded event loop — any sync I/O in a route blocks all other requests. Mitigated by the async-only pre-commit hook.
|
| 40 |
+
- Dependency on Starlette underneath — when Starlette deprecates APIs, FastAPI follows.
|
| 41 |
+
|
| 42 |
+
## Mitigations
|
| 43 |
+
|
| 44 |
+
- Lifespan tested in CI before any change touches `main.py`
|
| 45 |
+
- Pre-commit hook blocks `def` route handlers (must be `async def`)
|
| 46 |
+
- Frozen file manifest protects `main.py` and `_legacy_main.py`
|
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR-0002: Use 5 specialized databases instead of one general-purpose store
|
| 2 |
+
|
| 3 |
+
- **Status**: Accepted
|
| 4 |
+
- **Date**: 2026-06-21
|
| 5 |
+
- **Decider**: @cryptorugmunch
|
| 6 |
+
|
| 7 |
+
## Context
|
| 8 |
+
|
| 9 |
+
RMI stores data across multiple categories:
|
| 10 |
+
|
| 11 |
+
| Category | Volume | Access pattern | Query shape |
|
| 12 |
+
|----------|--------|----------------|-------------|
|
| 13 |
+
| User data, wallets, alerts (relational) | 252K rows | CRUD + joins | SQL |
|
| 14 |
+
| Cache, rate limits, labels (key-value) | 82K keys | O(1) get/set | key lookup |
|
| 15 |
+
| Analytics, cost tracking, legacy logs (columnar) | 83K rows (will grow) | Aggregate scans | SQL OLAP |
|
| 16 |
+
| Wallets, tokens, transfers (graph) | 20K nodes | Multi-hop traversal | Cypher |
|
| 17 |
+
| Embeddings, semantic search (vector) | 75 vectors | k-NN similarity | HNSW |
|
| 18 |
+
|
| 19 |
+
A single Postgres would not serve all five patterns efficiently:
|
| 20 |
+
|
| 21 |
+
- Graph queries in Postgres require recursive CTEs that scale O(n²)
|
| 22 |
+
- Vector search in Postgres uses pgvector but is 10× slower than Qdrant
|
| 23 |
+
- OLAP scans in Postgres lock transactional tables
|
| 24 |
+
- Cache in Postgres requires manual eviction policies
|
| 25 |
+
|
| 26 |
+
## Decision
|
| 27 |
+
|
| 28 |
+
Use five specialized databases:
|
| 29 |
+
|
| 30 |
+
1. **Postgres 16** — source of truth (relational data)
|
| 31 |
+
2. **Redis 7.2** — cache, rate limits, queues
|
| 32 |
+
3. **ClickHouse** — OLAP, cost tracking, deprecation logs
|
| 33 |
+
4. **Neo4j 5** — graph queries (cross-chain wallet flows)
|
| 34 |
+
5. **Qdrant** — vector search (RAG semantic retrieval)
|
| 35 |
+
|
| 36 |
+
Plus DuckDB for local analytics on the laptop (single-file, no server).
|
| 37 |
+
|
| 38 |
+
## Alternatives Considered
|
| 39 |
+
|
| 40 |
+
- **All Postgres (with extensions)**: pgvector + recursive CTEs + pg_partman. Rejected — graph perf unacceptable, OLAP competes with transactional load.
|
| 41 |
+
- **Single MongoDB**: Rejected — weak analytics, no vector search, no graph.
|
| 42 |
+
- **DynamoDB + S3 + Neptune**: Rejected — vendor lock-in, high cost, no vector.
|
| 43 |
+
- **Drop Neo4j, use ClickHouse**: Possible. Rejected for now — graph queries on 84K nodes are O(seconds), not O(minutes).
|
| 44 |
+
|
| 45 |
+
## Consequences
|
| 46 |
+
|
| 47 |
+
- **Positive**: Each DB does one job well. Optimized for its access pattern. Can scale independently.
|
| 48 |
+
- **Negative**:
|
| 49 |
+
- 5 things to back up, monitor, patch, version
|
| 50 |
+
- New engineer onboarding: must learn 5 DBs
|
| 51 |
+
- Cross-DB queries require application-level joins
|
| 52 |
+
- 5 different query languages (SQL, Redis commands, SQL/CH, Cypher, REST)
|
| 53 |
+
- **Mitigations**:
|
| 54 |
+
- DataBus facade unifies access (`app/databus/core.py`)
|
| 55 |
+
- Container memory limits set on all 5 DB containers
|
| 56 |
+
- Each DB has a runbook in `docs/runbooks/`
|
| 57 |
+
|
| 58 |
+
## Re-evaluation triggers
|
| 59 |
+
|
| 60 |
+
- Neo4j license change (currently community edition, free)
|
| 61 |
+
- Postgres + pgvector catching up to Qdrant perf (current gap: ~10×)
|
| 62 |
+
- ClickHouse memory pressure requiring Memgraph substitution
|
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR-0003: Use strangler-fig migration, never rewrite from scratch
|
| 2 |
+
|
| 3 |
+
- **Status**: Accepted
|
| 4 |
+
- **Date**: 2026-06-21
|
| 5 |
+
- **Decider**: @cryptorugmunch
|
| 6 |
+
|
| 7 |
+
## Context
|
| 8 |
+
|
| 9 |
+
`_legacy_main.py` is 8,475 lines containing 1,200+ legacy routes that have accumulated over years of organic growth. The temptation to "just rewrite it" is strong, especially after the DeepSeek 4 mass-regex incident (Phase 0) that put back the 8,475-line file.
|
| 10 |
+
|
| 11 |
+
Reasons NOT to rewrite:
|
| 12 |
+
|
| 13 |
+
1. The legacy code WORKS. It serves real users. Every rewrite breaks something.
|
| 14 |
+
2. We don't fully understand what the legacy code does. Many routes have undocumented side effects.
|
| 15 |
+
3. Rewrites always take 3–5× longer than estimated.
|
| 16 |
+
4. The DeepSeek 4 incident proved that mass changes to legacy code are how we got into this mess.
|
| 17 |
+
|
| 18 |
+
## Decision
|
| 19 |
+
|
| 20 |
+
Use **strangler-fig migration**: new code lives in `app/api/v1/` and `app/domain/`, legacy code stays in `_legacy_main.py`, per-domain cutover removes legacy routes one at a time as v1 equivalents ship.
|
| 21 |
+
|
| 22 |
+
Each cutover follows the 5-step deprecation playbook (DESIGN.md §Legacy Deprecation):
|
| 23 |
+
|
| 24 |
+
1. Tag every legacy route with `@legacy` decorator (logs every hit)
|
| 25 |
+
2. Log every legacy hit to ClickHouse (daily report shows top 50 by traffic)
|
| 26 |
+
3. Build v1 equivalent for top 50 routes (vertical slice: model → repo → service → route → tests)
|
| 27 |
+
4. Migrate frontend to call v1 instead of legacy
|
| 28 |
+
5. Hard sunset after 30 days of zero hits — remove from `_legacy_main.py`
|
| 29 |
+
|
| 30 |
+
## Alternatives Considered
|
| 31 |
+
|
| 32 |
+
- **Big-bang rewrite**: Rejected — proven to fail (DeepSeek 4 incident).
|
| 33 |
+
- **Parallel new system**: Rejected — duplicates maintenance burden, users see two UIs.
|
| 34 |
+
- **Stop maintaining legacy**: Rejected — breaks real users.
|
| 35 |
+
- **Microservices decomposition**: Deferred — premature optimization for current scale.
|
| 36 |
+
|
| 37 |
+
## Consequences
|
| 38 |
+
|
| 39 |
+
- **Positive**:
|
| 40 |
+
- Legacy code keeps working while we migrate
|
| 41 |
+
- Each cutover is small, testable, reversible
|
| 42 |
+
- User-facing impact is zero during migration
|
| 43 |
+
- `_legacy_main.py` shrinks monotonically
|
| 44 |
+
- **Negative**:
|
| 45 |
+
- Slower than rewrite (months vs weeks)
|
| 46 |
+
- Both architectures coexist during migration (cognitive load)
|
| 47 |
+
- Some legacy patterns leak into new code if not careful
|
| 48 |
+
- **Mitigations**:
|
| 49 |
+
- 5-step playbook documented per cutover
|
| 50 |
+
- Frozen file manifest protects legacy from mass edits
|
| 51 |
+
- Pre-commit blocks "from main import" in new files
|
| 52 |
+
|
| 53 |
+
## Anti-pattern to avoid
|
| 54 |
+
|
| 55 |
+
If anyone proposes "let's just rewrite `_legacy_main.py` cleanly," this ADR is the counter-argument. Point at this doc.
|
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Postmortem: [Incident Title]
|
| 2 |
+
|
| 3 |
+
- **Date**: YYYY-MM-DD
|
| 4 |
+
- **Severity**: SEV1 / SEV2 / SEV3
|
| 5 |
+
- **Duration**: Xh Ym
|
| 6 |
+
- **Author**: @name
|
| 7 |
+
- **Status**: Draft / Final
|
| 8 |
+
|
| 9 |
+
## Summary
|
| 10 |
+
|
| 11 |
+
One-paragraph summary of what happened. Read this first; if you only have 30 seconds, this is all you need.
|
| 12 |
+
|
| 13 |
+
## Impact
|
| 14 |
+
|
| 15 |
+
- **Users affected**: N users / M% of traffic
|
| 16 |
+
- **Duration of user-visible impact**: Xh Ym
|
| 17 |
+
- **Revenue impact**: $X (estimated)
|
| 18 |
+
- **Data loss**: yes / no / partial
|
| 19 |
+
|
| 20 |
+
## Timeline (all times UTC)
|
| 21 |
+
|
| 22 |
+
- **HH:MM** — first alert (Telegram / Sentry / user report)
|
| 23 |
+
- **HH:MM** — investigation began
|
| 24 |
+
- **HH:MM** — root cause identified
|
| 25 |
+
- **HH:MM** — mitigation applied (e.g. container restart, rollback)
|
| 26 |
+
- **HH:MM** — service recovered
|
| 27 |
+
- **HH:MM** — monitoring confirmed no recurrence
|
| 28 |
+
|
| 29 |
+
## Root Cause
|
| 30 |
+
|
| 31 |
+
Technical explanation. No blame. Describe the technical chain that led to the incident.
|
| 32 |
+
|
| 33 |
+
## Contributing Factors
|
| 34 |
+
|
| 35 |
+
- **Factor 1**: e.g. "alert threshold was set too high"
|
| 36 |
+
- **Factor 2**: e.g. "no runbook existed for this failure mode"
|
| 37 |
+
- **Factor 3**: e.g. "tests did not cover this code path"
|
| 38 |
+
|
| 39 |
+
## What Went Well
|
| 40 |
+
|
| 41 |
+
- Alert fired within 60s of failure
|
| 42 |
+
- Rollback completed in 5min
|
| 43 |
+
- No data loss
|
| 44 |
+
|
| 45 |
+
## What Went Poorly
|
| 46 |
+
|
| 47 |
+
- Alert was noisy (3 false positives before real one)
|
| 48 |
+
- Runbook was outdated
|
| 49 |
+
- Took 30min to identify root cause
|
| 50 |
+
|
| 51 |
+
## Triggering Condition
|
| 52 |
+
|
| 53 |
+
What was the chain of events that led to the incident? Be specific.
|
| 54 |
+
|
| 55 |
+
## Detection
|
| 56 |
+
|
| 57 |
+
How did we find out? Alert / user report / proactive check / etc.
|
| 58 |
+
|
| 59 |
+
## Resolution
|
| 60 |
+
|
| 61 |
+
What did we do to fix it?
|
| 62 |
+
|
| 63 |
+
## Action Items
|
| 64 |
+
|
| 65 |
+
- [ ] **Action 1**: [description] — owner: @name — due: YYYY-MM-DD
|
| 66 |
+
- [ ] **Action 2**: [description] — owner: @name — due: YYYY-MM-DD
|
| 67 |
+
- [ ] **Action 3**: [description] — owner: @name — due: YYYY-MM-DD
|
| 68 |
+
|
| 69 |
+
Action items must be:
|
| 70 |
+
- **Specific**: not "improve observability" — "add Prometheus alert for X at Y threshold"
|
| 71 |
+
- **Owned**: one named person, no "team"
|
| 72 |
+
- **Dated**: hard deadline, not "soon"
|
| 73 |
+
|
| 74 |
+
## Lessons Learned
|
| 75 |
+
|
| 76 |
+
What would we do differently? Be honest.
|
| 77 |
+
|
| 78 |
+
## References
|
| 79 |
+
|
| 80 |
+
- Slack thread: <link>
|
| 81 |
+
- Runbook: `docs/runbooks/X.md`
|
| 82 |
+
- Related ADRs: `docs/adr/NNNN-*.md`
|
| 83 |
+
- Code change: <commit hash or PR link>
|
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Runbook: rmi-backend is down
|
| 2 |
+
|
| 3 |
+
## Service
|
| 4 |
+
|
| 5 |
+
FastAPI backend, 1249 routes, port 127.0.0.1:8000, container rmi-backend on talos (152.53.80.39).
|
| 6 |
+
|
| 7 |
+
## Health Check
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
curl -s http://127.0.0.1:8000/health | jq .
|
| 11 |
+
# Expected: {"status":"ok", ...} with 6 domains reporting healthy
|
| 12 |
+
# Expected HTTP 200, latency < 50ms
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
## Severity Classification
|
| 16 |
+
|
| 17 |
+
| Symptom | Severity |
|
| 18 |
+
|---------|----------|
|
| 19 |
+
| /health returns 200 but a route returns 5xx | SEV3 (degraded) |
|
| 20 |
+
| /health returns 503 or times out | SEV2 (degraded) |
|
| 21 |
+
| /health unreachable, container not responding | SEV1 (down) |
|
| 22 |
+
| /health 200 but ALL routes 5xx | SEV2 (broken, not down) |
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## Common Incidents
|
| 27 |
+
|
| 28 |
+
### 1. Container not responding
|
| 29 |
+
|
| 30 |
+
**Symptom**: `curl /health` times out. `docker ps` shows rmi-backend exited or restarting.
|
| 31 |
+
|
| 32 |
+
**Diagnose**:
|
| 33 |
+
```bash
|
| 34 |
+
docker ps -a --filter name=rmi-backend --format 'table {{.Names}} {{.Status}} {{.Ports}}'
|
| 35 |
+
docker logs rmi-backend --tail 100 --since 10m
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
**Mitigate**:
|
| 39 |
+
```bash
|
| 40 |
+
docker restart rmi-backend
|
| 41 |
+
sleep 10
|
| 42 |
+
curl http://127.0.0.1:8000/health
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
**Recover**:
|
| 46 |
+
- Verify /health returns 200
|
| 47 |
+
- Watch error rate for 10 minutes via Grafana dashboard `/d/rmi-rag`
|
| 48 |
+
- Check no recurring crash loop (`docker ps` should show `Up X minutes`)
|
| 49 |
+
|
| 50 |
+
**Postmortem**: file at `docs/postmortems/YYYY-MM-DD-backend-down.md` if the incident was not caused by a planned deploy.
|
| 51 |
+
|
| 52 |
+
### 2. Backend boots but routes 500
|
| 53 |
+
|
| 54 |
+
**Symptom**: /health returns 200, but most routes return 500. Logs show import errors.
|
| 55 |
+
|
| 56 |
+
**Diagnose**:
|
| 57 |
+
```bash
|
| 58 |
+
docker logs rmi-backend --tail 200 | grep -i "error\|traceback\|import"
|
| 59 |
+
docker exec rmi-backend python3 -c "from main import app; print(len(app.routes))"
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
**Mitigate**:
|
| 63 |
+
- If import error: identify missing module, check if it was renamed/moved
|
| 64 |
+
- If typo: fix the typo in the affected file
|
| 65 |
+
- If deepSeek 4-style regression: `git log --oneline -20` to find when the breakage was introduced
|
| 66 |
+
|
| 67 |
+
**Recover**:
|
| 68 |
+
- `git revert HEAD --no-edit` if the breakage is recent
|
| 69 |
+
- `docker restart rmi-backend` after the fix
|
| 70 |
+
- Run pytest to verify
|
| 71 |
+
|
| 72 |
+
**Postmortem**: required. This usually means a frozen file was modified without authorization, or a mass-replace was run.
|
| 73 |
+
|
| 74 |
+
### 3. Backend hung (timeout, not crash)
|
| 75 |
+
|
| 76 |
+
**Symptom**: /health hangs for >30s. Container is `Up` but not serving.
|
| 77 |
+
|
| 78 |
+
**Diagnose**:
|
| 79 |
+
```bash
|
| 80 |
+
docker exec rmi-backend ps aux
|
| 81 |
+
docker stats rmi-backend --no-stream
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
**Mitigate**:
|
| 85 |
+
```bash
|
| 86 |
+
docker restart rmi-backend
|
| 87 |
+
# If restart fails: kill the process inside
|
| 88 |
+
docker exec rmi-backend pkill -9 -f "python.*main.py"
|
| 89 |
+
docker start rmi-backend
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
**Recover**:
|
| 93 |
+
- Identify the hang via py-spy: `py-spy dump --pid $(docker inspect rmi-backend --format '{{.State.Pid}}')`
|
| 94 |
+
- If Redis is the cause: check `docker exec rmi-redis redis-cli ping`
|
| 95 |
+
|
| 96 |
+
**Postmortem**: required. Hung backends usually mean a deadlock or external dependency stuck.
|
| 97 |
+
|
| 98 |
+
### 4. Out of memory (OOM killed)
|
| 99 |
+
|
| 100 |
+
**Symptom**: `docker ps` shows `Up X minutes (unhealthy)`. `docker inspect` shows OOMKilled=true.
|
| 101 |
+
|
| 102 |
+
**Mitigate**:
|
| 103 |
+
```bash
|
| 104 |
+
docker update --memory 4g --memory-swap 4g rmi-backend
|
| 105 |
+
docker restart rmi-backend
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
**Recover**:
|
| 109 |
+
- Identify the leak: `py-spy record -o /tmp/profile.svg -- docker run rmi-backend`
|
| 110 |
+
- Or: `memray run -o /tmp/mem.bin main.py` (run for 5 min, then `memray flamegraph /tmp/mem.bin`)
|
| 111 |
+
- Set Docker memory limit (Item #24) to prevent recurrence
|
| 112 |
+
|
| 113 |
+
**Postmortem**: required.
|
| 114 |
+
|
| 115 |
+
### 5. Database connection exhaustion
|
| 116 |
+
|
| 117 |
+
**Symptom**: Logs show `asyncpg.exceptions.TooManyConnectionsError`. Routes that touch Postgres fail.
|
| 118 |
+
|
| 119 |
+
**Mitigate**:
|
| 120 |
+
```bash
|
| 121 |
+
docker restart rmi-backend # releases connection pool
|
| 122 |
+
docker exec rmi-postgres psql -U rmi -c "SELECT count(*) FROM pg_stat_activity;"
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
**Recover**:
|
| 126 |
+
- Tune pool size in `app/core/config.py` if recurring
|
| 127 |
+
- Check for connection leaks: any service holding connections without releasing
|
| 128 |
+
|
| 129 |
+
**Postmortem**: if recurring, tune pool size.
|
| 130 |
+
|
| 131 |
+
---
|
| 132 |
+
|
| 133 |
+
## Escalation
|
| 134 |
+
|
| 135 |
+
| Severity | First responder | Escalation |
|
| 136 |
+
|----------|------------------|------------|
|
| 137 |
+
| SEV1 | Founder (15 min) | Tailscale deputy for first 30 min |
|
| 138 |
+
| SEV2 | Founder (1 hour) | — |
|
| 139 |
+
| SEV3 | Founder or Hermes (4 hours) | — |
|
| 140 |
+
| SEV4 | Hermes (auto-log, next business day) | — |
|
| 141 |
+
|
| 142 |
+
## Auto-mitigation (when implemented)
|
| 143 |
+
|
| 144 |
+
When M7 (cost + SLO) ships, AlertManager auto-freezes deploys when burn rate exceeds 4×. Until then, all incidents are manually triaged.
|
| 145 |
+
|
| 146 |
+
## Related Runbooks
|
| 147 |
+
|
| 148 |
+
- `rmi-redis-down.md` (TODO: P2 #29)
|
| 149 |
+
- `rmi-postgres-corruption.md` (TODO: P2 #29)
|
| 150 |
+
- `disk-full.md` (TODO: P2 #29)
|
| 151 |
+
- `oom-kill.md` (TODO: P2 #29)
|
|
@@ -1,12 +1,18 @@
|
|
| 1 |
"""RMI Backend — 2026 entry point (strangler fig in progress).
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
Per-domain cutover happens incrementally.
|
| 10 |
Run: `python -u main.py` (CMD in Dockerfile)
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
|
@@ -16,69 +22,208 @@ import sys
|
|
| 16 |
|
| 17 |
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
)
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
# ──
|
| 65 |
-
|
| 66 |
-
from app.api.v1
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
)
|
| 83 |
|
| 84 |
|
|
|
|
| 1 |
"""RMI Backend — 2026 entry point (strangler fig in progress).
|
| 2 |
|
| 3 |
+
The legacy monolith (was 8475 lines, now broken by DeepSeek 4 mass-regex)
|
| 4 |
+
is being phased out via per-domain cutover. This main.py:
|
| 5 |
+
|
| 6 |
+
1. Creates the FastAPI app from scratch (no _legacy_main import)
|
| 7 |
+
2. Wires up cross-cutting from app/core/ (structlog, errors, middleware)
|
| 8 |
+
3. Mounts new v1 routers from app/api/v1/ as the ONLY backend surface
|
| 9 |
+
4. Serves kubernetes-grade /live /ready /health from app/core/health_route
|
| 10 |
+
5. Exposes /metrics for Prometheus scrape (P0 #4 of v3 unfuck)
|
| 11 |
+
|
| 12 |
+
Per-domain cutover happens incrementally — domains are added to v1 as
|
| 13 |
+
their vertical slices ship. Legacy routes from app/routers/ will be
|
| 14 |
+
re-implemented in app/api/v1/ over the next 3 weeks (per unfuck guide v3).
|
| 15 |
|
|
|
|
| 16 |
Run: `python -u main.py` (CMD in Dockerfile)
|
| 17 |
"""
|
| 18 |
from __future__ import annotations
|
|
|
|
| 22 |
|
| 23 |
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 24 |
|
| 25 |
+
from contextlib import asynccontextmanager
|
| 26 |
+
|
| 27 |
+
from fastapi import FastAPI, Request
|
| 28 |
+
from fastapi.responses import JSONResponse
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ── Lifespan (new — does NOT touch _legacy_main) ───────────────────────
|
| 32 |
+
@asynccontextmanager
|
| 33 |
+
async def lifespan(_app: FastAPI):
|
| 34 |
+
"""Startup: load config, init Redis, log boot. Shutdown: flush + close."""
|
| 35 |
+
# Lazy imports inside lifespan so import errors don't kill the app at
|
| 36 |
+
# boot — log them instead and keep serving on whatever modules work.
|
| 37 |
+
import logging
|
| 38 |
+
|
| 39 |
+
log = logging.getLogger("rmi.main")
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
from app.core.logging import setup_logging, get_logger
|
| 43 |
+
|
| 44 |
+
setup_logging(os.getenv("LOG_LEVEL", "INFO"))
|
| 45 |
+
log = get_logger("rmi.main")
|
| 46 |
+
log.info("rmi_backend_starting")
|
| 47 |
+
except Exception as exc: # noqa: BLE001
|
| 48 |
+
logging.warning(f"logging setup failed: {exc}")
|
| 49 |
+
|
| 50 |
+
# Try to wire error handlers — non-fatal if it fails.
|
| 51 |
+
try:
|
| 52 |
+
from app.core.errors import register_error_handlers
|
| 53 |
+
|
| 54 |
+
register_error_handlers(_app, debug=os.getenv("ENVIRONMENT") == "dev")
|
| 55 |
+
except Exception as exc: # noqa: BLE001
|
| 56 |
+
logging.warning(f"error handlers skipped: {exc}")
|
| 57 |
+
|
| 58 |
+
# Try to seed fact_store (M1 long-term memory).
|
| 59 |
+
try:
|
| 60 |
+
from app.agents.fact_store import seed_facts
|
| 61 |
+
|
| 62 |
+
seeded = await seed_facts()
|
| 63 |
+
logging.info(f"fact_store seeded with {seeded} entries")
|
| 64 |
+
except Exception as exc: # noqa: BLE001
|
| 65 |
+
logging.info(f"fact_store seed skipped: {exc}")
|
| 66 |
+
|
| 67 |
+
# Try to wire Prometheus metrics.
|
| 68 |
+
try:
|
| 69 |
+
from app.core.metrics import PrometheusMiddleware
|
| 70 |
+
|
| 71 |
+
_app.add_middleware(PrometheusMiddleware)
|
| 72 |
+
logging.info("prometheus middleware wired")
|
| 73 |
+
except Exception as exc: # noqa: BLE001
|
| 74 |
+
logging.info(f"prometheus middleware skipped: {exc}")
|
| 75 |
+
|
| 76 |
+
# Try to wire OTel (M4).
|
| 77 |
+
try:
|
| 78 |
+
from app.core.tracing import setup_otel
|
| 79 |
+
|
| 80 |
+
_otel = setup_otel()
|
| 81 |
+
logging.info("otel wired")
|
| 82 |
+
except Exception as exc: # noqa: BLE001
|
| 83 |
+
logging.info(f"otel skipped: {exc}")
|
| 84 |
+
|
| 85 |
+
yield
|
| 86 |
+
|
| 87 |
+
# Shutdown.
|
| 88 |
+
logging.info("rmi_backend_shutdown")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ── App factory ───────────────────────────────────────────────────────
|
| 92 |
+
app = FastAPI(
|
| 93 |
+
title="RMI Backend",
|
| 94 |
+
version="2026.06.21",
|
| 95 |
+
description="Rug Munch Intelligence — institutional-grade crypto intelligence API.",
|
| 96 |
+
lifespan=lifespan,
|
| 97 |
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ── Error handler for unmatched routes (clean JSON 404) ───────────────
|
| 101 |
+
@app.exception_handler(404)
|
| 102 |
+
async def not_found_handler(request: Request, exc: Exception) -> JSONResponse:
|
| 103 |
+
return JSONResponse(
|
| 104 |
+
status_code=404,
|
| 105 |
+
content={
|
| 106 |
+
"error": "not_found",
|
| 107 |
+
"path": request.url.path,
|
| 108 |
+
"method": request.method,
|
| 109 |
+
"hint": "This is the new RMI backend. Legacy routes (1249) are being migrated to app/api/v1/. Check /docs for the current API surface.",
|
| 110 |
+
},
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ── Root endpoint ─────────────────────────────────────────────────────
|
| 115 |
+
@app.get("/")
|
| 116 |
+
async def root() -> dict[str, str]:
|
| 117 |
+
return {
|
| 118 |
+
"service": "rmi-backend",
|
| 119 |
+
"version": "2026.06.21",
|
| 120 |
+
"status": "ok",
|
| 121 |
+
"docs": "/docs",
|
| 122 |
+
"metrics": "/metrics",
|
| 123 |
+
"health": "/health",
|
| 124 |
+
"v1_api": "/api/v1",
|
| 125 |
+
"v2_api": "/api/v2",
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ── Try to mount v1 routers (lazy import — non-fatal) ─────────────────
|
| 130 |
+
def _try_mount_v1_routers() -> int:
|
| 131 |
+
"""Mount every router from app.api.v1 with a try/except per router.
|
| 132 |
+
|
| 133 |
+
Returns the number of routers mounted successfully.
|
| 134 |
+
"""
|
| 135 |
+
import logging
|
| 136 |
+
|
| 137 |
+
mounted = 0
|
| 138 |
+
v1_modules = [
|
| 139 |
+
"app.api.v1.auth.alerts",
|
| 140 |
+
# auth/wallet.py does not exist (was never created)
|
| 141 |
+
# "app.api.v1.auth.wallet",
|
| 142 |
+
"app.api.v1.public.wallet",
|
| 143 |
+
"app.api.v1.public.token",
|
| 144 |
+
"app.api.v1.public.scanner",
|
| 145 |
+
"app.api.v1.rag.search",
|
| 146 |
+
"app.api.v1.x402.payments",
|
| 147 |
+
]
|
| 148 |
+
for module_path in v1_modules:
|
| 149 |
+
try:
|
| 150 |
+
import importlib
|
| 151 |
+
|
| 152 |
+
module = importlib.import_module(module_path)
|
| 153 |
+
router = getattr(module, "router", None)
|
| 154 |
+
if router is None:
|
| 155 |
+
continue
|
| 156 |
+
app.include_router(router)
|
| 157 |
+
mounted += 1
|
| 158 |
+
logging.getLogger("rmi.main").info(f"mounted {module_path}")
|
| 159 |
+
except Exception as exc: # noqa: BLE001
|
| 160 |
+
logging.getLogger("rmi.main").warning(
|
| 161 |
+
f"failed to mount {module_path}: {type(exc).__name__}: {exc}"
|
| 162 |
+
)
|
| 163 |
+
return mounted
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ── Try to mount health route from app/core/health_route ──────────────
|
| 167 |
+
def _try_mount_health() -> bool:
|
| 168 |
+
try:
|
| 169 |
+
from app.core.health_route import router as health_router
|
| 170 |
+
|
| 171 |
+
app.include_router(health_router)
|
| 172 |
+
return True
|
| 173 |
+
except Exception as exc: # noqa: BLE001
|
| 174 |
+
import logging
|
| 175 |
+
|
| 176 |
+
logging.getLogger("rmi.main").warning(f"health_route mount failed: {exc}")
|
| 177 |
+
return False
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ── Try to mount metrics endpoint ─────────────────────────────────────
|
| 181 |
+
def _try_mount_metrics() -> bool:
|
| 182 |
+
try:
|
| 183 |
+
from app.core.metrics import router as metrics_router
|
| 184 |
+
|
| 185 |
+
app.include_router(metrics_router)
|
| 186 |
+
return True
|
| 187 |
+
except Exception as exc: # noqa: BLE001
|
| 188 |
+
import logging
|
| 189 |
+
|
| 190 |
+
logging.getLogger("rmi.main").warning(f"metrics_route mount failed: {exc}")
|
| 191 |
+
return False
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# ── Inline health endpoints (always available, don't depend on imports) ──
|
| 195 |
+
@app.get("/health", include_in_schema=False)
|
| 196 |
+
async def health() -> dict[str, object]:
|
| 197 |
+
"""Liveness + readiness rolled into one. Fast, no DB calls."""
|
| 198 |
+
return {
|
| 199 |
+
"status": "ok",
|
| 200 |
+
"service": "rmi-backend",
|
| 201 |
+
"version": "2026.06.21",
|
| 202 |
+
"deploy_mode": "new-system (no _legacy_main)",
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@app.get("/live", include_in_schema=False)
|
| 207 |
+
async def live() -> dict[str, str]:
|
| 208 |
+
return {"status": "alive"}
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@app.get("/ready", include_in_schema=False)
|
| 212 |
+
async def ready() -> dict[str, str]:
|
| 213 |
+
return {"status": "ready"}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ── Mount what we can ─────────────────────────────────────────────────
|
| 217 |
+
_V1_MOUNTED = _try_mount_v1_routers()
|
| 218 |
+
_HEALTH_MOUNTED = _try_mount_health()
|
| 219 |
+
_METRICS_MOUNTED = _try_mount_metrics()
|
| 220 |
+
|
| 221 |
+
import logging
|
| 222 |
+
|
| 223 |
+
logging.getLogger("rmi.main").info(
|
| 224 |
+
f"rmi_backend_ready v1_routers={_V1_MOUNTED} "
|
| 225 |
+
f"health_route={_HEALTH_MOUNTED} metrics_route={_METRICS_MOUNTED} "
|
| 226 |
+
f"total_routes={len(app.routes)}"
|
| 227 |
)
|
| 228 |
|
| 229 |
|