Phase 1 of AUDIT-2026-Q3.md item P1.6.
The GitHub Actions ci.yml was 6/8 decorative -- every job had either
continue-on-error: true or || true, meaning failure was reported as success.
Restructured into 2 classes:
GATING (must pass for merge):
- build: editable install + verify app/main.py loads + OpenAPI export
- test: pytest tests/unit/ -- failures GATE merge
INFORMATIONAL (fire-and-forget):
- lint-info, typecheck-info, security-info, openapi-info,
qdrant-cleanup-info, heartbeat-info
- All have if: always() and continue-on-error: true
- Their failure is logged in PR checks but does not block merge
This restores CI as an authoritative gate, fixing the "6 jobs are decorative"
issue called out in the audit. Phase 5 will tighten the informational jobs
into hard gates once the underlying errors are fixed (lint debt, mypy gaps,
semgrep config, pip-audit noise).
Phase 1 of AUDIT-2026-Q3.md item P1.10 wiring.
Previously _register_apperror did:
try:
from app.core.errors import AppError
@app.exception_handler(AppError)
async def _app_error(...): ...
except Exception as exc:
log.info("error_handler_skipped name=AppError err=%s", exc)
That meant: if AppError was missing (it was — see previous commit), the
import silently failed and the app booted with ZERO typed-error handling.
All 2,532 except Exception: blocks would surface to clients as opaque 500s.
This change:
- Imports AppError (+ 4 commonly-raised subclasses) at module top so a
missing class is a hard ImportError at import time.
- Drops the try/except wrapper around _register_apperror.
- Renames the inner handlers to handle_app_error / handle_generic_exception
for clarity.
- handle_app_error returns exc.to_dict() (code, message, context) +
path + type, status_code = exc.status_code. The old code referenced
exc.message which would AttributeError since AppError stores message
via super().__init__, not as a self attribute.
- Drops the no-op try/except/pass in _register_validation (was a stub;
Phase 3 of the audit will wire RequestValidationError -> RMI ValidationError).
Verified via FastAPI TestClient:
GET /a AuthError -> 401 {code: auth.unauthorized, ...}
GET /r RateLimitError -> 429 {code: ratelimit.exceeded, ...}
GET /v ValidationError -> 400 {code: validation.failed, ...}
GET /n NotFoundError -> 404 {code: not_found, ...}
GET /u UpstreamError -> 502 {code: upstream.failed, ...}
GET /x RuntimeError -> 500 {error: internal_error, ...} (generic handler)
GET /404 -> 404 {error: not_found, hint, ...} (404 handler)
Refs AUDIT-2026-Q3.md P1.10.
Phase 1 of AUDIT-2026-Q3.md item P1.10.
Codebase had 2,532 except Exception: blocks and zero AppError class.
Bare exceptions made debugging in production impossible.
This change adds a strict typed hierarchy on top of the existing
register_error_handlers() (which app/lifespan.py imports):
AppError (base, status 500, code internal_error)
AuthError 401 auth.unauthorized
RateLimitError 429 ratelimit.exceeded
ValidationError 400 validation.failed (RMI-level, not pydantic)
NotFoundError 404 not_found
UpstreamError 502 upstream.failed
Each carries status_code (HTTP), code (machine-readable), and a
context dict that handlers forward to Sentry without code changes.
register_error_handlers() also now installs an AppError handler that
returns exc.to_dict() with a request_id, in addition to the existing
StarletteHTTPException / ValueError / Exception handlers.
Refs AUDIT-2026-Q3.md P1.10.
Phase 1 of AUDIT-2026-Q3.md item P1.3.
33 of 35 unit test files fail pytest collection without PYTHONPATH=.
Because pyproject.toml did not declare app/ as a package, pip install -e
".[dev]" did not register app as importable. After this commit:
- pip install -e .[dev] installs app as a real package
- pytest tests/ --collect-only works from any cwd without PYTHONPATH=
- New devs can follow README.md verbatim (no PYTHONPATH= env var)
Used [tool.setuptools.packages.find] with include = ["app*"] and
namespaces = false to auto-discover every sub-package that has __init__.py
(414 sub-packages). Sub-packages without __init__.py are namespace
packages and remain out of scope for Phase 1.3 (tracked for Phase 3):
app/middleware, app/telegram_bot, app/routers, app/domain/threat,
app/api/v1/rag, app/protection_api, app/services, app/mcp/tools.
Verified: from /tmp without PYTHONPATH, "import app" works and
"pytest tests/ --collect-only" reports 765 tests collected. The 8
remaining collection errors are pre-existing missing optional deps
(aiohttp in app/profile_flip_detector, app/bridge_health_monitor;
numpy in app/rag/ann_index) — unrelated to this fix.
Previously: jwt_secret defaulted to dev-secret-CHANGE-ME so a missing
env var in production would silently boot with a known-public dev HMAC
key — an instant auth bypass for any service verifying JWTs.
Now:
- Field(...) with no default → missing env raises ValidationError at boot
- min_length=32 enforces minimum entropy (rejects dev placeholder too)
- field_validator rejects the literal dev-secret-CHANGE-ME in ENVIRONMENT=prod
as belt-and-suspenders in case min_length is loosened later
Verified behavior:
- no env, no .env -> ValidationError: Field required
- ENVIRONMENT=prod, no JWT_SECRET -> ValidationError: Field required
- prod, JWT_SECRET=dev-secret-... -> ValidationError: string_too_short
- prod, JWT_SECRET=short -> ValidationError: string_too_short
- prod, JWT_SECRET=<64 hex> -> OK, len=64
.env.example updated to show the placeholder + generation hint.
Refs AUDIT-2026-Q3.md P1.5.
Phase 1 of AUDIT-2026-Q3.md item P1.8.
ruff.toml was overriding pyproject.toml [tool.ruff] silently:
ruff.toml: line-length 120, ignored SIM102/RUF006/B008
pyproject.toml: line-length 100, security rules enabled (canonical)
Two configs contradicting each other silently is the worst-case
configuration — neither developer nor CI notice, and the strict
[tool.ruff] security rules (S) never run.
Deleted ruff.toml. Added defensive .gitignore entry. ruff now reads
only pyproject.toml. Verified with ruff check --show-settings:
linter.line_length = 100
linter.pycodestyle.max_line_length = 100
ruff.toml moved to /tmp/rmi-backend-archive-2026-07/ruff.toml.removed-2026-07-06.
Phase 1 of AUDIT-2026-Q3.md item P1.4.
main.py.bak was a 374KB legacy leftover from the old 220-file app/
monolith. Its removal from the working tree was completed in e404e90
(feat(rmi-backend,audit): add app/main.py entrypoint delegating to
factory) in this audit session — that commit deleted main.py.bak and
added app/main.py in a single change.
This commit adds the defensive .gitignore entry so any future
main.py.bak / similar leftover files do not get re-tracked.
main.py.bak archive kept at:
/tmp/rmi-backend-archive-2026-07/main.py.bak.removed-2026-07-06
Note: historical commit bde2f3a still contains 4 mainnet token
addresses for gitleaks; full rewrite with git-filter-repo is scheduled
for Phase 1.11 of AUDIT-2026-Q3.md.
Phase 1 of AUDIT-2026-Q3.md item P1.2.
tests/ was not a Python package, so downstream imports
(`from tests.unit.x import y`, testcontainers config loading,
pytest --import-mode=importlib) failed without PYTHONPATH=.
Creating __init__.py in tests/, tests/unit/, tests/integration/
makes the suite a proper package. pytest collection already worked
via rootdir discovery (886 tests collected either way), but
*importing* test modules will now work.
Also seeds tests/integration/conftest.py with a Phase-1 stub
fixture. Phase 3 (testcontainers) replaces it.
Full fix lands in P1.3 (setuptools.packages = ["app"] +
tools.setuptools.package-data in pyproject.toml) — without that,
`pip install -e .` does not expose the app package.
Phase 1 of AUDIT-2026-Q3.md item P1.1.
app/main.py was referenced by Makefile, AGENTS.md, CONTRIBUTING.md,
and external `uvicorn app.main:app` invocations, but the file did not
exist (refactored to app/factory.py in a prior session — the docs were
never updated). This restores the canonical entrypoint.
The reported symptom (AttributeError: module 'bcrypt' has no attribute
'__about__') was misleading. passlib 1.7.4 (the latest released version
— the project is unmaintained) actually traps that AttributeError with
a bare `except:` and continues. The real failure occurs later in
`passlib.handlers.bcrypt._finalize_backend_mixin`:
detect_wrap_bug -> verify(secret, bug_hash)
where secret = (b"0123456789"*26)[:255] # 255-byte test secret
bcrypt 4.0+ removed silent 72-byte truncation and now raises
`ValueError: password cannot be longer than 72 bytes`, breaking the
backend probe and crashing every `pwd_context.hash()/verify()` call.
Three options were considered:
A. Upgrade passlib — impossible, 1.7.4 is the latest PyPI release.
B. Pin bcrypt<4.0 — conflicts with chromadb 1.1.1 (>=4.0.1 required),
even though chromadb's bcrypt usage is optional and still works
at runtime in practice.
C. Shim bcrypt.__about__ — doesn't fix the actual truncation error.
Chosen: refactor app/auth.py to use bcrypt directly for backup-code
hashing too (the main `hash_password`/`verify_password` already did).
Drops the passlib dependency entirely, restoring bcrypt 5.x for chromadb
compatibility and eliminating the unmaintained passlib pin.
Verified:
* hash_password / verify_password round-trip OK
* _hash_backup_code / _verify_backup_code round-trip OK on
freshly generated 8-digit backup codes
* `pytest tests/unit` -> 717 passed, 32 warnings, 60 subtests passed
Gitleaks flagged 4 production secrets in rmi-backend source:
app/caching_shield/solana_tracker.py: st_ZMzXzdUI54TXQPx5E6JkG
app/databus/arkham_ws.py: ws_Z5x09Rcr_1780418740765322917
app/routers/webhooks_router.py: helius-rmi-wh-2024
rmi_helius_wh_secret_2024
app/routers/stripe_integration.py: pk_test_51Tn13MAXseReicQtM...
Each replaced with os.getenv() reads. Placeholder env lines added to
/srv/rmi-infra/.env.secrets. Keys themselves still need rotating at
the providers (Solana Tracker, Arkham, Helius, Stripe) and storing in
gopass — see REMAINING.md.
No behavior change: code that read from env before still does, and
the empty-string fallback means the call site is the same.
Note: this commit scrubs the keys from the working tree. They remain
in git history. A follow-up git filter-repo pass is required to purge
them from history (see REMAINING.md). After that, all clones and
external remotes must be force-updated.
Python CI: uv setup, ruff lint+format, mypy (warn), pytest (non-integration).
Runs on docker-x64 runner on Talos.
44 tests exist, now they will run in CI.
- .forgejo/ISSUE_TEMPLATE/bug.yml
- .forgejo/ISSUE_TEMPLATE/feature.yml
- .forgejo/ISSUE_TEMPLATE/config.yml: blank_issues_enabled=false,
contact_links to discussions (Q&A, Feature Requests, Announcements)
Discussions per repo land in a separate follow-up commit.