Complete Phase 1: policy kernel, REST service, and local smoke tooling

Implements QONTO-WP-0002 (policy-gated Qonto REST service with audit
logging, rate limiting, and credential handling) and the ADHOC-2026-07-21
follow-up (fixture-backed local smoke mode, repo classification metadata).
Marks QONTO-WP-0001/0002 and the ad-hoc workplan finished, and regenerates
WORK-RECORDS.md and the ADHOC workplan's state_hub_workstream_id via
fix-consistency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-22 21:21:05 +02:00
parent eef408bb19
commit ca12843013
33 changed files with 2533 additions and 30 deletions

26
.repo-classification.yaml Normal file
View file

@ -0,0 +1,26 @@
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-07-21'
classified_by: agent
category: tooling
domain: infotech
secondary_domains:
- financials
- agents
capability_tags:
- finance
- governance
- automation
- api
- observability
business_stake:
- technology
- operations
- finance
- automation
business_mechanics:
- control
- coordination
- operation
notes: Governed Qonto domain service for read-only finance awareness across agent harnesses.

View file

@ -126,18 +126,28 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
`tenants/binky/qonto-api` (`API_KEY`, `API_USER`). Clients use assistant URL +
workload identity only. Policy v1: **no spend / no volume-cost**.
**Local verification (until QONTO-WP-0002 lands runtime):**
**Local verification (Phase 1 runtime landed):**
Preferred, when `make` and local venv bootstrapping are available:
```bash
# Workplan frontmatter ↔ hub
statehub fix-consistency
# Docs present
test -f INTENT.md && test -f specs/ArchitectureBlueprint.md
make install-dev
make test
make lint
make run
```
After Phase 1 skeleton exists, prefer `make test` / `make lint` as defined in
the Makefile from QONTO-WP-0002-T01.
Verified fallback on this workstation:
```bash
# Reuse a sibling fleet venv that already contains FastAPI/httpx/pytest
PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest
# Syntax-only fallback when ruff/dev deps are unavailable
python3 -m compileall src tests
```
Operational details live in `docs/operator-runbook.md`.
---

33
Makefile Normal file
View file

@ -0,0 +1,33 @@
VENV ?= .venv
PYTHON ?= $(VENV)/bin/python
PIP ?= $(PYTHON) -m pip
PYTEST ?= $(PYTHON) -m pytest
RUFF ?= $(VENV)/bin/ruff
.PHONY: help install-dev test lint run
help:
@echo "make install-dev Create .venv and install runtime + dev dependencies"
@echo "make test Run the Phase 1 test suite"
@echo "make lint Run syntax and static checks"
@echo "make run Start the local REST API on 127.0.0.1:8080"
$(VENV)/bin/python:
python3 -m venv $(VENV)
$(VENV)/.dev-installed: pyproject.toml $(VENV)/bin/python
$(PIP) install --upgrade pip
$(PIP) install -e ".[dev]"
@touch $(VENV)/.dev-installed
install-dev: $(VENV)/.dev-installed
test: $(VENV)/.dev-installed
$(PYTEST)
lint: $(VENV)/.dev-installed
$(PYTHON) -m compileall src tests
$(RUFF) check src tests
run: $(VENV)/.dev-installed
$(PYTHON) -m qonto_assistant.main

View file

@ -14,10 +14,35 @@ no spend, no volume-cost actions.**
| [`INTENT.md`](INTENT.md) | Why this exists; boundaries |
| [`specs/ArchitectureBlueprint.md`](specs/ArchitectureBlueprint.md) | Architecture, phases, policy model |
| [`research/2026-07-21-mcp-gateway-and-governed-domain-assistant.md`](research/2026-07-21-mcp-gateway-and-governed-domain-assistant.md) | External + internal research |
| [`docs/operator-runbook.md`](docs/operator-runbook.md) | How to run and verify Phase 1 |
## Status
Scaffold + intent + blueprint. Implementation tracked in `workplans/`.
Phase 1 runtime is implemented:
- Python 3.12 service under `src/qonto_assistant/`
- default-deny YAML policy
- Qonto read-only client (`organization`, `transactions`)
- REST endpoints: `/v1/health`, `/v1/accounts`, `/v1/transactions`, `/v1/snapshot`
- audit metadata, rate limiting, concurrency bounds, tests
Current verification:
- `PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest``16 passed`
- `python3 -m compileall src tests scripts`
- `../state-hub/.venv/bin/python scripts/smoke_rest_api.py --python ../state-hub/.venv/bin/python`
Local fixture-backed smoke mode is available through `QONTO_FIXTURE_DIR`, so the
service can be exercised without real Qonto credentials. The smoke path checks
both a `31`-day recent snapshot and a `90`-day recurring-cost snapshot.
Normal local workflow, when toolchain support exists:
```bash
make install-dev
make test
make run
```
## Related

View file

@ -8,15 +8,18 @@
| Kind | ID | Status | Lane | Source |
| --- | --- | --- | --- | --- |
| workplan | ADHOC-2026-07-21 | finished | — | workplans/ADHOC-2026-07-21.md |
| workplan | QONTO-WP-0001 | finished | — | workplans/QONTO-WP-0001-statehub-bootstrap.md |
| workplan | QONTO-WP-0002 | ready | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| workplan | QONTO-WP-0002 | finished | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | ADHOC-2026-07-21-T01 | done | — | workplans/ADHOC-2026-07-21.md |
| task | ADHOC-2026-07-21-T02 | done | — | workplans/ADHOC-2026-07-21.md |
| task | QONTO-WP-0001-T01 | done | — | workplans/QONTO-WP-0001-statehub-bootstrap.md |
| task | QONTO-WP-0001-T02 | wait | — | workplans/QONTO-WP-0001-statehub-bootstrap.md |
| task | QONTO-WP-0001-T02 | done | — | workplans/QONTO-WP-0001-statehub-bootstrap.md |
| task | QONTO-WP-0001-T03 | done | — | workplans/QONTO-WP-0001-statehub-bootstrap.md |
| task | QONTO-WP-0002-T01 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T02 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T03 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T04 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T05 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T06 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T07 | todo | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T01 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T02 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T03 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T04 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T05 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T06 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |
| task | QONTO-WP-0002-T07 | done | — | workplans/QONTO-WP-0002-policy-kernel-and-rest.md |

183
docs/operator-runbook.md Normal file
View file

@ -0,0 +1,183 @@
# Qonto Assistant Operator Runbook
## What this Phase 1 service does
`qonto-assistant` is the read-only REST surface for governed Qonto access.
Phase 1 ships:
- policy-gated `GET /v1/accounts`
- policy-gated `GET /v1/transactions`
- policy-gated `GET /v1/snapshot`
- structured audit events without secrets
- env-backed or OpenBao-CLI-backed credential loading
Spend, transfer, card, invoicing, payment-link, and other volume-cost actions
remain denied by policy.
## Preferred local workflow
If `make` and `python3 -m venv` are available:
```bash
make install-dev
make test
make run
```
The API then listens on `http://127.0.0.1:8080`.
## Verified fallback on this workstation
This workstation currently lacks:
- `make`
- `python3 -m venv` support (`ensurepip` missing)
- `python3 -m pip`
Use an existing fleet virtualenv that already contains FastAPI/httpx/pytest:
```bash
PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest
python3 -m compileall src tests
```
This fallback was used to verify the current implementation.
## Credential sources
### Option A: env-injected credentials
Provide either:
- `API_USER` + `API_KEY`
- or `QONTO_ORGANIZATION_ID` + `QONTO_API_KEY`
Example:
```bash
export API_USER='...'
export API_KEY='...'
```
### Option B: OpenBao CLI fetch inside the service
Set:
```bash
export QONTO_CREDENTIAL_SOURCE=bao-cli
export QONTO_OPENBAO_PATH=tenants/binky/qonto-api
export QONTO_OPENBAO_COMMAND=bao
```
The service then shells out to `bao kv get -field=...` and caches the
credentials in memory for a short TTL.
## Start the API
Preferred:
```bash
make run
```
Fallback:
```bash
PYTHONPATH=src ../state-hub/.venv/bin/python -m qonto_assistant.main
```
## Fixture-backed local mode
For local smoke work without real bank credentials:
```bash
export QONTO_FIXTURE_DIR=tests/fixtures/qonto
PYTHONPATH=src ../state-hub/.venv/bin/python -m qonto_assistant.main
```
In this mode the service serves canned Qonto organization and transaction
payloads from `tests/fixtures/qonto/`.
## One-command HTTP smoke
```bash
../state-hub/.venv/bin/python scripts/smoke_rest_api.py \
--python ../state-hub/.venv/bin/python
```
This starts the service on a random local port against the fixture payloads,
checks `/v1/health`, `/v1/accounts`, a recent `31`-day snapshot, and a wider
`90`-day cost-review snapshot, then shuts the process down.
## Example calls
Minimal local call:
```bash
python3 - <<'PY'
import json
import urllib.request
req = urllib.request.Request(
"http://127.0.0.1:8080/v1/accounts",
headers={"X-Actor-ID": "local-operator", "X-Tenant-ID": "binky"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
print(json.dumps(json.load(resp), indent=2))
PY
```
Transactions view:
```bash
python3 - <<'PY'
import json
import urllib.request
req = urllib.request.Request(
"http://127.0.0.1:8080/v1/transactions?page_size=50&window_days=31",
headers={"X-Actor-ID": "finance-steward", "X-Tenant-ID": "binky"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
print(json.dumps(json.load(resp), indent=2))
PY
```
Snapshot for CostRunRate refresh:
```bash
python3 - <<'PY'
import json
import urllib.request
req = urllib.request.Request(
"http://127.0.0.1:8080/v1/snapshot?window_days=90&page_size=50",
headers={"X-Actor-ID": "finance-steward", "X-Tenant-ID": "binky"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
print(json.dumps(json.load(resp), indent=2))
PY
```
Use `window_days=31` for a recent-activity view. Use `window_days=90` or `93`
when recurring fixed-cost hints are required.
## CostRunRate refresh path
`binky-control` should consume `GET /v1/snapshot` and extract:
- redacted organization/account summary
- recent transactions
- recurring debit hints for fixed-cost review
The repo does not write directly into `binky-control/finance/CostRunRate.md`.
That consumer-side write remains outside this repo.
## Safety notes
- Never print or commit `API_KEY`.
- Prefer `X-Tenant-ID: binky` explicitly even in single-tenant dogfood.
- Audit output is metadata-only; account identifiers stay redacted by default.
- The service supports a `bearer` auth mode for future upstream evolution, but
the current dogfood path remains `legacy_api_key` because that is the proven
BINKY-WP-0005 header mode.

48
pyproject.toml Normal file
View file

@ -0,0 +1,48 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "qonto-assistant"
version = "0.1.0"
description = "Governed Qonto read-only REST assistant for multi-harness finance awareness."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "Coulomb" }]
dependencies = [
"fastapi>=0.115,<1.0",
"httpx>=0.27,<1.0",
"PyYAML>=6.0,<7.0",
"uvicorn[standard]>=0.30,<1.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.2,<9.0",
"ruff>=0.6,<1.0",
]
[project.scripts]
qonto-assistant = "qonto_assistant.main:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
qonto_assistant = ["policy/*.yaml"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
"--strict-markers",
"--disable-warnings",
"--tb=short",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "B"]

127
scripts/smoke_rest_api.py Normal file
View file

@ -0,0 +1,127 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run a local HTTP smoke test for qonto-assistant.")
parser.add_argument(
"--python",
default=sys.executable,
help="Python interpreter used to start qonto_assistant.main",
)
parser.add_argument(
"--fixture-dir",
default=str(Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "qonto"),
help="Fixture directory containing organization.json and transactions.json",
)
parser.add_argument(
"--startup-timeout",
type=float,
default=15.0,
help="Seconds to wait for the local API to start",
)
return parser.parse_args()
def free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def request_json(url: str, *, headers: dict[str, str] | None = None) -> dict[str, object]:
request = urllib.request.Request(url, headers=headers or {})
with urllib.request.urlopen(request, timeout=5) as response:
return json.load(response)
def wait_for_health(base_url: str, *, timeout_seconds: float) -> dict[str, object]:
deadline = time.monotonic() + timeout_seconds
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
return request_json(f"{base_url}/v1/health")
except Exception as exc: # noqa: BLE001
last_error = exc
time.sleep(0.25)
raise RuntimeError(f"Timed out waiting for {base_url}/v1/health: {last_error}")
def main() -> int:
args = parse_args()
repo_root = Path(__file__).resolve().parents[1]
port = free_port()
base_url = f"http://127.0.0.1:{port}"
env = os.environ.copy()
env["PYTHONPATH"] = str(repo_root / "src")
env["QONTO_ASSISTANT_HOST"] = "127.0.0.1"
env["QONTO_ASSISTANT_PORT"] = str(port)
env["QONTO_FIXTURE_DIR"] = str(Path(args.fixture_dir).resolve())
process = subprocess.Popen( # noqa: S603
[args.python, "-m", "qonto_assistant.main"],
cwd=repo_root,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
health = wait_for_health(base_url, timeout_seconds=args.startup_timeout)
headers = {"X-Actor-ID": "smoke", "X-Tenant-ID": "binky"}
accounts = request_json(f"{base_url}/v1/accounts", headers=headers)
snapshot_recent = request_json(
f"{base_url}/v1/snapshot?window_days=31&page_size=50",
headers=headers,
)
snapshot_cost = request_json(
f"{base_url}/v1/snapshot?window_days=90&page_size=50",
headers=headers,
)
assert health["status"] == "ok"
assert accounts["organization"]["name"] == "Binky Hedgehog GmbH"
assert accounts["accounts"][0]["iban_last4"] == "6810"
assert snapshot_recent["cost_run_rate_hints"]["recurring_debits"] == []
assert snapshot_cost["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31"
print(
json.dumps(
{
"health": health,
"accounts": accounts,
"snapshot_recent": snapshot_recent,
"snapshot_cost": snapshot_cost,
},
indent=2,
)
)
return 0
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
if process.stdout is not None:
output = process.stdout.read().strip()
if output:
sys.stderr.write(output + "\n")
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,3 @@
__all__ = ["__version__"]
__version__ = "0.1.0"

175
src/qonto_assistant/app.py Normal file
View file

@ -0,0 +1,175 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
import logging
from uuid import uuid4
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.concurrency import run_in_threadpool
from qonto_assistant import __version__
from qonto_assistant.audit import AuditLogger
from qonto_assistant.auth import actor_claims_from_request
from qonto_assistant.config import Settings
from qonto_assistant.credentials import build_credential_provider
from qonto_assistant.errors import QontoAssistantError, UpstreamError
from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import FixtureQontoClient, QontoClient
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
from qonto_assistant.service import CapabilityService
def create_app(
*,
settings: Settings | None = None,
service: CapabilityService | None = None,
audit_logger: AuditLogger | None = None,
rate_limiter: RateLimiter | None = None,
concurrency_limiter: ConcurrencyLimiter | None = None,
) -> FastAPI:
settings = settings or Settings.from_env()
audit_logger = audit_logger or AuditLogger()
service = service or _build_service(
settings=settings,
audit_logger=audit_logger,
rate_limiter=rate_limiter,
concurrency_limiter=concurrency_limiter,
)
app = FastAPI(title="qonto-assistant", version=__version__)
app.state.settings = settings
app.state.service = service
@app.exception_handler(QontoAssistantError)
def handle_qonto_error(_: Request, exc: QontoAssistantError) -> JSONResponse:
body = {"error_code": exc.error_code, "detail": exc.message}
if isinstance(exc, UpstreamError) and exc.upstream_status is not None:
body["upstream_status"] = exc.upstream_status
return JSONResponse(status_code=exc.status_code, content=body)
@app.get("/v1/health")
async def health() -> dict[str, str]:
return {
"status": "ok",
"service": settings.service_name,
"version": __version__,
"policy_file": str(settings.policy_file),
}
@app.get("/v1/accounts")
async def get_accounts(request: Request) -> JSONResponse:
claims = actor_claims_from_request(request, settings)
request_id = _request_id(request)
payload = await run_in_threadpool(service.get_accounts, claims=claims, request_id=request_id)
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
@app.get("/v1/transactions")
async def get_transactions(
request: Request,
account_slug: str | None = None,
page: int = 1,
page_size: int = 50,
window_days: int = 31,
status: str | None = "completed",
side: str | None = None,
) -> JSONResponse:
claims = actor_claims_from_request(request, settings)
request_id = _request_id(request)
payload = await run_in_threadpool(
service.list_transactions,
claims=claims,
request_id=request_id,
account_slug=account_slug,
page=page,
page_size=page_size,
window_days=window_days,
status=status,
side=side,
)
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
@app.get("/v1/snapshot")
async def get_snapshot(
request: Request,
window_days: int = 31,
page_size: int = 50,
) -> JSONResponse:
claims = actor_claims_from_request(request, settings)
request_id = _request_id(request)
payload = await run_in_threadpool(
service.get_snapshot,
claims=claims,
request_id=request_id,
window_days=window_days,
page_size=page_size,
)
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
@app.on_event("shutdown")
def shutdown_event() -> None:
with suppress(Exception):
service.client.close()
return app
def _request_id(request: Request) -> str:
existing = getattr(request.state, "request_id", None)
if existing:
return existing
request.state.request_id = request.headers.get("x-request-id", str(uuid4()))
return request.state.request_id
def _build_service(
*,
settings: Settings,
audit_logger: AuditLogger,
rate_limiter: RateLimiter | None,
concurrency_limiter: ConcurrencyLimiter | None,
) -> CapabilityService:
policy = PolicyEngine.from_file(
settings.policy_file,
required_scope=settings.required_scope,
enforce_scope=settings.enforce_scope,
)
if settings.qonto_fixture_dir is not None:
client = FixtureQontoClient(fixture_dir=settings.qonto_fixture_dir)
else:
credential_provider = build_credential_provider(settings)
client = QontoClient(
base_url=settings.qonto_base_url,
organization_path=settings.qonto_organization_path,
transactions_path=settings.qonto_transactions_path,
auth_mode=settings.qonto_auth_mode,
timeout_seconds=settings.qonto_timeout_seconds,
max_retries=settings.qonto_max_retries,
credential_provider=credential_provider,
)
return CapabilityService(
client=client,
policy=policy,
audit_logger=audit_logger,
rate_limiter=rate_limiter
or RateLimiter(
limit=settings.rate_limit_requests,
window_seconds=settings.rate_limit_window_seconds,
),
concurrency_limiter=concurrency_limiter
or ConcurrencyLimiter(limit=settings.max_concurrency),
)
def main() -> None:
settings = Settings.from_env()
logging.basicConfig(level=logging.INFO, format="%(message)s")
uvicorn.run(
"qonto_assistant.app:create_app",
factory=True,
host=settings.host,
port=settings.port,
reload=False,
)

View file

@ -0,0 +1,58 @@
from __future__ import annotations
import json
import logging
from collections.abc import Callable, Mapping
from dataclasses import asdict, is_dataclass
from datetime import UTC, datetime
from typing import Any
from qonto_assistant.contracts import AuditEvent
REDACTED = "[redacted]"
SECRET_KEYS = {
"api_key",
"authorization",
"authorization_header",
"openbao_token",
"secret",
"token",
}
def utc_now_iso() -> str:
return datetime.now(tz=UTC).replace(microsecond=0).isoformat()
def _sanitize(value: Any) -> Any:
if is_dataclass(value):
return _sanitize(asdict(value))
if isinstance(value, Mapping):
return {
key: (REDACTED if key.lower() in SECRET_KEYS else _sanitize(item))
for key, item in value.items()
}
if isinstance(value, list):
return [_sanitize(item) for item in value]
if isinstance(value, tuple):
return [_sanitize(item) for item in value]
return value
class AuditLogger:
def __init__(
self,
*,
logger: logging.Logger | None = None,
sink: Callable[[dict[str, Any]], None] | None = None,
) -> None:
self.logger = logger or logging.getLogger("qonto_assistant.audit")
self.logger.setLevel(logging.INFO)
self.sink = sink
def emit(self, event: AuditEvent | Mapping[str, Any]) -> dict[str, Any]:
payload = _sanitize(asdict(event) if is_dataclass(event) else dict(event))
if self.sink is not None:
self.sink(payload)
self.logger.info(json.dumps(payload, sort_keys=True))
return payload

View file

@ -0,0 +1,15 @@
from __future__ import annotations
from fastapi import Request
from qonto_assistant.config import Settings
from qonto_assistant.contracts import ActorClaims
def actor_claims_from_request(request: Request, settings: Settings) -> ActorClaims:
actor_id = request.headers.get("x-actor-id", "anonymous")
tenant_id = request.headers.get("x-tenant-id", settings.default_tenant_id)
lane = request.headers.get("x-actor-lane", settings.default_actor_lane)
raw_scopes = request.headers.get("x-actor-scopes", "")
scopes = frozenset(scope.strip() for scope in raw_scopes.split(",") if scope.strip())
return ActorClaims(actor_id=actor_id, tenant_id=tenant_id, lane=lane, scopes=scopes)

View file

@ -0,0 +1,74 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
@dataclass(frozen=True, slots=True)
class Settings:
service_name: str
default_tenant_id: str
default_actor_lane: str
required_scope: str
enforce_scope: bool
policy_file: Path
qonto_base_url: str
qonto_fixture_dir: Path | None
qonto_auth_mode: str
qonto_organization_path: str
qonto_transactions_path: str
qonto_timeout_seconds: float
qonto_max_retries: int
qonto_secret_ttl_seconds: int
rate_limit_requests: int
rate_limit_window_seconds: int
max_concurrency: int
credential_source: str
openbao_path: str
openbao_command: str
openbao_timeout_seconds: float
host: str
port: int
@classmethod
def from_env(cls) -> "Settings":
policy_file = Path(
os.getenv(
"QONTO_ASSISTANT_POLICY_FILE",
str(_repo_root() / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"),
)
)
return cls(
service_name=os.getenv("QONTO_ASSISTANT_SERVICE_NAME", "qonto-assistant"),
default_tenant_id=os.getenv("QONTO_ASSISTANT_DEFAULT_TENANT", "binky"),
default_actor_lane=os.getenv("QONTO_ASSISTANT_DEFAULT_LANE", "green"),
required_scope=os.getenv("QONTO_ASSISTANT_REQUIRED_SCOPE", "finance.qonto.read"),
enforce_scope=os.getenv("QONTO_ASSISTANT_ENFORCE_SCOPE", "false").lower() == "true",
policy_file=policy_file,
qonto_base_url=os.getenv("QONTO_BASE_URL", "https://thirdparty.qonto.com"),
qonto_fixture_dir=(
Path(os.environ["QONTO_FIXTURE_DIR"]).resolve()
if os.getenv("QONTO_FIXTURE_DIR")
else None
),
qonto_auth_mode=os.getenv("QONTO_AUTH_MODE", "legacy_api_key"),
qonto_organization_path=os.getenv("QONTO_ORGANIZATION_PATH", "/v2/organization"),
qonto_transactions_path=os.getenv("QONTO_TRANSACTIONS_PATH", "/v2/transactions"),
qonto_timeout_seconds=float(os.getenv("QONTO_TIMEOUT_SECONDS", "10")),
qonto_max_retries=int(os.getenv("QONTO_MAX_RETRIES", "1")),
qonto_secret_ttl_seconds=int(os.getenv("QONTO_SECRET_TTL_SECONDS", "300")),
rate_limit_requests=int(os.getenv("QONTO_RATE_LIMIT_REQUESTS", "20")),
rate_limit_window_seconds=int(os.getenv("QONTO_RATE_LIMIT_WINDOW_SECONDS", "60")),
max_concurrency=int(os.getenv("QONTO_MAX_CONCURRENCY", "4")),
credential_source=os.getenv("QONTO_CREDENTIAL_SOURCE", "env"),
openbao_path=os.getenv("QONTO_OPENBAO_PATH", "tenants/binky/qonto-api"),
openbao_command=os.getenv("QONTO_OPENBAO_COMMAND", "bao"),
openbao_timeout_seconds=float(os.getenv("QONTO_OPENBAO_TIMEOUT_SECONDS", "5")),
host=os.getenv("QONTO_ASSISTANT_HOST", "127.0.0.1"),
port=int(os.getenv("QONTO_ASSISTANT_PORT", "8080")),
)

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
ProtocolName = Literal["rest", "mcp"]
@dataclass(frozen=True, slots=True)
class ActorClaims:
actor_id: str
tenant_id: str
lane: str = "green"
scopes: frozenset[str] = field(default_factory=frozenset)
@dataclass(frozen=True, slots=True)
class CapabilityRequest:
capability_id: str
tenant_id: str
actor_claims: ActorClaims
resource_scope: str
request_args: dict[str, Any]
protocol: ProtocolName
response_class: str = "operational_summary"
@dataclass(frozen=True, slots=True)
class PolicyDecision:
allowed: bool
capability_id: str
policy_version: int
reason: str
request_args: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class QontoCredentials:
api_user: str
api_key: str
@dataclass(frozen=True, slots=True)
class AuditEvent:
request_id: str
timestamp: str
actor: str
tenant_id: str
capability: str
protocol: ProtocolName
decision: str
deny_reason: str | None
policy_version: int
latency_ms: int
qonto_http_status: int | None = None
result_count: int | None = None

View file

@ -0,0 +1,87 @@
from __future__ import annotations
import os
import subprocess
import time
from collections.abc import Callable
from qonto_assistant.config import Settings
from qonto_assistant.contracts import QontoCredentials
from qonto_assistant.errors import CredentialError
class EnvironmentCredentialProvider:
def get_credentials(self) -> QontoCredentials:
api_key = os.getenv("API_KEY") or os.getenv("QONTO_API_KEY")
api_user = os.getenv("API_USER") or os.getenv("QONTO_ORGANIZATION_ID")
if not api_key or not api_user:
raise CredentialError(
"Missing Qonto credentials in environment (API_KEY/API_USER or QONTO_* vars)"
)
return QontoCredentials(api_user=api_user, api_key=api_key)
def invalidate(self) -> None:
return None
class OpenBaoCliCredentialProvider:
def __init__(
self,
*,
command: str,
path: str,
timeout_seconds: float,
ttl_seconds: int,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> None:
self.command = command
self.path = path
self.timeout_seconds = timeout_seconds
self.ttl_seconds = ttl_seconds
self.runner = runner
self._cached: tuple[float, QontoCredentials] | None = None
def get_credentials(self) -> QontoCredentials:
now = time.monotonic()
if self._cached is not None and now - self._cached[0] < self.ttl_seconds:
return self._cached[1]
api_key = self._read_field("API_KEY")
api_user = self._read_field("API_USER")
credentials = QontoCredentials(api_user=api_user, api_key=api_key)
self._cached = (now, credentials)
return credentials
def invalidate(self) -> None:
self._cached = None
def _read_field(self, field_name: str) -> str:
result = self.runner(
[self.command, "kv", "get", f"-field={field_name}", self.path],
check=False,
capture_output=True,
text=True,
timeout=self.timeout_seconds,
)
if result.returncode != 0:
raise CredentialError(
f"OpenBao CLI failed while reading {field_name} from {self.path}: "
f"{result.stderr.strip() or result.stdout.strip() or 'unknown error'}"
)
value = result.stdout.strip()
if not value:
raise CredentialError(f"OpenBao returned an empty value for {field_name}")
return value
def build_credential_provider(settings: Settings) -> EnvironmentCredentialProvider | OpenBaoCliCredentialProvider:
if settings.credential_source == "bao-cli":
return OpenBaoCliCredentialProvider(
command=settings.openbao_command,
path=settings.openbao_path,
timeout_seconds=settings.openbao_timeout_seconds,
ttl_seconds=settings.qonto_secret_ttl_seconds,
)
if settings.credential_source == "env":
return EnvironmentCredentialProvider()
raise CredentialError(f"Unsupported credential source: {settings.credential_source}")

View file

@ -0,0 +1,65 @@
from __future__ import annotations
from qonto_assistant.contracts import PolicyDecision
class QontoAssistantError(Exception):
error_code = "internal_error"
status_code = 500
def __init__(self, message: str = "") -> None:
super().__init__(message or self.error_code)
self.message = message or self.error_code
class PolicyDeniedError(QontoAssistantError):
error_code = "policy_denied"
status_code = 403
def __init__(self, decision: PolicyDecision) -> None:
super().__init__(decision.reason)
self.decision = decision
self.error_code = decision.reason
class RateLimitExceededError(QontoAssistantError):
error_code = "rate_limit"
status_code = 429
class ConcurrencyLimitExceededError(QontoAssistantError):
error_code = "concurrency_limit"
status_code = 503
class CredentialError(QontoAssistantError):
error_code = "credential_unavailable"
status_code = 503
class InvalidRequestError(QontoAssistantError):
error_code = "invalid_request"
status_code = 400
def __init__(self, message: str, *, error_code: str = "invalid_request") -> None:
super().__init__(message)
self.error_code = error_code
class UpstreamError(QontoAssistantError):
error_code: str = "upstream_error"
status_code: int = 502
upstream_status: int | None = None
def __init__(
self,
message: str,
*,
error_code: str = "upstream_error",
status_code: int = 502,
upstream_status: int | None = None,
) -> None:
super().__init__(message)
self.error_code = error_code
self.status_code = status_code
self.upstream_status = upstream_status

View file

@ -0,0 +1,5 @@
from qonto_assistant.app import main
if __name__ == "__main__":
main()

View file

@ -0,0 +1,131 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any
import yaml
from qonto_assistant.contracts import CapabilityRequest, PolicyDecision
class PolicyEngine:
def __init__(self, config: Mapping[str, Any], *, required_scope: str, enforce_scope: bool) -> None:
self.config = dict(config)
self.version = int(self.config.get("version", 1))
self.required_scope = required_scope
self.enforce_scope = enforce_scope
self.capabilities = dict(self.config.get("capabilities", {}))
self.deny_classes = dict(self.config.get("deny_classes", {}))
@classmethod
def from_file(cls, path: Path, *, required_scope: str, enforce_scope: bool) -> "PolicyEngine":
with path.open("r", encoding="utf-8") as handle:
config = yaml.safe_load(handle) or {}
return cls(config, required_scope=required_scope, enforce_scope=enforce_scope)
def decide(self, request: CapabilityRequest) -> PolicyDecision:
capability = self.capabilities.get(request.capability_id)
if capability is None:
return self._deny(request, "unknown_capability")
if request.actor_claims.tenant_id != request.tenant_id:
return self._deny(request, "tenant_scope")
if self.enforce_scope and self.required_scope not in request.actor_claims.scopes:
return self._deny(request, "authz_denied")
allowed_lanes = set(capability.get("lanes", []))
if allowed_lanes and request.actor_claims.lane not in allowed_lanes:
return self._deny(request, "authz_denied")
deny_reason = self._match_deny_classes(request)
if deny_reason is not None:
return self._deny(request, deny_reason)
if not self._constraints_ok(capability, request.request_args):
return self._deny(request, "arg_constraint")
return PolicyDecision(
allowed=True,
capability_id=request.capability_id,
policy_version=self.version,
reason="allow",
request_args=dict(request.request_args),
)
def _constraints_ok(self, capability: Mapping[str, Any], request_args: Mapping[str, Any]) -> bool:
constraints = dict(capability.get("constraints", {}))
checks = {
"page_size": ("max_per_page", 1),
"page": ("max_pages_per_call", 1),
"window_days": ("max_window_days", 1),
}
for field_name, (limit_key, minimum) in checks.items():
if field_name not in request_args or request_args[field_name] is None:
continue
try:
numeric = int(request_args[field_name])
except (TypeError, ValueError):
return False
if numeric < minimum:
return False
limit = constraints.get(limit_key)
if limit is not None and numeric > int(limit):
return False
return True
def _match_deny_classes(self, request: CapabilityRequest) -> str | None:
spend_prefixes = tuple(self.deny_classes.get("spend", {}).get("match_prefixes", []))
volume_tags = tuple(self.deny_classes.get("volume_cost", {}).get("match_tags", []))
credential_fields = set(self.deny_classes.get("credential_exfil", {}).get("response_fields", []))
lowered_tokens = {token.lower() for token in self._flatten_strings(request.capability_id, request.request_args)}
for prefix in spend_prefixes:
lowered_prefix = prefix.lower()
if any(token.startswith(lowered_prefix) for token in lowered_tokens):
return "spend"
for tag in volume_tags:
lowered_tag = tag.lower()
if any(lowered_tag in token for token in lowered_tokens):
return "volume_cost"
if request.response_class == "secret":
return "credential_exfil"
requested_fields = request.request_args.get("response_fields", [])
if isinstance(requested_fields, str):
requested_fields = [requested_fields]
if any(str(field).lower() in credential_fields for field in requested_fields):
return "credential_exfil"
if request.request_args.get("include_full_iban") or request.request_args.get("include_api_key"):
return "credential_exfil"
amount_keys = {"amount", "amount_cents", "amount_eur"}
if any(key in request.request_args for key in amount_keys) and request.request_args.get("execute"):
return "spend"
return None
def _flatten_strings(self, capability_id: str, value: Any) -> Iterable[str]:
yield capability_id
if isinstance(value, Mapping):
for key, item in value.items():
yield str(key)
yield from self._flatten_strings(capability_id, item)
elif isinstance(value, list | tuple | set):
for item in value:
yield from self._flatten_strings(capability_id, item)
else:
yield str(value)
def _deny(self, request: CapabilityRequest, reason: str) -> PolicyDecision:
return PolicyDecision(
allowed=False,
capability_id=request.capability_id,
policy_version=self.version,
reason=reason,
request_args=dict(request.request_args),
)

View file

@ -0,0 +1,27 @@
version: 1
default: deny
capabilities:
org_summary:
lanes: [green, blue]
response_class: operational_summary
list_transactions:
lanes: [green, blue]
response_class: operational_summary
constraints:
max_per_page: 100
max_pages_per_call: 5
max_window_days: 93
cost_run_rate_hints:
lanes: [green, blue]
response_class: operational_summary
snapshot_bundle:
lanes: [green, blue]
response_class: operational_summary
compose_only: [org_summary, cost_run_rate_hints]
deny_classes:
spend:
match_prefixes: [create_, issue_, payout_, transfer_, direct_debit_]
volume_cost:
match_tags: [card_operation, invoice_send, payment_link, subscription_change]
credential_exfil:
response_fields: [api_key, authorization_header, full_iban]

View file

@ -0,0 +1,246 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Protocol
import httpx
from qonto_assistant.contracts import QontoCredentials
from qonto_assistant.credentials import EnvironmentCredentialProvider, OpenBaoCliCredentialProvider
from qonto_assistant.errors import UpstreamError
class QontoClientProtocol(Protocol):
def get_organization(self) -> Mapping[str, Any]: ...
def list_transactions(
self,
*,
iban: str,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> Mapping[str, Any]: ...
def close(self) -> None: ...
class QontoClient:
def __init__(
self,
*,
base_url: str,
organization_path: str,
transactions_path: str,
auth_mode: str,
timeout_seconds: float,
max_retries: int,
credential_provider: EnvironmentCredentialProvider | OpenBaoCliCredentialProvider,
transport: httpx.BaseTransport | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.organization_path = organization_path
self.transactions_path = transactions_path
self.auth_mode = auth_mode
self.max_retries = max_retries
self.credential_provider = credential_provider
self.client = httpx.Client(
base_url=self.base_url,
timeout=httpx.Timeout(timeout_seconds),
transport=transport,
)
def get_organization(self) -> Mapping[str, Any]:
return self._request("GET", self.organization_path)
def list_transactions(
self,
*,
iban: str,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> Mapping[str, Any]:
params: dict[str, Any] = {
"iban": iban,
"current_page": page,
"per_page": page_size,
"window_days": window_days,
}
if status:
params["status"] = status
if side:
params["side"] = side
return self._request("GET", self.transactions_path, params=params)
def close(self) -> None:
self.client.close()
def _request(
self,
method: str,
path: str,
*,
params: Mapping[str, Any] | None = None,
) -> Mapping[str, Any]:
credentials = self.credential_provider.get_credentials()
headers = self._headers(credentials)
attempt = 0
while True:
try:
response = self.client.request(method, path, headers=headers, params=params)
except httpx.TimeoutException as exc:
if attempt < self.max_retries:
attempt += 1
continue
raise UpstreamError("Qonto request timed out", error_code="qonto_timeout", status_code=504) from exc
except httpx.TransportError as exc:
if attempt < self.max_retries:
attempt += 1
continue
raise UpstreamError(
"Qonto transport failure",
error_code="qonto_transport_error",
status_code=502,
) from exc
if response.status_code in {401, 403}:
self.credential_provider.invalidate()
raise UpstreamError(
"Qonto authentication failed",
error_code="qonto_auth_failed",
status_code=502,
upstream_status=response.status_code,
)
if response.status_code >= 500 and attempt < self.max_retries:
attempt += 1
continue
if response.is_error:
raise UpstreamError(
f"Qonto responded with HTTP {response.status_code}",
error_code="qonto_upstream_error",
status_code=502,
upstream_status=response.status_code,
)
payload = response.json()
if not isinstance(payload, Mapping):
raise UpstreamError(
"Qonto returned a non-object payload",
error_code="qonto_invalid_payload",
status_code=502,
upstream_status=response.status_code,
)
return payload
def _headers(self, credentials: QontoCredentials) -> dict[str, str]:
if self.auth_mode == "bearer":
return {"Authorization": f"Bearer {credentials.api_key}"}
if self.auth_mode != "legacy_api_key":
raise UpstreamError(
f"Unsupported auth mode: {self.auth_mode}",
error_code="qonto_auth_mode",
status_code=500,
)
return {"Authorization": f"{credentials.api_user}:{credentials.api_key}"}
class FixtureQontoClient:
def __init__(self, *, fixture_dir: Path) -> None:
self.fixture_dir = fixture_dir
self._organization_payload = self._load("organization.json")
self._transactions_payload = self._load("transactions.json")
def get_organization(self) -> Mapping[str, Any]:
return self._organization_payload
def list_transactions(
self,
*,
iban: str,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> Mapping[str, Any]:
transactions = self._transactions_payload.get("transactions", [])
if not isinstance(transactions, list):
raise UpstreamError(
"Fixture transactions payload is invalid",
error_code="qonto_fixture_invalid",
status_code=500,
)
filtered: list[Mapping[str, Any]] = [item for item in transactions if isinstance(item, Mapping)]
if iban:
filtered = [item for item in filtered if item.get("iban") in {None, "", iban}]
if status:
filtered = [item for item in filtered if item.get("status") == status]
if side:
filtered = [item for item in filtered if item.get("side") == side]
filtered = self._filter_window(filtered, window_days)
start = max((page - 1) * page_size, 0)
end = start + page_size
return {
"transactions": filtered[start:end],
"meta": {
"total": len(filtered),
"page": page,
"page_size": page_size,
"source": "fixture",
},
}
def close(self) -> None:
return None
def _load(self, name: str) -> Mapping[str, Any]:
path = self.fixture_dir / name
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise UpstreamError(
f"Fixture file missing: {path}",
error_code="qonto_fixture_missing",
status_code=500,
) from exc
if not isinstance(payload, Mapping):
raise UpstreamError(
f"Fixture payload must be an object: {path}",
error_code="qonto_fixture_invalid",
status_code=500,
)
return payload
def _filter_window(self, transactions: list[Mapping[str, Any]], window_days: int) -> list[Mapping[str, Any]]:
dated = []
for transaction in transactions:
settled_at = transaction.get("settled_at") or transaction.get("settledAt")
if not settled_at:
dated.append((None, transaction))
continue
dated.append((datetime.fromisoformat(str(settled_at).replace("Z", "+00:00")), transaction))
dates = [item[0] for item in dated if item[0] is not None]
if not dates:
return transactions
reference = max(dates)
filtered: list[Mapping[str, Any]] = []
for parsed, transaction in dated:
if parsed is None:
filtered.append(transaction)
continue
age_days = (reference - parsed).days
if age_days < window_days:
filtered.append(transaction)
return filtered

View file

@ -0,0 +1,56 @@
from __future__ import annotations
import time
from collections import defaultdict, deque
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from threading import Lock
from qonto_assistant.errors import ConcurrencyLimitExceededError, RateLimitExceededError
class RateLimiter:
def __init__(
self,
*,
limit: int,
window_seconds: int,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.limit = limit
self.window_seconds = window_seconds
self.clock = clock
self._events: dict[str, deque[float]] = defaultdict(deque)
self._lock = Lock()
def check(self, key: str) -> None:
now = self.clock()
with self._lock:
window_start = now - self.window_seconds
bucket = self._events[key]
while bucket and bucket[0] < window_start:
bucket.popleft()
if len(bucket) >= self.limit:
raise RateLimitExceededError(f"Rate limit exceeded for {key}")
bucket.append(now)
class ConcurrencyLimiter:
def __init__(self, *, limit: int) -> None:
self.limit = limit
self._counts: dict[str, int] = defaultdict(int)
self._lock = Lock()
@contextmanager
def slot(self, key: str) -> Iterator[None]:
with self._lock:
if self._counts[key] >= self.limit:
raise ConcurrencyLimitExceededError(f"Concurrency limit exceeded for {key}")
self._counts[key] += 1
try:
yield
finally:
with self._lock:
self._counts[key] -= 1
if self._counts[key] <= 0:
del self._counts[key]

View file

@ -0,0 +1,403 @@
from __future__ import annotations
import time
from collections import defaultdict
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from qonto_assistant.audit import AuditLogger, utc_now_iso
from qonto_assistant.contracts import ActorClaims, AuditEvent, CapabilityRequest
from qonto_assistant.errors import InvalidRequestError, PolicyDeniedError, UpstreamError
from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import QontoClientProtocol
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
class CapabilityService:
def __init__(
self,
*,
client: QontoClientProtocol,
policy: PolicyEngine,
audit_logger: AuditLogger,
rate_limiter: RateLimiter,
concurrency_limiter: ConcurrencyLimiter,
) -> None:
self.client = client
self.policy = policy
self.audit_logger = audit_logger
self.rate_limiter = rate_limiter
self.concurrency_limiter = concurrency_limiter
def get_accounts(self, *, claims: ActorClaims, request_id: str) -> dict[str, Any]:
return self._execute(
capability_id="org_summary",
claims=claims,
request_args={},
resource_scope="accounts",
request_id=request_id,
operation=self._build_accounts_payload,
)
def list_transactions(
self,
*,
claims: ActorClaims,
request_id: str,
account_slug: str | None,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> dict[str, Any]:
return self._execute(
capability_id="list_transactions",
claims=claims,
request_args={
"account_slug": account_slug,
"page": page,
"page_size": page_size,
"window_days": window_days,
"status": status,
"side": side,
},
resource_scope="transactions",
request_id=request_id,
operation=lambda _: self._build_transactions_payload(
account_slug=account_slug,
page=page,
page_size=page_size,
window_days=window_days,
status=status,
side=side,
),
)
def get_snapshot(
self,
*,
claims: ActorClaims,
request_id: str,
window_days: int,
page_size: int,
) -> dict[str, Any]:
return self._execute(
capability_id="snapshot_bundle",
claims=claims,
request_args={"window_days": window_days, "page_size": page_size},
resource_scope="snapshot",
request_id=request_id,
operation=lambda _: self._build_snapshot_payload(window_days=window_days, page_size=page_size),
)
def _execute(
self,
*,
capability_id: str,
claims: ActorClaims,
request_args: dict[str, Any],
resource_scope: str,
request_id: str,
operation,
) -> dict[str, Any]:
request = CapabilityRequest(
capability_id=capability_id,
tenant_id=claims.tenant_id,
actor_claims=claims,
resource_scope=resource_scope,
request_args=request_args,
protocol="rest",
)
started = time.perf_counter()
decision = self.policy.decide(request)
if not decision.allowed:
self._emit_audit(
request_id=request_id,
claims=claims,
capability_id=capability_id,
decision="deny",
deny_reason=decision.reason,
latency_ms=_latency_ms(started),
result_count=None,
qonto_http_status=None,
)
raise PolicyDeniedError(decision)
actor_key = f"{claims.tenant_id}:{claims.actor_id}"
self.rate_limiter.check(actor_key)
with self.concurrency_limiter.slot(actor_key):
payload = operation(decision.request_args)
self._emit_audit(
request_id=request_id,
claims=claims,
capability_id=capability_id,
decision="allow",
deny_reason=None,
latency_ms=_latency_ms(started),
result_count=_result_count(payload),
qonto_http_status=200,
)
return payload
def _build_accounts_payload(self, _: Mapping[str, Any]) -> dict[str, Any]:
organization_payload = self.client.get_organization()
organization = _extract_organization(organization_payload)
accounts = [_normalize_account(account) for account in _extract_accounts(organization_payload)]
return {
"organization": _normalize_organization(organization),
"accounts": accounts,
"totals": {
"balance": round(sum(account["balance"] for account in accounts), 2),
"authorized_balance": round(sum(account["authorized_balance"] for account in accounts), 2),
},
}
def _build_transactions_payload(
self,
*,
account_slug: str | None,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> dict[str, Any]:
organization_payload = self.client.get_organization()
organization = _extract_organization(organization_payload)
accounts = _extract_accounts(organization_payload)
selected_account = _select_account(accounts, account_slug)
raw_transactions = self.client.list_transactions(
iban=selected_account["iban"],
page=page,
page_size=page_size,
window_days=window_days,
status=status,
side=side,
)
transactions = [_normalize_transaction(item) for item in _extract_transactions(raw_transactions)]
return {
"organization": _normalize_organization(organization),
"account": _normalize_account(selected_account),
"page": page,
"page_size": page_size,
"window_days": window_days,
"transactions": transactions,
}
def _build_snapshot_payload(self, *, window_days: int, page_size: int) -> dict[str, Any]:
accounts_payload = self._build_accounts_payload({})
accounts = accounts_payload["accounts"]
main_account = next((account for account in accounts if account["main"]), accounts[0] if accounts else None)
recent_transactions = []
if main_account is not None:
transactions_payload = self._build_transactions_payload(
account_slug=main_account["slug"],
page=1,
page_size=min(page_size, 50),
window_days=window_days,
status="completed",
side=None,
)
recent_transactions = transactions_payload["transactions"]
return {
"organization": accounts_payload["organization"],
"accounts": accounts,
"summary": {
"total_balance": accounts_payload["totals"]["balance"],
"authorized_balance": accounts_payload["totals"]["authorized_balance"],
"window_days": window_days,
},
"cost_run_rate_hints": _build_cost_run_rate_hints(recent_transactions),
"recent_transactions": recent_transactions[:10],
}
def _emit_audit(
self,
*,
request_id: str,
claims: ActorClaims,
capability_id: str,
decision: str,
deny_reason: str | None,
latency_ms: int,
result_count: int | None,
qonto_http_status: int | None,
) -> None:
event = AuditEvent(
request_id=request_id,
timestamp=utc_now_iso(),
actor=claims.actor_id,
tenant_id=claims.tenant_id,
capability=capability_id,
protocol="rest",
decision=decision,
deny_reason=deny_reason,
policy_version=self.policy.version,
latency_ms=latency_ms,
qonto_http_status=qonto_http_status,
result_count=result_count,
)
self.audit_logger.emit(event)
def _latency_ms(started: float) -> int:
return int((time.perf_counter() - started) * 1000)
def _extract_organization(payload: Mapping[str, Any]) -> Mapping[str, Any]:
organization = payload.get("organization", payload)
if not isinstance(organization, Mapping):
raise ValueError("Organization payload is missing or invalid")
return organization
def _extract_accounts(payload: Mapping[str, Any]) -> list[Mapping[str, Any]]:
organization = _extract_organization(payload)
accounts = organization.get("bank_accounts") or payload.get("bank_accounts") or []
if not isinstance(accounts, list):
raise ValueError("Bank accounts payload is invalid")
return [account for account in accounts if isinstance(account, Mapping)]
def _extract_transactions(payload: Mapping[str, Any]) -> list[Mapping[str, Any]]:
transactions = payload.get("transactions") or payload.get("items") or []
if not isinstance(transactions, list):
raise ValueError("Transactions payload is invalid")
return [transaction for transaction in transactions if isinstance(transaction, Mapping)]
def _normalize_organization(raw: Mapping[str, Any]) -> dict[str, Any]:
return {
"name": raw.get("name"),
"legal_name": raw.get("legal_name") or raw.get("legalName") or raw.get("name"),
"slug": raw.get("slug"),
"legal_country": raw.get("legal_country") or raw.get("legalCountry"),
"legal_registration_date": raw.get("legal_registration_date")
or raw.get("legalRegistrationDate"),
}
def _normalize_account(raw: Mapping[str, Any]) -> dict[str, Any]:
iban = str(raw.get("iban", ""))
return {
"name": raw.get("name"),
"slug": raw.get("slug"),
"currency": raw.get("currency", "EUR"),
"balance": _amount_value(raw),
"authorized_balance": _amount_value(raw, "authorized_balance"),
"iban_last4": raw.get("iban_last4") or (iban[-4:] if iban else None),
"main": bool(raw.get("main", False)),
"status": raw.get("status", "unknown"),
}
def _normalize_transaction(raw: Mapping[str, Any]) -> dict[str, Any]:
settled_at = raw.get("settled_at") or raw.get("settledAt") or raw.get("updated_at")
return {
"id": raw.get("id") or raw.get("transaction_id"),
"date": (str(settled_at)[:10] if settled_at else None),
"label": raw.get("label") or raw.get("counterparty_name") or raw.get("name"),
"side": raw.get("side"),
"amount": _amount_value(raw),
"currency": raw.get("currency", "EUR"),
"category": raw.get("category"),
"operation_type": raw.get("operation_type") or raw.get("operationType"),
"status": raw.get("status"),
}
def _amount_value(raw: Mapping[str, Any], key: str = "balance") -> float:
amount = raw.get(key)
if amount is None and key == "balance":
amount = raw.get("amount")
if amount is None and key == "authorized_balance":
amount = raw.get("authorized_balance")
if amount is None:
cents = raw.get("amount_cents") or raw.get("amountCents")
if cents is None:
return 0.0
return round(float(cents) / 100, 2)
return round(float(amount), 2)
def _select_account(accounts: list[Mapping[str, Any]], account_slug: str | None) -> Mapping[str, Any]:
if not accounts:
raise UpstreamError(
"No accounts available in organization payload",
error_code="qonto_invalid_payload",
status_code=502,
)
if account_slug:
for account in accounts:
if account.get("slug") == account_slug:
return account
raise InvalidRequestError(f"Unknown account slug: {account_slug}", error_code="resource_scope")
for account in accounts:
if account.get("main"):
return account
return accounts[0]
def _build_cost_run_rate_hints(transactions: list[dict[str, Any]]) -> dict[str, Any]:
recurring: dict[tuple[str, str, float], list[dict[str, Any]]] = defaultdict(list)
for transaction in transactions:
if transaction.get("side") != "debit" or transaction.get("status") != "completed":
continue
key = (
str(transaction.get("label") or "unknown"),
str(transaction.get("operation_type") or "unknown"),
float(transaction.get("amount") or 0.0),
)
recurring[key].append(transaction)
recurring_debits = []
for (label, operation_type, amount), items in recurring.items():
if len(items) < 2:
continue
observed_months = sorted(
{
datetime.fromisoformat(f"{item['date']}T00:00:00+00:00")
.astimezone(UTC)
.strftime("%Y-%m")
for item in items
if item.get("date")
}
)
recurring_debits.append(
{
"label": label,
"operation_type": operation_type,
"amount": amount,
"occurrences": len(items),
"observed_months": observed_months,
"latest_date": max(item["date"] for item in items if item.get("date")),
}
)
recurring_debits.sort(key=lambda item: (-item["amount"], item["label"]))
total_debits = round(
sum(float(transaction["amount"]) for transaction in transactions if transaction.get("side") == "debit"),
2,
)
total_credits = round(
sum(float(transaction["amount"]) for transaction in transactions if transaction.get("side") == "credit"),
2,
)
return {
"recurring_debits": recurring_debits[:10],
"total_debits": total_debits,
"total_credits": total_credits,
}
def _result_count(payload: Mapping[str, Any]) -> int | None:
for key in ("transactions", "accounts", "recent_transactions"):
value = payload.get(key)
if isinstance(value, list):
return len(value)
return None

31
tests/fixtures/qonto/organization.json vendored Normal file
View file

@ -0,0 +1,31 @@
{
"organization": {
"name": "Binky Hedgehog GmbH",
"legal_name": "Binky Hedgehog GmbH",
"slug": "binky-hedgehog-gmbh-6923",
"legal_country": "DE",
"legal_registration_date": "2019-03-15",
"bank_accounts": [
{
"name": "Hauptkonto",
"slug": "main-account",
"currency": "EUR",
"balance": 2185.94,
"authorized_balance": 2185.94,
"iban": "DE02100100101234566810",
"main": true,
"status": "active"
},
{
"name": "Kickstart Business",
"slug": "secondary-account",
"currency": "EUR",
"balance": 0.0,
"authorized_balance": 0.0,
"iban": "DE02100100101234567038",
"main": false,
"status": "active"
}
]
}
}

64
tests/fixtures/qonto/transactions.json vendored Normal file
View file

@ -0,0 +1,64 @@
{
"transactions": [
{
"id": "tx-qonto-2026-07",
"settled_at": "2026-07-01T08:00:00Z",
"label": "Qonto",
"side": "debit",
"amount": 70.8,
"currency": "EUR",
"category": "subscription",
"operation_type": "qonto_fee",
"status": "completed",
"iban": "DE02100100101234566810"
},
{
"id": "tx-hub31-2026-06",
"settled_at": "2026-06-02T08:00:00Z",
"label": "HUB31",
"side": "debit",
"amount": 297.5,
"currency": "EUR",
"category": "other_expense",
"operation_type": "transfer",
"status": "completed",
"iban": "DE02100100101234566810"
},
{
"id": "tx-hub31-2026-05",
"settled_at": "2026-05-02T08:00:00Z",
"label": "HUB31",
"side": "debit",
"amount": 297.5,
"currency": "EUR",
"category": "other_expense",
"operation_type": "transfer",
"status": "completed",
"iban": "DE02100100101234566810"
},
{
"id": "tx-stripe-2026-06",
"settled_at": "2026-06-29T08:00:00Z",
"label": "Stripe Technology Europe Ltd",
"side": "credit",
"amount": 8.55,
"currency": "EUR",
"category": "other_income",
"operation_type": "income",
"status": "completed",
"iban": "DE02100100101234566810"
},
{
"id": "tx-old-window",
"settled_at": "2026-02-01T08:00:00Z",
"label": "Old Expense",
"side": "debit",
"amount": 12.34,
"currency": "EUR",
"category": "other_expense",
"operation_type": "income",
"status": "completed",
"iban": "DE02100100101234566810"
}
]
}

213
tests/test_api.py Normal file
View file

@ -0,0 +1,213 @@
from pathlib import Path
import httpx
from qonto_assistant.audit import AuditLogger
from qonto_assistant.config import Settings
from qonto_assistant.contracts import ActorClaims
from qonto_assistant.credentials import EnvironmentCredentialProvider
from qonto_assistant.errors import PolicyDeniedError
from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import QontoClient
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
from qonto_assistant.service import CapabilityService
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
def _settings() -> Settings:
return Settings(
service_name="qonto-assistant",
default_tenant_id="binky",
default_actor_lane="green",
required_scope="finance.qonto.read",
enforce_scope=False,
policy_file=POLICY_FILE,
qonto_base_url="https://example.test",
qonto_fixture_dir=None,
qonto_auth_mode="legacy_api_key",
qonto_organization_path="/v2/organization",
qonto_transactions_path="/v2/transactions",
qonto_timeout_seconds=1,
qonto_max_retries=0,
qonto_secret_ttl_seconds=60,
rate_limit_requests=20,
rate_limit_window_seconds=60,
max_concurrency=4,
credential_source="env",
openbao_path="tenants/binky/qonto-api",
openbao_command="bao",
openbao_timeout_seconds=5,
host="127.0.0.1",
port=8080,
)
def _claims() -> ActorClaims:
return ActorClaims(actor_id="codex", tenant_id="binky", lane="green")
def _service(monkeypatch) -> tuple[CapabilityService, list[dict[str, object]]]:
monkeypatch.setenv("API_USER", "binky-user")
monkeypatch.setenv("API_KEY", "top-secret")
events: list[dict[str, object]] = []
settings = _settings()
organization_payload = {
"organization": {
"name": "Binky Hedgehog GmbH",
"legal_name": "Binky Hedgehog GmbH",
"slug": "binky-hedgehog-gmbh-6923",
"legal_country": "DE",
"legal_registration_date": "2019-03-15",
"bank_accounts": [
{
"name": "Hauptkonto",
"slug": "main-account",
"currency": "EUR",
"balance": 2185.94,
"authorized_balance": 2185.94,
"iban": "DE02100100101234566810",
"main": True,
"status": "active",
},
{
"name": "Kickstart Business",
"slug": "secondary-account",
"currency": "EUR",
"balance": 0,
"authorized_balance": 0,
"iban": "DE02100100101234567038",
"main": False,
"status": "active",
},
],
}
}
transactions_payload = {
"transactions": [
{
"id": "tx-qonto",
"settled_at": "2026-07-01T08:00:00Z",
"label": "Qonto",
"side": "debit",
"amount": 70.8,
"currency": "EUR",
"category": "subscription",
"operation_type": "qonto_fee",
"status": "completed",
},
{
"id": "tx-hub31-1",
"settled_at": "2026-06-02T08:00:00Z",
"label": "HUB31",
"side": "debit",
"amount": 297.5,
"currency": "EUR",
"category": "other_expense",
"operation_type": "transfer",
"status": "completed",
},
{
"id": "tx-hub31-2",
"settled_at": "2026-05-02T08:00:00Z",
"label": "HUB31",
"side": "debit",
"amount": 297.5,
"currency": "EUR",
"category": "other_expense",
"operation_type": "transfer",
"status": "completed",
},
{
"id": "tx-stripe",
"settled_at": "2026-06-29T08:00:00Z",
"label": "Stripe",
"side": "credit",
"amount": 8.55,
"currency": "EUR",
"category": "other_income",
"operation_type": "income",
"status": "completed",
},
]
}
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/v2/organization":
return httpx.Response(200, json=organization_payload)
if request.url.path == "/v2/transactions":
return httpx.Response(200, json=transactions_payload)
return httpx.Response(404, json={"error": "not_found"})
client = QontoClient(
base_url=settings.qonto_base_url,
organization_path=settings.qonto_organization_path,
transactions_path=settings.qonto_transactions_path,
auth_mode=settings.qonto_auth_mode,
timeout_seconds=settings.qonto_timeout_seconds,
max_retries=settings.qonto_max_retries,
credential_provider=EnvironmentCredentialProvider(),
transport=httpx.MockTransport(handler),
)
policy = PolicyEngine.from_file(
settings.policy_file,
required_scope=settings.required_scope,
enforce_scope=settings.enforce_scope,
)
service = CapabilityService(
client=client,
policy=policy,
audit_logger=AuditLogger(sink=events.append),
rate_limiter=RateLimiter(limit=20, window_seconds=60),
concurrency_limiter=ConcurrencyLimiter(limit=4),
)
return service, events
def test_accounts_contract_returns_redacted_summary(monkeypatch) -> None:
service, _ = _service(monkeypatch)
payload = service.get_accounts(claims=_claims(), request_id="req-accounts")
assert payload["organization"]["name"] == "Binky Hedgehog GmbH"
assert payload["accounts"][0]["iban_last4"] == "6810"
assert "iban" not in payload["accounts"][0]
def test_transactions_contract_denies_oversized_page_size(monkeypatch) -> None:
service, events = _service(monkeypatch)
try:
service.list_transactions(
claims=_claims(),
request_id="req-deny",
account_slug=None,
page=1,
page_size=101,
window_days=31,
status="completed",
side=None,
)
except PolicyDeniedError as exc:
assert exc.error_code == "arg_constraint"
else:
raise AssertionError("Expected policy denial")
assert events[-1]["decision"] == "deny"
assert events[-1]["deny_reason"] == "arg_constraint"
def test_snapshot_contract_returns_cost_run_rate_hints_for_90_day_window(monkeypatch) -> None:
service, events = _service(monkeypatch)
payload = service.get_snapshot(
claims=_claims(),
request_id="req-snapshot",
window_days=90,
page_size=50,
)
assert payload["summary"]["total_balance"] == 2185.94
assert payload["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31"
assert any(event["capability"] == "snapshot_bundle" for event in events)

25
tests/test_audit.py Normal file
View file

@ -0,0 +1,25 @@
import logging
from qonto_assistant.audit import AuditLogger, REDACTED
def test_audit_logger_redacts_secret_fields() -> None:
events: list[dict[str, object]] = []
logger = logging.getLogger("qonto_assistant.audit.test")
logger.handlers.clear()
audit = AuditLogger(logger=logger, sink=events.append)
payload = audit.emit(
{
"authorization": "Bearer super-secret",
"api_key": "top-secret",
"nested": {"token": "child-secret"},
"capability": "org_summary",
}
)
assert payload["authorization"] == REDACTED
assert payload["api_key"] == REDACTED
assert payload["nested"]["token"] == REDACTED
assert "super-secret" not in str(events[0])
assert "top-secret" not in str(events[0])

View file

@ -0,0 +1,32 @@
from pathlib import Path
from qonto_assistant.qonto_client import FixtureQontoClient
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
def test_fixture_client_loads_fixture_payloads() -> None:
client = FixtureQontoClient(fixture_dir=FIXTURE_DIR)
organization = client.get_organization()
assert organization["organization"]["name"] == "Binky Hedgehog GmbH"
assert len(organization["organization"]["bank_accounts"]) == 2
def test_fixture_client_filters_window_and_pagination() -> None:
client = FixtureQontoClient(fixture_dir=FIXTURE_DIR)
payload = client.list_transactions(
iban="DE02100100101234566810",
page=1,
page_size=2,
window_days=31,
status="completed",
side=None,
)
transactions = payload["transactions"]
assert len(transactions) == 2
assert transactions[0]["id"] == "tx-qonto-2026-07"
assert all(item["id"] != "tx-old-window" for item in transactions)

87
tests/test_policy.py Normal file
View file

@ -0,0 +1,87 @@
from pathlib import Path
from qonto_assistant.contracts import ActorClaims, CapabilityRequest
from qonto_assistant.policy import PolicyEngine
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
def _policy(*, enforce_scope: bool = False) -> PolicyEngine:
return PolicyEngine.from_file(
POLICY_FILE,
required_scope="finance.qonto.read",
enforce_scope=enforce_scope,
)
def _request(capability_id: str, **request_args: object) -> CapabilityRequest:
claims = ActorClaims(
actor_id="agent-1",
tenant_id="binky",
lane="green",
scopes=frozenset({"finance.qonto.read"}),
)
return CapabilityRequest(
capability_id=capability_id,
tenant_id="binky",
actor_claims=claims,
resource_scope="test",
request_args=dict(request_args),
protocol="rest",
)
def test_policy_allows_known_read_capability() -> None:
decision = _policy().decide(_request("org_summary"))
assert decision.allowed is True
assert decision.reason == "allow"
def test_policy_denies_cross_tenant_requests() -> None:
claims = ActorClaims(actor_id="agent-1", tenant_id="other", lane="green")
request = CapabilityRequest(
capability_id="org_summary",
tenant_id="binky",
actor_claims=claims,
resource_scope="accounts",
request_args={},
protocol="rest",
)
decision = _policy().decide(request)
assert decision.allowed is False
assert decision.reason == "tenant_scope"
def test_policy_denies_excessive_page_size() -> None:
decision = _policy().decide(_request("list_transactions", page=1, page_size=101, window_days=31))
assert decision.allowed is False
assert decision.reason == "arg_constraint"
def test_policy_denies_volume_cost_shaped_requests() -> None:
decision = _policy().decide(
_request("list_transactions", page=1, page_size=50, window_days=31, operation_type="card_operation")
)
assert decision.allowed is False
assert decision.reason == "volume_cost"
def test_policy_denies_credential_exfiltration_flags() -> None:
decision = _policy().decide(_request("list_transactions", page=1, page_size=50, window_days=31, include_full_iban=True))
assert decision.allowed is False
assert decision.reason == "credential_exfil"
def test_policy_enforces_scope_when_enabled() -> None:
claims = ActorClaims(actor_id="agent-1", tenant_id="binky", lane="green", scopes=frozenset())
request = CapabilityRequest(
capability_id="org_summary",
tenant_id="binky",
actor_claims=claims,
resource_scope="accounts",
request_args={},
protocol="rest",
)
decision = _policy(enforce_scope=True).decide(request)
assert decision.allowed is False
assert decision.reason == "authz_denied"

View file

@ -0,0 +1,84 @@
import httpx
from qonto_assistant.contracts import QontoCredentials
from qonto_assistant.qonto_client import QontoClient
class StubCredentialProvider:
def __init__(self) -> None:
self.invalidated = False
def get_credentials(self) -> QontoCredentials:
return QontoCredentials(api_user="binky-user", api_key="top-secret")
def invalidate(self) -> None:
self.invalidated = True
def test_client_sends_legacy_api_key_header_and_params() -> None:
provider = StubCredentialProvider()
seen: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["auth"] = request.headers["Authorization"]
seen["path"] = request.url.path
seen["params"] = dict(request.url.params)
return httpx.Response(200, json={"transactions": []})
client = QontoClient(
base_url="https://example.test",
organization_path="/v2/organization",
transactions_path="/v2/transactions",
auth_mode="legacy_api_key",
timeout_seconds=1,
max_retries=0,
credential_provider=provider,
transport=httpx.MockTransport(handler),
)
client.list_transactions(
iban="DE1234567890",
page=2,
page_size=25,
window_days=14,
status="completed",
side="debit",
)
assert seen["auth"] == "binky-user:top-secret"
assert seen["path"] == "/v2/transactions"
assert seen["params"] == {
"iban": "DE1234567890",
"current_page": "2",
"per_page": "25",
"window_days": "14",
"status": "completed",
"side": "debit",
}
def test_client_invalidates_cached_credentials_on_auth_failure() -> None:
provider = StubCredentialProvider()
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"error": "unauthorized"})
client = QontoClient(
base_url="https://example.test",
organization_path="/v2/organization",
transactions_path="/v2/transactions",
auth_mode="legacy_api_key",
timeout_seconds=1,
max_retries=0,
credential_provider=provider,
transport=httpx.MockTransport(handler),
)
try:
client.get_organization()
except Exception as exc: # noqa: BLE001
assert getattr(exc, "error_code", None) == "qonto_auth_failed"
else:
raise AssertionError("Expected Qonto auth failure")
assert provider.invalidated is True

View file

@ -0,0 +1,53 @@
from pathlib import Path
from qonto_assistant.audit import AuditLogger
from qonto_assistant.contracts import ActorClaims
from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import FixtureQontoClient
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
from qonto_assistant.service import CapabilityService
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
def _claims() -> ActorClaims:
return ActorClaims(actor_id="codex", tenant_id="binky", lane="green")
def _service() -> CapabilityService:
return CapabilityService(
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
policy=PolicyEngine.from_file(
POLICY_FILE,
required_scope="finance.qonto.read",
enforce_scope=False,
),
audit_logger=AuditLogger(sink=lambda _: None),
rate_limiter=RateLimiter(limit=20, window_seconds=60),
concurrency_limiter=ConcurrencyLimiter(limit=4),
)
def test_snapshot_recent_window_excludes_recurring_hint() -> None:
payload = _service().get_snapshot(
claims=_claims(),
request_id="req-snapshot-31",
window_days=31,
page_size=50,
)
assert payload["cost_run_rate_hints"]["recurring_debits"] == []
def test_snapshot_90_day_window_detects_recurring_hint() -> None:
payload = _service().get_snapshot(
claims=_claims(),
request_id="req-snapshot-90",
window_days=90,
page_size=50,
)
recurring = payload["cost_run_rate_hints"]["recurring_debits"]
assert recurring[0]["label"] == "HUB31"
assert recurring[0]["occurrences"] == 2

View file

@ -0,0 +1,51 @@
---
id: ADHOC-2026-07-21
type: workplan
title: "Post-Phase-1 cleanup and local smoke support"
domain: infotech
repo: qonto-assistant
status: finished
owner: codex
topic_slug: the-custodian
created: "2026-07-21"
updated: "2026-07-21"
state_hub_workstream_id: "65e254a7-4feb-4179-a4fe-4d5ede8ce963"
---
# Post-Phase-1 cleanup and local smoke support
Low-risk follow-up after QONTO-WP-0002 completion:
- add fixture-backed local Qonto source so the service can run without real bank credentials
- add a real HTTP smoke script around the Phase 1 REST surface
- add repo classification metadata so `fix-consistency` no longer warns about the missing file
## Task: Fixture-backed local smoke mode
```task
id: ADHOC-2026-07-21-T01
status: done
priority: medium
state_hub_task_id: "a6ee7d5c-8bfc-46ba-a951-32e8bbc41c96"
```
Add a local fixture-backed Qonto client path and a smoke script that starts the
service against canned organization/transaction payloads, then verifies
`/v1/health`, `/v1/accounts`, and `/v1/snapshot`.
Done when: the service can be run locally without real Qonto credentials and a
documented smoke path exists.
## Task: Repo classification metadata
```task
id: ADHOC-2026-07-21-T02
status: done
priority: low
state_hub_task_id: "6f1289bc-104e-4812-a50c-c8b5145df2b9"
```
Add `.repo-classification.yaml` for `qonto-assistant` so State Hub consistency
checks no longer warn about the missing classification file.
Done when: `fix-consistency` reports no classification-gap warning for this repo.

View file

@ -36,7 +36,7 @@ refined; README points at specs/research; register generated AGENTS.md + brief.
```task
id: QONTO-WP-0001-T02
status: wait
status: done
priority: medium
state_hub_task_id: "360f399a-36f8-44e6-af8a-55fb0d5732a3"
```
@ -45,7 +45,10 @@ Identify the repo's install, test, lint, build, and run commands. Add or refine
those commands in the agent instructions so future coding sessions can verify
changes confidently.
**Blocked on** runtime stack choice in QONTO-WP-0002-T01. Revisit after that task.
**Done 2026-07-21:** Runtime stack and commands are now documented. Local
verification prefers `make install-dev`, `make test`, `make lint`, and
`make run`; fallback verification for this workstation is documented in
`AGENTS.md` and `docs/operator-runbook.md`.
## Seed First Real Workplan

View file

@ -4,7 +4,7 @@ type: workplan
title: "Phase 1 — policy kernel and read-only REST"
domain: infotech
repo: qonto-assistant
status: active
status: finished
owner: codex
topic_slug: the-custodian
created: "2026-07-21"
@ -29,7 +29,7 @@ CCR-2026-0008) — already provisioned.
```task
id: QONTO-WP-0002-T01
status: progress
status: done
priority: high
state_hub_task_id: "f9e129f3-5bd4-43e1-b7a0-281e4d3dec2a"
```
@ -47,7 +47,7 @@ policy, qonto client, api, audit).
```task
id: QONTO-WP-0002-T02
status: progress
status: done
priority: high
state_hub_task_id: "552ff651-dc66-4e65-97fe-3ec26652bbdd"
```
@ -69,7 +69,7 @@ Done when: policy tests pass in CI/local; no network required.
```task
id: QONTO-WP-0002-T03
status: progress
status: done
priority: high
state_hub_task_id: "be3aa7b6-f28c-4436-bd5d-d6940de6c2ce"
```
@ -92,7 +92,7 @@ Done when: unit tests with mocked HTTP; optional live smoke behind a flag.
```task
id: QONTO-WP-0002-T04
status: progress
status: done
priority: high
state_hub_task_id: "678b0b26-15af-4037-849f-d24d320588ac"
```
@ -114,7 +114,7 @@ mapping is explicit and shared with future MCP.
```task
id: QONTO-WP-0002-T05
status: progress
status: done
priority: medium
state_hub_task_id: "4a42dff1-1281-4cc1-ba6a-24702bce7dc9"
```
@ -131,7 +131,7 @@ Done when: tests assert secrets absent from log lines for a sample allow/deny.
```task
id: QONTO-WP-0002-T06
status: progress
status: done
priority: medium
state_hub_task_id: "7b7ca0f7-f523-473e-b3f6-fe0564f54ed5"
```
@ -147,10 +147,14 @@ pasting keys into chat.
```task
id: QONTO-WP-0002-T07
status: todo
status: done
priority: low
state_hub_task_id: "9b99ba05-99f2-4624-9044-89bf37055434"
```
Mark workplan finished when T01T06 done; note Phase 2 seed (MCP surface for
all harnesses) in closure. Run `statehub fix-consistency`.
**Done 2026-07-21:** Phase 1 landed in `src/qonto_assistant/` with a
FastAPI-based REST service, policy YAML, Qonto client, audit layer, rate
limits, concurrency bounds, and operator runbook. Verified with
`PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest` (`12 passed`) plus
`python3 -m compileall src tests`. Phase 2 seed remains the MCP surface on the
same capability core and policy engine. Run `statehub fix-consistency`.