Spaces:
Build error
Build error
Upload folder using huggingface_hub
#3
by petter2025 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- Dockerfile +3 -60
- README.md +11 -25
- alembic/versions/a1f3c9d2e6b7_create_api_keys_table.py +0 -58
- alembic/versions/e4a7c1f9b3d2_create_onchain_rationales_table.py +0 -62
- app/api/deps.py +16 -139
- app/api/routes_admin.py +52 -205
- app/api/routes_governance.py +53 -496
- app/api/routes_history.py +3 -4
- app/api/routes_incidents.py +14 -16
- app/api/routes_intents.py +4 -10
- app/api/routes_memory.py +2 -3
- app/api/routes_onchain.py +0 -149
- app/api/routes_payments.py +13 -47
- app/api/routes_pricing.py +44 -22
- app/api/routes_risk.py +14 -10
- app/api/routes_users.py +17 -73
- app/api/webhooks.py +13 -34
- app/core/config.py +0 -1
- app/core/storage.py +2 -16
- app/core/usage_tracker.py +195 -493
- app/database/models_intents.py +36 -166
- app/database/models_onchain.py +0 -54
- app/main.py +33 -161
- app/models/infrastructure_intents.py +0 -4
- app/services/intent_store.py +6 -76
- app/services/outcome_service.py +23 -89
- app/services/risk_service.py +36 -255
- deploy/kubernetes/arf-api/configmap.yaml +0 -11
- deploy/kubernetes/arf-api/deployment.yaml +0 -65
- deploy/kubernetes/arf-api/hpa.yaml +0 -25
- deploy/kubernetes/arf-api/networkpolicy.yaml +0 -20
- deploy/kubernetes/arf-api/secret.yaml +0 -25
- deploy/kubernetes/arf-api/service.yaml +0 -16
- docs/authentication.md +13 -39
- docs/development.md +1 -2
- render.yaml +0 -2
- requirements-dev.txt +0 -2
- requirements.txt +4 -7
- tests/conftest.py +6 -29
- tests/test_deps.py +1 -75
- tests/test_governance.py +4 -85
- tests/test_healing_endpoint.py +4 -31
- tests/test_history.py +6 -2
- tests/test_integration.py +0 -305
- tests/test_intent_store.py +4 -5
- tests/test_outcome_service.py +1 -14
- tests/test_payments.py +11 -45
- tests/test_performance.py +0 -100
- tests/test_risk.py +1 -5
- tests/test_routes_admin.py +0 -126
Dockerfile
CHANGED
|
@@ -1,64 +1,7 @@
|
|
| 1 |
-
|
| 2 |
-
# ---- deps stage: needs git + a credentialed clone of the private ARF repos
|
| 3 |
-
# (agentic_reliability_framework, ARF-Bayesian-Pricing-Calculator).
|
| 4 |
-
# This stage is discarded after build -- the credential never reaches
|
| 5 |
-
# the final image's layers, env, or git config. ----
|
| 6 |
-
#
|
| 7 |
-
# GH_PAT is read via a BuildKit secret mount, not `ARG` -- an ARG's value is
|
| 8 |
-
# printed in plaintext as part of the logged RUN command that uses it (this
|
| 9 |
-
# is exactly how a real, live token ended up visible in a Render deploy log
|
| 10 |
-
# this session). A secret mount's value is never written to a log line or
|
| 11 |
-
# an image layer. REQUIRES a matching setup step in Render's dashboard
|
| 12 |
-
# before this will build: Render's Docker service settings -> Secret Files
|
| 13 |
-
# -> add a file named exactly `gh_pat` containing the token value (nothing
|
| 14 |
-
# else in the file). The old `GH_PAT` environment variable is no longer
|
| 15 |
-
# read by this Dockerfile and can be removed once this is confirmed working.
|
| 16 |
-
FROM python:3.12-slim AS deps
|
| 17 |
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
| 18 |
-
RUN --mount=type=secret,id=gh_pat,dst=/etc/secrets/gh_pat \
|
| 19 |
-
git config --global url."https://$(cat /etc/secrets/gh_pat)@github.com/".insteadOf "https://github.com/"
|
| 20 |
-
RUN python -m venv /opt/venv
|
| 21 |
-
ENV PATH="/opt/venv/bin:$PATH"
|
| 22 |
WORKDIR /app
|
| 23 |
COPY requirements.txt .
|
| 24 |
-
|
| 25 |
-
# transitively by sentence-transformers (for agentic_reliability_framework's
|
| 26 |
-
# RAG/semantic-memory features) and, left to the default PyPI index, resolves
|
| 27 |
-
# to the CUDA-enabled build (nvidia-cusparselt, cuda-toolkit, nvidia-nccl, ...)
|
| 28 |
-
# even though this service runs on CPU-only Render instances. That variant's
|
| 29 |
-
# extra weight is a real contributor to out-of-memory deploy failures.
|
| 30 |
-
#
|
| 31 |
-
# A separate `pip install torch==... --index-url .../cpu` RUN before this one
|
| 32 |
-
# does NOT work: it's a distinct resolve that only knows about the CPU wheel;
|
| 33 |
-
# the very next `pip install -r requirements.txt`, seeing no --index-url, only
|
| 34 |
-
# has the default PyPI index in view and re-resolves torch from there,
|
| 35 |
-
# silently replacing the CPU build with the CUDA one at the same version
|
| 36 |
-
# number (confirmed happening in a real deploy -- final `pip install` log
|
| 37 |
-
# showed plain `torch-2.13.0` plus the full nvidia/cuda-toolkit/triton stack,
|
| 38 |
-
# not `torch-2.13.0+cpu`). Putting torch and -r requirements.txt in one
|
| 39 |
-
# `pip install` call, with the CPU wheelhouse as the primary --index-url and
|
| 40 |
-
# PyPI as --extra-index-url, makes it a single resolve: torch is satisfied
|
| 41 |
-
# from the CPU index and nothing later re-derives a different build for it.
|
| 42 |
-
# Version pinned to 2.13.0 to match exactly what pip's resolver already chose
|
| 43 |
-
# for this dependency tree (confirmed available on the CPU index for
|
| 44 |
-
# cp312/manylinux before pinning it here, not assumed).
|
| 45 |
-
RUN pip install --no-cache-dir \
|
| 46 |
-
--index-url https://download.pytorch.org/whl/cpu \
|
| 47 |
-
--extra-index-url https://pypi.org/simple \
|
| 48 |
-
torch==2.13.0 \
|
| 49 |
-
-r requirements.txt
|
| 50 |
-
|
| 51 |
-
# ---- final stage: just the built venv + app code, no git, no credential ----
|
| 52 |
-
FROM python:3.12-slim
|
| 53 |
-
COPY --from=deps /opt/venv /opt/venv
|
| 54 |
-
ENV PATH="/opt/venv/bin:$PATH"
|
| 55 |
-
WORKDIR /app
|
| 56 |
COPY . .
|
| 57 |
-
|
| 58 |
-
# expands with a real shell interpreting the command; exec form passes
|
| 59 |
-
# arguments literally with no variable substitution at all. Render injects
|
| 60 |
-
# PORT and expects the app to bind to it (its deploy log explicitly failed
|
| 61 |
-
# port-scanning for it: "Bind your service to at least one port"); the
|
| 62 |
-
# Hugging Face Space mirror sets no such variable and expects the
|
| 63 |
-
# conventional default, 7860. One image, correct on both targets.
|
| 64 |
-
CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
WORKDIR /app
|
| 4 |
COPY requirements.txt .
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
COPY . .
|
| 7 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,31 +1,16 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: ARF API
|
| 3 |
-
emoji: 🛡️
|
| 4 |
-
colorFrom: blue
|
| 5 |
-
colorTo: gray
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
# arf-api
|
| 11 |
|
| 12 |
ARF API Control Plane (FastAPI)
|
| 13 |
|
| 14 |
## Live Demo
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
integration target -- point real pilot/customer integrations at Render once its URL is
|
| 20 |
-
confirmed live, not at the Space URL.
|
| 21 |
-
|
| 22 |
-
- **HF Space (public mirror)**: [https://arf-ai-agentic-reliability-framework-api.hf.space](https://arf-ai-agentic-reliability-framework-api.hf.space)
|
| 23 |
-
- **Interactive Documentation**: [https://arf-ai-agentic-reliability-framework-api.hf.space/docs](https://arf-ai-agentic-reliability-framework-api.hf.space/docs)
|
| 24 |
|
| 25 |
## Quick Start (Local Development)
|
| 26 |
|
| 27 |
1. **Install dependencies**:
|
| 28 |
-
|
| 29 |
```bash
|
| 30 |
pip install -r requirements.txt
|
| 31 |
```
|
|
@@ -39,11 +24,9 @@ ARF_HMC_MODEL – path to HMC model JSON (default: models/hmc_model.json)
|
|
| 39 |
|
| 40 |
ARF_USE_HYPERPRIORS – true/false
|
| 41 |
|
| 42 |
-
API_KEY –
|
| 43 |
```
|
| 44 |
|
| 45 |
-
The settings that actually gate access are `ARF_INTERNAL_API_KEY` and `ARF_ADMIN_API_KEY`, not `API_KEY` above — see [docs/authentication.md](docs/authentication.md) for what's actually enforced and what isn't yet.
|
| 46 |
-
|
| 47 |
3. **Run the app locally**:
|
| 48 |
|
| 49 |
```bash
|
|
@@ -98,7 +81,7 @@ curl -X POST "http://localhost:8000/api/v1/v1/incidents/evaluate" -H "Content-
|
|
| 98 |
"justification": "Causal: If we apply restart_container instead of no_action, latency would change from 600.00 to 510.00 (Δ = -90.00). Based on heuristic causal model.",
|
| 99 |
"confidence": 0.85,
|
| 100 |
"risk_score": 0.54,
|
| 101 |
-
"status": "
|
| 102 |
},
|
| 103 |
"causal_explanation": {
|
| 104 |
"factual_outcome": 600,
|
|
@@ -106,7 +89,9 @@ curl -X POST "http://localhost:8000/api/v1/v1/incidents/evaluate" -H "Content-
|
|
| 106 |
"effect": -90,
|
| 107 |
"explanation_text": "If we apply restart_container instead of no_action, latency would change from 600.00 to 510.00 (Δ = -90.00). Based on heuristic causal model.",
|
| 108 |
"is_model_based": false,
|
| 109 |
-
"warnings": [
|
|
|
|
|
|
|
| 110 |
},
|
| 111 |
"utility_decision": {
|
| 112 |
"best_action": "restart_container",
|
|
@@ -126,10 +111,11 @@ curl -X POST "http://localhost:8000/api/v1/v1/incidents/evaluate" -H "Content-
|
|
| 126 |
Tests
|
| 127 |
-----
|
| 128 |
|
| 129 |
-
Run `pytest`. Tests
|
| 130 |
|
| 131 |
Notes
|
| 132 |
-----
|
| 133 |
|
| 134 |
- The governance endpoints use an in-process `RiskEngine` initialized at startup.
|
| 135 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# arf-api
|
| 2 |
|
| 3 |
ARF API Control Plane (FastAPI)
|
| 4 |
|
| 5 |
## Live Demo
|
| 6 |
|
| 7 |
+
The API is deployed and accessible at:
|
| 8 |
+
- **Base URL**: [https://a-r-f-agentic-reliability-framework-api.hf.space](https://a-r-f-agentic-reliability-framework-api.hf.space)
|
| 9 |
+
- **Interactive Documentation**: [https://a-r-f-agentic-reliability-framework-api.hf.space/docs](https://a-r-f-agentic-reliability-framework-api.hf.space/docs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
## Quick Start (Local Development)
|
| 12 |
|
| 13 |
1. **Install dependencies**:
|
|
|
|
| 14 |
```bash
|
| 15 |
pip install -r requirements.txt
|
| 16 |
```
|
|
|
|
| 24 |
|
| 25 |
ARF_USE_HYPERPRIORS – true/false
|
| 26 |
|
| 27 |
+
API_KEY – optional (currently not enforced)
|
| 28 |
```
|
| 29 |
|
|
|
|
|
|
|
| 30 |
3. **Run the app locally**:
|
| 31 |
|
| 32 |
```bash
|
|
|
|
| 81 |
"justification": "Causal: If we apply restart_container instead of no_action, latency would change from 600.00 to 510.00 (Δ = -90.00). Based on heuristic causal model.",
|
| 82 |
"confidence": 0.85,
|
| 83 |
"risk_score": 0.54,
|
| 84 |
+
"status": "oss_advisory_only"
|
| 85 |
},
|
| 86 |
"causal_explanation": {
|
| 87 |
"factual_outcome": 600,
|
|
|
|
| 89 |
"effect": -90,
|
| 90 |
"explanation_text": "If we apply restart_container instead of no_action, latency would change from 600.00 to 510.00 (Δ = -90.00). Based on heuristic causal model.",
|
| 91 |
"is_model_based": false,
|
| 92 |
+
"warnings": [
|
| 93 |
+
"Using heuristic causal model (no fitted SCM)."
|
| 94 |
+
]
|
| 95 |
},
|
| 96 |
"utility_decision": {
|
| 97 |
"best_action": "restart_container",
|
|
|
|
| 111 |
Tests
|
| 112 |
-----
|
| 113 |
|
| 114 |
+
Run `pytest`. Tests use a temporary SQLite DB (`sqlite:///./test.db`) created by the test fixtures.
|
| 115 |
|
| 116 |
Notes
|
| 117 |
-----
|
| 118 |
|
| 119 |
- The governance endpoints use an in-process `RiskEngine` initialized at startup.
|
| 120 |
+
- The outcome recording endpoint is not implemented in this repository and returns HTTP 501.
|
| 121 |
+
|
alembic/versions/a1f3c9d2e6b7_create_api_keys_table.py
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 1 |
-
"""create api_keys table (moves API key storage off ephemeral SQLite)
|
| 2 |
-
|
| 3 |
-
api_keys previously lived only in a SQLite file per service (arf_usage.db),
|
| 4 |
-
which is wiped on every Render deploy/restart on the Free plan and was also
|
| 5 |
-
independently duplicated between arf-api and arf-gateway. This creates the
|
| 6 |
-
durable, single source of truth in Postgres. Rows are hashed at rest
|
| 7 |
-
(pepper-HMAC lookup_hash + salted key_hash) -- no plaintext key column,
|
| 8 |
-
since there is no pre-pepper data to migrate here (confirmed with the user:
|
| 9 |
-
existing SQLite api_keys rows in both services are safe to discard, keys get
|
| 10 |
-
reissued via POST /admin/keys).
|
| 11 |
-
|
| 12 |
-
Revision ID: a1f3c9d2e6b7
|
| 13 |
-
Revises: d36deffe7fa2
|
| 14 |
-
Create Date: 2026-08-24 00:00:00.000000
|
| 15 |
-
|
| 16 |
-
"""
|
| 17 |
-
from typing import Sequence, Union
|
| 18 |
-
|
| 19 |
-
from alembic import op
|
| 20 |
-
import sqlalchemy as sa
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
# revision identifiers, used by Alembic.
|
| 24 |
-
revision: str = 'a1f3c9d2e6b7'
|
| 25 |
-
down_revision: Union[str, Sequence[str], None] = 'd36deffe7fa2'
|
| 26 |
-
branch_labels: Union[str, Sequence[str], None] = None
|
| 27 |
-
depends_on: Union[str, Sequence[str], None] = None
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def upgrade() -> None:
|
| 31 |
-
"""Upgrade schema."""
|
| 32 |
-
# No FK to tenants.id here, deliberately -- the pre-existing SQLite
|
| 33 |
-
# schema this replaces never enforced one either (seeded/demo keys via
|
| 34 |
-
# ARF_API_KEYS may reference synthetic tenant_ids that don't have a
|
| 35 |
-
# tenants row), and adding one now would be a behavior change beyond
|
| 36 |
-
# this migration's scope.
|
| 37 |
-
op.create_table(
|
| 38 |
-
'api_keys',
|
| 39 |
-
sa.Column('id', sa.Integer(), nullable=False),
|
| 40 |
-
sa.Column('tenant_id', sa.String(length=64), nullable=False),
|
| 41 |
-
sa.Column('tier', sa.String(length=32), nullable=False),
|
| 42 |
-
sa.Column('created_at', sa.DateTime(), nullable=False),
|
| 43 |
-
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
| 44 |
-
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
| 45 |
-
sa.Column('salt', sa.String(length=64), nullable=False),
|
| 46 |
-
sa.Column('key_hash', sa.String(length=64), nullable=False),
|
| 47 |
-
sa.Column('lookup_hash', sa.String(length=64), nullable=False),
|
| 48 |
-
sa.PrimaryKeyConstraint('id'),
|
| 49 |
-
)
|
| 50 |
-
op.create_index(op.f('ix_api_keys_tenant_id'), 'api_keys', ['tenant_id'], unique=False)
|
| 51 |
-
op.create_unique_constraint('uq_api_keys_lookup_hash', 'api_keys', ['lookup_hash'])
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def downgrade() -> None:
|
| 55 |
-
"""Downgrade schema."""
|
| 56 |
-
op.drop_constraint('uq_api_keys_lookup_hash', 'api_keys', type_='unique')
|
| 57 |
-
op.drop_index(op.f('ix_api_keys_tenant_id'), table_name='api_keys')
|
| 58 |
-
op.drop_table('api_keys')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
alembic/versions/e4a7c1f9b3d2_create_onchain_rationales_table.py
DELETED
|
@@ -1,62 +0,0 @@
|
|
| 1 |
-
"""create onchain_rationales table
|
| 2 |
-
|
| 3 |
-
Stores the plaintext preimage of an anchored `RiskAttestation.rationale_hash`
|
| 4 |
-
(arf-onchain's RiskAttestationRegistry / enterprise's
|
| 5 |
-
arf_enterprise.onchain.attestation). The chain only ever holds the hash --
|
| 6 |
-
see models_onchain.py's module docstring for why the text has to live here,
|
| 7 |
-
keyed by that same hash, or the anchored hash is unverifiable.
|
| 8 |
-
|
| 9 |
-
Revision ID: e4a7c1f9b3d2
|
| 10 |
-
Revises: a1f3c9d2e6b7
|
| 11 |
-
Create Date: 2026-09-07 00:00:00.000000
|
| 12 |
-
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
from typing import Sequence, Union
|
| 16 |
-
|
| 17 |
-
from alembic import op
|
| 18 |
-
import sqlalchemy as sa
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
# revision identifiers, used by Alembic.
|
| 22 |
-
revision: str = "e4a7c1f9b3d2"
|
| 23 |
-
down_revision: Union[str, Sequence[str], None] = "a1f3c9d2e6b7"
|
| 24 |
-
branch_labels: Union[str, Sequence[str], None] = None
|
| 25 |
-
depends_on: Union[str, Sequence[str], None] = None
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def upgrade() -> None:
|
| 29 |
-
"""Upgrade schema."""
|
| 30 |
-
op.create_table(
|
| 31 |
-
"onchain_rationales",
|
| 32 |
-
sa.Column("id", sa.String(length=64), nullable=False),
|
| 33 |
-
sa.Column("rationale_hash", sa.String(length=66), nullable=False),
|
| 34 |
-
sa.Column("rationale", sa.Text(), nullable=False),
|
| 35 |
-
sa.Column("agent_address", sa.String(length=42), nullable=True),
|
| 36 |
-
sa.Column("evaluator_address", sa.String(length=42), nullable=True),
|
| 37 |
-
sa.Column("created_at", sa.DateTime(), nullable=False),
|
| 38 |
-
sa.PrimaryKeyConstraint("id"),
|
| 39 |
-
)
|
| 40 |
-
op.create_index(
|
| 41 |
-
op.f("ix_onchain_rationales_rationale_hash"),
|
| 42 |
-
"onchain_rationales",
|
| 43 |
-
["rationale_hash"],
|
| 44 |
-
unique=True,
|
| 45 |
-
)
|
| 46 |
-
op.create_index(
|
| 47 |
-
op.f("ix_onchain_rationales_created_at"),
|
| 48 |
-
"onchain_rationales",
|
| 49 |
-
["created_at"],
|
| 50 |
-
unique=False,
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def downgrade() -> None:
|
| 55 |
-
"""Downgrade schema."""
|
| 56 |
-
op.drop_index(
|
| 57 |
-
op.f("ix_onchain_rationales_created_at"), table_name="onchain_rationales"
|
| 58 |
-
)
|
| 59 |
-
op.drop_index(
|
| 60 |
-
op.f("ix_onchain_rationales_rationale_hash"), table_name="onchain_rationales"
|
| 61 |
-
)
|
| 62 |
-
op.drop_table("onchain_rationales")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/api/deps.py
CHANGED
|
@@ -1,52 +1,20 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Dependency injection module for the ARF Agentic Reliability Framework API.
|
| 3 |
-
|
| 4 |
-
Provides FastAPI dependencies for database sessions, rate limiting, and
|
| 5 |
-
singleton instances of the core ARF engines (RiskEngine, DecisionEngine,
|
| 6 |
-
LyapunovStabilityController, CausalEffectEstimator, RAGGraphMemory, and
|
| 7 |
-
(v4.3.1) SkillRegistry). All engine dependencies are lazily initialised
|
| 8 |
-
and cached for the lifetime of the application process.
|
| 9 |
-
|
| 10 |
-
v4.3.2: Added verify_internal_key dependency to secure direct API access.
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
import os
|
| 14 |
import sys
|
| 15 |
-
from typing import Optional
|
| 16 |
from app.database.session import SessionLocal
|
| 17 |
from slowapi import Limiter
|
| 18 |
from slowapi.util import get_remote_address
|
| 19 |
from app.core.config import settings
|
| 20 |
|
| 21 |
-
from fastapi import Header, HTTPException
|
| 22 |
-
|
| 23 |
# ARF core engine imports
|
| 24 |
from agentic_reliability_framework.core.governance.risk_engine import RiskEngine
|
| 25 |
from agentic_reliability_framework.core.decision.decision_engine import DecisionEngine
|
| 26 |
from agentic_reliability_framework.core.governance.stability_controller import LyapunovStabilityController
|
| 27 |
-
from agentic_reliability_framework.core.governance.
|
| 28 |
from agentic_reliability_framework.runtime.memory.rag_graph import RAGGraphMemory
|
| 29 |
from agentic_reliability_framework.core.models.event import ReliabilityEvent, HealingAction
|
| 30 |
|
| 31 |
-
# ── v4.3.1: Skill Registry (optional) ──────────────────────────
|
| 32 |
-
try:
|
| 33 |
-
from agentic_reliability_framework.core.governance.skill_registry import SkillRegistry
|
| 34 |
-
_SKILL_REGISTRY_AVAILABLE = True
|
| 35 |
-
except ImportError:
|
| 36 |
-
SkillRegistry = None
|
| 37 |
-
_SKILL_REGISTRY_AVAILABLE = False
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
# ---------------------------------------------------------------------------
|
| 41 |
-
# Database dependency
|
| 42 |
-
# ---------------------------------------------------------------------------
|
| 43 |
|
|
|
|
| 44 |
def get_db():
|
| 45 |
-
"""
|
| 46 |
-
Yield a SQLAlchemy database session and ensure it is closed after use.
|
| 47 |
-
|
| 48 |
-
This dependency is intended to be used with FastAPI's `Depends` mechanism.
|
| 49 |
-
"""
|
| 50 |
db = SessionLocal()
|
| 51 |
try:
|
| 52 |
yield db
|
|
@@ -54,77 +22,23 @@ def get_db():
|
|
| 54 |
db.close()
|
| 55 |
|
| 56 |
|
| 57 |
-
#
|
| 58 |
-
# Rate limiter
|
| 59 |
-
# ---------------------------------------------------------------------------
|
| 60 |
-
|
| 61 |
limiter = Limiter(
|
| 62 |
key_func=get_remote_address,
|
| 63 |
-
default_limits=[
|
| 64 |
-
)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
# ---------------------------------------------------------------------------
|
| 68 |
-
# Internal API key verification (v4.3.2)
|
| 69 |
-
# ---------------------------------------------------------------------------
|
| 70 |
-
|
| 71 |
-
INTERNAL_API_KEY = os.getenv("ARF_INTERNAL_API_KEY", "")
|
| 72 |
|
| 73 |
|
| 74 |
-
|
| 75 |
-
"""
|
| 76 |
-
FastAPI dependency that verifies the internal API key header.
|
| 77 |
-
|
| 78 |
-
The request must include an X‑Internal‑Key header matching
|
| 79 |
-
ARF_INTERNAL_API_KEY. This fails closed: if ARF_INTERNAL_API_KEY is not
|
| 80 |
-
configured, every request is rejected with 401 rather than being let
|
| 81 |
-
through unauthenticated.
|
| 82 |
-
|
| 83 |
-
This guards against direct access to the API when deployed behind
|
| 84 |
-
the Go gateway. The gateway is configured to inject this header
|
| 85 |
-
for authenticated requests.
|
| 86 |
-
"""
|
| 87 |
-
if not INTERNAL_API_KEY:
|
| 88 |
-
raise HTTPException(status_code=401, detail="Internal API key is not configured")
|
| 89 |
-
if x_internal_key is None:
|
| 90 |
-
raise HTTPException(status_code=401, detail="Missing internal API key")
|
| 91 |
-
# Use a constant‑time comparison to avoid timing attacks.
|
| 92 |
-
if not _constant_time_compare(x_internal_key, INTERNAL_API_KEY):
|
| 93 |
-
raise HTTPException(status_code=401, detail="Invalid internal API key")
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def _constant_time_compare(a: str, b: str) -> bool:
|
| 97 |
-
"""Compare two strings in constant time to prevent timing attacks."""
|
| 98 |
-
if len(a) != len(b):
|
| 99 |
-
return False
|
| 100 |
-
result = 0
|
| 101 |
-
for x, y in zip(a, b):
|
| 102 |
-
result |= ord(x) ^ ord(y)
|
| 103 |
-
return result == 0
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
# ---------------------------------------------------------------------------
|
| 107 |
-
# Singleton engine instances (lazy, cached)
|
| 108 |
-
# ---------------------------------------------------------------------------
|
| 109 |
-
|
| 110 |
_risk_engine = None
|
| 111 |
_decision_engine = None
|
| 112 |
_stability_controller = None
|
| 113 |
_causal_explainer = None
|
| 114 |
_rag_graph = None
|
| 115 |
-
_skill_registry = None
|
| 116 |
-
|
| 117 |
|
| 118 |
-
def _seed_rag_graph(rag: RAGGraphMemory) -> None:
|
| 119 |
-
"""
|
| 120 |
-
Populate the RAG graph with a small set of synthetic historical
|
| 121 |
-
healing‑action outcomes to provide initial memory for the decision engine.
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
rag : RAGGraphMemory
|
| 126 |
-
An already‑instantiated RAG graph memory instance.
|
| 127 |
-
"""
|
| 128 |
seed_data = [
|
| 129 |
("seed_restart_1", "test", HealingAction.RESTART_CONTAINER.value, True, 2),
|
| 130 |
("seed_restart_2", "test", HealingAction.RESTART_CONTAINER.value, True, 3),
|
|
@@ -144,23 +58,19 @@ def _seed_rag_graph(rag: RAGGraphMemory) -> None:
|
|
| 144 |
component=comp,
|
| 145 |
latency_p99=500,
|
| 146 |
error_rate=0.1,
|
| 147 |
-
service_mesh="default"
|
| 148 |
)
|
| 149 |
rag.record_outcome(
|
| 150 |
incident_id=inc_id,
|
| 151 |
event=event,
|
| 152 |
action_taken=action,
|
| 153 |
success=success,
|
| 154 |
-
resolution_time_minutes=res_time
|
| 155 |
)
|
| 156 |
print("Seeded RAG graph with historical data", file=sys.stderr)
|
| 157 |
|
| 158 |
|
| 159 |
-
def get_rag_graph()
|
| 160 |
-
"""
|
| 161 |
-
Return a singleton instance of the RAG graph memory, seeded with
|
| 162 |
-
synthetic historical data on first access.
|
| 163 |
-
"""
|
| 164 |
global _rag_graph
|
| 165 |
if _rag_graph is None:
|
| 166 |
_rag_graph = RAGGraphMemory()
|
|
@@ -168,11 +78,7 @@ def get_rag_graph() -> RAGGraphMemory:
|
|
| 168 |
return _rag_graph
|
| 169 |
|
| 170 |
|
| 171 |
-
def get_decision_engine()
|
| 172 |
-
"""
|
| 173 |
-
Return a singleton DecisionEngine, wiring it to the shared RAG graph
|
| 174 |
-
memory.
|
| 175 |
-
"""
|
| 176 |
global _decision_engine
|
| 177 |
if _decision_engine is None:
|
| 178 |
rag = get_rag_graph()
|
|
@@ -180,51 +86,22 @@ def get_decision_engine() -> DecisionEngine:
|
|
| 180 |
return _decision_engine
|
| 181 |
|
| 182 |
|
| 183 |
-
def get_risk_engine()
|
| 184 |
-
"""
|
| 185 |
-
Return a singleton RiskEngine instance.
|
| 186 |
-
"""
|
| 187 |
global _risk_engine
|
| 188 |
if _risk_engine is None:
|
| 189 |
_risk_engine = RiskEngine()
|
| 190 |
return _risk_engine
|
| 191 |
|
| 192 |
|
| 193 |
-
def get_stability_controller()
|
| 194 |
-
"""
|
| 195 |
-
Return a singleton LyapunovStabilityController instance.
|
| 196 |
-
"""
|
| 197 |
global _stability_controller
|
| 198 |
if _stability_controller is None:
|
| 199 |
_stability_controller = LyapunovStabilityController()
|
| 200 |
return _stability_controller
|
| 201 |
|
| 202 |
|
| 203 |
-
def get_causal_explainer()
|
| 204 |
-
"""
|
| 205 |
-
Return a singleton CausalEffectEstimator instance.
|
| 206 |
-
|
| 207 |
-
The estimator uses Inverse Probability Weighting (IPW) and causal forests
|
| 208 |
-
to provide counterfactual explanations for governance decisions.
|
| 209 |
-
"""
|
| 210 |
global _causal_explainer
|
| 211 |
if _causal_explainer is None:
|
| 212 |
-
_causal_explainer =
|
| 213 |
return _causal_explainer
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
def get_skill_registry() -> "Optional[SkillRegistry]":
|
| 217 |
-
"""
|
| 218 |
-
Return a singleton SkillRegistry instance (v4.3.1).
|
| 219 |
-
|
| 220 |
-
The registry manages procedural skill artefacts, versioning, per‑skill
|
| 221 |
-
reliability models (Beta‑Binomial), and the COLLECT‑DIAGNOSE‑REVISE‑PROMOTE
|
| 222 |
-
evolution loop. If the SkillRegistry module is not installed, returns None.
|
| 223 |
-
"""
|
| 224 |
-
global _skill_registry
|
| 225 |
-
if not _SKILL_REGISTRY_AVAILABLE:
|
| 226 |
-
return None
|
| 227 |
-
if _skill_registry is None:
|
| 228 |
-
from agentic_reliability_framework.core.governance.skill_registry import SkillRegistry
|
| 229 |
-
_skill_registry = SkillRegistry()
|
| 230 |
-
return _skill_registry
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import sys
|
|
|
|
| 2 |
from app.database.session import SessionLocal
|
| 3 |
from slowapi import Limiter
|
| 4 |
from slowapi.util import get_remote_address
|
| 5 |
from app.core.config import settings
|
| 6 |
|
|
|
|
|
|
|
| 7 |
# ARF core engine imports
|
| 8 |
from agentic_reliability_framework.core.governance.risk_engine import RiskEngine
|
| 9 |
from agentic_reliability_framework.core.decision.decision_engine import DecisionEngine
|
| 10 |
from agentic_reliability_framework.core.governance.stability_controller import LyapunovStabilityController
|
| 11 |
+
from agentic_reliability_framework.core.governance.causal_explainer import CausalExplainer
|
| 12 |
from agentic_reliability_framework.runtime.memory.rag_graph import RAGGraphMemory
|
| 13 |
from agentic_reliability_framework.core.models.event import ReliabilityEvent, HealingAction
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
# Dependency to get DB session
|
| 17 |
def get_db():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
db = SessionLocal()
|
| 19 |
try:
|
| 20 |
yield db
|
|
|
|
| 22 |
db.close()
|
| 23 |
|
| 24 |
|
| 25 |
+
# Rate limiter with default limit from settings
|
|
|
|
|
|
|
|
|
|
| 26 |
limiter = Limiter(
|
| 27 |
key_func=get_remote_address,
|
| 28 |
+
default_limits=[
|
| 29 |
+
settings.RATE_LIMIT])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
+
# ARF engine dependencies (singletons for simplicity)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
_risk_engine = None
|
| 34 |
_decision_engine = None
|
| 35 |
_stability_controller = None
|
| 36 |
_causal_explainer = None
|
| 37 |
_rag_graph = None
|
|
|
|
|
|
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
+
def _seed_rag_graph(rag):
|
| 41 |
+
"""Seed the RAG graph with historical healing action outcomes."""
|
|
|
|
|
|
|
|
|
|
| 42 |
seed_data = [
|
| 43 |
("seed_restart_1", "test", HealingAction.RESTART_CONTAINER.value, True, 2),
|
| 44 |
("seed_restart_2", "test", HealingAction.RESTART_CONTAINER.value, True, 3),
|
|
|
|
| 58 |
component=comp,
|
| 59 |
latency_p99=500,
|
| 60 |
error_rate=0.1,
|
| 61 |
+
service_mesh="default"
|
| 62 |
)
|
| 63 |
rag.record_outcome(
|
| 64 |
incident_id=inc_id,
|
| 65 |
event=event,
|
| 66 |
action_taken=action,
|
| 67 |
success=success,
|
| 68 |
+
resolution_time_minutes=res_time
|
| 69 |
)
|
| 70 |
print("Seeded RAG graph with historical data", file=sys.stderr)
|
| 71 |
|
| 72 |
|
| 73 |
+
def get_rag_graph():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
global _rag_graph
|
| 75 |
if _rag_graph is None:
|
| 76 |
_rag_graph = RAGGraphMemory()
|
|
|
|
| 78 |
return _rag_graph
|
| 79 |
|
| 80 |
|
| 81 |
+
def get_decision_engine():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
global _decision_engine
|
| 83 |
if _decision_engine is None:
|
| 84 |
rag = get_rag_graph()
|
|
|
|
| 86 |
return _decision_engine
|
| 87 |
|
| 88 |
|
| 89 |
+
def get_risk_engine():
|
|
|
|
|
|
|
|
|
|
| 90 |
global _risk_engine
|
| 91 |
if _risk_engine is None:
|
| 92 |
_risk_engine = RiskEngine()
|
| 93 |
return _risk_engine
|
| 94 |
|
| 95 |
|
| 96 |
+
def get_stability_controller():
|
|
|
|
|
|
|
|
|
|
| 97 |
global _stability_controller
|
| 98 |
if _stability_controller is None:
|
| 99 |
_stability_controller = LyapunovStabilityController()
|
| 100 |
return _stability_controller
|
| 101 |
|
| 102 |
|
| 103 |
+
def get_causal_explainer():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
global _causal_explainer
|
| 105 |
if _causal_explainer is None:
|
| 106 |
+
_causal_explainer = CausalExplainer()
|
| 107 |
return _causal_explainer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/api/routes_admin.py
CHANGED
|
@@ -2,37 +2,26 @@
|
|
| 2 |
Admin API endpoints for API key management and audit logs.
|
| 3 |
These endpoints should be protected (e.g., by an admin API key) in production.
|
| 4 |
"""
|
| 5 |
-
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
| 6 |
from pydantic import BaseModel
|
| 7 |
from typing import Optional
|
| 8 |
from datetime import datetime
|
| 9 |
-
import os
|
| 10 |
-
import secrets
|
| 11 |
import uuid
|
| 12 |
-
from
|
| 13 |
-
from app.api.deps import get_db
|
| 14 |
-
from app.core import usage_tracker
|
| 15 |
-
from app.core.usage_tracker import Tier
|
| 16 |
-
from app.database.models_intents import TenantDB
|
| 17 |
|
| 18 |
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 19 |
-
#
|
| 20 |
-
|
| 21 |
-
ADMIN_API_KEY = os.getenv("ARF_ADMIN_API_KEY")
|
| 22 |
|
| 23 |
|
| 24 |
def verify_admin(admin_key: str = Query(..., alias="admin_key")):
|
| 25 |
-
if
|
| 26 |
-
raise HTTPException(status_code=403, detail="Admin API is not configured")
|
| 27 |
-
if not secrets.compare_digest(admin_key, ADMIN_API_KEY):
|
| 28 |
raise HTTPException(status_code=403, detail="Invalid admin key")
|
| 29 |
return True
|
| 30 |
|
| 31 |
|
| 32 |
class CreateKeyRequest(BaseModel):
|
| 33 |
tier: str
|
| 34 |
-
tenant_id: Optional[str] = None # attach to an existing tenant; omit to create a new one
|
| 35 |
-
org_name: Optional[str] = None # used only when creating a new tenant
|
| 36 |
|
| 37 |
|
| 38 |
class UpdateTierRequest(BaseModel):
|
|
@@ -40,146 +29,78 @@ class UpdateTierRequest(BaseModel):
|
|
| 40 |
|
| 41 |
|
| 42 |
@router.post("/keys", dependencies=[Depends(verify_admin)])
|
| 43 |
-
async def create_api_key(req: CreateKeyRequest
|
| 44 |
-
# Previously called get_or_create_api_key(new_key, tier_enum) -- since
|
| 45 |
-
# that function's signature is (key, tenant_id, tier=FREE), tier_enum
|
| 46 |
-
# was silently accepted as tenant_id and every key of the same tier
|
| 47 |
-
# collided onto one bogus tenant_id (e.g. every "free" key sharing
|
| 48 |
-
# tenant_id="free"). tenant_id gates real per-tenant isolation
|
| 49 |
-
# elsewhere (BetaStateDB, IntentDB, decision audit log), so this was a
|
| 50 |
-
# cross-tenant data bug, not just a mislabeled field.
|
| 51 |
if req.tier not in [t.value for t in Tier]:
|
| 52 |
raise HTTPException(
|
| 53 |
status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}")
|
| 54 |
-
tier_enum = Tier(req.tier)
|
| 55 |
-
|
| 56 |
-
tenant_id = req.tenant_id
|
| 57 |
-
if tenant_id:
|
| 58 |
-
if not db.query(TenantDB).filter(TenantDB.id == tenant_id).first():
|
| 59 |
-
raise HTTPException(status_code=404, detail=f"Tenant {tenant_id} not found")
|
| 60 |
-
else:
|
| 61 |
-
tenant_id = str(uuid.uuid4())
|
| 62 |
-
db.add(TenantDB(
|
| 63 |
-
id=tenant_id,
|
| 64 |
-
name=req.org_name or "Default Organization",
|
| 65 |
-
created_at=datetime.utcnow(),
|
| 66 |
-
created_by="admin",
|
| 67 |
-
))
|
| 68 |
-
db.commit()
|
| 69 |
-
|
| 70 |
new_key = f"sk_live_{uuid.uuid4().hex[:24]}"
|
| 71 |
-
|
| 72 |
-
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
-
@router.get("/keys", dependencies=[Depends(verify_admin)])
|
| 76 |
async def list_api_keys(limit: int = 100, offset: int = 0):
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
tier/deactivate endpoints below. `current_month_usage` is not shown
|
| 81 |
-
here: `monthly_counts` is intentionally still keyed by the raw API key
|
| 82 |
-
(arf-gateway depends on reading it that way), which this endpoint no
|
| 83 |
-
longer has -- query `/admin/keys/{api_key}/audit` with the real key for
|
| 84 |
-
per-key usage/audit history instead.
|
| 85 |
-
"""
|
| 86 |
-
with usage_tracker.tracker._get_pg_conn() as conn:
|
| 87 |
-
rows = usage_tracker.tracker._pg_execute(
|
| 88 |
-
conn,
|
| 89 |
-
"SELECT lookup_hash, tier, created_at, last_used_at, is_active FROM api_keys "
|
| 90 |
-
"ORDER BY created_at DESC LIMIT %s OFFSET %s",
|
| 91 |
(limit, offset)
|
| 92 |
-
).fetchall()
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
"
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
return {"keys": keys, "total": len(keys)}
|
| 105 |
|
| 106 |
|
| 107 |
-
@router.patch("/keys/{
|
| 108 |
async def update_key_tier(
|
| 109 |
-
|
| 110 |
req: UpdateTierRequest = Body(...),
|
| 111 |
):
|
| 112 |
if req.tier not in [t.value for t in Tier]:
|
| 113 |
raise HTTPException(
|
| 114 |
status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}")
|
| 115 |
-
with
|
| 116 |
-
row =
|
| 117 |
-
|
| 118 |
if not row:
|
| 119 |
-
conn.rollback()
|
| 120 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 121 |
-
|
| 122 |
-
|
| 123 |
conn.commit()
|
| 124 |
return {"message": f"Tier updated to {req.tier}"}
|
| 125 |
|
| 126 |
|
| 127 |
-
@router.
|
| 128 |
-
async def rotate_api_key(
|
| 129 |
-
key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)"),
|
| 130 |
-
):
|
| 131 |
-
"""Atomically deactivate a key and issue a new one on the same tenant
|
| 132 |
-
and tier -- the single action a leaked/compromised key actually needs.
|
| 133 |
-
Doing this as get-old + deactivate + create separately (the only option
|
| 134 |
-
before this endpoint existed) risks losing track of the tenant_id
|
| 135 |
-
partway through, or leaving the old key active if a later step fails.
|
| 136 |
-
The new plaintext key is returned exactly once, like create_api_key's."""
|
| 137 |
-
with usage_tracker.tracker._get_pg_conn() as conn:
|
| 138 |
-
old_row = usage_tracker.tracker._pg_execute(
|
| 139 |
-
conn, "SELECT tenant_id, tier FROM api_keys WHERE lookup_hash = %s", (key_id,)
|
| 140 |
-
).fetchone()
|
| 141 |
-
if not old_row:
|
| 142 |
-
conn.rollback()
|
| 143 |
-
raise HTTPException(status_code=404, detail="API key not found")
|
| 144 |
-
|
| 145 |
-
new_key = f"sk_live_{uuid.uuid4().hex[:24]}"
|
| 146 |
-
salt = secrets.token_hex(16)
|
| 147 |
-
usage_tracker.tracker._pg_execute(
|
| 148 |
-
conn, "UPDATE api_keys SET is_active = false WHERE lookup_hash = %s", (key_id,))
|
| 149 |
-
usage_tracker.tracker._pg_execute(
|
| 150 |
-
conn,
|
| 151 |
-
"INSERT INTO api_keys "
|
| 152 |
-
"(tenant_id, tier, created_at, is_active, salt, key_hash, lookup_hash) "
|
| 153 |
-
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
| 154 |
-
(old_row["tenant_id"], old_row["tier"], datetime.utcnow(), True,
|
| 155 |
-
salt, usage_tracker.tracker._salted_hash(new_key, salt), usage_tracker.tracker._lookup_hash(new_key)),
|
| 156 |
-
)
|
| 157 |
-
conn.commit()
|
| 158 |
-
|
| 159 |
-
return {
|
| 160 |
-
"api_key": new_key,
|
| 161 |
-
"tenant_id": old_row["tenant_id"],
|
| 162 |
-
"tier": old_row["tier"],
|
| 163 |
-
"deactivated_key_id": key_id,
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
@router.delete("/keys/{key_id}", dependencies=[Depends(verify_admin)])
|
| 168 |
async def deactivate_api_key(
|
| 169 |
-
|
| 170 |
-
with
|
| 171 |
-
row =
|
| 172 |
-
|
| 173 |
if not row:
|
| 174 |
-
conn.rollback()
|
| 175 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 176 |
-
|
| 177 |
-
|
| 178 |
conn.commit()
|
| 179 |
return {"message": "API key deactivated"}
|
| 180 |
|
| 181 |
|
| 182 |
-
@router.get("/keys/{api_key}/audit", dependencies=[Depends(verify_admin)])
|
| 183 |
async def get_audit_logs(
|
| 184 |
api_key: str = Path(..., description="The API key to audit"),
|
| 185 |
start_date: Optional[str] = Query(None),
|
|
@@ -188,23 +109,20 @@ async def get_audit_logs(
|
|
| 188 |
):
|
| 189 |
start = datetime.fromisoformat(start_date) if start_date else None
|
| 190 |
end = datetime.fromisoformat(end_date) if end_date else None
|
| 191 |
-
logs =
|
| 192 |
return {"api_key": api_key, "logs": logs}
|
| 193 |
|
| 194 |
|
| 195 |
-
@router.get("/stats", dependencies=[Depends(verify_admin)])
|
| 196 |
async def get_global_stats():
|
| 197 |
-
with
|
| 198 |
-
total_keys =
|
| 199 |
-
|
| 200 |
-
pg_conn.commit()
|
| 201 |
-
with usage_tracker.tracker._get_conn() as conn:
|
| 202 |
total_requests = conn.execute(
|
| 203 |
"SELECT COUNT(*) FROM usage_log").fetchone()[0]
|
| 204 |
by_tier = conn.execute(
|
| 205 |
"SELECT tier, COUNT(*) as count FROM usage_log GROUP BY tier"
|
| 206 |
).fetchall()
|
| 207 |
-
month =
|
| 208 |
current_month_requests = conn.execute(
|
| 209 |
"SELECT SUM(count) FROM monthly_counts WHERE year_month = ?", (month,)
|
| 210 |
).fetchone()[0] or 0
|
|
@@ -214,74 +132,3 @@ async def get_global_stats():
|
|
| 214 |
"current_month_evaluations": current_month_requests,
|
| 215 |
"by_tier": [{"tier": row[0], "count": row[1]} for row in by_tier],
|
| 216 |
}
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
# ---------------------------------------------------------------------------
|
| 220 |
-
# Enterprise execution approvals (v4.3.4, opt-in -- see routes_governance.py's
|
| 221 |
-
# POST /intents/{id}/execute). Without these, the durable approval ledger has
|
| 222 |
-
# no way to actually be resolved through the API at all -- present but
|
| 223 |
-
# unusable. app.state.approval_store is None whenever ARF_ENABLE_EXECUTION
|
| 224 |
-
# is unset/false or arf_enterprise isn't installed (see main.py lifespan).
|
| 225 |
-
# ---------------------------------------------------------------------------
|
| 226 |
-
|
| 227 |
-
class ResolveApprovalRequest(BaseModel):
|
| 228 |
-
approved: bool
|
| 229 |
-
note: Optional[str] = None
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
def _require_approval_store(request: Request):
|
| 233 |
-
approval_store = getattr(request.app.state, "approval_store", None)
|
| 234 |
-
if approval_store is None:
|
| 235 |
-
raise HTTPException(
|
| 236 |
-
status_code=501,
|
| 237 |
-
detail="Enterprise execution approvals are not enabled on this deployment "
|
| 238 |
-
"(ARF_ENABLE_EXECUTION unset, or arf_enterprise is not installed)",
|
| 239 |
-
)
|
| 240 |
-
return approval_store
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
@router.get("/executions/pending", dependencies=[Depends(verify_admin)])
|
| 244 |
-
async def list_pending_executions(request: Request, limit: int = 100, offset: int = 0):
|
| 245 |
-
approval_store = _require_approval_store(request)
|
| 246 |
-
pending = approval_store.list_pending(limit=limit, offset=offset)
|
| 247 |
-
return {
|
| 248 |
-
"pending": [
|
| 249 |
-
{
|
| 250 |
-
"approval_id": r.id,
|
| 251 |
-
"decision_id": r.decision_id,
|
| 252 |
-
"intent_id": r.intent_id,
|
| 253 |
-
"level": r.level,
|
| 254 |
-
"approval_required": r.approval_required,
|
| 255 |
-
"requested_at": r.requested_at.isoformat(),
|
| 256 |
-
}
|
| 257 |
-
for r in pending
|
| 258 |
-
],
|
| 259 |
-
"total": len(pending),
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
@router.post("/executions/{approval_id}/resolve", dependencies=[Depends(verify_admin)])
|
| 264 |
-
async def resolve_execution_approval(
|
| 265 |
-
request: Request,
|
| 266 |
-
req: ResolveApprovalRequest,
|
| 267 |
-
approval_id: str = Path(..., description="The approval_id from POST /intents/{id}/execute's 202 response"),
|
| 268 |
-
):
|
| 269 |
-
# resolved_by is a fixed "admin" constant, not derived from admin_key in
|
| 270 |
-
# any way -- even a prefix of that secret has no business being
|
| 271 |
-
# persisted into a database row a GET endpoint can read back. Matches
|
| 272 |
-
# create_api_key's existing created_by="admin" pattern above: this
|
| 273 |
-
# codebase has one shared admin credential, not per-admin identity, so
|
| 274 |
-
# there's nothing more specific to record.
|
| 275 |
-
approval_store = _require_approval_store(request)
|
| 276 |
-
resolved = approval_store.resolve(
|
| 277 |
-
approval_id, approved=req.approved, resolved_by="admin", note=req.note
|
| 278 |
-
)
|
| 279 |
-
if not resolved:
|
| 280 |
-
raise HTTPException(
|
| 281 |
-
status_code=404,
|
| 282 |
-
detail="Approval not found or already resolved",
|
| 283 |
-
)
|
| 284 |
-
return {
|
| 285 |
-
"message": f"Approval {'approved' if req.approved else 'rejected'}",
|
| 286 |
-
"approval_id": approval_id,
|
| 287 |
-
}
|
|
|
|
| 2 |
Admin API endpoints for API key management and audit logs.
|
| 3 |
These endpoints should be protected (e.g., by an admin API key) in production.
|
| 4 |
"""
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
| 6 |
from pydantic import BaseModel
|
| 7 |
from typing import Optional
|
| 8 |
from datetime import datetime
|
|
|
|
|
|
|
| 9 |
import uuid
|
| 10 |
+
from app.core.usage_tracker import tracker, Tier
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 13 |
+
# Simple in‑memory admin key (replace with proper auth in production)
|
| 14 |
+
ADMIN_API_KEY = "admin_secret_change_me"
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
def verify_admin(admin_key: str = Query(..., alias="admin_key")):
|
| 18 |
+
if admin_key != ADMIN_API_KEY:
|
|
|
|
|
|
|
| 19 |
raise HTTPException(status_code=403, detail="Invalid admin key")
|
| 20 |
return True
|
| 21 |
|
| 22 |
|
| 23 |
class CreateKeyRequest(BaseModel):
|
| 24 |
tier: str
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
class UpdateTierRequest(BaseModel):
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
@router.post("/keys", dependencies=[Depends(verify_admin)])
|
| 32 |
+
async def create_api_key(req: CreateKeyRequest):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
if req.tier not in [t.value for t in Tier]:
|
| 34 |
raise HTTPException(
|
| 35 |
status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
new_key = f"sk_live_{uuid.uuid4().hex[:24]}"
|
| 37 |
+
tier_enum = Tier(req.tier)
|
| 38 |
+
tracker.get_or_create_api_key(new_key, tier_enum)
|
| 39 |
+
return {"api_key": new_key, "tier": req.tier}
|
| 40 |
|
| 41 |
|
|
|
|
| 42 |
async def list_api_keys(limit: int = 100, offset: int = 0):
|
| 43 |
+
with tracker._get_conn() as conn:
|
| 44 |
+
rows = conn.execute(
|
| 45 |
+
"SELECT key, tier, created_at, last_used_at, is_active FROM api_keys ORDER BY created_at DESC LIMIT ? OFFSET ?", # noqa: E501
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
(limit, offset)
|
| 47 |
+
).fetchall() # noqa: E501
|
| 48 |
+
keys = []
|
| 49 |
+
for row in rows:
|
| 50 |
+
month = tracker._get_month_key()
|
| 51 |
+
usage_row = conn.execute(
|
| 52 |
+
"SELECT count FROM monthly_counts WHERE api_key = ? AND year_month = ?",
|
| 53 |
+
(row["key"], month)
|
| 54 |
+
).fetchone()
|
| 55 |
+
usage = usage_row["count"] if usage_row else 0
|
| 56 |
+
keys.append(
|
| 57 |
+
{
|
| 58 |
+
"key": row["key"],
|
| 59 |
+
"tier": row["tier"],
|
| 60 |
+
"created_at": datetime.fromtimestamp(
|
| 61 |
+
row["created_at"]).isoformat(),
|
| 62 |
+
"last_used_at": datetime.fromtimestamp(
|
| 63 |
+
row["last_used_at"]).isoformat() if row["last_used_at"] else None,
|
| 64 |
+
"is_active": bool(
|
| 65 |
+
row["is_active"]),
|
| 66 |
+
"current_month_usage": usage,
|
| 67 |
+
})
|
| 68 |
return {"keys": keys, "total": len(keys)}
|
| 69 |
|
| 70 |
|
| 71 |
+
@router.patch("/keys/{api_key}/tier", dependencies=[Depends(verify_admin)])
|
| 72 |
async def update_key_tier(
|
| 73 |
+
api_key: str = Path(..., description="The API key to update"),
|
| 74 |
req: UpdateTierRequest = Body(...),
|
| 75 |
):
|
| 76 |
if req.tier not in [t.value for t in Tier]:
|
| 77 |
raise HTTPException(
|
| 78 |
status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}")
|
| 79 |
+
with tracker._get_conn() as conn:
|
| 80 |
+
row = conn.execute(
|
| 81 |
+
"SELECT key FROM api_keys WHERE key = ?", (api_key,)).fetchone()
|
| 82 |
if not row:
|
|
|
|
| 83 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 84 |
+
conn.execute("UPDATE api_keys SET tier = ? WHERE key = ?",
|
| 85 |
+
(req.tier, api_key))
|
| 86 |
conn.commit()
|
| 87 |
return {"message": f"Tier updated to {req.tier}"}
|
| 88 |
|
| 89 |
|
| 90 |
+
@router.delete("/keys/{api_key}", dependencies=[Depends(verify_admin)])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
async def deactivate_api_key(
|
| 92 |
+
api_key: str = Path(..., description="The API key to deactivate")):
|
| 93 |
+
with tracker._get_conn() as conn:
|
| 94 |
+
row = conn.execute(
|
| 95 |
+
"SELECT key FROM api_keys WHERE key = ?", (api_key,)).fetchone()
|
| 96 |
if not row:
|
|
|
|
| 97 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 98 |
+
conn.execute(
|
| 99 |
+
"UPDATE api_keys SET is_active = 0 WHERE key = ?", (api_key,))
|
| 100 |
conn.commit()
|
| 101 |
return {"message": "API key deactivated"}
|
| 102 |
|
| 103 |
|
|
|
|
| 104 |
async def get_audit_logs(
|
| 105 |
api_key: str = Path(..., description="The API key to audit"),
|
| 106 |
start_date: Optional[str] = Query(None),
|
|
|
|
| 109 |
):
|
| 110 |
start = datetime.fromisoformat(start_date) if start_date else None
|
| 111 |
end = datetime.fromisoformat(end_date) if end_date else None
|
| 112 |
+
logs = tracker.get_audit_logs(api_key, start, end, limit)
|
| 113 |
return {"api_key": api_key, "logs": logs}
|
| 114 |
|
| 115 |
|
|
|
|
| 116 |
async def get_global_stats():
|
| 117 |
+
with tracker._get_conn() as conn:
|
| 118 |
+
total_keys = conn.execute(
|
| 119 |
+
"SELECT COUNT(*) FROM api_keys WHERE is_active = 1").fetchone()[0]
|
|
|
|
|
|
|
| 120 |
total_requests = conn.execute(
|
| 121 |
"SELECT COUNT(*) FROM usage_log").fetchone()[0]
|
| 122 |
by_tier = conn.execute(
|
| 123 |
"SELECT tier, COUNT(*) as count FROM usage_log GROUP BY tier"
|
| 124 |
).fetchall()
|
| 125 |
+
month = tracker._get_month_key()
|
| 126 |
current_month_requests = conn.execute(
|
| 127 |
"SELECT SUM(count) FROM monthly_counts WHERE year_month = ?", (month,)
|
| 128 |
).fetchone()[0] or 0
|
|
|
|
| 132 |
"current_month_evaluations": current_month_requests,
|
| 133 |
"by_tier": [{"tier": row[0], "count": row[1]} for row in by_tier],
|
| 134 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/api/routes_governance.py
CHANGED
|
@@ -1,59 +1,25 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Routes for governance evaluation – tenant‑aware, audited, and Rust‑enforced.
|
| 3 |
-
|
| 4 |
-
This module provides the primary API endpoints for evaluating infrastructure
|
| 5 |
-
intents and healing decisions. It integrates:
|
| 6 |
-
|
| 7 |
-
- Idempotent quota consumption (usage tracker)
|
| 8 |
-
- Tenant isolation (tenant_id resolved server-side from the authenticated API key
|
| 9 |
-
via the ``enforce_quota`` dependency; never taken from a client-supplied header)
|
| 10 |
-
- Auditable decision logging (DecisionAuditLogDB)
|
| 11 |
-
- Pricing telemetry (optional, to arf‑pricing‑calculator)
|
| 12 |
-
- OpenTelemetry tracing
|
| 13 |
-
- Optional Rust execution ladder for mechanical enforcement
|
| 14 |
-
- **v4.3.1**: Full governance loop produces a Bayesian HealingIntent with skill
|
| 15 |
-
posterior parameters (α, β) for the enterprise SkillGate.
|
| 16 |
-
Includes persistent stability controller and temporal monitor for
|
| 17 |
-
cross‑request state accumulation, and a merging policy evaluator that
|
| 18 |
-
respects both external and internal policy violations.
|
| 19 |
-
Healing endpoint now optionally accepts skill context for Bayesian
|
| 20 |
-
utility‑aware action selection.
|
| 21 |
-
- **v4.3.2**: Passes criticality parameter for dynamic gate tuning (Feature 3).
|
| 22 |
-
Internal API key verification added to secure direct access.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks, Header
|
| 26 |
from fastapi.encoders import jsonable_encoder
|
| 27 |
-
from fastapi.responses import JSONResponse
|
| 28 |
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
from pydantic import BaseModel
|
| 30 |
import uuid
|
| 31 |
import logging
|
| 32 |
-
import os
|
| 33 |
import time
|
| 34 |
-
import
|
| 35 |
-
from typing import Optional, Dict, Any, List
|
| 36 |
|
| 37 |
-
from app.models.infrastructure_intents import InfrastructureIntentRequest
|
| 38 |
-
from app.services.intent_adapter import to_oss_intent
|
| 39 |
-
from app.services.risk_service import evaluate_intent_full, evaluate_healing_decision
|
| 40 |
-
from app.services.intent_store import save_evaluated_intent
|
| 41 |
-
from app.services.outcome_service import record_outcome
|
| 42 |
-
from app.api.deps import get_db, get_skill_registry, verify_internal_key # <-- v4.3.2
|
| 43 |
-
from app.database.session import SessionLocal
|
| 44 |
-
from app.core.usage_tracker import enforce_quota # <-- tenant resolution
|
| 45 |
-
from app.database.models_intents import DecisionAuditLogDB, IntentDB
|
| 46 |
from agentic_reliability_framework.core.models.event import ReliabilityEvent
|
| 47 |
-
from agentic_reliability_framework.core.governance.policies import (
|
| 48 |
-
PolicyEvaluator,
|
| 49 |
-
allow_all,
|
| 50 |
-
)
|
| 51 |
|
| 52 |
-
# ===== USAGE TRACKER =====
|
| 53 |
import app.core.usage_tracker
|
| 54 |
from app.core.usage_tracker import UsageRecord
|
| 55 |
|
| 56 |
-
# ===== PRICING CALCULATOR =====
|
| 57 |
try:
|
| 58 |
from arf_pricing_calculator.storage.buffer import add_event
|
| 59 |
PRICING_AVAILABLE = True
|
|
@@ -61,66 +27,7 @@ except ImportError:
|
|
| 61 |
PRICING_AVAILABLE = False
|
| 62 |
add_event = None
|
| 63 |
|
| 64 |
-
# =====
|
| 65 |
-
try:
|
| 66 |
-
from arf_enterprise.execution_ladder import ExecutionLadder
|
| 67 |
-
RUST_AVAILABLE = True
|
| 68 |
-
except ImportError:
|
| 69 |
-
RUST_AVAILABLE = False
|
| 70 |
-
ExecutionLadder = None
|
| 71 |
-
|
| 72 |
-
# ===== ENTERPRISE EXECUTOR (optional) =====
|
| 73 |
-
# `arf_enterprise` is not in requirements.txt -- it's a proprietary,
|
| 74 |
-
# private-repo package, and unlike agentic_reliability_framework/
|
| 75 |
-
# arf-pricing-calculator (plain git+https URLs with no visible credential
|
| 76 |
-
# setup in the Dockerfile) I can't confirm Render's build can actually
|
| 77 |
-
# clone it. Stays fully optional/try-except-guarded, same shape as
|
| 78 |
-
# RUST_AVAILABLE above, deliberately not added as a hard dependency this
|
| 79 |
-
# session -- see POST /intents/{id}/execute below and .env.example for what
|
| 80 |
-
# an operator needs to do to actually turn this on.
|
| 81 |
-
try:
|
| 82 |
-
from arf_enterprise.executor import EnterpriseExecutor
|
| 83 |
-
from arf_enterprise.actuators.fake import FakeCloudActuator
|
| 84 |
-
from arf_enterprise.config import EnterpriseConfig
|
| 85 |
-
from arf_enterprise.store import ApprovalStore, PostgresStore
|
| 86 |
-
from arf_enterprise.exceptions import (
|
| 87 |
-
ExecutionError as EnterpriseExecutionError,
|
| 88 |
-
PendingApprovalError,
|
| 89 |
-
SafetyError as EnterpriseSafetyError,
|
| 90 |
-
)
|
| 91 |
-
ENTERPRISE_EXECUTOR_AVAILABLE = True
|
| 92 |
-
except ImportError:
|
| 93 |
-
ENTERPRISE_EXECUTOR_AVAILABLE = False
|
| 94 |
-
EnterpriseExecutor = None
|
| 95 |
-
FakeCloudActuator = None
|
| 96 |
-
EnterpriseConfig = None
|
| 97 |
-
ApprovalStore = None
|
| 98 |
-
PostgresStore = None
|
| 99 |
-
EnterpriseExecutionError = None
|
| 100 |
-
PendingApprovalError = None
|
| 101 |
-
EnterpriseSafetyError = None
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def _trusted_signing_keys() -> List[str]:
|
| 105 |
-
"""Parse ARF_TRUSTED_SIGNING_KEYS into a list of hex fingerprints.
|
| 106 |
-
|
| 107 |
-
Comma-separated, matching EnterpriseConfig.from_env's own parsing.
|
| 108 |
-
Splitting matters: the variable holds N fingerprints, and passing the
|
| 109 |
-
raw string as a single-element list would register the literal
|
| 110 |
-
"abc,def" as one key -- so neither real key would be trusted, and an
|
| 111 |
-
unset variable would register the empty string as trusted rather than
|
| 112 |
-
trusting nothing.
|
| 113 |
-
|
| 114 |
-
An empty result is the correct fail-closed state: the ladder then
|
| 115 |
-
rejects every signed intent, which is loud and safe.
|
| 116 |
-
"""
|
| 117 |
-
raw = os.getenv("ARF_TRUSTED_SIGNING_KEYS", "")
|
| 118 |
-
return [k.strip() for k in raw.split(",") if k.strip()]
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
ARF_ENABLE_EXECUTION = os.getenv("ARF_ENABLE_EXECUTION", "false").lower() == "true"
|
| 122 |
-
|
| 123 |
-
# ===== OPEN TELEMETRY =====
|
| 124 |
try:
|
| 125 |
from opentelemetry import trace
|
| 126 |
from opentelemetry.trace import Status, StatusCode
|
|
@@ -131,9 +38,7 @@ except ImportError:
|
|
| 131 |
_tracer = None
|
| 132 |
|
| 133 |
logger = logging.getLogger(__name__)
|
| 134 |
-
|
| 135 |
-
# v4.3.2: protect all governance endpoints with internal API key verification
|
| 136 |
-
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 137 |
|
| 138 |
|
| 139 |
class OutcomeRequest(BaseModel):
|
|
@@ -141,136 +46,12 @@ class OutcomeRequest(BaseModel):
|
|
| 141 |
success: bool
|
| 142 |
recorded_by: str
|
| 143 |
notes: str = ""
|
| 144 |
-
# v4.3.1: optional skill provenance for reliability feedback
|
| 145 |
-
skill_id: Optional[str] = None
|
| 146 |
-
skill_version: Optional[int] = None
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
class ExecuteIntentRequest(BaseModel):
|
| 150 |
-
"""The client already received `healing_intent` in /intents/evaluate's
|
| 151 |
-
response -- resubmitting it here (rather than this endpoint trying to
|
| 152 |
-
reconstruct a signed, action/component/parameters-bearing intent from
|
| 153 |
-
IntentDB's stored oss_payload, which doesn't carry the signature) is
|
| 154 |
-
the cheapest correct design; IntentDB is used only to confirm the
|
| 155 |
-
intent exists and belongs to the caller's tenant."""
|
| 156 |
-
healing_intent: Dict[str, Any]
|
| 157 |
-
human_approved: bool = False
|
| 158 |
-
admin_approved: bool = False
|
| 159 |
-
# v4.3.1: optional skill provenance, forwarded to record_outcome the
|
| 160 |
-
# same way OutcomeRequest already does.
|
| 161 |
-
skill_id: Optional[str] = None
|
| 162 |
-
skill_version: Optional[int] = None
|
| 163 |
|
| 164 |
|
| 165 |
class HealingDecisionRequest(BaseModel):
|
| 166 |
event: ReliabilityEvent
|
| 167 |
-
# v4.3.1: optional skill context for Bayesian utility
|
| 168 |
-
skill_id: Optional[str] = None
|
| 169 |
-
skill_version: Optional[int] = None
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
# --------------------------------------------------------------------------
|
| 173 |
-
# Helper: write audit log (idempotent)
|
| 174 |
-
# --------------------------------------------------------------------------
|
| 175 |
-
async def write_audit_log(
|
| 176 |
-
tenant_id: str,
|
| 177 |
-
deterministic_id: str,
|
| 178 |
-
healing_intent: Dict[str, Any],
|
| 179 |
-
trace_id: Optional[str] = None,
|
| 180 |
-
idempotency_key: Optional[str] = None,
|
| 181 |
-
) -> None:
|
| 182 |
-
"""
|
| 183 |
-
Store a governance decision in the immutable audit log.
|
| 184 |
-
Idempotent on (tenant_id, deterministic_id) – if already exists, skip.
|
| 185 |
-
|
| 186 |
-
Runs as a BackgroundTask, which executes after the response has already
|
| 187 |
-
been sent -- and after FastAPI has already torn down the request's
|
| 188 |
-
`Depends(get_db)` session. Reusing that session here would mean every
|
| 189 |
-
query silently reopens a fresh connection/transaction that nothing then
|
| 190 |
-
guarantees gets closed (least of all on the idempotent-skip path below,
|
| 191 |
-
which used to return with no commit/rollback/close at all), leaving an
|
| 192 |
-
idle-in-transaction connection that blocks any later DDL against these
|
| 193 |
-
tables (e.g. test teardown's `Base.metadata.drop_all()`) indefinitely.
|
| 194 |
-
Owning and closing our own session here avoids that entirely.
|
| 195 |
-
"""
|
| 196 |
-
db = SessionLocal()
|
| 197 |
-
try:
|
| 198 |
-
# Check if already logged (idempotency)
|
| 199 |
-
existing = db.query(DecisionAuditLogDB).filter(
|
| 200 |
-
DecisionAuditLogDB.tenant_id == tenant_id,
|
| 201 |
-
DecisionAuditLogDB.deterministic_id == deterministic_id
|
| 202 |
-
).first()
|
| 203 |
-
if existing:
|
| 204 |
-
logger.info(f"Audit log already exists for {deterministic_id}, skipping.")
|
| 205 |
-
return
|
| 206 |
-
|
| 207 |
-
# Extract fields that are actually present in DecisionAuditLogDB
|
| 208 |
-
risk_score = healing_intent.get("risk_score", 0.5)
|
| 209 |
-
action = healing_intent.get("recommended_action", "deny")
|
| 210 |
-
justification = healing_intent.get("justification", "")
|
| 211 |
-
metadata = healing_intent.get("metadata", {})
|
| 212 |
-
memory_success_rate = metadata.get("memory_success_rate")
|
| 213 |
-
memory_weight = metadata.get("memory_weight")
|
| 214 |
-
counterfactual = metadata.get("counterfactual")
|
| 215 |
-
|
| 216 |
-
audit_entry = DecisionAuditLogDB(
|
| 217 |
-
tenant_id=tenant_id,
|
| 218 |
-
deterministic_id=deterministic_id,
|
| 219 |
-
timestamp=datetime.datetime.utcnow(),
|
| 220 |
-
risk_score=risk_score,
|
| 221 |
-
action=action,
|
| 222 |
-
justification=justification,
|
| 223 |
-
memory_success_rate=memory_success_rate,
|
| 224 |
-
memory_weight=memory_weight,
|
| 225 |
-
counterfactual=counterfactual,
|
| 226 |
-
trace_id=trace_id,
|
| 227 |
-
)
|
| 228 |
-
db.add(audit_entry)
|
| 229 |
-
db.commit()
|
| 230 |
-
logger.info(f"Audit log written for {deterministic_id}")
|
| 231 |
-
finally:
|
| 232 |
-
db.close()
|
| 233 |
|
| 234 |
|
| 235 |
-
# --------------------------------------------------------------------------
|
| 236 |
-
# Policy evaluator that merges external violations with internal checks
|
| 237 |
-
# --------------------------------------------------------------------------
|
| 238 |
-
class MergingPolicyEvaluator(PolicyEvaluator):
|
| 239 |
-
"""
|
| 240 |
-
A policy evaluator that combines a base evaluator (the governance loop's
|
| 241 |
-
own policy tree) with a set of pre‑computed violations (e.g., from an
|
| 242 |
-
external Rust enforcer or the request body). The effective violation list
|
| 243 |
-
is the union of both sources, preserving order and removing duplicates.
|
| 244 |
-
"""
|
| 245 |
-
def __init__(self, base_evaluator: PolicyEvaluator, pre_violations: List[str]):
|
| 246 |
-
# We must call the PolicyEvaluator constructor with a root policy,
|
| 247 |
-
# but the base evaluator will be used for actual evaluation.
|
| 248 |
-
super().__init__(base_evaluator.get_root_policy())
|
| 249 |
-
self._base = base_evaluator
|
| 250 |
-
self._pre = list(pre_violations)
|
| 251 |
-
|
| 252 |
-
def evaluate(self, intent, context=None):
|
| 253 |
-
base_violations = self._base.evaluate(intent, context)
|
| 254 |
-
# Merge with pre‑computed violations, preserving order and removing duplicates
|
| 255 |
-
merged = []
|
| 256 |
-
seen = set()
|
| 257 |
-
for v in self._pre:
|
| 258 |
-
if v not in seen:
|
| 259 |
-
merged.append(v)
|
| 260 |
-
seen.add(v)
|
| 261 |
-
for v in base_violations:
|
| 262 |
-
if v not in seen:
|
| 263 |
-
merged.append(v)
|
| 264 |
-
seen.add(v)
|
| 265 |
-
return merged
|
| 266 |
-
|
| 267 |
-
def get_root_policy(self):
|
| 268 |
-
return self._base.get_root_policy()
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
# --------------------------------------------------------------------------
|
| 272 |
-
# Endpoint: evaluate infrastructure intent
|
| 273 |
-
# --------------------------------------------------------------------------
|
| 274 |
@router.post("/intents/evaluate")
|
| 275 |
async def evaluate_intent_endpoint(
|
| 276 |
request: Request,
|
|
@@ -278,14 +59,11 @@ async def evaluate_intent_endpoint(
|
|
| 278 |
background_tasks: BackgroundTasks,
|
| 279 |
db: Session = Depends(get_db),
|
| 280 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
| 281 |
-
skill_registry=Depends(get_skill_registry), # v4.3.1
|
| 282 |
-
quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key
|
| 283 |
):
|
| 284 |
"""
|
| 285 |
-
Evaluate an infrastructure intent with idempotency
|
| 286 |
-
full governance loop analysis, Bayesian skill posterior injection,
|
| 287 |
-
and optional criticality parameter for dynamic gate tuning (v4.3.2).
|
| 288 |
"""
|
|
|
|
| 289 |
span = None
|
| 290 |
if OTEL_AVAILABLE and _tracer:
|
| 291 |
span = _tracer.start_span("governance.evaluate_intent")
|
|
@@ -293,21 +71,21 @@ async def evaluate_intent_endpoint(
|
|
| 293 |
span.set_attribute("environment", str(intent_req.environment))
|
| 294 |
|
| 295 |
start_time = time.time()
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
tenant_id = quota["tenant_id"]
|
| 300 |
|
| 301 |
current_tracker = app.core.usage_tracker.tracker
|
| 302 |
if current_tracker is None:
|
| 303 |
if span:
|
| 304 |
span.set_status(Status(StatusCode.ERROR, "tracker unavailable"))
|
| 305 |
span.end()
|
| 306 |
-
raise HTTPException(status_code=503,
|
|
|
|
| 307 |
|
| 308 |
record = UsageRecord(
|
| 309 |
api_key=api_key,
|
| 310 |
-
tier=
|
| 311 |
timestamp=start_time,
|
| 312 |
endpoint="/api/v1/intents/evaluate",
|
| 313 |
request_body=intent_req.model_dump(),
|
|
@@ -324,84 +102,40 @@ async def evaluate_intent_endpoint(
|
|
| 324 |
if existing_response:
|
| 325 |
return existing_response
|
| 326 |
else:
|
| 327 |
-
raise HTTPException(status_code=429,
|
|
|
|
| 328 |
|
| 329 |
try:
|
| 330 |
oss_intent = to_oss_intent(intent_req)
|
| 331 |
risk_engine = request.app.state.risk_engine
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
policy_engine = getattr(request.app.state, "policy_engine", None)
|
| 335 |
-
if policy_engine is not None and hasattr(policy_engine, 'root_policy'):
|
| 336 |
-
base_evaluator = PolicyEvaluator(policy_engine.root_policy)
|
| 337 |
-
else:
|
| 338 |
-
base_evaluator = PolicyEvaluator(allow_all())
|
| 339 |
-
|
| 340 |
-
# Wrap it to also include the pre‑computed violations from the request
|
| 341 |
-
policy_evaluator = MergingPolicyEvaluator(
|
| 342 |
-
base_evaluator,
|
| 343 |
-
intent_req.policy_violations
|
| 344 |
-
)
|
| 345 |
-
|
| 346 |
-
# Optional components from app state
|
| 347 |
-
memory = getattr(request.app.state, "rag_graph", None)
|
| 348 |
-
hallucination_probe = getattr(request.app.state, "epistemic_probe", None)
|
| 349 |
-
predictive_engine = getattr(request.app.state, "predictive_engine", None)
|
| 350 |
-
business_calculator = getattr(request.app.state, "business_calculator", None)
|
| 351 |
-
|
| 352 |
-
# Stateful monitors (v4.3.1)
|
| 353 |
-
stability_controller = getattr(request.app.state, "stability_controller", None)
|
| 354 |
-
temporal_monitor = getattr(request.app.state, "temporal_monitor", None)
|
| 355 |
-
|
| 356 |
-
# Run the full governance loop, injecting skill context and criticality if present
|
| 357 |
-
result = evaluate_intent_full(
|
| 358 |
intent=oss_intent,
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
memory=memory,
|
| 362 |
-
hallucination_probe=hallucination_probe,
|
| 363 |
-
predictive_engine=predictive_engine,
|
| 364 |
-
business_calculator=business_calculator,
|
| 365 |
-
stability_controller=stability_controller,
|
| 366 |
-
temporal_monitor=temporal_monitor,
|
| 367 |
-
skill_id=intent_req.skill_id,
|
| 368 |
-
skill_registry=skill_registry,
|
| 369 |
-
tenant_id=tenant_id,
|
| 370 |
-
criticality=intent_req.criticality, # v4.3.2
|
| 371 |
)
|
| 372 |
|
| 373 |
if span:
|
| 374 |
span.set_attribute("risk_score", result["risk_score"])
|
|
|
|
| 375 |
|
| 376 |
-
deterministic_id =
|
| 377 |
api_payload = jsonable_encoder(intent_req.model_dump())
|
| 378 |
oss_payload = jsonable_encoder(oss_intent.model_dump())
|
| 379 |
|
| 380 |
save_evaluated_intent(
|
| 381 |
db=db,
|
| 382 |
deterministic_id=deterministic_id,
|
| 383 |
-
tenant_id=tenant_id,
|
| 384 |
intent_type=intent_req.intent_type,
|
| 385 |
api_payload=api_payload,
|
| 386 |
oss_payload=oss_payload,
|
| 387 |
environment=str(intent_req.environment),
|
| 388 |
-
risk_score=result["risk_score"]
|
| 389 |
)
|
| 390 |
|
| 391 |
result["intent_id"] = deterministic_id
|
| 392 |
response_data = result
|
| 393 |
|
| 394 |
-
# ---- Write audit log (asynchronously) ----
|
| 395 |
-
healing_intent_dict = result.get("healing_intent", result)
|
| 396 |
-
background_tasks.add_task(
|
| 397 |
-
write_audit_log,
|
| 398 |
-
tenant_id=tenant_id,
|
| 399 |
-
deterministic_id=deterministic_id,
|
| 400 |
-
healing_intent=healing_intent_dict,
|
| 401 |
-
trace_id=span.get_span_context().trace_id if span else None,
|
| 402 |
-
idempotency_key=idempotency_key,
|
| 403 |
-
)
|
| 404 |
-
|
| 405 |
if current_tracker:
|
| 406 |
background_tasks.add_task(
|
| 407 |
current_tracker._insert_audit_log,
|
|
@@ -435,174 +169,31 @@ async def evaluate_intent_endpoint(
|
|
| 435 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 436 |
span.record_exception(e)
|
| 437 |
span.end()
|
| 438 |
-
raise HTTPException(status_code=500, detail=
|
| 439 |
|
| 440 |
|
| 441 |
-
# --------------------------------------------------------------------------
|
| 442 |
-
# Endpoint: execute a previously evaluated intent (v4.3.4, opt-in)
|
| 443 |
-
# --------------------------------------------------------------------------
|
| 444 |
-
@router.post("/intents/{deterministic_id}/execute")
|
| 445 |
-
async def execute_intent_endpoint(
|
| 446 |
-
request: Request,
|
| 447 |
-
deterministic_id: str,
|
| 448 |
-
exec_req: ExecuteIntentRequest,
|
| 449 |
-
db: Session = Depends(get_db),
|
| 450 |
-
skill_registry=Depends(get_skill_registry),
|
| 451 |
-
quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key
|
| 452 |
-
):
|
| 453 |
-
"""
|
| 454 |
-
Execute a previously evaluated healing intent through
|
| 455 |
-
arf_enterprise.EnterpriseExecutor -- gate re-check (Rust ladder),
|
| 456 |
-
optional durable approval, actuation, and independent read-back
|
| 457 |
-
verification, whose *verified* result (not a client self-report) feeds
|
| 458 |
-
record_outcome the same way /intents/outcome already does.
|
| 459 |
-
|
| 460 |
-
Off by default (ARF_ENABLE_EXECUTION unset or false) and a 501 if the
|
| 461 |
-
arf_enterprise package isn't importable -- this is new, opt-in
|
| 462 |
-
capability, not a replacement for the existing advisory-only flow.
|
| 463 |
-
Uses FakeCloudActuator regardless of what's configured elsewhere:
|
| 464 |
-
selecting a real cloud actuator (which provider, which credentials) is
|
| 465 |
-
a deliberate later step for whoever actually deploys against real
|
| 466 |
-
infrastructure, not something this endpoint defaults into.
|
| 467 |
-
"""
|
| 468 |
-
if not ENTERPRISE_EXECUTOR_AVAILABLE:
|
| 469 |
-
raise HTTPException(
|
| 470 |
-
status_code=501,
|
| 471 |
-
detail="arf_enterprise is not installed on this deployment; execution is unavailable",
|
| 472 |
-
)
|
| 473 |
-
if not ARF_ENABLE_EXECUTION:
|
| 474 |
-
raise HTTPException(
|
| 475 |
-
status_code=501,
|
| 476 |
-
detail="Execution is not enabled (set ARF_ENABLE_EXECUTION=true to opt in)",
|
| 477 |
-
)
|
| 478 |
-
|
| 479 |
-
tenant_id = quota["tenant_id"]
|
| 480 |
-
|
| 481 |
-
# Existence + tenant-ownership check only -- the healing_intent to
|
| 482 |
-
# execute comes from the request body (see ExecuteIntentRequest), not
|
| 483 |
-
# reconstructed from what's stored here.
|
| 484 |
-
intent_row = db.query(IntentDB).filter(
|
| 485 |
-
IntentDB.deterministic_id == deterministic_id,
|
| 486 |
-
IntentDB.tenant_id == tenant_id,
|
| 487 |
-
).one_or_none()
|
| 488 |
-
if not intent_row:
|
| 489 |
-
raise HTTPException(status_code=404, detail=f"Intent not found: {deterministic_id}")
|
| 490 |
-
|
| 491 |
-
risk_engine = request.app.state.risk_engine
|
| 492 |
-
|
| 493 |
-
def _on_verified_outcome(intent: Dict[str, Any], verified_success: bool, context: Dict[str, Any]) -> None:
|
| 494 |
-
try:
|
| 495 |
-
record_outcome(
|
| 496 |
-
db=db,
|
| 497 |
-
tenant_id=tenant_id,
|
| 498 |
-
deterministic_id=deterministic_id,
|
| 499 |
-
success=verified_success,
|
| 500 |
-
recorded_by="enterprise_executor",
|
| 501 |
-
notes=f"Auto-recorded from verified execution. Observed: {context.get('observed')}",
|
| 502 |
-
risk_engine=risk_engine,
|
| 503 |
-
skill_id=exec_req.skill_id,
|
| 504 |
-
skill_version=exec_req.skill_version,
|
| 505 |
-
skill_registry=skill_registry,
|
| 506 |
-
)
|
| 507 |
-
except Exception:
|
| 508 |
-
# Execution already happened by the time this fires -- a failure
|
| 509 |
-
# here must not be raised back through EnterpriseExecutor.execute()
|
| 510 |
-
# (which would misreport a real actuation as failed). Logged so
|
| 511 |
-
# the risk-engine-not-updated case is visible, not silent.
|
| 512 |
-
logger.exception(
|
| 513 |
-
"Failed to record verified outcome for intent %s after execution",
|
| 514 |
-
deterministic_id,
|
| 515 |
-
)
|
| 516 |
-
|
| 517 |
-
# Singleton initialised once at startup (main.py lifespan) rather than
|
| 518 |
-
# constructed fresh per request -- None here means either
|
| 519 |
-
# ARF_ENABLE_EXECUTION wasn't set (unreachable, checked above) or the
|
| 520 |
-
# ledger failed to initialise at startup, in which case boolean-trust
|
| 521 |
-
# mode is the documented fallback (see EnterpriseExecutor.execute).
|
| 522 |
-
approval_store = getattr(request.app.state, "approval_store", None)
|
| 523 |
-
|
| 524 |
-
# A narrow config carrying only the trust anchor. Deliberately NOT
|
| 525 |
-
# EnterpriseConfig.from_env(), which would also pick up ARF_CLOUD,
|
| 526 |
-
# ARF_MAX_BLAST_RADIUS, ARF_ENFORCE_BUSINESS_HOURS and the audit/safety
|
| 527 |
-
# toggles -- turning on guardrails and audit logging as a side effect of
|
| 528 |
-
# enabling signing is a behaviour change nobody asked for. Everything
|
| 529 |
-
# other than the trusted keys stays on today's defaults.
|
| 530 |
-
#
|
| 531 |
-
# Without this the executor got EnterpriseConfig() with an empty
|
| 532 |
-
# trusted_signing_keys, so the ladder trusted no keys and rejected every
|
| 533 |
-
# signed intent as "Untrusted signing key" -- the whole execute path was
|
| 534 |
-
# unreachable regardless of ARF_ENABLE_EXECUTION.
|
| 535 |
-
trusted_keys = _trusted_signing_keys()
|
| 536 |
-
if not trusted_keys:
|
| 537 |
-
logger.warning(
|
| 538 |
-
"ARF_TRUSTED_SIGNING_KEYS is unset or empty; the execution ladder "
|
| 539 |
-
"trusts no signing keys and will reject every signed intent. Set it "
|
| 540 |
-
"to the hex fingerprint(s) of the key(s) permitted to sign intents."
|
| 541 |
-
)
|
| 542 |
-
|
| 543 |
-
executor = EnterpriseExecutor(
|
| 544 |
-
config=EnterpriseConfig(trusted_signing_keys=trusted_keys),
|
| 545 |
-
actuator=FakeCloudActuator(),
|
| 546 |
-
approval_store=approval_store,
|
| 547 |
-
on_verified_outcome=_on_verified_outcome,
|
| 548 |
-
)
|
| 549 |
-
|
| 550 |
-
try:
|
| 551 |
-
result = await executor.execute(
|
| 552 |
-
exec_req.healing_intent,
|
| 553 |
-
human_approved=exec_req.human_approved,
|
| 554 |
-
admin_approved=exec_req.admin_approved,
|
| 555 |
-
)
|
| 556 |
-
return result
|
| 557 |
-
except PendingApprovalError as e:
|
| 558 |
-
return JSONResponse(
|
| 559 |
-
status_code=202,
|
| 560 |
-
content={
|
| 561 |
-
"status": "pending_approval",
|
| 562 |
-
"approval_id": e.approval_id,
|
| 563 |
-
"level": e.level,
|
| 564 |
-
"approval_required": e.approval_required,
|
| 565 |
-
"detail": str(e),
|
| 566 |
-
},
|
| 567 |
-
)
|
| 568 |
-
except (EnterpriseExecutionError, EnterpriseSafetyError) as e:
|
| 569 |
-
# A legitimate "did not execute" outcome (ladder denial, safety
|
| 570 |
-
# constraint, verification mismatch) -- not a server bug, so not a
|
| 571 |
-
# 5xx.
|
| 572 |
-
raise HTTPException(status_code=422, detail=str(e))
|
| 573 |
-
except Exception:
|
| 574 |
-
logger.exception("Unexpected error in execute_intent_endpoint")
|
| 575 |
-
raise HTTPException(status_code=500, detail="Internal server error")
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
# --------------------------------------------------------------------------
|
| 579 |
-
# Endpoint: record outcome (unchanged)
|
| 580 |
-
# --------------------------------------------------------------------------
|
| 581 |
@router.post("/intents/outcome")
|
| 582 |
async def record_outcome_endpoint(
|
| 583 |
request: Request,
|
| 584 |
outcome: OutcomeRequest,
|
| 585 |
db: Session = Depends(get_db),
|
| 586 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
| 587 |
-
skill_registry=Depends(get_skill_registry),
|
| 588 |
-
quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key
|
| 589 |
):
|
| 590 |
-
"""
|
| 591 |
-
|
|
|
|
|
|
|
|
|
|
| 592 |
try:
|
| 593 |
risk_engine = request.app.state.risk_engine
|
| 594 |
outcome_record = record_outcome(
|
| 595 |
db=db,
|
| 596 |
-
tenant_id=tenant_id,
|
| 597 |
deterministic_id=outcome.deterministic_id,
|
| 598 |
success=outcome.success,
|
| 599 |
recorded_by=outcome.recorded_by,
|
| 600 |
notes=outcome.notes,
|
| 601 |
risk_engine=risk_engine,
|
| 602 |
idempotency_key=idempotency_key,
|
| 603 |
-
skill_id=outcome.skill_id,
|
| 604 |
-
skill_version=outcome.skill_version,
|
| 605 |
-
skill_registry=skill_registry,
|
| 606 |
)
|
| 607 |
|
| 608 |
if PRICING_AVAILABLE and add_event is not None:
|
|
@@ -614,54 +205,51 @@ async def record_outcome_endpoint(
|
|
| 614 |
"source": "arf_api_outcome"
|
| 615 |
}
|
| 616 |
add_event(event)
|
| 617 |
-
logger.info(
|
|
|
|
|
|
|
| 618 |
except Exception as e:
|
| 619 |
-
logger.warning(
|
|
|
|
|
|
|
| 620 |
|
| 621 |
return {"message": "Outcome recorded", "outcome_id": outcome_record.id}
|
| 622 |
-
except Exception:
|
| 623 |
-
|
| 624 |
-
raise HTTPException(status_code=500, detail="Internal server error")
|
| 625 |
|
| 626 |
|
| 627 |
-
# --------------------------------------------------------------------------
|
| 628 |
-
# Endpoint: evaluate healing decision (now with skill context)
|
| 629 |
-
# --------------------------------------------------------------------------
|
| 630 |
@router.post("/healing/evaluate")
|
| 631 |
async def evaluate_healing_decision_endpoint(
|
| 632 |
request: Request,
|
| 633 |
decision_req: HealingDecisionRequest,
|
| 634 |
background_tasks: BackgroundTasks,
|
| 635 |
-
db: Session = Depends(get_db),
|
| 636 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
| 637 |
-
skill_registry=Depends(get_skill_registry), # v4.3.1
|
| 638 |
-
quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key
|
| 639 |
):
|
| 640 |
"""
|
| 641 |
-
Evaluate a healing decision
|
| 642 |
-
and now incorporate Bayesian skill reliability if skill context is provided.
|
| 643 |
"""
|
|
|
|
| 644 |
span = None
|
| 645 |
if OTEL_AVAILABLE and _tracer:
|
| 646 |
span = _tracer.start_span("governance.evaluate_healing")
|
| 647 |
span.set_attribute("component", decision_req.event.component)
|
| 648 |
|
| 649 |
start_time = time.time()
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
tenant_id = quota["tenant_id"]
|
| 654 |
|
| 655 |
current_tracker = app.core.usage_tracker.tracker
|
| 656 |
if current_tracker is None:
|
| 657 |
if span:
|
| 658 |
span.set_status(Status(StatusCode.ERROR, "tracker unavailable"))
|
| 659 |
span.end()
|
| 660 |
-
raise HTTPException(status_code=503,
|
|
|
|
| 661 |
|
| 662 |
record = UsageRecord(
|
| 663 |
api_key=api_key,
|
| 664 |
-
tier=
|
| 665 |
timestamp=start_time,
|
| 666 |
endpoint="/api/v1/healing/evaluate",
|
| 667 |
request_body=decision_req.model_dump(),
|
|
@@ -678,7 +266,8 @@ async def evaluate_healing_decision_endpoint(
|
|
| 678 |
if existing_response:
|
| 679 |
return existing_response
|
| 680 |
else:
|
| 681 |
-
raise HTTPException(status_code=429,
|
|
|
|
| 682 |
|
| 683 |
try:
|
| 684 |
policy_engine = request.app.state.policy_engine
|
|
@@ -693,38 +282,6 @@ async def evaluate_healing_decision_endpoint(
|
|
| 693 |
rag_graph=rag_graph,
|
| 694 |
model=model,
|
| 695 |
tokenizer=tokenizer,
|
| 696 |
-
# v4.3.1: pass skill context if provided
|
| 697 |
-
skill_id=decision_req.skill_id,
|
| 698 |
-
skill_version=decision_req.skill_version,
|
| 699 |
-
skill_registry=skill_registry,
|
| 700 |
-
)
|
| 701 |
-
|
| 702 |
-
# ---- Optional Rust enforcement ----
|
| 703 |
-
if RUST_AVAILABLE and response_data.get("recommended_action") == "approve":
|
| 704 |
-
try:
|
| 705 |
-
intent_dict = response_data.get("healing_intent", response_data)
|
| 706 |
-
ladder = ExecutionLadder()
|
| 707 |
-
rust_result = ladder.evaluate(intent_dict)
|
| 708 |
-
if not rust_result.get("allowed", False):
|
| 709 |
-
response_data["recommended_action"] = "escalate"
|
| 710 |
-
response_data["justification"] = (
|
| 711 |
-
f"Rust enforcement blocked: {rust_result.get('reason', 'gate failure')}"
|
| 712 |
-
)
|
| 713 |
-
response_data["rust_result"] = rust_result
|
| 714 |
-
logger.warning(f"Rust enforcement overrode approval: {rust_result}")
|
| 715 |
-
except Exception as e:
|
| 716 |
-
logger.warning(f"Rust enforcement failed: {e}")
|
| 717 |
-
|
| 718 |
-
# ---- Write audit log (asynchronously) ----
|
| 719 |
-
deterministic_id = response_data.get("intent_id", str(uuid.uuid4()))
|
| 720 |
-
healing_intent_dict = response_data.get("healing_intent", response_data)
|
| 721 |
-
background_tasks.add_task(
|
| 722 |
-
write_audit_log,
|
| 723 |
-
tenant_id=tenant_id,
|
| 724 |
-
deterministic_id=deterministic_id,
|
| 725 |
-
healing_intent=healing_intent_dict,
|
| 726 |
-
trace_id=span.get_span_context().trace_id if span else None,
|
| 727 |
-
idempotency_key=idempotency_key,
|
| 728 |
)
|
| 729 |
|
| 730 |
if span:
|
|
@@ -760,4 +317,4 @@ async def evaluate_healing_decision_endpoint(
|
|
| 760 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 761 |
span.record_exception(e)
|
| 762 |
span.end()
|
| 763 |
-
raise HTTPException(status_code=500, detail=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks, Header
|
| 2 |
from fastapi.encoders import jsonable_encoder
|
|
|
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
+
from app.models.infrastructure_intents import InfrastructureIntentRequest
|
| 5 |
+
from app.services.intent_adapter import to_oss_intent
|
| 6 |
+
from app.services.risk_service import evaluate_intent, evaluate_healing_decision
|
| 7 |
+
from app.services.intent_store import save_evaluated_intent
|
| 8 |
+
from app.services.outcome_service import record_outcome
|
| 9 |
+
from app.api.deps import get_db
|
| 10 |
from pydantic import BaseModel
|
| 11 |
import uuid
|
| 12 |
import logging
|
|
|
|
| 13 |
import time
|
| 14 |
+
from typing import Optional
|
|
|
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from agentic_reliability_framework.core.models.event import ReliabilityEvent
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
# ===== USAGE TRACKER IMPORTS =====
|
| 19 |
import app.core.usage_tracker
|
| 20 |
from app.core.usage_tracker import UsageRecord
|
| 21 |
|
| 22 |
+
# ===== PRICING CALCULATOR INTEGRATION =====
|
| 23 |
try:
|
| 24 |
from arf_pricing_calculator.storage.buffer import add_event
|
| 25 |
PRICING_AVAILABLE = True
|
|
|
|
| 27 |
PRICING_AVAILABLE = False
|
| 28 |
add_event = None
|
| 29 |
|
| 30 |
+
# ===== OpenTelemetry (optional) =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
try:
|
| 32 |
from opentelemetry import trace
|
| 33 |
from opentelemetry.trace import Status, StatusCode
|
|
|
|
| 38 |
_tracer = None
|
| 39 |
|
| 40 |
logger = logging.getLogger(__name__)
|
| 41 |
+
router = APIRouter()
|
|
|
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
class OutcomeRequest(BaseModel):
|
|
|
|
| 46 |
success: bool
|
| 47 |
recorded_by: str
|
| 48 |
notes: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
class HealingDecisionRequest(BaseModel):
|
| 52 |
event: ReliabilityEvent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
@router.post("/intents/evaluate")
|
| 56 |
async def evaluate_intent_endpoint(
|
| 57 |
request: Request,
|
|
|
|
| 59 |
background_tasks: BackgroundTasks,
|
| 60 |
db: Session = Depends(get_db),
|
| 61 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
|
|
|
|
|
|
| 62 |
):
|
| 63 |
"""
|
| 64 |
+
Evaluate an infrastructure intent with idempotency and atomic quota consumption.
|
|
|
|
|
|
|
| 65 |
"""
|
| 66 |
+
# ── optional trace ──────────────────────────────────────
|
| 67 |
span = None
|
| 68 |
if OTEL_AVAILABLE and _tracer:
|
| 69 |
span = _tracer.start_span("governance.evaluate_intent")
|
|
|
|
| 71 |
span.set_attribute("environment", str(intent_req.environment))
|
| 72 |
|
| 73 |
start_time = time.time()
|
| 74 |
+
api_key = request.headers.get("Authorization", "").replace("Bearer ", "")
|
| 75 |
+
if not api_key:
|
| 76 |
+
api_key = request.query_params.get("api_key", "unknown")
|
|
|
|
| 77 |
|
| 78 |
current_tracker = app.core.usage_tracker.tracker
|
| 79 |
if current_tracker is None:
|
| 80 |
if span:
|
| 81 |
span.set_status(Status(StatusCode.ERROR, "tracker unavailable"))
|
| 82 |
span.end()
|
| 83 |
+
raise HTTPException(status_code=503,
|
| 84 |
+
detail="Usage tracking service unavailable")
|
| 85 |
|
| 86 |
record = UsageRecord(
|
| 87 |
api_key=api_key,
|
| 88 |
+
tier=None,
|
| 89 |
timestamp=start_time,
|
| 90 |
endpoint="/api/v1/intents/evaluate",
|
| 91 |
request_body=intent_req.model_dump(),
|
|
|
|
| 102 |
if existing_response:
|
| 103 |
return existing_response
|
| 104 |
else:
|
| 105 |
+
raise HTTPException(status_code=429,
|
| 106 |
+
detail="Monthly evaluation quota exceeded")
|
| 107 |
|
| 108 |
try:
|
| 109 |
oss_intent = to_oss_intent(intent_req)
|
| 110 |
risk_engine = request.app.state.risk_engine
|
| 111 |
+
result = evaluate_intent(
|
| 112 |
+
engine=risk_engine,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
intent=oss_intent,
|
| 114 |
+
cost_estimate=intent_req.estimated_cost,
|
| 115 |
+
policy_violations=intent_req.policy_violations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
)
|
| 117 |
|
| 118 |
if span:
|
| 119 |
span.set_attribute("risk_score", result["risk_score"])
|
| 120 |
+
span.set_attribute("deterministic_id", str(uuid.uuid4())) # will be overwritten later, but fine for trace
|
| 121 |
|
| 122 |
+
deterministic_id = str(uuid.uuid4())
|
| 123 |
api_payload = jsonable_encoder(intent_req.model_dump())
|
| 124 |
oss_payload = jsonable_encoder(oss_intent.model_dump())
|
| 125 |
|
| 126 |
save_evaluated_intent(
|
| 127 |
db=db,
|
| 128 |
deterministic_id=deterministic_id,
|
|
|
|
| 129 |
intent_type=intent_req.intent_type,
|
| 130 |
api_payload=api_payload,
|
| 131 |
oss_payload=oss_payload,
|
| 132 |
environment=str(intent_req.environment),
|
| 133 |
+
risk_score=result["risk_score"]
|
| 134 |
)
|
| 135 |
|
| 136 |
result["intent_id"] = deterministic_id
|
| 137 |
response_data = result
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
if current_tracker:
|
| 140 |
background_tasks.add_task(
|
| 141 |
current_tracker._insert_audit_log,
|
|
|
|
| 169 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 170 |
span.record_exception(e)
|
| 171 |
span.end()
|
| 172 |
+
raise HTTPException(status_code=500, detail=error_msg)
|
| 173 |
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
@router.post("/intents/outcome")
|
| 176 |
async def record_outcome_endpoint(
|
| 177 |
request: Request,
|
| 178 |
outcome: OutcomeRequest,
|
| 179 |
db: Session = Depends(get_db),
|
| 180 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
|
|
|
|
|
|
| 181 |
):
|
| 182 |
+
"""
|
| 183 |
+
Record an outcome for a previously evaluated intent.
|
| 184 |
+
Idempotent based on deterministic_id and success value (handled in service).
|
| 185 |
+
Also updates the pricing calculator's calibration buffer if available.
|
| 186 |
+
"""
|
| 187 |
try:
|
| 188 |
risk_engine = request.app.state.risk_engine
|
| 189 |
outcome_record = record_outcome(
|
| 190 |
db=db,
|
|
|
|
| 191 |
deterministic_id=outcome.deterministic_id,
|
| 192 |
success=outcome.success,
|
| 193 |
recorded_by=outcome.recorded_by,
|
| 194 |
notes=outcome.notes,
|
| 195 |
risk_engine=risk_engine,
|
| 196 |
idempotency_key=idempotency_key,
|
|
|
|
|
|
|
|
|
|
| 197 |
)
|
| 198 |
|
| 199 |
if PRICING_AVAILABLE and add_event is not None:
|
|
|
|
| 205 |
"source": "arf_api_outcome"
|
| 206 |
}
|
| 207 |
add_event(event)
|
| 208 |
+
logger.info(
|
| 209 |
+
f"Added outcome to pricing buffer for intent {
|
| 210 |
+
outcome.deterministic_id}")
|
| 211 |
except Exception as e:
|
| 212 |
+
logger.warning(
|
| 213 |
+
f"Failed to update pricing buffer for intent {
|
| 214 |
+
outcome.deterministic_id}: {e}")
|
| 215 |
|
| 216 |
return {"message": "Outcome recorded", "outcome_id": outcome_record.id}
|
| 217 |
+
except Exception as e:
|
| 218 |
+
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
| 219 |
|
| 220 |
|
|
|
|
|
|
|
|
|
|
| 221 |
@router.post("/healing/evaluate")
|
| 222 |
async def evaluate_healing_decision_endpoint(
|
| 223 |
request: Request,
|
| 224 |
decision_req: HealingDecisionRequest,
|
| 225 |
background_tasks: BackgroundTasks,
|
|
|
|
| 226 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
|
|
|
|
|
|
| 227 |
):
|
| 228 |
"""
|
| 229 |
+
Evaluate a healing decision with idempotency and atomic quota consumption.
|
|
|
|
| 230 |
"""
|
| 231 |
+
# ── optional trace ──────────────────────────────────────
|
| 232 |
span = None
|
| 233 |
if OTEL_AVAILABLE and _tracer:
|
| 234 |
span = _tracer.start_span("governance.evaluate_healing")
|
| 235 |
span.set_attribute("component", decision_req.event.component)
|
| 236 |
|
| 237 |
start_time = time.time()
|
| 238 |
+
api_key = request.headers.get("Authorization", "").replace("Bearer ", "")
|
| 239 |
+
if not api_key:
|
| 240 |
+
api_key = request.query_params.get("api_key", "unknown")
|
|
|
|
| 241 |
|
| 242 |
current_tracker = app.core.usage_tracker.tracker
|
| 243 |
if current_tracker is None:
|
| 244 |
if span:
|
| 245 |
span.set_status(Status(StatusCode.ERROR, "tracker unavailable"))
|
| 246 |
span.end()
|
| 247 |
+
raise HTTPException(status_code=503,
|
| 248 |
+
detail="Usage tracking service unavailable")
|
| 249 |
|
| 250 |
record = UsageRecord(
|
| 251 |
api_key=api_key,
|
| 252 |
+
tier=None,
|
| 253 |
timestamp=start_time,
|
| 254 |
endpoint="/api/v1/healing/evaluate",
|
| 255 |
request_body=decision_req.model_dump(),
|
|
|
|
| 266 |
if existing_response:
|
| 267 |
return existing_response
|
| 268 |
else:
|
| 269 |
+
raise HTTPException(status_code=429,
|
| 270 |
+
detail="Monthly evaluation quota exceeded")
|
| 271 |
|
| 272 |
try:
|
| 273 |
policy_engine = request.app.state.policy_engine
|
|
|
|
| 282 |
rag_graph=rag_graph,
|
| 283 |
model=model,
|
| 284 |
tokenizer=tokenizer,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
)
|
| 286 |
|
| 287 |
if span:
|
|
|
|
| 317 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 318 |
span.record_exception(e)
|
| 319 |
span.end()
|
| 320 |
+
raise HTTPException(status_code=500, detail=error_msg)
|
app/api/routes_history.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
| 1 |
-
from fastapi import APIRouter
|
| 2 |
-
from app.api.deps import verify_internal_key
|
| 3 |
from app.core.storage import incident_history
|
| 4 |
|
| 5 |
-
router = APIRouter(
|
| 6 |
|
| 7 |
|
| 8 |
@router.get("/history")
|
| 9 |
async def get_history():
|
| 10 |
-
return {"incidents":
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
|
|
|
| 2 |
from app.core.storage import incident_history
|
| 3 |
|
| 4 |
+
router = APIRouter()
|
| 5 |
|
| 6 |
|
| 7 |
@router.get("/history")
|
| 8 |
async def get_history():
|
| 9 |
+
return {"incidents": incident_history}
|
app/api/routes_incidents.py
CHANGED
|
@@ -30,21 +30,23 @@ from agentic_reliability_framework.core.models.event import (
|
|
| 30 |
ReliabilityEvent,
|
| 31 |
)
|
| 32 |
|
| 33 |
-
from app.api.deps import verify_internal_key
|
| 34 |
from app.causal_explainer import CausalExplainer
|
| 35 |
-
from app.core.
|
| 36 |
-
from app.core import usage_tracker
|
| 37 |
-
from app.core.usage_tracker import UsageRecord, enforce_quota
|
| 38 |
|
| 39 |
logger = logging.getLogger(__name__)
|
| 40 |
|
| 41 |
router = APIRouter()
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
# ---------------------------------------------------------------------------
|
| 45 |
# POST /api/v1/report_incident
|
| 46 |
# ---------------------------------------------------------------------------
|
| 47 |
-
@router.post("/report_incident"
|
| 48 |
async def report_incident(event: ReliabilityEvent) -> dict[str, str]:
|
| 49 |
"""
|
| 50 |
Record a ``ReliabilityEvent`` in the in‑memory incident history.
|
|
@@ -52,10 +54,7 @@ async def report_incident(event: ReliabilityEvent) -> dict[str, str]:
|
|
| 52 |
This endpoint is used by internal monitoring tools to feed incident
|
| 53 |
data into the causal explainer and downstream analysis. The event
|
| 54 |
is stored as a JSON‑safe dictionary and is **not** persisted across
|
| 55 |
-
API restarts.
|
| 56 |
-
data-bearing route in this API requires -- previously this endpoint had
|
| 57 |
-
no auth dependency at all, so anyone could write into the incident
|
| 58 |
-
history that feeds the causal explainer and ``GET /history``.
|
| 59 |
|
| 60 |
Parameters
|
| 61 |
----------
|
|
@@ -199,7 +198,7 @@ async def evaluate_incident(
|
|
| 199 |
),
|
| 200 |
"confidence": 1.0 - result.get("uncertainty", 0.0),
|
| 201 |
"risk_score": result["risk_score"],
|
| 202 |
-
"status": "
|
| 203 |
}
|
| 204 |
|
| 205 |
response_data = {
|
|
@@ -228,7 +227,7 @@ async def evaluate_incident(
|
|
| 228 |
# ------------------------------------------------------------------
|
| 229 |
# Asynchronous usage logging
|
| 230 |
# ------------------------------------------------------------------
|
| 231 |
-
if
|
| 232 |
record = UsageRecord(
|
| 233 |
api_key=api_key,
|
| 234 |
tier=tier,
|
|
@@ -238,7 +237,7 @@ async def evaluate_incident(
|
|
| 238 |
response=response_data,
|
| 239 |
processing_ms=(time.time() - start_time) * 1000,
|
| 240 |
)
|
| 241 |
-
await
|
| 242 |
|
| 243 |
logger.warning(
|
| 244 |
"Deprecated endpoint /v1/incidents/evaluate called by key %s",
|
|
@@ -250,8 +249,7 @@ async def evaluate_incident(
|
|
| 250 |
raise
|
| 251 |
except Exception as exc:
|
| 252 |
error_msg = str(exc)
|
| 253 |
-
|
| 254 |
-
if usage_tracker.tracker:
|
| 255 |
record = UsageRecord(
|
| 256 |
api_key=api_key,
|
| 257 |
tier=tier,
|
|
@@ -261,5 +259,5 @@ async def evaluate_incident(
|
|
| 261 |
error=error_msg,
|
| 262 |
processing_ms=(time.time() - start_time) * 1000,
|
| 263 |
)
|
| 264 |
-
await
|
| 265 |
-
raise HTTPException(status_code=500, detail=
|
|
|
|
| 30 |
ReliabilityEvent,
|
| 31 |
)
|
| 32 |
|
|
|
|
| 33 |
from app.causal_explainer import CausalExplainer
|
| 34 |
+
from app.core.usage_tracker import UsageRecord, enforce_quota, tracker
|
|
|
|
|
|
|
| 35 |
|
| 36 |
logger = logging.getLogger(__name__)
|
| 37 |
|
| 38 |
router = APIRouter()
|
| 39 |
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# In‑memory incident store (for auditing / debugging only)
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
incident_history: list[dict] = []
|
| 44 |
+
|
| 45 |
|
| 46 |
# ---------------------------------------------------------------------------
|
| 47 |
# POST /api/v1/report_incident
|
| 48 |
# ---------------------------------------------------------------------------
|
| 49 |
+
@router.post("/report_incident")
|
| 50 |
async def report_incident(event: ReliabilityEvent) -> dict[str, str]:
|
| 51 |
"""
|
| 52 |
Record a ``ReliabilityEvent`` in the in‑memory incident history.
|
|
|
|
| 54 |
This endpoint is used by internal monitoring tools to feed incident
|
| 55 |
data into the causal explainer and downstream analysis. The event
|
| 56 |
is stored as a JSON‑safe dictionary and is **not** persisted across
|
| 57 |
+
API restarts.
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
Parameters
|
| 60 |
----------
|
|
|
|
| 198 |
),
|
| 199 |
"confidence": 1.0 - result.get("uncertainty", 0.0),
|
| 200 |
"risk_score": result["risk_score"],
|
| 201 |
+
"status": "oss_advisory_only",
|
| 202 |
}
|
| 203 |
|
| 204 |
response_data = {
|
|
|
|
| 227 |
# ------------------------------------------------------------------
|
| 228 |
# Asynchronous usage logging
|
| 229 |
# ------------------------------------------------------------------
|
| 230 |
+
if tracker:
|
| 231 |
record = UsageRecord(
|
| 232 |
api_key=api_key,
|
| 233 |
tier=tier,
|
|
|
|
| 237 |
response=response_data,
|
| 238 |
processing_ms=(time.time() - start_time) * 1000,
|
| 239 |
)
|
| 240 |
+
await tracker.increment_usage_async(record, background_tasks)
|
| 241 |
|
| 242 |
logger.warning(
|
| 243 |
"Deprecated endpoint /v1/incidents/evaluate called by key %s",
|
|
|
|
| 249 |
raise
|
| 250 |
except Exception as exc:
|
| 251 |
error_msg = str(exc)
|
| 252 |
+
if tracker:
|
|
|
|
| 253 |
record = UsageRecord(
|
| 254 |
api_key=api_key,
|
| 255 |
tier=tier,
|
|
|
|
| 259 |
error=error_msg,
|
| 260 |
processing_ms=(time.time() - start_time) * 1000,
|
| 261 |
)
|
| 262 |
+
await tracker.increment_usage_async(record, background_tasks)
|
| 263 |
+
raise HTTPException(status_code=500, detail=error_msg)
|
app/api/routes_intents.py
CHANGED
|
@@ -1,13 +1,8 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
| 3 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
-
from app.api.deps import verify_internal_key
|
| 5 |
from app.models.intent_models import IntentSimulation, IntentSimulationResponse
|
| 6 |
from app.services.intent_service import simulate_intent
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 11 |
|
| 12 |
|
| 13 |
@router.post("/simulate_intent", response_model=IntentSimulationResponse)
|
|
@@ -15,6 +10,5 @@ async def simulate_intent_endpoint(intent: IntentSimulation):
|
|
| 15 |
try:
|
| 16 |
result = simulate_intent(intent)
|
| 17 |
return IntentSimulationResponse(**result)
|
| 18 |
-
except Exception:
|
| 19 |
-
|
| 20 |
-
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
|
|
|
|
|
|
|
|
|
| 2 |
from app.models.intent_models import IntentSimulation, IntentSimulationResponse
|
| 3 |
from app.services.intent_service import simulate_intent
|
| 4 |
|
| 5 |
+
router = APIRouter()
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
@router.post("/simulate_intent", response_model=IntentSimulationResponse)
|
|
|
|
| 10 |
try:
|
| 11 |
result = simulate_intent(intent)
|
| 12 |
return IntentSimulationResponse(**result)
|
| 13 |
+
except Exception as e:
|
| 14 |
+
raise HTTPException(status_code=500, detail=str(e))
|
|
|
app/api/routes_memory.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
-
from fastapi import APIRouter,
|
| 2 |
-
from app.api.deps import verify_internal_key
|
| 3 |
|
| 4 |
-
router = APIRouter(
|
| 5 |
|
| 6 |
|
| 7 |
@router.get("/stats")
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Request
|
|
|
|
| 2 |
|
| 3 |
+
router = APIRouter()
|
| 4 |
|
| 5 |
|
| 6 |
@router.get("/stats")
|
app/api/routes_onchain.py
DELETED
|
@@ -1,149 +0,0 @@
|
|
| 1 |
-
"""Routes for on-chain attestation rationale.
|
| 2 |
-
|
| 3 |
-
`RiskAttestationRegistry` (arf-onchain) anchors only a `rationale_hash` --
|
| 4 |
-
never the reasoning itself, to keep operational detail about a customer's
|
| 5 |
-
infrastructure off a public chain. These endpoints are the off-chain half:
|
| 6 |
-
a reference risk evaluator (`arf_enterprise.onchain.evaluator`) persists the
|
| 7 |
-
plaintext here immediately after signing an attestation, keyed by the same
|
| 8 |
-
hash it put on-chain, and an auditor who reads a `DecisionAnchored` or
|
| 9 |
-
`AttestationIssued` event can fetch the reasoning behind it here.
|
| 10 |
-
|
| 11 |
-
Internal-key gated like `routes_governance.py`: this is a service-to-service
|
| 12 |
-
surface for the evaluator process and for auditor tooling, not a
|
| 13 |
-
tenant-scoped customer endpoint -- see `OnchainRationaleDB`'s docstring for
|
| 14 |
-
why there is no tenant_id to enforce here.
|
| 15 |
-
"""
|
| 16 |
-
|
| 17 |
-
import logging
|
| 18 |
-
|
| 19 |
-
from fastapi import APIRouter, Depends, HTTPException, Response
|
| 20 |
-
from pydantic import BaseModel, field_validator
|
| 21 |
-
from sqlalchemy.orm import Session
|
| 22 |
-
|
| 23 |
-
from app.api.deps import get_db, verify_internal_key
|
| 24 |
-
from app.database.models_onchain import OnchainRationaleDB
|
| 25 |
-
|
| 26 |
-
logger = logging.getLogger(__name__)
|
| 27 |
-
|
| 28 |
-
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def _validate_hex_hash(value: str) -> str:
|
| 32 |
-
text = value.strip()
|
| 33 |
-
if not text.startswith("0x") or len(text) != 66:
|
| 34 |
-
raise ValueError("rationale_hash must be a 0x-prefixed 32-byte hex string")
|
| 35 |
-
try:
|
| 36 |
-
int(text, 16)
|
| 37 |
-
except ValueError:
|
| 38 |
-
raise ValueError("rationale_hash is not valid hex") from None
|
| 39 |
-
return text.lower()
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
class RationaleRequest(BaseModel):
|
| 43 |
-
rationale_hash: str
|
| 44 |
-
rationale: str
|
| 45 |
-
agent_address: str | None = None
|
| 46 |
-
evaluator_address: str | None = None
|
| 47 |
-
|
| 48 |
-
@field_validator("rationale_hash")
|
| 49 |
-
@classmethod
|
| 50 |
-
def _validate_hash(cls, value: str) -> str:
|
| 51 |
-
return _validate_hex_hash(value)
|
| 52 |
-
|
| 53 |
-
@field_validator("rationale")
|
| 54 |
-
@classmethod
|
| 55 |
-
def _validate_rationale(cls, value: str) -> str:
|
| 56 |
-
if not value.strip():
|
| 57 |
-
raise ValueError("rationale must not be empty")
|
| 58 |
-
return value
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
class RationaleResponse(BaseModel):
|
| 62 |
-
rationale_hash: str
|
| 63 |
-
rationale: str
|
| 64 |
-
agent_address: str | None
|
| 65 |
-
evaluator_address: str | None
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
@router.post("/onchain/rationale", status_code=201)
|
| 69 |
-
async def persist_rationale(
|
| 70 |
-
req: RationaleRequest,
|
| 71 |
-
response: Response,
|
| 72 |
-
db: Session = Depends(get_db),
|
| 73 |
-
):
|
| 74 |
-
"""Store the plaintext behind an anchored `rationale_hash`.
|
| 75 |
-
|
| 76 |
-
Idempotent on `rationale_hash`: signing the same decision twice (a
|
| 77 |
-
retry after a network error, for instance) posts the same hash and
|
| 78 |
-
text, so the second call is a no-op rather than a uniqueness-constraint
|
| 79 |
-
error. A *different* text arriving for a hash already on record is
|
| 80 |
-
refused -- that would mean either hash collision or a caller bug, and
|
| 81 |
-
silently overwriting an anchored record's preimage is the one thing
|
| 82 |
-
this table must never do.
|
| 83 |
-
"""
|
| 84 |
-
existing = (
|
| 85 |
-
db.query(OnchainRationaleDB)
|
| 86 |
-
.filter(OnchainRationaleDB.rationale_hash == req.rationale_hash)
|
| 87 |
-
.one_or_none()
|
| 88 |
-
)
|
| 89 |
-
if existing is not None:
|
| 90 |
-
if existing.rationale != req.rationale:
|
| 91 |
-
raise HTTPException(
|
| 92 |
-
status_code=409,
|
| 93 |
-
detail=(
|
| 94 |
-
"rationale_hash already recorded with different text; "
|
| 95 |
-
"an anchored hash's preimage cannot be overwritten"
|
| 96 |
-
),
|
| 97 |
-
)
|
| 98 |
-
# The route decorator's status_code=201 is FastAPI's default for
|
| 99 |
-
# every plain-dict return from this handler, including this one --
|
| 100 |
-
# it must be overridden explicitly here or a replayed post reports
|
| 101 |
-
# itself as newly Created.
|
| 102 |
-
response.status_code = 200
|
| 103 |
-
return {"status": "already_recorded", "rationale_hash": req.rationale_hash}
|
| 104 |
-
|
| 105 |
-
row = OnchainRationaleDB(
|
| 106 |
-
rationale_hash=req.rationale_hash,
|
| 107 |
-
rationale=req.rationale,
|
| 108 |
-
agent_address=req.agent_address,
|
| 109 |
-
evaluator_address=req.evaluator_address,
|
| 110 |
-
)
|
| 111 |
-
db.add(row)
|
| 112 |
-
db.commit()
|
| 113 |
-
logger.info("persisted rationale for hash %s", req.rationale_hash)
|
| 114 |
-
return {"status": "recorded", "rationale_hash": req.rationale_hash}
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
@router.get("/onchain/rationale/{rationale_hash}", response_model=RationaleResponse)
|
| 118 |
-
async def get_rationale(
|
| 119 |
-
rationale_hash: str,
|
| 120 |
-
db: Session = Depends(get_db),
|
| 121 |
-
):
|
| 122 |
-
"""Fetch the plaintext behind an anchored `rationale_hash`.
|
| 123 |
-
|
| 124 |
-
What an auditor calls after reading a `DecisionAnchored` event off-chain
|
| 125 |
-
-- the hash from the event is the only key this endpoint accepts, by
|
| 126 |
-
design: there is no listing or search here, only lookup by the exact
|
| 127 |
-
value that was signed and anchored.
|
| 128 |
-
"""
|
| 129 |
-
try:
|
| 130 |
-
normalized = _validate_hex_hash(rationale_hash)
|
| 131 |
-
except ValueError as exc:
|
| 132 |
-
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 133 |
-
|
| 134 |
-
row = (
|
| 135 |
-
db.query(OnchainRationaleDB)
|
| 136 |
-
.filter(OnchainRationaleDB.rationale_hash == normalized)
|
| 137 |
-
.one_or_none()
|
| 138 |
-
)
|
| 139 |
-
if row is None:
|
| 140 |
-
raise HTTPException(
|
| 141 |
-
status_code=404, detail="no rationale recorded for this hash"
|
| 142 |
-
)
|
| 143 |
-
|
| 144 |
-
return RationaleResponse(
|
| 145 |
-
rationale_hash=row.rationale_hash,
|
| 146 |
-
rationale=row.rationale,
|
| 147 |
-
agent_address=row.agent_address,
|
| 148 |
-
evaluator_address=row.evaluator_address,
|
| 149 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/api/routes_payments.py
CHANGED
|
@@ -2,16 +2,12 @@
|
|
| 2 |
Payment endpoints – Stripe Checkout integration.
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
import logging
|
| 6 |
import os
|
| 7 |
import stripe
|
| 8 |
-
from fastapi import APIRouter,
|
| 9 |
from pydantic import BaseModel
|
| 10 |
|
| 11 |
-
from app.core import
|
| 12 |
-
from app.core.usage_tracker import Tier, resolve_api_key_identity
|
| 13 |
-
|
| 14 |
-
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/payments", tags=["payments"])
|
| 17 |
|
|
@@ -21,46 +17,24 @@ STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
|
| 21 |
|
| 22 |
|
| 23 |
class CheckoutRequest(BaseModel):
|
|
|
|
|
|
|
| 24 |
success_url: str
|
| 25 |
cancel_url: str
|
| 26 |
|
| 27 |
|
| 28 |
@router.post("/create-checkout-session")
|
| 29 |
-
async def create_checkout_session(
|
| 30 |
-
|
| 31 |
-
identity: dict = Depends(resolve_api_key_identity),
|
| 32 |
-
):
|
| 33 |
-
"""Create a Stripe Checkout session for the Pro tier.
|
| 34 |
-
|
| 35 |
-
Identity comes from `resolve_api_key_identity` (Authorization: Bearer
|
| 36 |
-
header, same pepper-HMAC lookup every other authenticated endpoint
|
| 37 |
-
uses) rather than from a caller-supplied field in the request body --
|
| 38 |
-
previously this endpoint took `api_key` as a JSON field with no
|
| 39 |
-
`Depends()` gate at all, so nothing distinguished "the caller proved
|
| 40 |
-
they hold this key" from "the caller typed this string into a
|
| 41 |
-
request." `enforce_quota` is deliberately not used here: a FREE-tier
|
| 42 |
-
caller who has exhausted their monthly quota must still be able to
|
| 43 |
-
reach this endpoint, since upgrading is often exactly what they are
|
| 44 |
-
trying to do.
|
| 45 |
-
"""
|
| 46 |
if not stripe.api_key:
|
| 47 |
raise HTTPException(status_code=500, detail="Stripe not configured")
|
| 48 |
-
if not usage_tracker.tracker:
|
| 49 |
-
raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
|
| 50 |
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
raise HTTPException(status_code=400,
|
| 53 |
detail="Only free tier keys can be upgraded")
|
| 54 |
|
| 55 |
-
# tenant_id, not the raw api_key, is what travels to Stripe from here
|
| 56 |
-
# on. api_key is a bearer secret -- usage_tracker.py exists specifically
|
| 57 |
-
# to never store it in plaintext (pepper-HMAC lookup + salted
|
| 58 |
-
# verification hash), and Stripe's dashboard/webhook logs/API are a
|
| 59 |
-
# third party with no reason to ever see it. tenant_id is an opaque,
|
| 60 |
-
# non-secret row identifier and is exactly what the webhook needs to
|
| 61 |
-
# look up which tenant's keys to retier.
|
| 62 |
-
tenant_id = identity["tenant_id"]
|
| 63 |
-
|
| 64 |
try:
|
| 65 |
checkout_session = stripe.checkout.Session.create(
|
| 66 |
payment_method_types=["card"],
|
|
@@ -74,17 +48,9 @@ async def create_checkout_session(
|
|
| 74 |
mode="subscription",
|
| 75 |
success_url=req.success_url,
|
| 76 |
cancel_url=req.cancel_url,
|
| 77 |
-
metadata={"
|
| 78 |
-
client_reference_id=
|
| 79 |
-
# checkout.session.completed carries this metadata via
|
| 80 |
-
# session.metadata (handled below), but customer.subscription.*
|
| 81 |
-
# events only carry the *subscription's own* metadata -- Stripe
|
| 82 |
-
# does not copy Session.metadata onto the Subscription it
|
| 83 |
-
# creates. Without this, cancellations can't be traced back to
|
| 84 |
-
# a tenant and PRO tier never downgrades.
|
| 85 |
-
subscription_data={"metadata": {"tenant_id": tenant_id}},
|
| 86 |
)
|
| 87 |
return {"sessionId": checkout_session.id, "url": checkout_session.url}
|
| 88 |
-
except Exception:
|
| 89 |
-
|
| 90 |
-
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
| 2 |
Payment endpoints – Stripe Checkout integration.
|
| 3 |
"""
|
| 4 |
|
|
|
|
| 5 |
import os
|
| 6 |
import stripe
|
| 7 |
+
from fastapi import APIRouter, HTTPException
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
+
from app.core.usage_tracker import tracker, Tier
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
router = APIRouter(prefix="/payments", tags=["payments"])
|
| 13 |
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class CheckoutRequest(BaseModel):
|
| 20 |
+
api_key: str
|
| 21 |
+
|
| 22 |
success_url: str
|
| 23 |
cancel_url: str
|
| 24 |
|
| 25 |
|
| 26 |
@router.post("/create-checkout-session")
|
| 27 |
+
async def create_checkout_session(req: CheckoutRequest):
|
| 28 |
+
"""Create a Stripe Checkout session for the Pro tier."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
if not stripe.api_key:
|
| 30 |
raise HTTPException(status_code=500, detail="Stripe not configured")
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
# Verify the API key exists and is free tier
|
| 33 |
+
tier = tracker.get_tier(req.api_key) if tracker else None
|
| 34 |
+
if tier != Tier.FREE:
|
| 35 |
raise HTTPException(status_code=400,
|
| 36 |
detail="Only free tier keys can be upgraded")
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
try:
|
| 39 |
checkout_session = stripe.checkout.Session.create(
|
| 40 |
payment_method_types=["card"],
|
|
|
|
| 48 |
mode="subscription",
|
| 49 |
success_url=req.success_url,
|
| 50 |
cancel_url=req.cancel_url,
|
| 51 |
+
metadata={"api_key": req.api_key},
|
| 52 |
+
client_reference_id=req.api_key,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
)
|
| 54 |
return {"sessionId": checkout_session.id, "url": checkout_session.url}
|
| 55 |
+
except Exception as e:
|
| 56 |
+
raise HTTPException(status_code=500, detail=str(e))
|
|
|
app/api/routes_pricing.py
CHANGED
|
@@ -57,26 +57,48 @@ async def run_pricing(
|
|
| 57 |
quota: dict = Depends(enforce_quota),
|
| 58 |
):
|
| 59 |
"""
|
| 60 |
-
Multi‑run pricing with cooldown and buffer persistence.
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
`random.random() > risk_score` -- a fabricated result, not a real deal
|
| 64 |
-
outcome -- into a calibration buffer with no customer_id scoping, so
|
| 65 |
-
every customer's calls read and wrote the same file. Net effect: every
|
| 66 |
-
customer's price was shaped by every other customer's randomly-generated
|
| 67 |
-
outcomes, not just their own. Disabled until both are fixed: (1) a real
|
| 68 |
-
outcome-ingestion path (this endpoint must not invent one), and (2) the
|
| 69 |
-
buffer scoped per customer. See AUDIT_arf-bayesian-pricing-calculator.md
|
| 70 |
-
and AUDIT_arf-api.md (workspace root) for the original findings and
|
| 71 |
-
recommended fix. `Depends(enforce_quota)` stays active so this still
|
| 72 |
-
requires the same auth it always did -- only authenticated callers reach
|
| 73 |
-
the disabled-notice below; everyone else still gets the normal 401/403.
|
| 74 |
"""
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
quota: dict = Depends(enforce_quota),
|
| 58 |
):
|
| 59 |
"""
|
| 60 |
+
Multi‑run pricing with cooldown and buffer persistence.
|
| 61 |
+
Each run’s simulated outcome is added to the buffer, so subsequent runs
|
| 62 |
+
see an updated posterior.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
"""
|
| 64 |
+
# We need to reuse the same buffer across runs; we'll load it per request.
|
| 65 |
+
# For simplicity, we'll load from the default location.
|
| 66 |
+
from arf_pricing_calculator.storage.buffer import load_buffer, add_event
|
| 67 |
+
from arf_pricing_calculator.orchestration.cooldown import enforce_cooldown, is_cooldown_active
|
| 68 |
+
|
| 69 |
+
outputs = []
|
| 70 |
+
buffer = load_buffer() # loads from calibration_buffer.json
|
| 71 |
+
|
| 72 |
+
for i in range(req.runs):
|
| 73 |
+
if not req.force and is_cooldown_active(
|
| 74 |
+
req.customer_id, req.cooldown_hours):
|
| 75 |
+
raise HTTPException(status_code=429,
|
| 76 |
+
detail=f"Cooldown active after {i} runs")
|
| 77 |
+
|
| 78 |
+
pricing_input = parse_input_dict(req.input)
|
| 79 |
+
engine = PricingEngine(calibration_buffer=buffer)
|
| 80 |
+
out = engine.estimate(pricing_input)
|
| 81 |
+
|
| 82 |
+
# Simulate an outcome (in real use, this would come from the actual
|
| 83 |
+
# deal)
|
| 84 |
+
import random
|
| 85 |
+
outcome = "success" if random.random() > out.risk_score else "failure" # nosec B311
|
| 86 |
+
|
| 87 |
+
event = {
|
| 88 |
+
"run_id": out.run_history_id,
|
| 89 |
+
"customer_id": req.customer_id,
|
| 90 |
+
"outcome": outcome,
|
| 91 |
+
"price": out.recommended_price,
|
| 92 |
+
"value": out.expected_value,
|
| 93 |
+
"risk_score": out.risk_score,
|
| 94 |
+
"run_number": i + 1,
|
| 95 |
+
}
|
| 96 |
+
add_event(event)
|
| 97 |
+
buffer = load_buffer() # reload after update
|
| 98 |
+
|
| 99 |
+
outputs.append(out)
|
| 100 |
+
|
| 101 |
+
if i < req.runs - 1:
|
| 102 |
+
enforce_cooldown(req.customer_id, req.cooldown_hours)
|
| 103 |
+
|
| 104 |
+
return outputs
|
app/api/routes_risk.py
CHANGED
|
@@ -1,13 +1,8 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
| 3 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
-
from app.api.deps import verify_internal_key
|
| 5 |
from app.models.risk_models import RiskResponse
|
| 6 |
from app.services.risk_service import get_system_risk
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 11 |
|
| 12 |
|
| 13 |
@router.get("/get_risk", response_model=RiskResponse)
|
|
@@ -18,9 +13,8 @@ async def get_risk():
|
|
| 18 |
raise HTTPException(
|
| 19 |
status_code=501,
|
| 20 |
detail="This endpoint is deprecated and not implemented")
|
| 21 |
-
except Exception:
|
| 22 |
-
|
| 23 |
-
raise HTTPException(status_code=500, detail="Internal server error")
|
| 24 |
|
| 25 |
if risk < 0.3:
|
| 26 |
status = "low"
|
|
@@ -31,3 +25,13 @@ async def get_risk():
|
|
| 31 |
else:
|
| 32 |
status = "critical"
|
| 33 |
return RiskResponse(system_risk=risk, status=status)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
|
|
|
|
|
|
|
|
|
| 2 |
from app.models.risk_models import RiskResponse
|
| 3 |
from app.services.risk_service import get_system_risk
|
| 4 |
|
| 5 |
+
router = APIRouter()
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
@router.get("/get_risk", response_model=RiskResponse)
|
|
|
|
| 13 |
raise HTTPException(
|
| 14 |
status_code=501,
|
| 15 |
detail="This endpoint is deprecated and not implemented")
|
| 16 |
+
except Exception as e:
|
| 17 |
+
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
| 18 |
|
| 19 |
if risk < 0.3:
|
| 20 |
status = "low"
|
|
|
|
| 25 |
else:
|
| 26 |
status = "critical"
|
| 27 |
return RiskResponse(system_risk=risk, status=status)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.get("/history")
|
| 31 |
+
async def get_risk_history():
|
| 32 |
+
import random
|
| 33 |
+
import datetime
|
| 34 |
+
now = datetime.datetime.now()
|
| 35 |
+
data = [{"time": (now - datetime.timedelta(hours=i)).isoformat(),
|
| 36 |
+
"risk": round(random.uniform(0.2, 0.8), 2)} for i in range(24, 0, -1)]
|
| 37 |
+
return data
|
app/api/routes_users.py
CHANGED
|
@@ -1,18 +1,12 @@
|
|
| 1 |
"""
|
| 2 |
-
User endpoints – registration
|
| 3 |
"""
|
| 4 |
|
| 5 |
import uuid
|
| 6 |
-
from
|
| 7 |
-
from fastapi import APIRouter, Depends, HTTPException, Request, Query
|
| 8 |
-
from sqlalchemy.orm import Session
|
| 9 |
from slowapi import Limiter
|
| 10 |
from slowapi.util import get_remote_address
|
| 11 |
-
|
| 12 |
-
from app.core import usage_tracker
|
| 13 |
-
from app.core.usage_tracker import enforce_quota, Tier
|
| 14 |
-
from app.api.deps import get_db
|
| 15 |
-
from app.database.models_intents import TenantDB # <-- NEW
|
| 16 |
|
| 17 |
router = APIRouter(prefix="/users", tags=["users"])
|
| 18 |
|
|
@@ -22,93 +16,43 @@ limiter = Limiter(key_func=get_remote_address, default_limits=["5/hour"])
|
|
| 22 |
|
| 23 |
@router.post("/register")
|
| 24 |
@limiter.limit("5/hour")
|
| 25 |
-
async def register_user(
|
| 26 |
-
request: Request,
|
| 27 |
-
db: Session = Depends(get_db),
|
| 28 |
-
org_name: str = Query(None, description="Optional organisation name for the new tenant"),
|
| 29 |
-
):
|
| 30 |
"""
|
| 31 |
-
Public endpoint to create a new free‑tier API key
|
| 32 |
Rate‑limited to 5 requests per hour per IP address.
|
| 33 |
"""
|
| 34 |
-
if
|
| 35 |
-
raise HTTPException(
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
tenant_id = str(uuid.uuid4())
|
| 39 |
-
name = org_name or "Default Organization"
|
| 40 |
-
new_tenant = TenantDB(
|
| 41 |
-
id=tenant_id,
|
| 42 |
-
name=name,
|
| 43 |
-
created_at=datetime.utcnow(),
|
| 44 |
-
created_by="self_service"
|
| 45 |
-
)
|
| 46 |
-
db.add(new_tenant)
|
| 47 |
-
db.commit()
|
| 48 |
-
db.refresh(new_tenant)
|
| 49 |
|
| 50 |
-
#
|
| 51 |
new_key = f"sk_free_{uuid.uuid4().hex[:24]}"
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
if not success:
|
| 54 |
-
# Rollback tenant creation if key creation fails
|
| 55 |
-
db.delete(new_tenant)
|
| 56 |
-
db.commit()
|
| 57 |
raise HTTPException(status_code=500, detail="Failed to create API key")
|
| 58 |
|
| 59 |
return {
|
| 60 |
"api_key": new_key,
|
| 61 |
-
"tenant_id": tenant_id,
|
| 62 |
"tier": "free",
|
| 63 |
-
"
|
| 64 |
-
"message": "API key and tenant created. Store the key securely – you won't see it again."
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
@router.get("/me")
|
| 69 |
-
async def get_current_user_info(
|
| 70 |
-
request: Request,
|
| 71 |
-
quota: dict = Depends(enforce_quota),
|
| 72 |
-
db: Session = Depends(get_db),
|
| 73 |
-
):
|
| 74 |
-
"""
|
| 75 |
-
Return information about the current user's tenant and quota.
|
| 76 |
-
Requires API key in Authorization header.
|
| 77 |
-
"""
|
| 78 |
-
tenant_id = quota.get("tenant_id")
|
| 79 |
-
if not tenant_id:
|
| 80 |
-
raise HTTPException(status_code=403, detail="No tenant associated with this API key")
|
| 81 |
-
|
| 82 |
-
tenant = db.query(TenantDB).filter(TenantDB.id == tenant_id).first()
|
| 83 |
-
if not tenant:
|
| 84 |
-
raise HTTPException(status_code=404, detail="Tenant not found")
|
| 85 |
-
|
| 86 |
-
return {
|
| 87 |
-
"tenant_id": tenant_id,
|
| 88 |
-
"organization": tenant.name,
|
| 89 |
-
"created_at": tenant.created_at.isoformat() if tenant.created_at else None,
|
| 90 |
-
"tier": quota["tier"].value,
|
| 91 |
-
"remaining": quota["remaining"],
|
| 92 |
-
"limit": quota["limit"],
|
| 93 |
-
}
|
| 94 |
|
| 95 |
|
| 96 |
@router.get("/quota")
|
| 97 |
async def get_user_quota(
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
):
|
| 101 |
"""
|
| 102 |
-
Return the current user's tier
|
| 103 |
Requires API key in Authorization header.
|
| 104 |
"""
|
| 105 |
tier = quota["tier"]
|
| 106 |
remaining = quota["remaining"]
|
| 107 |
limit = tier.monthly_evaluation_limit if tier else None
|
| 108 |
-
tenant_id = quota.get("tenant_id")
|
| 109 |
|
| 110 |
return {
|
| 111 |
-
"tenant_id": tenant_id,
|
| 112 |
"tier": tier.value,
|
| 113 |
"remaining": remaining,
|
| 114 |
"limit": limit,
|
|
|
|
| 1 |
"""
|
| 2 |
+
User endpoints – registration and quota information.
|
| 3 |
"""
|
| 4 |
|
| 5 |
import uuid
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
|
|
|
|
|
| 7 |
from slowapi import Limiter
|
| 8 |
from slowapi.util import get_remote_address
|
| 9 |
+
from app.core.usage_tracker import tracker, enforce_quota, Tier
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
router = APIRouter(prefix="/users", tags=["users"])
|
| 12 |
|
|
|
|
| 16 |
|
| 17 |
@router.post("/register")
|
| 18 |
@limiter.limit("5/hour")
|
| 19 |
+
async def register_user(request: Request):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
"""
|
| 21 |
+
Public endpoint to create a new free‑tier API key.
|
| 22 |
Rate‑limited to 5 requests per hour per IP address.
|
| 23 |
"""
|
| 24 |
+
if tracker is None:
|
| 25 |
+
raise HTTPException(
|
| 26 |
+
status_code=503,
|
| 27 |
+
detail="Usage tracking not available")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
# Generate a new API key
|
| 30 |
new_key = f"sk_free_{uuid.uuid4().hex[:24]}"
|
| 31 |
+
|
| 32 |
+
# Store it as FREE tier
|
| 33 |
+
success = tracker.get_or_create_api_key(new_key, Tier.FREE)
|
| 34 |
if not success:
|
|
|
|
|
|
|
|
|
|
| 35 |
raise HTTPException(status_code=500, detail="Failed to create API key")
|
| 36 |
|
| 37 |
return {
|
| 38 |
"api_key": new_key,
|
|
|
|
| 39 |
"tier": "free",
|
| 40 |
+
"message": "API key created. Store it securely – you won't see it again."}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
@router.get("/quota")
|
| 44 |
async def get_user_quota(
|
| 45 |
+
request: Request,
|
| 46 |
+
quota: dict = Depends(enforce_quota)):
|
|
|
|
| 47 |
"""
|
| 48 |
+
Return the current user's tier and remaining evaluation quota.
|
| 49 |
Requires API key in Authorization header.
|
| 50 |
"""
|
| 51 |
tier = quota["tier"]
|
| 52 |
remaining = quota["remaining"]
|
| 53 |
limit = tier.monthly_evaluation_limit if tier else None
|
|
|
|
| 54 |
|
| 55 |
return {
|
|
|
|
| 56 |
"tier": tier.value,
|
| 57 |
"remaining": remaining,
|
| 58 |
"limit": limit,
|
app/api/webhooks.py
CHANGED
|
@@ -2,24 +2,16 @@
|
|
| 2 |
Stripe webhook handler – updates API key tier on subscription events.
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
import logging
|
| 6 |
import os
|
| 7 |
import stripe
|
| 8 |
from fastapi import APIRouter, Request, HTTPException
|
| 9 |
-
from app.core.usage_tracker import
|
| 10 |
-
|
| 11 |
-
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
| 14 |
|
| 15 |
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
| 16 |
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
| 17 |
|
| 18 |
-
# Subscription statuses that mean "not entitled to Pro anymore". Deliberately
|
| 19 |
-
# does NOT include "past_due" -- whether a failed-payment grace period keeps
|
| 20 |
-
# Pro access is a dunning-policy decision, not a bug, and isn't decided here.
|
| 21 |
-
_DOWNGRADE_STATUSES = {"canceled", "unpaid", "incomplete_expired"}
|
| 22 |
-
|
| 23 |
|
| 24 |
@router.post("/stripe")
|
| 25 |
async def stripe_webhook(request: Request):
|
|
@@ -38,33 +30,20 @@ async def stripe_webhook(request: Request):
|
|
| 38 |
except stripe.error.SignatureVerificationError:
|
| 39 |
raise HTTPException(status_code=400, detail="Invalid signature")
|
| 40 |
|
| 41 |
-
#
|
| 42 |
-
# routes_payments.py's create_checkout_session for why the raw bearer
|
| 43 |
-
# key must never round-trip through a third party.
|
| 44 |
if event["type"] == "checkout.session.completed":
|
| 45 |
session = event["data"]["object"]
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
logger.info(
|
| 52 |
-
"checkout.session.completed with payment_status=%r; not upgrading yet",
|
| 53 |
-
session.get("payment_status"),
|
| 54 |
-
)
|
| 55 |
-
return {"status": "ok"}
|
| 56 |
-
tenant_id = session.get("client_reference_id") or session.get(
|
| 57 |
-
"metadata", {}).get("tenant_id")
|
| 58 |
-
if tenant_id:
|
| 59 |
-
update_key_tier_by_tenant_id(tenant_id, Tier.PRO)
|
| 60 |
-
elif event["type"] in ("customer.subscription.deleted", "customer.subscription.updated"):
|
| 61 |
subscription = event["data"]["object"]
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
update_key_tier_by_tenant_id(tenant_id, Tier.FREE)
|
| 69 |
|
| 70 |
return {"status": "ok"}
|
|
|
|
| 2 |
Stripe webhook handler – updates API key tier on subscription events.
|
| 3 |
"""
|
| 4 |
|
|
|
|
| 5 |
import os
|
| 6 |
import stripe
|
| 7 |
from fastapi import APIRouter, Request, HTTPException
|
| 8 |
+
from app.core.usage_tracker import update_key_tier, Tier
|
|
|
|
|
|
|
| 9 |
|
| 10 |
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
| 11 |
|
| 12 |
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
| 13 |
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
@router.post("/stripe")
|
| 17 |
async def stripe_webhook(request: Request):
|
|
|
|
| 30 |
except stripe.error.SignatureVerificationError:
|
| 31 |
raise HTTPException(status_code=400, detail="Invalid signature")
|
| 32 |
|
| 33 |
+
# Handle subscription events
|
|
|
|
|
|
|
| 34 |
if event["type"] == "checkout.session.completed":
|
| 35 |
session = event["data"]["object"]
|
| 36 |
+
api_key = session.get("client_reference_id") or session.get(
|
| 37 |
+
"metadata", {}).get("api_key")
|
| 38 |
+
if api_key:
|
| 39 |
+
update_key_tier(api_key, Tier.PRO)
|
| 40 |
+
elif event["type"] == "customer.subscription.deleted":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
subscription = event["data"]["object"]
|
| 42 |
+
# You need to store a mapping from subscription ID to API key.
|
| 43 |
+
# For simplicity, we assume you stored it in metadata during checkout.
|
| 44 |
+
# Alternatively, look up by customer ID.
|
| 45 |
+
api_key = subscription.get("metadata", {}).get("api_key")
|
| 46 |
+
if api_key:
|
| 47 |
+
update_key_tier(api_key, Tier.FREE)
|
|
|
|
| 48 |
|
| 49 |
return {"status": "ok"}
|
app/core/config.py
CHANGED
|
@@ -14,7 +14,6 @@ class Settings(BaseSettings):
|
|
| 14 |
ARF_USAGE_DB_PATH: str = "arf_usage.db"
|
| 15 |
ARF_REDIS_URL: Optional[str] = None
|
| 16 |
ARF_API_KEYS: str = "{}" # JSON string of {key: tier}
|
| 17 |
-
ARF_KEY_PEPPER: Optional[str] = None # required if ARF_USAGE_TRACKING is true
|
| 18 |
|
| 19 |
# Tracing (OpenTelemetry)
|
| 20 |
OTEL_EXPORTER_OTLP_ENDPOINT: Optional[str] = None
|
|
|
|
| 14 |
ARF_USAGE_DB_PATH: str = "arf_usage.db"
|
| 15 |
ARF_REDIS_URL: Optional[str] = None
|
| 16 |
ARF_API_KEYS: str = "{}" # JSON string of {key: tier}
|
|
|
|
| 17 |
|
| 18 |
# Tracing (OpenTelemetry)
|
| 19 |
OTEL_EXPORTER_OTLP_ENDPOINT: Optional[str] = None
|
app/core/storage.py
CHANGED
|
@@ -1,16 +1,2 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
Bounded (maxlen), not persisted across restarts -- exists to give the
|
| 4 |
-
causal explainer and GET /history recent context, not as a durable audit
|
| 5 |
-
trail. The cap protects against unbounded memory growth from
|
| 6 |
-
POST /report_incident, which can be called repeatedly by anything holding
|
| 7 |
-
a valid internal key.
|
| 8 |
-
|
| 9 |
-
Shared by app.api.routes_incidents (writes, via report_incident) and
|
| 10 |
-
app.api.routes_history (reads, via GET /history) -- both must import this
|
| 11 |
-
same object rather than declaring their own list, or writes and reads
|
| 12 |
-
silently operate on two different lists.
|
| 13 |
-
"""
|
| 14 |
-
from collections import deque
|
| 15 |
-
|
| 16 |
-
incident_history: deque = deque(maxlen=10_000)
|
|
|
|
| 1 |
+
# Simple in-memory list for incident history
|
| 2 |
+
incident_history = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/core/usage_tracker.py
CHANGED
|
@@ -1,52 +1,12 @@
|
|
| 1 |
"""
|
| 2 |
Usage Tracker for ARF API – quotas, tiers, and audit logging.
|
| 3 |
Thread‑safe, atomic quota consumption, idempotent, fail‑closed.
|
| 4 |
-
|
| 5 |
-
Extended for multi‑tenancy: each API key is linked to a tenant ID.
|
| 6 |
-
Tenant ID is stored in the `api_keys` table and used for resource isolation.
|
| 7 |
-
|
| 8 |
-
API keys in `api_keys` are never stored in plaintext. The lookup index is
|
| 9 |
-
HMAC-SHA256(pepper, key) -- a deterministic but one-way value computed with
|
| 10 |
-
a server-only secret (ARF_KEY_PEPPER), so a leaked database alone does not
|
| 11 |
-
expose usable keys. A per-row random salt plus a second SHA-256 check is a
|
| 12 |
-
defense-in-depth verification layer after the row is found by lookup hash.
|
| 13 |
-
This mirrors arf-gateway's internal/auth/apikey.go.
|
| 14 |
-
|
| 15 |
-
`api_keys` itself lives in Postgres (DATABASE_URL), not in the SQLite file
|
| 16 |
-
the rest of this module uses -- a single durable table that both arf-api
|
| 17 |
-
and arf-gateway point at, instead of each service's own local SQLite copy
|
| 18 |
-
(which on Render's Free plan is wiped on every deploy/restart anyway).
|
| 19 |
-
|
| 20 |
-
`usage_log` and `idempotency_keys` remain SQLite-only, local to this
|
| 21 |
-
service -- arf-gateway never reads them. `monthly_counts` is different:
|
| 22 |
-
arf-gateway's Go code (internal/auth/apikey.go) queries it from Postgres
|
| 23 |
-
directly, via the pgx driver against the same DATABASE_URL, to compute
|
| 24 |
-
each key's remaining quota. It never had a way to read this service's
|
| 25 |
-
local SQLite file (a different process, a different disk, a different
|
| 26 |
-
protocol), so a prior version of this module -- which wrote
|
| 27 |
-
`monthly_counts` to SQLite only -- left that Postgres table permanently
|
| 28 |
-
empty, and arf-gateway's quota check silently treated every key as having
|
| 29 |
-
consumed nothing all month, every month. `consume_quota_and_log` now
|
| 30 |
-
mirrors every successfully-counted call into the Postgres `monthly_counts`
|
| 31 |
-
table too (`_record_pg_monthly_count`), best-effort and logged loudly on
|
| 32 |
-
failure rather than raised -- this service's own quota decision is still
|
| 33 |
-
made from its local SQLite/Redis count and must not fail because a
|
| 34 |
-
mirroring write to a peer service's view did. Still keyed by the raw API
|
| 35 |
-
key (not hashed), matching the existing SQLite schema and a separately
|
| 36 |
-
tracked plaintext-storage gap -- not addressed by this fix.
|
| 37 |
"""
|
| 38 |
-
|
| 39 |
-
import hmac
|
| 40 |
import json
|
| 41 |
-
import logging
|
| 42 |
-
import os
|
| 43 |
-
import secrets
|
| 44 |
import sqlite3
|
| 45 |
import threading
|
| 46 |
import time
|
| 47 |
-
|
| 48 |
-
import psycopg2
|
| 49 |
-
import psycopg2.extras
|
| 50 |
from contextlib import contextmanager
|
| 51 |
from datetime import datetime, timedelta
|
| 52 |
from dataclasses import dataclass
|
|
@@ -64,7 +24,6 @@ except ImportError:
|
|
| 64 |
|
| 65 |
|
| 66 |
class Tier(str, Enum):
|
| 67 |
-
"""Pricing tiers with associated quota limits and audit retention."""
|
| 68 |
FREE = "free"
|
| 69 |
PRO = "pro"
|
| 70 |
PREMIUM = "premium"
|
|
@@ -72,18 +31,16 @@ class Tier(str, Enum):
|
|
| 72 |
|
| 73 |
@property
|
| 74 |
def monthly_evaluation_limit(self) -> Optional[int]:
|
| 75 |
-
"""Monthly evaluation quota. None = unlimited."""
|
| 76 |
limits = {
|
| 77 |
Tier.FREE: 1000,
|
| 78 |
Tier.PRO: 10_000,
|
| 79 |
Tier.PREMIUM: 50_000,
|
| 80 |
-
Tier.ENTERPRISE: None,
|
| 81 |
}
|
| 82 |
return limits[self]
|
| 83 |
|
| 84 |
@property
|
| 85 |
def audit_log_retention_days(self) -> int:
|
| 86 |
-
"""How many days to keep usage and decision audit logs."""
|
| 87 |
retention = {
|
| 88 |
Tier.FREE: 7,
|
| 89 |
Tier.PRO: 30,
|
|
@@ -95,7 +52,7 @@ class Tier(str, Enum):
|
|
| 95 |
|
| 96 |
@dataclass
|
| 97 |
class UsageRecord:
|
| 98 |
-
"""Single
|
| 99 |
api_key: str
|
| 100 |
tier: Tier
|
| 101 |
timestamp: float
|
|
@@ -106,51 +63,14 @@ class UsageRecord:
|
|
| 106 |
processing_ms: Optional[float] = None
|
| 107 |
|
| 108 |
|
| 109 |
-
# Bounded retry for the initial Postgres connect -- see _get_pg_conn's
|
| 110 |
-
# docstring. 5 attempts with exponential backoff (1+2+4+8 = 15s of sleep,
|
| 111 |
-
# worst case) comfortably fits inside a container's normal boot window
|
| 112 |
-
# without turning a real outage into a long hang.
|
| 113 |
-
_PG_CONNECT_MAX_ATTEMPTS = 5
|
| 114 |
-
_PG_CONNECT_BACKOFF_BASE = 1.0
|
| 115 |
-
|
| 116 |
-
logger = logging.getLogger(__name__)
|
| 117 |
-
|
| 118 |
-
|
| 119 |
class UsageTracker:
|
| 120 |
"""
|
| 121 |
Thread‑safe usage tracker with atomic quota consumption and idempotency.
|
| 122 |
-
Extended to support tenant isolation: each API key is linked to a tenant.
|
| 123 |
"""
|
| 124 |
|
| 125 |
-
# Whether the Postgres api_keys schema check has already run in this
|
| 126 |
-
# process. Class-level, not per-instance: the schema is a property of
|
| 127 |
-
# the database, not of a tracker object, and every instance in a
|
| 128 |
-
# process points at the same DATABASE_URL. Guarded by a lock because
|
| 129 |
-
# _get_pg_conn is called from request threads.
|
| 130 |
-
_pg_schema_ready: bool = False
|
| 131 |
-
_pg_schema_lock = threading.Lock()
|
| 132 |
-
|
| 133 |
def __init__(self, db_path: str = "arf_usage.db",
|
| 134 |
-
redis_url: Optional[str] = None
|
| 135 |
-
pepper: Optional[str] = None):
|
| 136 |
self.db_path = db_path
|
| 137 |
-
self._pepper = pepper if pepper is not None else os.getenv("ARF_KEY_PEPPER", "")
|
| 138 |
-
if not self._pepper:
|
| 139 |
-
raise RuntimeError(
|
| 140 |
-
"ARF_KEY_PEPPER is not set -- refusing to start without it, "
|
| 141 |
-
"since it is required to look up or verify any API key."
|
| 142 |
-
)
|
| 143 |
-
if len(self._pepper) < 32:
|
| 144 |
-
raise RuntimeError(
|
| 145 |
-
f"ARF_KEY_PEPPER is too short ({len(self._pepper)} chars); "
|
| 146 |
-
"use at least 32 random characters."
|
| 147 |
-
)
|
| 148 |
-
self._pg_dsn = os.getenv("DATABASE_URL", "")
|
| 149 |
-
if not self._pg_dsn:
|
| 150 |
-
raise RuntimeError(
|
| 151 |
-
"DATABASE_URL is not set -- refusing to start without it, "
|
| 152 |
-
"since api_keys is stored in Postgres, not SQLite."
|
| 153 |
-
)
|
| 154 |
self._local = threading.local()
|
| 155 |
self._init_db()
|
| 156 |
|
|
@@ -158,42 +78,12 @@ class UsageTracker:
|
|
| 158 |
if redis_url and REDIS_AVAILABLE:
|
| 159 |
self._redis_client = redis.from_url(redis_url)
|
| 160 |
elif redis_url:
|
| 161 |
-
raise ImportError(
|
| 162 |
-
|
| 163 |
-
def _lookup_hash(self, key: str) -> str:
|
| 164 |
-
"""Deterministic pepper-HMAC used to find a key's row without ever
|
| 165 |
-
storing or querying by the plaintext key."""
|
| 166 |
-
return hmac.new(self._pepper.encode(), key.encode(), hashlib.sha256).hexdigest()
|
| 167 |
-
|
| 168 |
-
@staticmethod
|
| 169 |
-
def _salted_hash(key: str, salt_hex: str) -> str:
|
| 170 |
-
"""Per-row salted verification hash, checked after a row has
|
| 171 |
-
already been found via lookup hash."""
|
| 172 |
-
return hashlib.sha256(bytes.fromhex(salt_hex) + key.encode()).hexdigest()
|
| 173 |
-
|
| 174 |
-
def _verify_key(self, conn, api_key: str) -> Optional[dict]:
|
| 175 |
-
"""Look up a row by pepper-HMAC, then verify with the salted hash.
|
| 176 |
-
`conn` is a Postgres connection from _get_pg_conn. Returns the row
|
| 177 |
-
(tenant_id, tier, is_active, salt, key_hash) if the key is valid and
|
| 178 |
-
active, else None."""
|
| 179 |
-
row = self._pg_execute(
|
| 180 |
-
conn,
|
| 181 |
-
"SELECT tenant_id, tier, is_active, salt, key_hash FROM api_keys "
|
| 182 |
-
"WHERE lookup_hash = %s",
|
| 183 |
-
(self._lookup_hash(api_key),)
|
| 184 |
-
).fetchone()
|
| 185 |
-
if not row or not row["is_active"]:
|
| 186 |
-
return None
|
| 187 |
-
if not hmac.compare_digest(self._salted_hash(api_key, row["salt"]), row["key_hash"]):
|
| 188 |
-
return None
|
| 189 |
-
return row
|
| 190 |
|
| 191 |
@contextmanager
|
| 192 |
def _get_conn(self):
|
| 193 |
-
"""Get a thread‑local SQLite connection with
|
| 194 |
-
|
| 195 |
-
Backs usage_log/monthly_counts/idempotency_keys only -- api_keys
|
| 196 |
-
lives in Postgres, see _get_pg_conn below."""
|
| 197 |
if not hasattr(self._local, "conn"):
|
| 198 |
self._local.conn = sqlite3.connect(
|
| 199 |
self.db_path, check_same_thread=False, isolation_level=None)
|
|
@@ -201,160 +91,17 @@ class UsageTracker:
|
|
| 201 |
self._local.conn.execute("PRAGMA journal_mode=WAL")
|
| 202 |
yield self._local.conn
|
| 203 |
|
| 204 |
-
@contextmanager
|
| 205 |
-
def _get_pg_conn(self):
|
| 206 |
-
"""Get a thread-local Postgres connection for the api_keys table.
|
| 207 |
-
Rows come back as dict-like objects (row["col"]) via RealDictCursor,
|
| 208 |
-
matching the sqlite3.Row access pattern used elsewhere in this file.
|
| 209 |
-
|
| 210 |
-
Retries the initial connect with backoff, but how patiently depends
|
| 211 |
-
on who is asking:
|
| 212 |
-
|
| 213 |
-
- **Startup** (`warm_up`, `retries=True`): a short DNS blip during a
|
| 214 |
-
cold container boot has been observed on Render, so a few seconds
|
| 215 |
-
of retrying is worth it to come up cleanly.
|
| 216 |
-
- **Request path** (the default, `retries=False`): fails fast. A
|
| 217 |
-
request thread that blocks for 15s on a database outage doesn't
|
| 218 |
-
make the request succeed; it holds a worker thread hostage, and
|
| 219 |
-
under any concurrency the pool is exhausted and the whole service
|
| 220 |
-
stops responding -- including its health endpoint. A prompt 503 is
|
| 221 |
-
strictly better than a slow one.
|
| 222 |
-
|
| 223 |
-
Genuine connection errors (bad credentials, wrong host) still raise
|
| 224 |
-
either way, preserving fail-closed."""
|
| 225 |
-
if not hasattr(self._local, "pg_conn") or self._local.pg_conn.closed:
|
| 226 |
-
self._connect_pg(retries=False)
|
| 227 |
-
yield self._local.pg_conn
|
| 228 |
-
|
| 229 |
-
def _connect_pg(self, retries: bool) -> None:
|
| 230 |
-
"""Open this thread's Postgres connection, optionally retrying."""
|
| 231 |
-
attempts = _PG_CONNECT_MAX_ATTEMPTS if retries else 1
|
| 232 |
-
last_exc: Optional[psycopg2.OperationalError] = None
|
| 233 |
-
for attempt in range(attempts):
|
| 234 |
-
try:
|
| 235 |
-
self._local.pg_conn = psycopg2.connect(
|
| 236 |
-
self._pg_dsn, cursor_factory=psycopg2.extras.RealDictCursor)
|
| 237 |
-
last_exc = None
|
| 238 |
-
break
|
| 239 |
-
except psycopg2.OperationalError as exc:
|
| 240 |
-
last_exc = exc
|
| 241 |
-
if attempt < attempts - 1:
|
| 242 |
-
time.sleep(_PG_CONNECT_BACKOFF_BASE * (2 ** attempt))
|
| 243 |
-
if last_exc is not None:
|
| 244 |
-
raise last_exc
|
| 245 |
-
self._ensure_pg_schema(self._local.pg_conn)
|
| 246 |
-
|
| 247 |
-
def warm_up(self) -> bool:
|
| 248 |
-
"""Best-effort startup connection, with retries.
|
| 249 |
-
|
| 250 |
-
Returns True if Postgres is reachable and the api_keys schema is
|
| 251 |
-
ready. Returns False -- rather than raising -- when it isn't, so a
|
| 252 |
-
caller can log the degradation and still start serving. That
|
| 253 |
-
asymmetry is the point: an unreachable database at boot should cost
|
| 254 |
-
api_keys-backed functionality, not the entire service.
|
| 255 |
-
"""
|
| 256 |
-
try:
|
| 257 |
-
self._connect_pg(retries=True)
|
| 258 |
-
return True
|
| 259 |
-
except psycopg2.OperationalError:
|
| 260 |
-
return False
|
| 261 |
-
|
| 262 |
-
def _ensure_pg_schema(self, conn) -> None:
|
| 263 |
-
"""Run the api_keys schema check once per process, on the first
|
| 264 |
-
connection that actually succeeds.
|
| 265 |
-
|
| 266 |
-
Guarded by a process-wide flag rather than done in __init__ so a
|
| 267 |
-
database that is unreachable at startup doesn't prevent the tracker
|
| 268 |
-
from existing -- it just means the first request that needs
|
| 269 |
-
Postgres pays for the schema check, and requests before that get a
|
| 270 |
-
clean 503 from enforce_quota instead of the whole service being
|
| 271 |
-
down. The statements are all IF NOT EXISTS, so re-running them on a
|
| 272 |
-
later process is harmless."""
|
| 273 |
-
if UsageTracker._pg_schema_ready:
|
| 274 |
-
return
|
| 275 |
-
with UsageTracker._pg_schema_lock:
|
| 276 |
-
if UsageTracker._pg_schema_ready:
|
| 277 |
-
return
|
| 278 |
-
self._create_pg_schema(conn)
|
| 279 |
-
UsageTracker._pg_schema_ready = True
|
| 280 |
-
|
| 281 |
-
@staticmethod
|
| 282 |
-
def _pg_execute(conn, sql: str, params: tuple = ()):
|
| 283 |
-
"""Run a query against a Postgres connection and return the cursor,
|
| 284 |
-
so callers can chain .fetchone()/.fetchall() the same way sqlite3's
|
| 285 |
-
conn.execute(...) is used elsewhere in this file."""
|
| 286 |
-
cur = conn.cursor()
|
| 287 |
-
cur.execute(sql, params)
|
| 288 |
-
return cur
|
| 289 |
-
|
| 290 |
-
def _create_pg_schema(self, conn):
|
| 291 |
-
"""Idempotently ensure the Postgres api_keys table/index exist.
|
| 292 |
-
|
| 293 |
-
Takes an already-open connection rather than acquiring one, because
|
| 294 |
-
its only caller is _ensure_pg_schema, which runs *from inside*
|
| 295 |
-
_get_pg_conn -- acquiring another connection here would recurse.
|
| 296 |
-
|
| 297 |
-
The canonical schema is the Alembic migration
|
| 298 |
-
(alembic/versions/*_create_api_keys_table.py) -- but nothing in this
|
| 299 |
-
codebase runs `alembic upgrade head` automatically on deploy (a
|
| 300 |
-
known gap, tracked separately), and arf-gateway's Go code needs the
|
| 301 |
-
same table without going through Python/Alembic at all. Mirroring
|
| 302 |
-
the same CREATE TABLE IF NOT EXISTS self-healing pattern this file
|
| 303 |
-
already uses for its SQLite tables keeps both services (and tests)
|
| 304 |
-
working whether or not the migration has actually been applied.
|
| 305 |
-
Column set/types must stay in sync with that migration."""
|
| 306 |
-
self._pg_execute(conn, """
|
| 307 |
-
CREATE TABLE IF NOT EXISTS api_keys (
|
| 308 |
-
id SERIAL PRIMARY KEY,
|
| 309 |
-
tenant_id VARCHAR(64) NOT NULL,
|
| 310 |
-
tier VARCHAR(32) NOT NULL,
|
| 311 |
-
created_at TIMESTAMP NOT NULL,
|
| 312 |
-
last_used_at TIMESTAMP,
|
| 313 |
-
is_active BOOLEAN NOT NULL DEFAULT true,
|
| 314 |
-
salt VARCHAR(64) NOT NULL,
|
| 315 |
-
key_hash VARCHAR(64) NOT NULL,
|
| 316 |
-
lookup_hash VARCHAR(64) NOT NULL
|
| 317 |
-
)
|
| 318 |
-
""")
|
| 319 |
-
self._pg_execute(conn, """
|
| 320 |
-
CREATE UNIQUE INDEX IF NOT EXISTS uq_api_keys_lookup_hash
|
| 321 |
-
ON api_keys (lookup_hash)
|
| 322 |
-
""")
|
| 323 |
-
self._pg_execute(conn, """
|
| 324 |
-
CREATE INDEX IF NOT EXISTS ix_api_keys_tenant_id
|
| 325 |
-
ON api_keys (tenant_id)
|
| 326 |
-
""")
|
| 327 |
-
# Mirrors arf-gateway's self-healing CREATE TABLE IF NOT EXISTS for
|
| 328 |
-
# this same table (internal/auth/apikey.go's NewValidator) -- either
|
| 329 |
-
# service may be the first to connect to a fresh database.
|
| 330 |
-
self._pg_execute(conn, """
|
| 331 |
-
CREATE TABLE IF NOT EXISTS monthly_counts (
|
| 332 |
-
api_key TEXT NOT NULL,
|
| 333 |
-
year_month TEXT NOT NULL,
|
| 334 |
-
count INTEGER NOT NULL DEFAULT 0,
|
| 335 |
-
PRIMARY KEY (api_key, year_month)
|
| 336 |
-
)
|
| 337 |
-
""")
|
| 338 |
-
conn.commit()
|
| 339 |
-
|
| 340 |
def _init_db(self):
|
| 341 |
-
"""Initialise SQLite tables for usage_log/monthly_counts/idempotency_keys.
|
| 342 |
-
|
| 343 |
-
Deliberately does NOT touch Postgres. The api_keys schema is
|
| 344 |
-
ensured lazily on the first successful Postgres connection instead
|
| 345 |
-
(see _ensure_pg_schema) so that constructing a UsageTracker never
|
| 346 |
-
depends on the database being reachable *at that instant*.
|
| 347 |
-
|
| 348 |
-
This is the difference between a degraded service and no service.
|
| 349 |
-
Every other subsystem in main.py's lifespan already degrades
|
| 350 |
-
gracefully when Postgres is unreachable -- the Beta-state loader
|
| 351 |
-
logs a warning and continues -- but init_tracker raised, and
|
| 352 |
-
main.py turns that into RuntimeError, killing the process. On
|
| 353 |
-
Render that produced a crash loop that outlived the port-detection
|
| 354 |
-
window, so a DNS failure lasting seconds took the whole deploy
|
| 355 |
-
down and left the previous release serving.
|
| 356 |
-
"""
|
| 357 |
with self._get_conn() as conn:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
conn.execute("""
|
| 359 |
CREATE TABLE IF NOT EXISTS usage_log (
|
| 360 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -392,107 +139,75 @@ class UsageTracker:
|
|
| 392 |
def _get_month_key(self) -> str:
|
| 393 |
return datetime.now().strftime("%Y-%m")
|
| 394 |
|
| 395 |
-
def get_or_create_api_key(self, key: str,
|
| 396 |
-
"""
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
key: The API key (plain text -- hashed before storage, never persisted as-is).
|
| 401 |
-
tenant_id: UUID of the tenant (must already exist in main DB).
|
| 402 |
-
tier: Initial tier for the key.
|
| 403 |
-
|
| 404 |
-
Returns:
|
| 405 |
-
True if key was created (or already exists for the same tenant).
|
| 406 |
-
"""
|
| 407 |
-
lookup_hash = self._lookup_hash(key)
|
| 408 |
-
with self._get_pg_conn() as conn:
|
| 409 |
-
row = self._pg_execute(
|
| 410 |
-
conn, "SELECT tenant_id FROM api_keys WHERE lookup_hash = %s", (lookup_hash,)
|
| 411 |
-
).fetchone()
|
| 412 |
if row:
|
| 413 |
-
# Key already exists – ensure it belongs to the same tenant
|
| 414 |
-
if row["tenant_id"] != tenant_id:
|
| 415 |
-
conn.rollback()
|
| 416 |
-
raise ValueError(f"Key {key[:8]}... already belongs to a different tenant.")
|
| 417 |
-
conn.commit()
|
| 418 |
return True
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
"INSERT INTO api_keys "
|
| 423 |
-
"(tenant_id, tier, created_at, is_active, salt, key_hash, lookup_hash) "
|
| 424 |
-
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
| 425 |
-
(tenant_id, tier.value, datetime.utcnow(), True,
|
| 426 |
-
salt, self._salted_hash(key, salt), lookup_hash)
|
| 427 |
)
|
| 428 |
conn.commit()
|
| 429 |
return True
|
| 430 |
|
| 431 |
def get_tier(self, api_key: str) -> Optional[Tier]:
|
| 432 |
"""Return the tier for a given API key, or None if key invalid/inactive."""
|
| 433 |
-
with self.
|
| 434 |
-
row =
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
return row["tenant_id"] if row else None
|
| 442 |
|
| 443 |
def update_api_key_tier(self, api_key: str, new_tier: Tier) -> bool:
|
| 444 |
"""Update the tier of an existing API key. Returns True if successful."""
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (lookup_hash,)
|
| 449 |
-
).fetchone()
|
| 450 |
if not row:
|
| 451 |
-
conn.rollback()
|
| 452 |
return False
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
(new_tier.value,
|
|
|
|
| 456 |
conn.commit()
|
| 457 |
return True
|
| 458 |
|
| 459 |
-
def update_tier_by_tenant_id(self, tenant_id: str, new_tier: Tier) -> bool:
|
| 460 |
-
"""Update the tier of every active API key belonging to tenant_id.
|
| 461 |
-
|
| 462 |
-
Used by the Stripe webhook, which must never handle a raw API key
|
| 463 |
-
(Stripe's own systems -- dashboard, logs, webhook payloads -- are a
|
| 464 |
-
third party; the plaintext bearer secret has no business being
|
| 465 |
-
stored there, which is exactly what passing it as Checkout
|
| 466 |
-
metadata used to do). tenant_id is not a secret -- it's an opaque
|
| 467 |
-
row identifier -- so it's safe to round-trip through Stripe."""
|
| 468 |
-
with self._get_pg_conn() as conn:
|
| 469 |
-
cur = self._pg_execute(
|
| 470 |
-
conn,
|
| 471 |
-
"UPDATE api_keys SET tier = %s WHERE tenant_id = %s AND is_active = true",
|
| 472 |
-
(new_tier.value, tenant_id),
|
| 473 |
-
)
|
| 474 |
-
updated = cur.rowcount > 0
|
| 475 |
-
conn.commit()
|
| 476 |
-
return updated
|
| 477 |
-
|
| 478 |
# --------------------------------------------------------------------------
|
| 479 |
-
# Atomic quota consumption
|
| 480 |
# --------------------------------------------------------------------------
|
| 481 |
-
def _consume_quota_atomic_sqlite(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
limit = tier.monthly_evaluation_limit
|
| 483 |
if limit is None:
|
|
|
|
| 484 |
with self._get_conn() as conn:
|
| 485 |
conn.execute(
|
| 486 |
-
"INSERT INTO monthly_counts (api_key, year_month, count)
|
| 487 |
-
|
|
|
|
| 488 |
(api_key, month)
|
| 489 |
)
|
| 490 |
conn.commit()
|
| 491 |
return True
|
| 492 |
|
|
|
|
| 493 |
with self._get_conn() as conn:
|
| 494 |
conn.execute("BEGIN IMMEDIATE")
|
| 495 |
try:
|
|
|
|
| 496 |
row = conn.execute(
|
| 497 |
"SELECT count FROM monthly_counts WHERE api_key = ? AND year_month = ?",
|
| 498 |
(api_key, month)
|
|
@@ -501,9 +216,11 @@ class UsageTracker:
|
|
| 501 |
if current >= limit:
|
| 502 |
conn.rollback()
|
| 503 |
return False
|
|
|
|
| 504 |
conn.execute(
|
| 505 |
-
"INSERT INTO monthly_counts (api_key, year_month, count)
|
| 506 |
-
|
|
|
|
| 507 |
(api_key, month)
|
| 508 |
)
|
| 509 |
conn.commit()
|
|
@@ -512,9 +229,15 @@ class UsageTracker:
|
|
| 512 |
conn.rollback()
|
| 513 |
raise
|
| 514 |
|
| 515 |
-
def _consume_quota_atomic_redis(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
limit = tier.monthly_evaluation_limit
|
| 517 |
if limit is None:
|
|
|
|
| 518 |
redis_key = f"arf:quota:{api_key}:{month}"
|
| 519 |
self._redis_client.incr(redis_key)
|
| 520 |
self._redis_client.expire(redis_key, timedelta(days=31))
|
|
@@ -528,156 +251,152 @@ class UsageTracker:
|
|
| 528 |
return 0
|
| 529 |
end
|
| 530 |
local new = redis.call('INCR', key)
|
| 531 |
-
redis.call('EXPIRE', key, 2678400)
|
| 532 |
return 1
|
| 533 |
"""
|
| 534 |
redis_key = f"arf:quota:{api_key}:{month}"
|
| 535 |
result = self._redis_client.eval(lua_script, 1, redis_key, limit)
|
| 536 |
return result == 1
|
| 537 |
|
| 538 |
-
def _record_pg_monthly_count(self, api_key: str, month: str) -> None:
|
| 539 |
-
"""Mirror one successfully-counted call into Postgres `monthly_counts`,
|
| 540 |
-
the table arf-gateway's Go code actually reads to compute quota
|
| 541 |
-
remaining (see this module's docstring). Best-effort: this service's
|
| 542 |
-
own quota decision was already made from SQLite/Redis before this is
|
| 543 |
-
called, so a failure here must not fail the request that already
|
| 544 |
-
legitimately counted against quota -- but it also must not fail
|
| 545 |
-
*silently*, since a swallowed error here is exactly how arf-gateway's
|
| 546 |
-
quota check went blind for every key in the first place. Logged at
|
| 547 |
-
ERROR, not raised."""
|
| 548 |
-
try:
|
| 549 |
-
with self._get_pg_conn() as conn:
|
| 550 |
-
self._pg_execute(
|
| 551 |
-
conn,
|
| 552 |
-
"INSERT INTO monthly_counts (api_key, year_month, count) "
|
| 553 |
-
"VALUES (%s, %s, 1) ON CONFLICT (api_key, year_month) "
|
| 554 |
-
"DO UPDATE SET count = monthly_counts.count + 1",
|
| 555 |
-
(api_key, month),
|
| 556 |
-
)
|
| 557 |
-
conn.commit()
|
| 558 |
-
except Exception:
|
| 559 |
-
logger.error(
|
| 560 |
-
"Failed to mirror monthly_counts to Postgres for api_key=%s month=%s -- "
|
| 561 |
-
"arf-gateway's quota check will undercount usage for this key until this "
|
| 562 |
-
"is resolved.",
|
| 563 |
-
api_key, month, exc_info=True,
|
| 564 |
-
)
|
| 565 |
-
|
| 566 |
# --------------------------------------------------------------------------
|
| 567 |
-
# Idempotency handling
|
| 568 |
# --------------------------------------------------------------------------
|
| 569 |
def _is_idempotent_key_used(self, key: str) -> bool:
|
|
|
|
| 570 |
with self._get_conn() as conn:
|
| 571 |
row = conn.execute(
|
| 572 |
"SELECT 1 FROM idempotency_keys WHERE key = ?", (key,)).fetchone()
|
| 573 |
return row is not None
|
| 574 |
|
| 575 |
def _mark_idempotent_key_used(self, key: str, ttl_seconds: int = 86400):
|
|
|
|
| 576 |
with self._get_conn() as conn:
|
| 577 |
conn.execute(
|
| 578 |
"INSERT INTO idempotency_keys (key, consumed_at) VALUES (?, ?)",
|
| 579 |
(key, time.time())
|
| 580 |
)
|
| 581 |
conn.commit()
|
|
|
|
|
|
|
| 582 |
|
| 583 |
# --------------------------------------------------------------------------
|
| 584 |
-
# Core usage recording (atomic + idempotent)
|
| 585 |
# --------------------------------------------------------------------------
|
| 586 |
-
def consume_quota_and_log(
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
|
| 591 |
month = self._get_month_key()
|
|
|
|
| 592 |
if self._redis_client:
|
| 593 |
-
quota_ok = self._consume_quota_atomic_redis(
|
|
|
|
| 594 |
else:
|
| 595 |
-
quota_ok = self._consume_quota_atomic_sqlite(
|
|
|
|
| 596 |
|
| 597 |
if not quota_ok:
|
| 598 |
return False, None
|
| 599 |
|
| 600 |
-
|
| 601 |
-
|
| 602 |
try:
|
| 603 |
with self._get_conn() as conn:
|
| 604 |
conn.execute(
|
| 605 |
"""INSERT INTO usage_log
|
| 606 |
-
(api_key, tier, timestamp, endpoint,
|
| 607 |
-
|
|
|
|
| 608 |
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 609 |
-
(record.api_key,
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
record.
|
| 613 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
conn.commit()
|
| 615 |
except sqlite3.IntegrityError as e:
|
|
|
|
|
|
|
| 616 |
if "UNIQUE constraint failed: usage_log.idempotency_key" in str(e):
|
| 617 |
-
return False, {"idempotent": True,
|
|
|
|
| 618 |
raise
|
| 619 |
|
| 620 |
if idempotency_key:
|
| 621 |
self._mark_idempotent_key_used(idempotency_key)
|
|
|
|
| 622 |
return True, None
|
| 623 |
|
| 624 |
-
def _insert_audit_log(self, record: UsageRecord) -> None:
|
| 625 |
-
"""Insert a standalone usage_log row for a call whose quota was
|
| 626 |
-
already consumed at request time (see consume_quota_and_log) --
|
| 627 |
-
used by routes_governance.py's background tasks to record the
|
| 628 |
-
response body once it's known, under a distinct endpoint suffix
|
| 629 |
-
(e.g. ".../response"). Best-effort and logged, not raised, for the
|
| 630 |
-
same reason _record_pg_monthly_count is: this runs after the
|
| 631 |
-
response has already been sent to the caller, so it must not
|
| 632 |
-
surface as a request failure -- a background task exception here
|
| 633 |
-
is otherwise swallowed silently. `record.tier` is None at both real
|
| 634 |
-
call sites (tier only matters for quota consumption, already done
|
| 635 |
-
by the earlier consume_quota_and_log call for the same request),
|
| 636 |
-
but usage_log.tier is NOT NULL, so an absent tier is recorded as
|
| 637 |
-
"unknown" rather than raising or silently guessing a real tier."""
|
| 638 |
-
tier_value = record.tier.value if record.tier else "unknown"
|
| 639 |
-
try:
|
| 640 |
-
with self._get_conn() as conn:
|
| 641 |
-
conn.execute(
|
| 642 |
-
"""INSERT INTO usage_log
|
| 643 |
-
(api_key, tier, timestamp, endpoint, request_body, response, error,
|
| 644 |
-
processing_ms, idempotency_key)
|
| 645 |
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 646 |
-
(record.api_key, tier_value, record.timestamp, record.endpoint,
|
| 647 |
-
json.dumps(record.request_body, default=str) if record.request_body else None,
|
| 648 |
-
json.dumps(record.response, default=str) if record.response else None,
|
| 649 |
-
record.error, record.processing_ms, None)
|
| 650 |
-
)
|
| 651 |
-
conn.commit()
|
| 652 |
-
except Exception:
|
| 653 |
-
logger.error(
|
| 654 |
-
"Failed to insert audit log for api_key=%s endpoint=%s",
|
| 655 |
-
record.api_key, record.endpoint, exc_info=True,
|
| 656 |
-
)
|
| 657 |
-
|
| 658 |
# --------------------------------------------------------------------------
|
| 659 |
-
# Legacy interface (kept for compatibility)
|
| 660 |
# --------------------------------------------------------------------------
|
| 661 |
-
def increment_usage_sync(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
success, _ = self.consume_quota_and_log(record, idempotency_key)
|
| 663 |
return success
|
| 664 |
|
| 665 |
-
async def increment_usage_async(
|
| 666 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
return self.increment_usage_sync(record, idempotency_key)
|
| 668 |
|
| 669 |
# --------------------------------------------------------------------------
|
| 670 |
-
# Quota inspection
|
| 671 |
# --------------------------------------------------------------------------
|
| 672 |
def get_remaining_quota(self, api_key: str, tier: Tier) -> Optional[int]:
|
|
|
|
| 673 |
limit = tier.monthly_evaluation_limit
|
| 674 |
if limit is None:
|
| 675 |
return None
|
|
|
|
| 676 |
month = self._get_month_key()
|
| 677 |
if self._redis_client:
|
| 678 |
redis_key = f"arf:quota:{api_key}:{month}"
|
| 679 |
count = int(self._redis_client.get(redis_key) or 0)
|
| 680 |
return max(0, limit - count)
|
|
|
|
| 681 |
with self._get_conn() as conn:
|
| 682 |
row = conn.execute(
|
| 683 |
"SELECT count FROM monthly_counts WHERE api_key = ? AND year_month = ?",
|
|
@@ -687,10 +406,16 @@ class UsageTracker:
|
|
| 687 |
return max(0, limit - count)
|
| 688 |
|
| 689 |
# --------------------------------------------------------------------------
|
| 690 |
-
# Audit and maintenance
|
| 691 |
# --------------------------------------------------------------------------
|
| 692 |
-
def get_audit_logs(
|
| 693 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
query = "SELECT * FROM usage_log WHERE api_key = ?"
|
| 695 |
params = [api_key]
|
| 696 |
if start_date:
|
|
@@ -701,109 +426,86 @@ class UsageTracker:
|
|
| 701 |
params.append(end_date.timestamp())
|
| 702 |
query += " ORDER BY timestamp DESC LIMIT ?"
|
| 703 |
params.append(limit)
|
|
|
|
| 704 |
with self._get_conn() as conn:
|
| 705 |
rows = conn.execute(query, params).fetchall()
|
| 706 |
return [dict(row) for row in rows]
|
| 707 |
|
| 708 |
def clean_old_logs(self):
|
|
|
|
| 709 |
with self._get_conn() as conn:
|
|
|
|
| 710 |
for tier in Tier:
|
| 711 |
retention_days = tier.audit_log_retention_days
|
|
|
|
|
|
|
| 712 |
cutoff = time.time() - retention_days * 86400
|
| 713 |
conn.execute(
|
| 714 |
"DELETE FROM usage_log WHERE tier = ? AND timestamp < ?",
|
| 715 |
(tier.value, cutoff)
|
| 716 |
)
|
|
|
|
| 717 |
cutoff = time.time() - 7 * 86400
|
| 718 |
-
conn.execute(
|
|
|
|
| 719 |
conn.commit()
|
| 720 |
|
| 721 |
|
| 722 |
# --------------------------------------------------------------------------
|
| 723 |
-
# Global instance and FastAPI dependency
|
| 724 |
# --------------------------------------------------------------------------
|
| 725 |
-
# Rebound by init_tracker() during the app lifespan, which means consumers
|
| 726 |
-
# MUST reach it through the module -- `from app.core import usage_tracker`,
|
| 727 |
-
# then `usage_tracker.tracker`. Never `from app.core.usage_tracker import
|
| 728 |
-
# tracker`: that copies the *binding* (None) at import time, and init_tracker
|
| 729 |
-
# rebinding this global does not update the importer's copy. Five modules
|
| 730 |
-
# did exactly that (main, routes_admin, routes_incidents, routes_payments,
|
| 731 |
-
# routes_users) and every one of them saw None forever -- silently skipping
|
| 732 |
-
# metering and disabling signup/checkout, and crashing the Render deploy
|
| 733 |
-
# outright once main.py called a method on it. Functions defined *in* this
|
| 734 |
-
# module (enforce_quota, update_key_tier*) are safe to import by name: they
|
| 735 |
-
# resolve `tracker` here, at call time.
|
| 736 |
tracker: Optional[UsageTracker] = None
|
| 737 |
|
| 738 |
|
| 739 |
-
def init_tracker(
|
|
|
|
|
|
|
|
|
|
| 740 |
global tracker
|
| 741 |
tracker = UsageTracker(db_path, redis_url)
|
| 742 |
|
| 743 |
|
| 744 |
def update_key_tier(api_key: str, new_tier: Tier) -> bool:
|
|
|
|
| 745 |
if tracker is None:
|
| 746 |
return False
|
| 747 |
return tracker.update_api_key_tier(api_key, new_tier)
|
| 748 |
|
| 749 |
|
| 750 |
-
def
|
| 751 |
-
if tracker is None:
|
| 752 |
-
return False
|
| 753 |
-
return tracker.update_tier_by_tenant_id(tenant_id, new_tier)
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
def _extract_api_key(request: Request, api_key: str = None) -> str:
|
| 757 |
-
if api_key:
|
| 758 |
-
return api_key
|
| 759 |
-
auth_header = request.headers.get("Authorization")
|
| 760 |
-
if auth_header and auth_header.startswith("Bearer "):
|
| 761 |
-
return auth_header[7:]
|
| 762 |
-
return request.query_params.get("api_key")
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
async def resolve_api_key_identity(request: Request, api_key: str = None):
|
| 766 |
"""
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
Deliberately separate from `enforce_quota`: a caller whose quota is
|
| 771 |
-
already exhausted must still be able to reach an endpoint like
|
| 772 |
-
`/payments/create-checkout-session` (upgrading tier is often exactly
|
| 773 |
-
what a rate-limited caller is trying to do) -- gating that path behind
|
| 774 |
-
`enforce_quota` would 429 the one action that lets them fix it.
|
| 775 |
"""
|
|
|
|
| 776 |
if tracker is None:
|
| 777 |
-
raise HTTPException(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
|
| 779 |
-
api_key = _extract_api_key(request, api_key)
|
| 780 |
if not api_key:
|
| 781 |
raise HTTPException(status_code=401, detail="Missing API key")
|
| 782 |
|
| 783 |
tier = tracker.get_tier(api_key)
|
| 784 |
if tier is None:
|
| 785 |
-
raise HTTPException(
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
if not tenant_id:
|
| 789 |
-
raise HTTPException(status_code=403, detail="API key not associated with a tenant")
|
| 790 |
-
|
| 791 |
-
request.state.api_key = api_key
|
| 792 |
-
request.state.tier = tier
|
| 793 |
-
request.state.tenant_id = tenant_id
|
| 794 |
-
|
| 795 |
-
return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id}
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
async def enforce_quota(request: Request, api_key: str = None):
|
| 799 |
-
"""
|
| 800 |
-
FastAPI dependency that enforces quota and attaches tenant_id to request state.
|
| 801 |
-
"""
|
| 802 |
-
identity = await resolve_api_key_identity(request, api_key)
|
| 803 |
-
api_key, tier, tenant_id = identity["api_key"], identity["tier"], identity["tenant_id"]
|
| 804 |
|
| 805 |
remaining = tracker.get_remaining_quota(api_key, tier)
|
| 806 |
if remaining is not None and remaining <= 0:
|
| 807 |
-
raise HTTPException(status_code=429,
|
|
|
|
| 808 |
|
| 809 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Usage Tracker for ARF API – quotas, tiers, and audit logging.
|
| 3 |
Thread‑safe, atomic quota consumption, idempotent, fail‑closed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
+
|
|
|
|
| 6 |
import json
|
|
|
|
|
|
|
|
|
|
| 7 |
import sqlite3
|
| 8 |
import threading
|
| 9 |
import time
|
|
|
|
|
|
|
|
|
|
| 10 |
from contextlib import contextmanager
|
| 11 |
from datetime import datetime, timedelta
|
| 12 |
from dataclasses import dataclass
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
class Tier(str, Enum):
|
|
|
|
| 27 |
FREE = "free"
|
| 28 |
PRO = "pro"
|
| 29 |
PREMIUM = "premium"
|
|
|
|
| 31 |
|
| 32 |
@property
|
| 33 |
def monthly_evaluation_limit(self) -> Optional[int]:
|
|
|
|
| 34 |
limits = {
|
| 35 |
Tier.FREE: 1000,
|
| 36 |
Tier.PRO: 10_000,
|
| 37 |
Tier.PREMIUM: 50_000,
|
| 38 |
+
Tier.ENTERPRISE: None, # unlimited
|
| 39 |
}
|
| 40 |
return limits[self]
|
| 41 |
|
| 42 |
@property
|
| 43 |
def audit_log_retention_days(self) -> int:
|
|
|
|
| 44 |
retention = {
|
| 45 |
Tier.FREE: 7,
|
| 46 |
Tier.PRO: 30,
|
|
|
|
| 52 |
|
| 53 |
@dataclass
|
| 54 |
class UsageRecord:
|
| 55 |
+
"""Single evaluation usage record."""
|
| 56 |
api_key: str
|
| 57 |
tier: Tier
|
| 58 |
timestamp: float
|
|
|
|
| 63 |
processing_ms: Optional[float] = None
|
| 64 |
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
class UsageTracker:
|
| 67 |
"""
|
| 68 |
Thread‑safe usage tracker with atomic quota consumption and idempotency.
|
|
|
|
| 69 |
"""
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
def __init__(self, db_path: str = "arf_usage.db",
|
| 72 |
+
redis_url: Optional[str] = None):
|
|
|
|
| 73 |
self.db_path = db_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
self._local = threading.local()
|
| 75 |
self._init_db()
|
| 76 |
|
|
|
|
| 78 |
if redis_url and REDIS_AVAILABLE:
|
| 79 |
self._redis_client = redis.from_url(redis_url)
|
| 80 |
elif redis_url:
|
| 81 |
+
raise ImportError(
|
| 82 |
+
"Redis client not installed. Run: pip install redis")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
@contextmanager
|
| 85 |
def _get_conn(self):
|
| 86 |
+
"""Get a thread‑local SQLite connection with write‑ahead logging and immediate transactions."""
|
|
|
|
|
|
|
|
|
|
| 87 |
if not hasattr(self._local, "conn"):
|
| 88 |
self._local.conn = sqlite3.connect(
|
| 89 |
self.db_path, check_same_thread=False, isolation_level=None)
|
|
|
|
| 91 |
self._local.conn.execute("PRAGMA journal_mode=WAL")
|
| 92 |
yield self._local.conn
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
def _init_db(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
with self._get_conn() as conn:
|
| 96 |
+
conn.execute("""
|
| 97 |
+
CREATE TABLE IF NOT EXISTS api_keys (
|
| 98 |
+
key TEXT PRIMARY KEY,
|
| 99 |
+
tier TEXT NOT NULL,
|
| 100 |
+
created_at REAL NOT NULL,
|
| 101 |
+
last_used_at REAL,
|
| 102 |
+
is_active INTEGER DEFAULT 1
|
| 103 |
+
)
|
| 104 |
+
""")
|
| 105 |
conn.execute("""
|
| 106 |
CREATE TABLE IF NOT EXISTS usage_log (
|
| 107 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
| 139 |
def _get_month_key(self) -> str:
|
| 140 |
return datetime.now().strftime("%Y-%m")
|
| 141 |
|
| 142 |
+
def get_or_create_api_key(self, key: str, tier: Tier = Tier.FREE) -> bool:
|
| 143 |
+
"""Register a new API key. Returns True if key exists or was created."""
|
| 144 |
+
with self._get_conn() as conn:
|
| 145 |
+
row = conn.execute(
|
| 146 |
+
"SELECT key FROM api_keys WHERE key = ?", (key,)).fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
if row:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
return True
|
| 149 |
+
conn.execute(
|
| 150 |
+
"INSERT INTO api_keys (key, tier, created_at, is_active) VALUES (?, ?, ?, ?)",
|
| 151 |
+
(key, tier.value, time.time(), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
)
|
| 153 |
conn.commit()
|
| 154 |
return True
|
| 155 |
|
| 156 |
def get_tier(self, api_key: str) -> Optional[Tier]:
|
| 157 |
"""Return the tier for a given API key, or None if key invalid/inactive."""
|
| 158 |
+
with self._get_conn() as conn:
|
| 159 |
+
row = conn.execute(
|
| 160 |
+
"SELECT tier FROM api_keys WHERE key = ? AND is_active = 1",
|
| 161 |
+
(api_key,)
|
| 162 |
+
).fetchone()
|
| 163 |
+
if not row:
|
| 164 |
+
return None
|
| 165 |
+
return Tier(row["tier"])
|
|
|
|
| 166 |
|
| 167 |
def update_api_key_tier(self, api_key: str, new_tier: Tier) -> bool:
|
| 168 |
"""Update the tier of an existing API key. Returns True if successful."""
|
| 169 |
+
with self._get_conn() as conn:
|
| 170 |
+
row = conn.execute(
|
| 171 |
+
"SELECT key FROM api_keys WHERE key = ?", (api_key,)).fetchone()
|
|
|
|
|
|
|
| 172 |
if not row:
|
|
|
|
| 173 |
return False
|
| 174 |
+
conn.execute(
|
| 175 |
+
"UPDATE api_keys SET tier = ? WHERE key = ?",
|
| 176 |
+
(new_tier.value,
|
| 177 |
+
api_key))
|
| 178 |
conn.commit()
|
| 179 |
return True
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
# --------------------------------------------------------------------------
|
| 182 |
+
# Atomic quota consumption
|
| 183 |
# --------------------------------------------------------------------------
|
| 184 |
+
def _consume_quota_atomic_sqlite(
|
| 185 |
+
self,
|
| 186 |
+
api_key: str,
|
| 187 |
+
tier: Tier,
|
| 188 |
+
month: str) -> bool: # noqa: E501
|
| 189 |
+
"""
|
| 190 |
+
Atomically increment counter only if under limit.
|
| 191 |
+
Returns True if quota was consumed, False if limit reached.
|
| 192 |
+
"""
|
| 193 |
limit = tier.monthly_evaluation_limit
|
| 194 |
if limit is None:
|
| 195 |
+
# Unlimited – still increment for tracking but always succeed
|
| 196 |
with self._get_conn() as conn:
|
| 197 |
conn.execute(
|
| 198 |
+
"""INSERT INTO monthly_counts (api_key, year_month, count)
|
| 199 |
+
VALUES (?, ?, 1)
|
| 200 |
+
ON CONFLICT(api_key, year_month) DO UPDATE SET count = count + 1""",
|
| 201 |
(api_key, month)
|
| 202 |
)
|
| 203 |
conn.commit()
|
| 204 |
return True
|
| 205 |
|
| 206 |
+
# Use BEGIN IMMEDIATE to lock the database for the transaction
|
| 207 |
with self._get_conn() as conn:
|
| 208 |
conn.execute("BEGIN IMMEDIATE")
|
| 209 |
try:
|
| 210 |
+
# Get current count (or 0)
|
| 211 |
row = conn.execute(
|
| 212 |
"SELECT count FROM monthly_counts WHERE api_key = ? AND year_month = ?",
|
| 213 |
(api_key, month)
|
|
|
|
| 216 |
if current >= limit:
|
| 217 |
conn.rollback()
|
| 218 |
return False
|
| 219 |
+
# Increment
|
| 220 |
conn.execute(
|
| 221 |
+
"""INSERT INTO monthly_counts (api_key, year_month, count)
|
| 222 |
+
VALUES (?, ?, 1)
|
| 223 |
+
ON CONFLICT(api_key, year_month) DO UPDATE SET count = count + 1""",
|
| 224 |
(api_key, month)
|
| 225 |
)
|
| 226 |
conn.commit()
|
|
|
|
| 229 |
conn.rollback()
|
| 230 |
raise
|
| 231 |
|
| 232 |
+
def _consume_quota_atomic_redis(
|
| 233 |
+
self,
|
| 234 |
+
api_key: str,
|
| 235 |
+
tier: Tier,
|
| 236 |
+
month: str) -> bool:
|
| 237 |
+
"""Atomic Lua script for Redis: INCR only if below limit."""
|
| 238 |
limit = tier.monthly_evaluation_limit
|
| 239 |
if limit is None:
|
| 240 |
+
# Unlimited – just increment and return True
|
| 241 |
redis_key = f"arf:quota:{api_key}:{month}"
|
| 242 |
self._redis_client.incr(redis_key)
|
| 243 |
self._redis_client.expire(redis_key, timedelta(days=31))
|
|
|
|
| 251 |
return 0
|
| 252 |
end
|
| 253 |
local new = redis.call('INCR', key)
|
| 254 |
+
redis.call('EXPIRE', key, 2678400) -- 31 days
|
| 255 |
return 1
|
| 256 |
"""
|
| 257 |
redis_key = f"arf:quota:{api_key}:{month}"
|
| 258 |
result = self._redis_client.eval(lua_script, 1, redis_key, limit)
|
| 259 |
return result == 1
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
# --------------------------------------------------------------------------
|
| 262 |
+
# Idempotency handling
|
| 263 |
# --------------------------------------------------------------------------
|
| 264 |
def _is_idempotent_key_used(self, key: str) -> bool:
|
| 265 |
+
"""Check if idempotency key already processed."""
|
| 266 |
with self._get_conn() as conn:
|
| 267 |
row = conn.execute(
|
| 268 |
"SELECT 1 FROM idempotency_keys WHERE key = ?", (key,)).fetchone()
|
| 269 |
return row is not None
|
| 270 |
|
| 271 |
def _mark_idempotent_key_used(self, key: str, ttl_seconds: int = 86400):
|
| 272 |
+
"""Store idempotency key with expiration (cleanup later)."""
|
| 273 |
with self._get_conn() as conn:
|
| 274 |
conn.execute(
|
| 275 |
"INSERT INTO idempotency_keys (key, consumed_at) VALUES (?, ?)",
|
| 276 |
(key, time.time())
|
| 277 |
)
|
| 278 |
conn.commit()
|
| 279 |
+
# Optionally schedule cleanup of old keys (can be done in a background
|
| 280 |
+
# thread)
|
| 281 |
|
| 282 |
# --------------------------------------------------------------------------
|
| 283 |
+
# Core usage recording (atomic + idempotent)
|
| 284 |
# --------------------------------------------------------------------------
|
| 285 |
+
def consume_quota_and_log(
|
| 286 |
+
self,
|
| 287 |
+
record: UsageRecord,
|
| 288 |
+
idempotency_key: Optional[str] = None,
|
| 289 |
+
) -> Tuple[bool, Optional[Dict[str, Any]]]:
|
| 290 |
+
"""
|
| 291 |
+
Atomically consume quota and insert audit log.
|
| 292 |
+
Returns (success, existing_response) where existing_response is not None
|
| 293 |
+
only when idempotency_key matched a previous successful call.
|
| 294 |
+
"""
|
| 295 |
+
# Idempotency check (if key provided)
|
| 296 |
+
if idempotency_key:
|
| 297 |
+
if self._is_idempotent_key_used(idempotency_key):
|
| 298 |
+
# Retrieve previous response from audit log (simplified – you may cache full response)
|
| 299 |
+
# For full idempotency, we would store the response body in idempotency table.
|
| 300 |
+
# Here we return a marker that caller should use cached
|
| 301 |
+
# response.
|
| 302 |
+
return False, {"idempotent": True,
|
| 303 |
+
"message": "Already processed"}
|
| 304 |
|
| 305 |
month = self._get_month_key()
|
| 306 |
+
# Atomic quota consumption
|
| 307 |
if self._redis_client:
|
| 308 |
+
quota_ok = self._consume_quota_atomic_redis(
|
| 309 |
+
record.api_key, record.tier, month)
|
| 310 |
else:
|
| 311 |
+
quota_ok = self._consume_quota_atomic_sqlite(
|
| 312 |
+
record.api_key, record.tier, month)
|
| 313 |
|
| 314 |
if not quota_ok:
|
| 315 |
return False, None
|
| 316 |
|
| 317 |
+
# Insert audit log (with idempotency key as unique constraint)
|
|
|
|
| 318 |
try:
|
| 319 |
with self._get_conn() as conn:
|
| 320 |
conn.execute(
|
| 321 |
"""INSERT INTO usage_log
|
| 322 |
+
(api_key, tier, timestamp, endpoint,
|
| 323 |
+
request_body, response, error, processing_ms,
|
| 324 |
+
idempotency_key)
|
| 325 |
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 326 |
+
(record.api_key,
|
| 327 |
+
record.tier.value,
|
| 328 |
+
record.timestamp,
|
| 329 |
+
record.endpoint,
|
| 330 |
+
json.dumps(
|
| 331 |
+
record.request_body) if record.request_body else None,
|
| 332 |
+
json.dumps(
|
| 333 |
+
record.response) if record.response else None,
|
| 334 |
+
record.error,
|
| 335 |
+
record.processing_ms,
|
| 336 |
+
idempotency_key,
|
| 337 |
+
))
|
| 338 |
conn.commit()
|
| 339 |
except sqlite3.IntegrityError as e:
|
| 340 |
+
# Duplicate idempotency_key – already inserted by another
|
| 341 |
+
# concurrent request
|
| 342 |
if "UNIQUE constraint failed: usage_log.idempotency_key" in str(e):
|
| 343 |
+
return False, {"idempotent": True,
|
| 344 |
+
"message": "Already processed"}
|
| 345 |
raise
|
| 346 |
|
| 347 |
if idempotency_key:
|
| 348 |
self._mark_idempotent_key_used(idempotency_key)
|
| 349 |
+
# Removed stray # noqa: E501 comment that was wrongly indented here
|
| 350 |
return True, None
|
| 351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
# --------------------------------------------------------------------------
|
| 353 |
+
# Legacy interface (kept for compatibility but deprecated)
|
| 354 |
# --------------------------------------------------------------------------
|
| 355 |
+
def increment_usage_sync(
|
| 356 |
+
self,
|
| 357 |
+
record: UsageRecord,
|
| 358 |
+
idempotency_key: Optional[str] = None) -> bool:
|
| 359 |
+
"""
|
| 360 |
+
Synchronously record usage and increment counter.
|
| 361 |
+
Returns True if within quota and recorded, False otherwise.
|
| 362 |
+
This method now uses the atomic implementation.
|
| 363 |
+
"""
|
| 364 |
success, _ = self.consume_quota_and_log(record, idempotency_key)
|
| 365 |
return success
|
| 366 |
|
| 367 |
+
async def increment_usage_async(
|
| 368 |
+
self,
|
| 369 |
+
record: UsageRecord,
|
| 370 |
+
background_tasks: BackgroundTasks,
|
| 371 |
+
idempotency_key: Optional[str] = None
|
| 372 |
+
) -> bool:
|
| 373 |
+
"""
|
| 374 |
+
Asynchronously record usage using FastAPI BackgroundTasks.
|
| 375 |
+
Still does the atomic check synchronously, then schedules the insert.
|
| 376 |
+
"""
|
| 377 |
+
# First, do atomic quota check (synchronous) – we must ensure we don't double-consume.
|
| 378 |
+
# Because background tasks may run later, we still need to reserve quota now.
|
| 379 |
+
# Simplified: we call consume_quota_and_log synchronously – that defeats async benefit.
|
| 380 |
+
# Better to use a queue or Redis with background processing.
|
| 381 |
+
# For this fix, we'll use the sync method (blocking) but still support
|
| 382 |
+
# idempotency.
|
| 383 |
return self.increment_usage_sync(record, idempotency_key)
|
| 384 |
|
| 385 |
# --------------------------------------------------------------------------
|
| 386 |
+
# Quota inspection (non‑atomic, for display only)
|
| 387 |
# --------------------------------------------------------------------------
|
| 388 |
def get_remaining_quota(self, api_key: str, tier: Tier) -> Optional[int]:
|
| 389 |
+
"""Return remaining evaluations for the month (non‑atomic, for info only)."""
|
| 390 |
limit = tier.monthly_evaluation_limit
|
| 391 |
if limit is None:
|
| 392 |
return None
|
| 393 |
+
|
| 394 |
month = self._get_month_key()
|
| 395 |
if self._redis_client:
|
| 396 |
redis_key = f"arf:quota:{api_key}:{month}"
|
| 397 |
count = int(self._redis_client.get(redis_key) or 0)
|
| 398 |
return max(0, limit - count)
|
| 399 |
+
|
| 400 |
with self._get_conn() as conn:
|
| 401 |
row = conn.execute(
|
| 402 |
"SELECT count FROM monthly_counts WHERE api_key = ? AND year_month = ?",
|
|
|
|
| 406 |
return max(0, limit - count)
|
| 407 |
|
| 408 |
# --------------------------------------------------------------------------
|
| 409 |
+
# Audit and maintenance
|
| 410 |
# --------------------------------------------------------------------------
|
| 411 |
+
def get_audit_logs(
|
| 412 |
+
self,
|
| 413 |
+
api_key: str,
|
| 414 |
+
start_date: Optional[datetime] = None,
|
| 415 |
+
end_date: Optional[datetime] = None,
|
| 416 |
+
limit: int = 100,
|
| 417 |
+
) -> List[Dict[str, Any]]:
|
| 418 |
+
"""Retrieve audit logs for a given API key."""
|
| 419 |
query = "SELECT * FROM usage_log WHERE api_key = ?"
|
| 420 |
params = [api_key]
|
| 421 |
if start_date:
|
|
|
|
| 426 |
params.append(end_date.timestamp())
|
| 427 |
query += " ORDER BY timestamp DESC LIMIT ?"
|
| 428 |
params.append(limit)
|
| 429 |
+
|
| 430 |
with self._get_conn() as conn:
|
| 431 |
rows = conn.execute(query, params).fetchall()
|
| 432 |
return [dict(row) for row in rows]
|
| 433 |
|
| 434 |
def clean_old_logs(self):
|
| 435 |
+
"""Delete logs older than retention period for each tier, and old idempotency keys."""
|
| 436 |
with self._get_conn() as conn:
|
| 437 |
+
# Delete old usage logs
|
| 438 |
for tier in Tier:
|
| 439 |
retention_days = tier.audit_log_retention_days
|
| 440 |
+
if retention_days is None:
|
| 441 |
+
continue
|
| 442 |
cutoff = time.time() - retention_days * 86400
|
| 443 |
conn.execute(
|
| 444 |
"DELETE FROM usage_log WHERE tier = ? AND timestamp < ?",
|
| 445 |
(tier.value, cutoff)
|
| 446 |
)
|
| 447 |
+
# Delete idempotency keys older than 7 days
|
| 448 |
cutoff = time.time() - 7 * 86400
|
| 449 |
+
conn.execute(
|
| 450 |
+
"DELETE FROM idempotency_keys WHERE consumed_at < ?", (cutoff,))
|
| 451 |
conn.commit()
|
| 452 |
|
| 453 |
|
| 454 |
# --------------------------------------------------------------------------
|
| 455 |
+
# Global instance and FastAPI dependency (fail‑closed)
|
| 456 |
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
tracker: Optional[UsageTracker] = None
|
| 458 |
|
| 459 |
|
| 460 |
+
def init_tracker(
|
| 461 |
+
db_path: str = "arf_usage.db",
|
| 462 |
+
redis_url: Optional[str] = None):
|
| 463 |
+
"""Initialize the global tracker. Must be called before enforce_quota."""
|
| 464 |
global tracker
|
| 465 |
tracker = UsageTracker(db_path, redis_url)
|
| 466 |
|
| 467 |
|
| 468 |
def update_key_tier(api_key: str, new_tier: Tier) -> bool:
|
| 469 |
+
"""Globally accessible helper to update API key tier."""
|
| 470 |
if tracker is None:
|
| 471 |
return False
|
| 472 |
return tracker.update_api_key_tier(api_key, new_tier)
|
| 473 |
|
| 474 |
|
| 475 |
+
async def enforce_quota(request: Request, api_key: str = None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
"""
|
| 477 |
+
Dependency that checks API key and remaining quota.
|
| 478 |
+
FAILS CLOSED: if tracker not initialised, raises HTTP 503.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
"""
|
| 480 |
+
# P0 fix: No fallback that allows all requests
|
| 481 |
if tracker is None:
|
| 482 |
+
raise HTTPException(
|
| 483 |
+
status_code=503,
|
| 484 |
+
detail="Usage tracking service not initialised. Please contact administrator.")
|
| 485 |
+
|
| 486 |
+
# Extract API key from header or query
|
| 487 |
+
if api_key is None:
|
| 488 |
+
auth_header = request.headers.get("Authorization")
|
| 489 |
+
if auth_header and auth_header.startswith("Bearer "):
|
| 490 |
+
api_key = auth_header[7:]
|
| 491 |
+
else:
|
| 492 |
+
api_key = request.query_params.get("api_key")
|
| 493 |
|
|
|
|
| 494 |
if not api_key:
|
| 495 |
raise HTTPException(status_code=401, detail="Missing API key")
|
| 496 |
|
| 497 |
tier = tracker.get_tier(api_key)
|
| 498 |
if tier is None:
|
| 499 |
+
raise HTTPException(
|
| 500 |
+
status_code=403,
|
| 501 |
+
detail="Invalid or inactive API key")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
|
| 503 |
remaining = tracker.get_remaining_quota(api_key, tier)
|
| 504 |
if remaining is not None and remaining <= 0:
|
| 505 |
+
raise HTTPException(status_code=429,
|
| 506 |
+
detail="Monthly evaluation quota exceeded")
|
| 507 |
|
| 508 |
+
# Store in request state for later logging (optional)
|
| 509 |
+
request.state.api_key = api_key
|
| 510 |
+
request.state.tier = tier
|
| 511 |
+
return {"api_key": api_key, "tier": tier, "remaining": remaining}
|
app/database/models_intents.py
CHANGED
|
@@ -1,123 +1,50 @@
|
|
| 1 |
-
|
| 2 |
-
Database models for the ARF API Control Plane.
|
| 3 |
-
|
| 4 |
-
This module defines the SQLAlchemy ORM models for:
|
| 5 |
-
- Tenants (multi‑tenant isolation root)
|
| 6 |
-
- Intents (InfrastructureIntent evaluations)
|
| 7 |
-
- Outcomes (recorded results of executed intents)
|
| 8 |
-
- Beta state (conjugate Bayesian posteriors per tenant and category)
|
| 9 |
-
- Audit logs (immutable decision records for compliance)
|
| 10 |
-
|
| 11 |
-
All tables include a `tenant_id` column to enforce data partitioning.
|
| 12 |
-
|
| 13 |
-
API keys and usage/quota logs are tracked separately in
|
| 14 |
-
`app/core/usage_tracker.py` (SQLite, pepper-HMAC hashed) -- an
|
| 15 |
-
`APIKeyDB`/`UsageLogDB` pair used to live here as a second, unused,
|
| 16 |
-
plaintext-keyed parallel schema; removed 2026-08-23 since nothing
|
| 17 |
-
referenced them.
|
| 18 |
-
"""
|
| 19 |
-
|
| 20 |
-
import uuid
|
| 21 |
-
from sqlalchemy import (
|
| 22 |
-
Column, Integer, String, DateTime, Boolean, Text, JSON,
|
| 23 |
-
Float, ForeignKey, UniqueConstraint, Index
|
| 24 |
-
)
|
| 25 |
from sqlalchemy.orm import relationship
|
| 26 |
import datetime
|
| 27 |
from .base import Base
|
| 28 |
|
| 29 |
|
| 30 |
-
# ============================================================================
|
| 31 |
-
# Tenant table – root of multi‑tenancy
|
| 32 |
-
# ============================================================================
|
| 33 |
-
|
| 34 |
-
class TenantDB(Base):
|
| 35 |
-
"""
|
| 36 |
-
Represents a customer tenant (organisation). All other tables
|
| 37 |
-
reference this table via a foreign key `tenant_id`.
|
| 38 |
-
|
| 39 |
-
Attributes:
|
| 40 |
-
id (str): UUID of the tenant (primary key).
|
| 41 |
-
name (str): Human‑readable organisation name.
|
| 42 |
-
created_at (datetime): UTC timestamp of creation.
|
| 43 |
-
created_by (str, optional): Email or user ID of the creator.
|
| 44 |
-
"""
|
| 45 |
-
__tablename__ = "tenants"
|
| 46 |
-
|
| 47 |
-
id = Column(String(64), primary_key=True, index=True)
|
| 48 |
-
name = Column(String(256), nullable=False)
|
| 49 |
-
created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
|
| 50 |
-
created_by = Column(String(128), nullable=True)
|
| 51 |
-
|
| 52 |
-
# Relationships
|
| 53 |
-
intents = relationship("IntentDB", back_populates="tenant")
|
| 54 |
-
beta_states = relationship("BetaStateDB", back_populates="tenant")
|
| 55 |
-
audit_logs = relationship("DecisionAuditLogDB", back_populates="tenant")
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
# ============================================================================
|
| 59 |
-
# Intents (evaluations) – now tenant‑scoped
|
| 60 |
-
# ============================================================================
|
| 61 |
-
|
| 62 |
class IntentDB(Base):
|
| 63 |
-
"""
|
| 64 |
-
Stores each InfrastructureIntent evaluation request and its resulting
|
| 65 |
-
risk score. One‑to‑many with OutcomeDB.
|
| 66 |
-
|
| 67 |
-
Attributes:
|
| 68 |
-
id (int): Auto‑increment primary key.
|
| 69 |
-
deterministic_id (str): Client‑provided idempotency identifier (unique).
|
| 70 |
-
tenant_id (str): Tenant that owns this intent.
|
| 71 |
-
intent_type (str): Type of intent (e.g., "provision_resource").
|
| 72 |
-
payload (JSON): Original API request payload.
|
| 73 |
-
oss_payload (JSON): Canonical OSS intent representation.
|
| 74 |
-
environment (str, optional): Environment label (prod, staging, etc.).
|
| 75 |
-
created_at (datetime): UTC timestamp of evaluation.
|
| 76 |
-
evaluated_at (datetime, optional): When the risk engine processed it.
|
| 77 |
-
risk_score (str, optional): String representation of the risk score.
|
| 78 |
-
"""
|
| 79 |
__tablename__ = "intents"
|
| 80 |
-
|
| 81 |
id = Column(Integer, primary_key=True, index=True)
|
| 82 |
-
deterministic_id = Column(
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
| 84 |
intent_type = Column(String(64), nullable=False)
|
| 85 |
payload = Column(JSON, nullable=False)
|
| 86 |
oss_payload = Column(JSON, nullable=True)
|
| 87 |
environment = Column(String(32), nullable=True)
|
| 88 |
-
created_at = Column(
|
|
|
|
|
|
|
|
|
|
| 89 |
evaluated_at = Column(DateTime, nullable=True)
|
| 90 |
risk_score = Column(String(32), nullable=True)
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
|
| 96 |
|
| 97 |
class OutcomeDB(Base):
|
| 98 |
-
"""
|
| 99 |
-
Records the outcome (success/failure) of a previously evaluated intent.
|
| 100 |
-
Only one outcome per intent is allowed (unique constraint on intent_id).
|
| 101 |
-
|
| 102 |
-
Attributes:
|
| 103 |
-
id (int): Primary key.
|
| 104 |
-
intent_id (int): Foreign key to `intents.id`.
|
| 105 |
-
success (bool): Whether the executed action succeeded.
|
| 106 |
-
recorded_by (str, optional): Identity of the caller (e.g., API key owner).
|
| 107 |
-
notes (str, optional): Free‑text notes.
|
| 108 |
-
recorded_at (datetime): UTC timestamp.
|
| 109 |
-
idempotency_key (str, optional): Unique idempotency key for this outcome.
|
| 110 |
-
"""
|
| 111 |
__tablename__ = "intent_outcomes"
|
| 112 |
-
|
| 113 |
id = Column(Integer, primary_key=True, index=True)
|
| 114 |
-
intent_id = Column(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
success = Column(Boolean, nullable=False)
|
| 116 |
recorded_by = Column(String(128), nullable=True)
|
| 117 |
notes = Column(Text, nullable=True)
|
| 118 |
-
recorded_at = Column(
|
|
|
|
|
|
|
|
|
|
| 119 |
idempotency_key = Column(String(128), unique=True, nullable=True)
|
| 120 |
-
|
| 121 |
intent = relationship("IntentDB", back_populates="outcomes")
|
| 122 |
|
| 123 |
__table_args__ = (
|
|
@@ -125,81 +52,24 @@ class OutcomeDB(Base):
|
|
| 125 |
)
|
| 126 |
|
| 127 |
|
| 128 |
-
#
|
| 129 |
-
#
|
| 130 |
-
#
|
| 131 |
-
|
| 132 |
class BetaStateDB(Base):
|
| 133 |
"""
|
| 134 |
-
Stores the posterior parameters (α, β) of the
|
| 135 |
-
|
| 136 |
-
isolated per customer.
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
tenant_id (str): Tenant that owns this state.
|
| 141 |
-
category (str): ActionCategory value (e.g., "database", "compute").
|
| 142 |
-
alpha (float): α parameter of the Beta distribution.
|
| 143 |
-
beta (float): β parameter of the Beta distribution.
|
| 144 |
-
updated_at (datetime): Last update timestamp (auto‑set).
|
| 145 |
"""
|
| 146 |
__tablename__ = "beta_state"
|
| 147 |
|
| 148 |
id = Column(Integer, primary_key=True, index=True)
|
| 149 |
-
|
| 150 |
-
category = Column(String(32), nullable=False, index=True)
|
| 151 |
alpha = Column(Float, nullable=False)
|
| 152 |
beta = Column(Float, nullable=False)
|
| 153 |
-
updated_at = Column(
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
)
|
| 158 |
-
|
| 159 |
-
# Relationships
|
| 160 |
-
tenant = relationship("TenantDB", back_populates="beta_states")
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
# ============================================================================
|
| 164 |
-
# NEW: Audit log for compliance (immutable decision records)
|
| 165 |
-
# ============================================================================
|
| 166 |
-
|
| 167 |
-
class DecisionAuditLogDB(Base):
|
| 168 |
-
"""
|
| 169 |
-
Immutable, tamper‑evident record of every governance decision.
|
| 170 |
-
Designed for compliance (SOC2, ISO) and forensic analysis.
|
| 171 |
-
|
| 172 |
-
Attributes:
|
| 173 |
-
id (str): UUID primary key.
|
| 174 |
-
tenant_id (str): Tenant that owns the decision.
|
| 175 |
-
deterministic_id (str): Intent identifier (idempotency key).
|
| 176 |
-
timestamp (datetime): UTC decision time.
|
| 177 |
-
risk_score (float): Fused Bayesian risk score (0‑1).
|
| 178 |
-
action (str): Selected action (approve, deny, escalate).
|
| 179 |
-
justification (str): Human‑readable explanation.
|
| 180 |
-
memory_success_rate (float, optional): Memory‑based correction value.
|
| 181 |
-
memory_weight (float, optional): Weight assigned to memory.
|
| 182 |
-
counterfactual (JSON, optional): Structured counterfactual explanation.
|
| 183 |
-
trace_id (str, optional): OpenTelemetry trace ID for debugging.
|
| 184 |
-
signature (str, optional): Ed25519 signature for tamper‑proofing.
|
| 185 |
-
"""
|
| 186 |
-
__tablename__ = "decision_audit_log"
|
| 187 |
-
|
| 188 |
-
id = Column(String(64), primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 189 |
-
tenant_id = Column(String(64), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
| 190 |
-
deterministic_id = Column(String(64), nullable=False, index=True)
|
| 191 |
-
timestamp = Column(DateTime, default=datetime.datetime.utcnow, nullable=False, index=True)
|
| 192 |
-
risk_score = Column(Float, nullable=False)
|
| 193 |
-
action = Column(String(32), nullable=False)
|
| 194 |
-
justification = Column(Text, nullable=False)
|
| 195 |
-
memory_success_rate = Column(Float, nullable=True)
|
| 196 |
-
memory_weight = Column(Float, nullable=True)
|
| 197 |
-
counterfactual = Column(JSON, nullable=True)
|
| 198 |
-
trace_id = Column(String(128), nullable=True)
|
| 199 |
-
signature = Column(String(256), nullable=True)
|
| 200 |
-
|
| 201 |
-
__table_args__ = (
|
| 202 |
-
Index("idx_audit_tenant_time", "tenant_id", "timestamp"),
|
| 203 |
-
)
|
| 204 |
-
|
| 205 |
-
tenant = relationship("TenantDB", back_populates="audit_logs")
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, JSON, Float, ForeignKey, UniqueConstraint
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from sqlalchemy.orm import relationship
|
| 3 |
import datetime
|
| 4 |
from .base import Base
|
| 5 |
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
class IntentDB(Base):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
__tablename__ = "intents"
|
|
|
|
| 9 |
id = Column(Integer, primary_key=True, index=True)
|
| 10 |
+
deterministic_id = Column(
|
| 11 |
+
String(64),
|
| 12 |
+
unique=True,
|
| 13 |
+
index=True,
|
| 14 |
+
nullable=False)
|
| 15 |
intent_type = Column(String(64), nullable=False)
|
| 16 |
payload = Column(JSON, nullable=False)
|
| 17 |
oss_payload = Column(JSON, nullable=True)
|
| 18 |
environment = Column(String(32), nullable=True)
|
| 19 |
+
created_at = Column(
|
| 20 |
+
DateTime,
|
| 21 |
+
default=datetime.datetime.utcnow,
|
| 22 |
+
nullable=False)
|
| 23 |
evaluated_at = Column(DateTime, nullable=True)
|
| 24 |
risk_score = Column(String(32), nullable=True)
|
| 25 |
+
outcomes = relationship(
|
| 26 |
+
"OutcomeDB",
|
| 27 |
+
back_populates="intent",
|
| 28 |
+
cascade="all, delete-orphan")
|
| 29 |
|
| 30 |
|
| 31 |
class OutcomeDB(Base):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
__tablename__ = "intent_outcomes"
|
|
|
|
| 33 |
id = Column(Integer, primary_key=True, index=True)
|
| 34 |
+
intent_id = Column(
|
| 35 |
+
Integer,
|
| 36 |
+
ForeignKey(
|
| 37 |
+
"intents.id",
|
| 38 |
+
ondelete="CASCADE"),
|
| 39 |
+
nullable=False)
|
| 40 |
success = Column(Boolean, nullable=False)
|
| 41 |
recorded_by = Column(String(128), nullable=True)
|
| 42 |
notes = Column(Text, nullable=True)
|
| 43 |
+
recorded_at = Column(
|
| 44 |
+
DateTime,
|
| 45 |
+
default=datetime.datetime.utcnow,
|
| 46 |
+
nullable=False)
|
| 47 |
idempotency_key = Column(String(128), unique=True, nullable=True)
|
|
|
|
| 48 |
intent = relationship("IntentDB", back_populates="outcomes")
|
| 49 |
|
| 50 |
__table_args__ = (
|
|
|
|
| 52 |
)
|
| 53 |
|
| 54 |
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# NEW: Persistence for the conjugate Bayesian state
|
| 57 |
+
# ---------------------------------------------------------------------------
|
|
|
|
| 58 |
class BetaStateDB(Base):
|
| 59 |
"""
|
| 60 |
+
Stores the per‑category posterior parameters (α, β) of the BetaStore
|
| 61 |
+
so that online learning survives API restarts.
|
|
|
|
| 62 |
|
| 63 |
+
Only one row per ActionCategory is expected; the 'category' column is
|
| 64 |
+
unique. Updates are performed via merge / upsert.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
"""
|
| 66 |
__tablename__ = "beta_state"
|
| 67 |
|
| 68 |
id = Column(Integer, primary_key=True, index=True)
|
| 69 |
+
category = Column(String(32), unique=True, nullable=False, index=True)
|
|
|
|
| 70 |
alpha = Column(Float, nullable=False)
|
| 71 |
beta = Column(Float, nullable=False)
|
| 72 |
+
updated_at = Column(
|
| 73 |
+
DateTime,
|
| 74 |
+
default=datetime.datetime.utcnow,
|
| 75 |
+
onupdate=datetime.datetime.utcnow)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/database/models_onchain.py
DELETED
|
@@ -1,54 +0,0 @@
|
|
| 1 |
-
"""Database models for on-chain governance attestations.
|
| 2 |
-
|
| 3 |
-
``RiskAttestation.rationale_hash`` (see arf-onchain's ``AttestationLib.sol``
|
| 4 |
-
and enterprise's ``arf_enterprise.onchain.attestation``) is a ``keccak256``
|
| 5 |
-
digest anchored on Monad -- the chain deliberately never stores the rationale
|
| 6 |
-
text itself, only its hash, to keep operational detail about a customer's
|
| 7 |
-
infrastructure off a public ledger. That means the text has to live
|
| 8 |
-
somewhere off-chain, keyed by the same hash, or the anchored hash proves
|
| 9 |
-
nothing: nobody could ever produce the preimage to check it against.
|
| 10 |
-
|
| 11 |
-
This table is that store. It is intentionally separate from
|
| 12 |
-
``DecisionAuditLogDB`` (``models_intents.py``) rather than an extension of
|
| 13 |
-
it: that table is written for every governance decision, on-chain or not,
|
| 14 |
-
and already has its own signature column for a different purpose (Ed25519
|
| 15 |
-
intent-signing, not the secp256k1 EIP-712 signature the guard verifies).
|
| 16 |
-
Conflating the two would mean a column that is only sometimes meaningful
|
| 17 |
-
depending on whether the decision was ever attested on-chain.
|
| 18 |
-
"""
|
| 19 |
-
|
| 20 |
-
import uuid
|
| 21 |
-
import datetime
|
| 22 |
-
|
| 23 |
-
from sqlalchemy import Column, String, DateTime, Text
|
| 24 |
-
|
| 25 |
-
from .base import Base
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
class OnchainRationaleDB(Base):
|
| 29 |
-
"""The plaintext preimage of an anchored ``rationale_hash``.
|
| 30 |
-
|
| 31 |
-
Keyed by the hash itself (unique, indexed) rather than by an
|
| 32 |
-
auto-incrementing id: a lookup always starts from a hash read off-chain
|
| 33 |
-
(from `DecisionAnchored` or `AttestationIssued`), never from a row id
|
| 34 |
-
nothing on-chain knows about.
|
| 35 |
-
|
| 36 |
-
No ``tenant_id`` / foreign key to ``tenants``: an on-chain agent is
|
| 37 |
-
identified by its wallet address, not by this service's tenant concept,
|
| 38 |
-
and the two are not yet bridged. `evaluator_address` and `agent_address`
|
| 39 |
-
are recorded instead so a row can still be attributed and audited
|
| 40 |
-
without assuming a tenant relationship that may not exist.
|
| 41 |
-
"""
|
| 42 |
-
|
| 43 |
-
__tablename__ = "onchain_rationales"
|
| 44 |
-
|
| 45 |
-
id = Column(String(64), primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 46 |
-
rationale_hash = Column(
|
| 47 |
-
String(66), nullable=False, unique=True, index=True
|
| 48 |
-
) # "0x" + 64 hex chars
|
| 49 |
-
rationale = Column(Text, nullable=False)
|
| 50 |
-
agent_address = Column(String(42), nullable=True)
|
| 51 |
-
evaluator_address = Column(String(42), nullable=True)
|
| 52 |
-
created_at = Column(
|
| 53 |
-
DateTime, default=datetime.datetime.utcnow, nullable=False, index=True
|
| 54 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/main.py
CHANGED
|
@@ -9,8 +9,7 @@ enterprise clients, and monitoring infrastructure).
|
|
| 9 |
It is responsible for:
|
| 10 |
|
| 11 |
* **Lifetime management** of the Bayesian risk engine, policy engine,
|
| 12 |
-
semantic memory (RAG graph),
|
| 13 |
-
the stability controller and temporal reliability monitor.
|
| 14 |
* **Observability** via optional OpenTelemetry tracing and Prometheus metrics
|
| 15 |
(the latter exposed automatically by ``prometheus-fastapi-instrumentator``
|
| 16 |
on ``/metrics``).
|
|
@@ -25,7 +24,6 @@ All heavy components are loaded **lazily and best‑effort** – if a dependency
|
|
| 25 |
is missing the API continues to serve health‑check and status endpoints,
|
| 26 |
degrading gracefully rather than crashing.
|
| 27 |
"""
|
| 28 |
-
import hashlib
|
| 29 |
import logging
|
| 30 |
import os
|
| 31 |
import sys
|
|
@@ -35,9 +33,8 @@ import time as _time
|
|
| 35 |
from contextlib import asynccontextmanager
|
| 36 |
from typing import Dict
|
| 37 |
|
| 38 |
-
from fastapi import FastAPI
|
| 39 |
from fastapi.middleware.cors import CORSMiddleware
|
| 40 |
-
from fastapi.responses import JSONResponse
|
| 41 |
|
| 42 |
# ── Optional: Prometheus metrics ─────────────────────────────
|
| 43 |
try:
|
|
@@ -74,24 +71,14 @@ except ImportError:
|
|
| 74 |
RAGGraphMemory = None
|
| 75 |
MemoryConstants = None
|
| 76 |
|
| 77 |
-
# ── Stability & temporal monitors ───────────────────────────
|
| 78 |
-
from agentic_reliability_framework.core.governance.stability_controller import (
|
| 79 |
-
LyapunovStabilityController,
|
| 80 |
-
)
|
| 81 |
-
from agentic_reliability_framework.core.temporal_reliability import (
|
| 82 |
-
TemporalReliabilityMonitor,
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
# ── Usage tracker ────────────────────────────────────────────
|
| 86 |
-
from app.core import
|
| 87 |
-
from app.core.usage_tracker import init_tracker, Tier
|
| 88 |
|
| 89 |
from app.api import (
|
| 90 |
routes_governance,
|
| 91 |
routes_history,
|
| 92 |
routes_incidents,
|
| 93 |
routes_intents,
|
| 94 |
-
routes_onchain,
|
| 95 |
routes_risk,
|
| 96 |
routes_memory,
|
| 97 |
routes_admin,
|
|
@@ -122,12 +109,11 @@ async def lifespan(app: FastAPI):
|
|
| 122 |
|
| 123 |
Initialisation order:
|
| 124 |
1. Risk engine (Bayesian scoring + HMC).
|
| 125 |
-
2. Load persisted conjugate posterior state
|
| 126 |
3. OpenTelemetry tracing (console exporter by default).
|
| 127 |
4. Policy engine, RAG memory, and epistemic model.
|
| 128 |
-
5.
|
| 129 |
-
6.
|
| 130 |
-
7. Wilson confidence monitor for Rust enforcer canary promotion.
|
| 131 |
"""
|
| 132 |
logger.info("🚀 Starting ARF API Control Plane")
|
| 133 |
logger.debug(f"Python path: {sys.path}")
|
|
@@ -155,31 +141,35 @@ async def lifespan(app: FastAPI):
|
|
| 155 |
logger.exception("💥 Fatal error initializing RiskEngine")
|
| 156 |
raise RuntimeError("RiskEngine initialization failed") from e
|
| 157 |
|
| 158 |
-
# ── 2. Persisted Bayesian state
|
| 159 |
try:
|
| 160 |
from app.database.session import SessionLocal
|
| 161 |
-
from app.database.models_intents import BetaStateDB
|
| 162 |
from agentic_reliability_framework.core.governance.risk_engine import ActionCategory
|
| 163 |
|
| 164 |
db = SessionLocal()
|
| 165 |
try:
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
| 179 |
finally:
|
| 180 |
db.close()
|
| 181 |
except Exception as e:
|
| 182 |
-
logger.warning(
|
|
|
|
|
|
|
| 183 |
|
| 184 |
# ── 3. Tracing (OpenTelemetry) ─────────────────────────
|
| 185 |
try:
|
|
@@ -241,27 +231,12 @@ async def lifespan(app: FastAPI):
|
|
| 241 |
)
|
| 242 |
app.state.epistemic_model = None
|
| 243 |
app.state.epistemic_tokenizer = None
|
| 244 |
-
|
| 245 |
-
# ── 5. Stability controller & temporal monitor (v4.3.1) ─
|
| 246 |
-
try:
|
| 247 |
-
app.state.stability_controller = LyapunovStabilityController()
|
| 248 |
-
logger.info("✅ LyapunovStabilityController initialized.")
|
| 249 |
-
except Exception as e:
|
| 250 |
-
logger.warning(f"Stability controller initialization failed: {e}")
|
| 251 |
-
app.state.stability_controller = None
|
| 252 |
-
|
| 253 |
-
try:
|
| 254 |
-
app.state.temporal_monitor = TemporalReliabilityMonitor()
|
| 255 |
-
logger.info("✅ TemporalReliabilityMonitor initialized.")
|
| 256 |
-
except Exception as e:
|
| 257 |
-
logger.warning(f"Temporal monitor initialization failed: {e}")
|
| 258 |
-
app.state.temporal_monitor = None
|
| 259 |
else:
|
| 260 |
logger.warning(
|
| 261 |
-
"agentic_reliability_framework not installed; risk engine, policy engine, RAG
|
| 262 |
)
|
| 263 |
|
| 264 |
-
# ──
|
| 265 |
usage_tracking_disabled = (
|
| 266 |
os.getenv("ARF_USAGE_TRACKING", "true").lower() == "false"
|
| 267 |
)
|
|
@@ -272,65 +247,14 @@ async def lifespan(app: FastAPI):
|
|
| 272 |
db_path=os.getenv("ARF_USAGE_DB_PATH", "arf_usage.db"),
|
| 273 |
redis_url=os.getenv("ARF_REDIS_URL"),
|
| 274 |
)
|
| 275 |
-
|
| 276 |
-
# Constructing the tracker no longer touches Postgres, so warm
|
| 277 |
-
# it deliberately here: a few seconds of retrying is worth it to
|
| 278 |
-
# come up with the api_keys schema ready.
|
| 279 |
-
#
|
| 280 |
-
# A failure is logged and survived, NOT raised. Everything above
|
| 281 |
-
# in this lifespan already degrades that way -- the Beta-state
|
| 282 |
-
# loader logs a warning on the identical error and continues --
|
| 283 |
-
# and the asymmetry here is what turned a database blip into a
|
| 284 |
-
# total outage: raising crash-loops the process, which on Render
|
| 285 |
-
# exhausts the port-detection window, fails the deploy, and
|
| 286 |
-
# leaves the previous release serving. Starting degraded means
|
| 287 |
-
# the health endpoint answers, the deploy succeeds, and API
|
| 288 |
-
# requests get a clean 503 from enforce_quota until the database
|
| 289 |
-
# is reachable -- at which point they recover with no redeploy.
|
| 290 |
-
postgres_ready = usage_tracker.tracker.warm_up()
|
| 291 |
-
if not postgres_ready:
|
| 292 |
-
logger.error(
|
| 293 |
-
"Usage tracker started WITHOUT a Postgres connection: api_keys "
|
| 294 |
-
"is unreachable, so API-key validation and quota enforcement "
|
| 295 |
-
"will fail (503) until it recovers. Check that DATABASE_URL's "
|
| 296 |
-
"host resolves from this service, and that the database is "
|
| 297 |
-
"running and in the same region."
|
| 298 |
-
)
|
| 299 |
-
|
| 300 |
-
# Seed initial API keys from environment variable (for testing
|
| 301 |
-
# / demo). Skipped entirely when Postgres is unreachable: every
|
| 302 |
-
# get_or_create_api_key below is a write to api_keys, so
|
| 303 |
-
# attempting it would raise straight back into the handler that
|
| 304 |
-
# kills the process -- reintroducing the crash loop the warm-up
|
| 305 |
-
# above exists to prevent.
|
| 306 |
api_keys_json = os.getenv("ARF_API_KEYS", "{}")
|
| 307 |
-
if not postgres_ready and api_keys_json not in ("", "{}"):
|
| 308 |
-
logger.warning(
|
| 309 |
-
"Skipping ARF_API_KEYS seeding: Postgres is unreachable. "
|
| 310 |
-
"Seeded keys will not exist until the database recovers "
|
| 311 |
-
"and the service is restarted."
|
| 312 |
-
)
|
| 313 |
-
api_keys_json = "{}"
|
| 314 |
try:
|
| 315 |
api_keys = json.loads(api_keys_json)
|
| 316 |
for key, tier_str in api_keys.items():
|
| 317 |
try:
|
| 318 |
tier = Tier(tier_str.lower())
|
| 319 |
-
|
| 320 |
-
# -- tier was silently accepted as tenant_id, so
|
| 321 |
-
# every seeded key of the same tier collided onto
|
| 322 |
-
# one bogus tenant_id. These are demo/env-seeded
|
| 323 |
-
# keys with no real TenantDB row, but each still
|
| 324 |
-
# needs its own tenant_id to avoid cross-key
|
| 325 |
-
# contamination in tenant-scoped state elsewhere
|
| 326 |
-
# (BetaStateDB, IntentDB, decision audit log). A
|
| 327 |
-
# fixed-length key prefix isn't safe here: keys
|
| 328 |
-
# generated elsewhere in this codebase share an
|
| 329 |
-
# 8-char prefix ("sk_live_"/"sk_free_"), so a
|
| 330 |
-
# prefix-based id would collide the same way the
|
| 331 |
-
# original bug did. Hash the whole key instead.
|
| 332 |
-
tenant_id = "env-seed-" + hashlib.sha256(key.encode()).hexdigest()[:16]
|
| 333 |
-
usage_tracker.tracker.get_or_create_api_key(key, tenant_id=tenant_id, tier=tier)
|
| 334 |
logger.info(f"Seeded API key for tier {tier.value}")
|
| 335 |
except ValueError:
|
| 336 |
logger.warning(
|
|
@@ -340,52 +264,16 @@ async def lifespan(app: FastAPI):
|
|
| 340 |
logger.warning(
|
| 341 |
"ARF_API_KEYS environment variable is not valid JSON; skipping seeding."
|
| 342 |
)
|
| 343 |
-
app.state.usage_tracker =
|
| 344 |
-
|
| 345 |
-
logger.info("✅ Usage tracker ready.")
|
| 346 |
-
else:
|
| 347 |
-
logger.warning("⚠️ Usage tracker started in degraded mode (no Postgres).")
|
| 348 |
except Exception as e:
|
| 349 |
-
# Still fail closed on genuine configuration errors -- a missing
|
| 350 |
-
# or too-short ARF_KEY_PEPPER, an unset DATABASE_URL. Those never
|
| 351 |
-
# self-resolve, and starting without them would mean serving with
|
| 352 |
-
# broken API-key hashing. Database *reachability* is handled
|
| 353 |
-
# above and no longer reaches here.
|
| 354 |
logger.critical(f"Failed to initialise usage tracker: {e}")
|
| 355 |
raise RuntimeError("Usage tracker initialisation failed") from e
|
| 356 |
else:
|
| 357 |
logger.info("Usage tracking disabled by ARF_USAGE_TRACKING=false.")
|
| 358 |
app.state.usage_tracker = None
|
| 359 |
|
| 360 |
-
# ──
|
| 361 |
-
# Singleton for the same reason usage_tracker/risk_engine are: a fresh
|
| 362 |
-
# PostgresStore() per request would open a brand-new DB connection (and
|
| 363 |
-
# re-run its schema check) on every call to POST
|
| 364 |
-
# /intents/{id}/execute instead of reusing one across requests handled
|
| 365 |
-
# by the same worker. Only initialised when execution is actually
|
| 366 |
-
# opted into (ARF_ENABLE_EXECUTION=true) and arf_enterprise is
|
| 367 |
-
# importable -- always sets app.state.approval_store (to None if
|
| 368 |
-
# either condition isn't met) so downstream code never needs a
|
| 369 |
-
# hasattr/getattr guard.
|
| 370 |
-
app.state.approval_store = None
|
| 371 |
-
if os.getenv("ARF_ENABLE_EXECUTION", "false").lower() == "true":
|
| 372 |
-
try:
|
| 373 |
-
from arf_enterprise.store import ApprovalStore, PostgresStore
|
| 374 |
-
app.state.approval_store = ApprovalStore(PostgresStore())
|
| 375 |
-
logger.info("✅ Enterprise execution approval ledger ready.")
|
| 376 |
-
except ImportError:
|
| 377 |
-
logger.warning(
|
| 378 |
-
"ARF_ENABLE_EXECUTION=true but arf_enterprise is not installed; "
|
| 379 |
-
"POST /intents/{id}/execute will return 501."
|
| 380 |
-
)
|
| 381 |
-
except Exception as e:
|
| 382 |
-
logger.error(
|
| 383 |
-
"Failed to initialise the enterprise approval ledger (%s); "
|
| 384 |
-
"POST /intents/{id}/execute will fall back to boolean-trust "
|
| 385 |
-
"mode for approvals rather than failing startup entirely.", e
|
| 386 |
-
)
|
| 387 |
-
|
| 388 |
-
# ── 7. Wilson confidence monitor ──────────────────────────
|
| 389 |
try:
|
| 390 |
from app.services.wilson_monitor import update as wilson_update
|
| 391 |
from prometheus_client import REGISTRY
|
|
@@ -447,19 +335,6 @@ def create_app() -> FastAPI:
|
|
| 447 |
)
|
| 448 |
logger.debug("CORS middleware configured")
|
| 449 |
|
| 450 |
-
# ── Generic exception handler ────────────────────────────
|
| 451 |
-
# Defense-in-depth for anything that escapes a route's own try/except
|
| 452 |
-
# uncaught (HTTPException instances are unaffected -- FastAPI's own,
|
| 453 |
-
# more specific handler for those still takes precedence). Logs the
|
| 454 |
-
# real exception server-side and returns a generic message: routes
|
| 455 |
-
# that catch their own exceptions have each been fixed to do the same,
|
| 456 |
-
# but this exists so a bug that skips that pattern doesn't leak raw
|
| 457 |
-
# exception text (paths, internals, third-party SDK details) to callers.
|
| 458 |
-
@app.exception_handler(Exception)
|
| 459 |
-
async def unhandled_exception_handler(request: Request, exc: Exception):
|
| 460 |
-
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
| 461 |
-
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
| 462 |
-
|
| 463 |
# ── Rate limiter ──────────────────────────────────────────
|
| 464 |
if SLOWAPI_AVAILABLE:
|
| 465 |
app.state.limiter = limiter
|
|
@@ -492,9 +367,6 @@ def create_app() -> FastAPI:
|
|
| 492 |
app.include_router(
|
| 493 |
routes_governance.router, prefix="/api/v1", tags=["governance"]
|
| 494 |
)
|
| 495 |
-
app.include_router(
|
| 496 |
-
routes_onchain.router, prefix="/api/v1", tags=["onchain"]
|
| 497 |
-
)
|
| 498 |
app.include_router(
|
| 499 |
routes_memory.router, prefix="/v1/memory", tags=["memory"]
|
| 500 |
)
|
|
|
|
| 9 |
It is responsible for:
|
| 10 |
|
| 11 |
* **Lifetime management** of the Bayesian risk engine, policy engine,
|
| 12 |
+
semantic memory (RAG graph), and epistemic models.
|
|
|
|
| 13 |
* **Observability** via optional OpenTelemetry tracing and Prometheus metrics
|
| 14 |
(the latter exposed automatically by ``prometheus-fastapi-instrumentator``
|
| 15 |
on ``/metrics``).
|
|
|
|
| 24 |
is missing the API continues to serve health‑check and status endpoints,
|
| 25 |
degrading gracefully rather than crashing.
|
| 26 |
"""
|
|
|
|
| 27 |
import logging
|
| 28 |
import os
|
| 29 |
import sys
|
|
|
|
| 33 |
from contextlib import asynccontextmanager
|
| 34 |
from typing import Dict
|
| 35 |
|
| 36 |
+
from fastapi import FastAPI
|
| 37 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 38 |
|
| 39 |
# ── Optional: Prometheus metrics ─────────────────────────────
|
| 40 |
try:
|
|
|
|
| 71 |
RAGGraphMemory = None
|
| 72 |
MemoryConstants = None
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
# ── Usage tracker ────────────────────────────────────────────
|
| 75 |
+
from app.core.usage_tracker import init_tracker, tracker, Tier
|
|
|
|
| 76 |
|
| 77 |
from app.api import (
|
| 78 |
routes_governance,
|
| 79 |
routes_history,
|
| 80 |
routes_incidents,
|
| 81 |
routes_intents,
|
|
|
|
| 82 |
routes_risk,
|
| 83 |
routes_memory,
|
| 84 |
routes_admin,
|
|
|
|
| 109 |
|
| 110 |
Initialisation order:
|
| 111 |
1. Risk engine (Bayesian scoring + HMC).
|
| 112 |
+
2. Load persisted conjugate posterior state (``beta_state`` table).
|
| 113 |
3. OpenTelemetry tracing (console exporter by default).
|
| 114 |
4. Policy engine, RAG memory, and epistemic model.
|
| 115 |
+
5. Usage tracker (SQLite / Redis).
|
| 116 |
+
6. Wilson confidence monitor for Rust enforcer canary promotion.
|
|
|
|
| 117 |
"""
|
| 118 |
logger.info("🚀 Starting ARF API Control Plane")
|
| 119 |
logger.debug(f"Python path: {sys.path}")
|
|
|
|
| 141 |
logger.exception("💥 Fatal error initializing RiskEngine")
|
| 142 |
raise RuntimeError("RiskEngine initialization failed") from e
|
| 143 |
|
| 144 |
+
# ── 2. Persisted Bayesian state ───────────────────────
|
| 145 |
try:
|
| 146 |
from app.database.session import SessionLocal
|
| 147 |
+
from app.database.models_intents import BetaStateDB
|
| 148 |
from agentic_reliability_framework.core.governance.risk_engine import ActionCategory
|
| 149 |
|
| 150 |
db = SessionLocal()
|
| 151 |
try:
|
| 152 |
+
rows = db.query(BetaStateDB).all()
|
| 153 |
+
if rows:
|
| 154 |
+
state = {
|
| 155 |
+
ActionCategory(row.category): (row.alpha, row.beta)
|
| 156 |
+
for row in rows
|
| 157 |
+
}
|
| 158 |
+
app.state.risk_engine.beta_store.load_state(state)
|
| 159 |
+
logger.info(
|
| 160 |
+
"Loaded Bayesian posterior state from database (%d categories).",
|
| 161 |
+
len(state),
|
| 162 |
+
)
|
| 163 |
+
else:
|
| 164 |
+
logger.info(
|
| 165 |
+
"No persisted Bayesian state found; using default priors."
|
| 166 |
+
)
|
| 167 |
finally:
|
| 168 |
db.close()
|
| 169 |
except Exception as e:
|
| 170 |
+
logger.warning(
|
| 171 |
+
"Could not load Bayesian state from database: %s", e
|
| 172 |
+
)
|
| 173 |
|
| 174 |
# ── 3. Tracing (OpenTelemetry) ─────────────────────────
|
| 175 |
try:
|
|
|
|
| 231 |
)
|
| 232 |
app.state.epistemic_model = None
|
| 233 |
app.state.epistemic_tokenizer = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
else:
|
| 235 |
logger.warning(
|
| 236 |
+
"agentic_reliability_framework not installed; risk engine, policy engine, RAG disabled."
|
| 237 |
)
|
| 238 |
|
| 239 |
+
# ── 5. Usage tracker ──────────────────────────────────────
|
| 240 |
usage_tracking_disabled = (
|
| 241 |
os.getenv("ARF_USAGE_TRACKING", "true").lower() == "false"
|
| 242 |
)
|
|
|
|
| 247 |
db_path=os.getenv("ARF_USAGE_DB_PATH", "arf_usage.db"),
|
| 248 |
redis_url=os.getenv("ARF_REDIS_URL"),
|
| 249 |
)
|
| 250 |
+
# Seed initial API keys from environment variable (for testing / demo)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
api_keys_json = os.getenv("ARF_API_KEYS", "{}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
try:
|
| 253 |
api_keys = json.loads(api_keys_json)
|
| 254 |
for key, tier_str in api_keys.items():
|
| 255 |
try:
|
| 256 |
tier = Tier(tier_str.lower())
|
| 257 |
+
tracker.get_or_create_api_key(key, tier)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
logger.info(f"Seeded API key for tier {tier.value}")
|
| 259 |
except ValueError:
|
| 260 |
logger.warning(
|
|
|
|
| 264 |
logger.warning(
|
| 265 |
"ARF_API_KEYS environment variable is not valid JSON; skipping seeding."
|
| 266 |
)
|
| 267 |
+
app.state.usage_tracker = tracker
|
| 268 |
+
logger.info("✅ Usage tracker ready.")
|
|
|
|
|
|
|
|
|
|
| 269 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
logger.critical(f"Failed to initialise usage tracker: {e}")
|
| 271 |
raise RuntimeError("Usage tracker initialisation failed") from e
|
| 272 |
else:
|
| 273 |
logger.info("Usage tracking disabled by ARF_USAGE_TRACKING=false.")
|
| 274 |
app.state.usage_tracker = None
|
| 275 |
|
| 276 |
+
# ── 6. Wilson confidence monitor ──────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
try:
|
| 278 |
from app.services.wilson_monitor import update as wilson_update
|
| 279 |
from prometheus_client import REGISTRY
|
|
|
|
| 335 |
)
|
| 336 |
logger.debug("CORS middleware configured")
|
| 337 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
# ── Rate limiter ──────────────────────────────────────────
|
| 339 |
if SLOWAPI_AVAILABLE:
|
| 340 |
app.state.limiter = limiter
|
|
|
|
| 367 |
app.include_router(
|
| 368 |
routes_governance.router, prefix="/api/v1", tags=["governance"]
|
| 369 |
)
|
|
|
|
|
|
|
|
|
|
| 370 |
app.include_router(
|
| 371 |
routes_memory.router, prefix="/v1/memory", tags=["memory"]
|
| 372 |
)
|
app/models/infrastructure_intents.py
CHANGED
|
@@ -15,10 +15,6 @@ class BaseIntentRequest(BaseModel):
|
|
| 15 |
policy_violations: List[str] = Field(default_factory=list)
|
| 16 |
requester: str = Field(...)
|
| 17 |
provenance: Dict[str, Any] = Field(default_factory=dict)
|
| 18 |
-
# v4.3.1: optional skill identifier for Bayesian promotion gate
|
| 19 |
-
skill_id: Optional[str] = None
|
| 20 |
-
# v4.3.2: optional criticality parameter for dynamic gate tuning (Feature 3)
|
| 21 |
-
criticality: Optional[float] = Field(None, ge=0, le=1)
|
| 22 |
|
| 23 |
|
| 24 |
class ProvisionResourceRequest(BaseIntentRequest):
|
|
|
|
| 15 |
policy_violations: List[str] = Field(default_factory=list)
|
| 16 |
requester: str = Field(...)
|
| 17 |
provenance: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
class ProvisionResourceRequest(BaseIntentRequest):
|
app/services/intent_store.py
CHANGED
|
@@ -1,18 +1,3 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Intent storage service – persists evaluated intents to the database with tenant isolation.
|
| 3 |
-
|
| 4 |
-
This module provides two functions:
|
| 5 |
-
- `save_evaluated_intent`: stores a new intent or updates an existing one (idempotent on deterministic_id).
|
| 6 |
-
- `get_intent_by_deterministic_id`: retrieves an intent by its unique deterministic ID.
|
| 7 |
-
|
| 8 |
-
All operations are tenant‑aware: the `tenant_id` must be provided and is stored in the `IntentDB` record.
|
| 9 |
-
|
| 10 |
-
The function signatures have been extended to accept `tenant_id` as a mandatory parameter,
|
| 11 |
-
ensuring that every stored intent is correctly partitioned by tenant.
|
| 12 |
-
|
| 13 |
-
Extended docstring includes mathematical justification for idempotency and isolation.
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
import datetime
|
| 17 |
from sqlalchemy.orm import Session
|
| 18 |
from app.database.models_intents import IntentDB
|
|
@@ -22,69 +7,31 @@ from typing import Any, Dict, Optional
|
|
| 22 |
def save_evaluated_intent(
|
| 23 |
db: Session,
|
| 24 |
deterministic_id: str,
|
| 25 |
-
tenant_id: str,
|
| 26 |
intent_type: str,
|
| 27 |
api_payload: Dict[str, Any],
|
| 28 |
oss_payload: Dict[str, Any],
|
| 29 |
environment: str,
|
| 30 |
-
risk_score: float
|
| 31 |
) -> IntentDB:
|
| 32 |
-
"""
|
| 33 |
-
Store an evaluated infrastructure intent in the database.
|
| 34 |
-
|
| 35 |
-
Idempotent on `deterministic_id`: if an intent with the same ID already exists,
|
| 36 |
-
it is updated with the latest risk score and OSS payload instead of creating a duplicate.
|
| 37 |
-
The `tenant_id` is stored and used to enforce multi‑tenancy at the database level.
|
| 38 |
-
|
| 39 |
-
Parameters
|
| 40 |
-
----------
|
| 41 |
-
db : Session
|
| 42 |
-
SQLAlchemy database session.
|
| 43 |
-
deterministic_id : str
|
| 44 |
-
Unique identifier for the intent (idempotency key).
|
| 45 |
-
tenant_id : str
|
| 46 |
-
UUID of the tenant that owns this intent.
|
| 47 |
-
intent_type : str
|
| 48 |
-
Type of intent (e.g., "provision_resource").
|
| 49 |
-
api_payload : Dict[str, Any]
|
| 50 |
-
Original API request payload.
|
| 51 |
-
oss_payload : Dict[str, Any]
|
| 52 |
-
Canonical OSS intent representation.
|
| 53 |
-
environment : str
|
| 54 |
-
Deployment environment (e.g., "prod", "staging").
|
| 55 |
-
risk_score : float
|
| 56 |
-
Computed Bayesian risk score (0‑1).
|
| 57 |
-
|
| 58 |
-
Returns
|
| 59 |
-
-------
|
| 60 |
-
IntentDB
|
| 61 |
-
The stored or updated IntentDB object.
|
| 62 |
-
"""
|
| 63 |
-
# Check if intent already exists (idempotent)
|
| 64 |
existing = db.query(IntentDB).filter(
|
| 65 |
-
IntentDB.deterministic_id == deterministic_id
|
| 66 |
-
).one_or_none()
|
| 67 |
if existing:
|
| 68 |
-
# Update the existing record
|
| 69 |
existing.evaluated_at = datetime.datetime.utcnow()
|
| 70 |
existing.risk_score = str(risk_score)
|
| 71 |
existing.oss_payload = oss_payload
|
| 72 |
-
# Note: tenant_id cannot change; we assume it's the same as stored.
|
| 73 |
db.add(existing)
|
| 74 |
db.commit()
|
| 75 |
db.refresh(existing)
|
| 76 |
return existing
|
| 77 |
|
| 78 |
-
# Create a new intent record
|
| 79 |
intent = IntentDB(
|
| 80 |
-
tenant_id=tenant_id, # <-- CRITICAL: tenant isolation
|
| 81 |
deterministic_id=deterministic_id,
|
| 82 |
intent_type=intent_type,
|
| 83 |
payload=api_payload,
|
| 84 |
oss_payload=oss_payload,
|
| 85 |
environment=environment,
|
| 86 |
evaluated_at=datetime.datetime.utcnow(),
|
| 87 |
-
risk_score=str(risk_score)
|
| 88 |
)
|
| 89 |
db.add(intent)
|
| 90 |
db.commit()
|
|
@@ -93,24 +40,7 @@ def save_evaluated_intent(
|
|
| 93 |
|
| 94 |
|
| 95 |
def get_intent_by_deterministic_id(
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
) -> Optional[IntentDB]:
|
| 99 |
-
"""
|
| 100 |
-
Retrieve an intent record by its deterministic ID.
|
| 101 |
-
|
| 102 |
-
Parameters
|
| 103 |
-
----------
|
| 104 |
-
db : Session
|
| 105 |
-
SQLAlchemy database session.
|
| 106 |
-
deterministic_id : str
|
| 107 |
-
Unique identifier of the intent.
|
| 108 |
-
|
| 109 |
-
Returns
|
| 110 |
-
-------
|
| 111 |
-
Optional[IntentDB]
|
| 112 |
-
The intent if found, else None.
|
| 113 |
-
"""
|
| 114 |
return db.query(IntentDB).filter(
|
| 115 |
-
IntentDB.deterministic_id == deterministic_id
|
| 116 |
-
).one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import datetime
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from app.database.models_intents import IntentDB
|
|
|
|
| 7 |
def save_evaluated_intent(
|
| 8 |
db: Session,
|
| 9 |
deterministic_id: str,
|
|
|
|
| 10 |
intent_type: str,
|
| 11 |
api_payload: Dict[str, Any],
|
| 12 |
oss_payload: Dict[str, Any],
|
| 13 |
environment: str,
|
| 14 |
+
risk_score: float
|
| 15 |
) -> IntentDB:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
existing = db.query(IntentDB).filter(
|
| 17 |
+
IntentDB.deterministic_id == deterministic_id).one_or_none()
|
|
|
|
| 18 |
if existing:
|
|
|
|
| 19 |
existing.evaluated_at = datetime.datetime.utcnow()
|
| 20 |
existing.risk_score = str(risk_score)
|
| 21 |
existing.oss_payload = oss_payload
|
|
|
|
| 22 |
db.add(existing)
|
| 23 |
db.commit()
|
| 24 |
db.refresh(existing)
|
| 25 |
return existing
|
| 26 |
|
|
|
|
| 27 |
intent = IntentDB(
|
|
|
|
| 28 |
deterministic_id=deterministic_id,
|
| 29 |
intent_type=intent_type,
|
| 30 |
payload=api_payload,
|
| 31 |
oss_payload=oss_payload,
|
| 32 |
environment=environment,
|
| 33 |
evaluated_at=datetime.datetime.utcnow(),
|
| 34 |
+
risk_score=str(risk_score)
|
| 35 |
)
|
| 36 |
db.add(intent)
|
| 37 |
db.commit()
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
def get_intent_by_deterministic_id(
|
| 43 |
+
db: Session,
|
| 44 |
+
deterministic_id: str) -> Optional[IntentDB]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
return db.query(IntentDB).filter(
|
| 46 |
+
IntentDB.deterministic_id == deterministic_id).one_or_none()
|
|
|
app/services/outcome_service.py
CHANGED
|
@@ -1,6 +1,4 @@
|
|
| 1 |
-
"""Outcome recording with idempotency, no dummy fallbacks, and timezone-aware timestamps.
|
| 2 |
-
Also updates per‑skill reliability models when skill provenance is present (v4.3.1).
|
| 3 |
-
"""
|
| 4 |
|
| 5 |
import datetime
|
| 6 |
import logging
|
|
@@ -20,19 +18,11 @@ from app.database.models_intents import IntentDB, OutcomeDB, BetaStateDB
|
|
| 20 |
|
| 21 |
logger = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
-
# ── v4.3.1: optional skill registry integration ──────────────
|
| 24 |
-
try:
|
| 25 |
-
from agentic_reliability_framework.core.governance.skill_registry import SkillRegistry
|
| 26 |
-
SKILL_REGISTRY_AVAILABLE = True
|
| 27 |
-
except ImportError:
|
| 28 |
-
SkillRegistry = None
|
| 29 |
-
SKILL_REGISTRY_AVAILABLE = False
|
| 30 |
-
|
| 31 |
|
| 32 |
# ---------------------------------------------------------------------------
|
| 33 |
-
#
|
| 34 |
# ---------------------------------------------------------------------------
|
| 35 |
-
def _persist_beta_state(db: Session,
|
| 36 |
"""
|
| 37 |
Write the current Beta posterior parameters to the beta_state table.
|
| 38 |
This is called after every outcome update so that online learning
|
|
@@ -41,19 +31,8 @@ def _persist_beta_state(db: Session, tenant_id: str, risk_engine: RiskEngine) ->
|
|
| 41 |
try:
|
| 42 |
state = risk_engine.beta_store.get_state()
|
| 43 |
for cat, (alpha, beta) in state.items():
|
| 44 |
-
# Upsert
|
| 45 |
-
|
| 46 |
-
# so merge() would always attempt an INSERT and collide with the
|
| 47 |
-
# unique constraint on the second write for the same pair.
|
| 48 |
-
row = db.query(BetaStateDB).filter(
|
| 49 |
-
BetaStateDB.tenant_id == tenant_id,
|
| 50 |
-
BetaStateDB.category == cat.value,
|
| 51 |
-
).first()
|
| 52 |
-
if row is not None:
|
| 53 |
-
row.alpha = alpha
|
| 54 |
-
row.beta = beta
|
| 55 |
-
else:
|
| 56 |
-
db.add(BetaStateDB(tenant_id=tenant_id, category=cat.value, alpha=alpha, beta=beta))
|
| 57 |
db.commit()
|
| 58 |
logger.debug("Persisted Beta posterior parameters to database.")
|
| 59 |
except Exception as e:
|
|
@@ -83,16 +62,12 @@ def reconstruct_oss_intent_from_json(
|
|
| 83 |
|
| 84 |
def record_outcome(
|
| 85 |
db: Session,
|
| 86 |
-
tenant_id: str,
|
| 87 |
deterministic_id: str,
|
| 88 |
success: bool,
|
| 89 |
recorded_by: Optional[str],
|
| 90 |
notes: Optional[str],
|
| 91 |
risk_engine: RiskEngine,
|
| 92 |
idempotency_key: Optional[str] = None,
|
| 93 |
-
skill_id: Optional[str] = None, # v4.3.1
|
| 94 |
-
skill_version: Optional[int] = None, # v4.3.1
|
| 95 |
-
skill_registry: Optional["SkillRegistry"] = None, # v4.3.1
|
| 96 |
) -> OutcomeDB:
|
| 97 |
"""
|
| 98 |
Record an outcome for a previously evaluated intent.
|
|
@@ -103,52 +78,25 @@ def record_outcome(
|
|
| 103 |
No dummy intents are created. If the OSS intent cannot be reconstructed, the risk engine
|
| 104 |
is NOT updated – we log an error and still record the outcome.
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
Optional user or system identifier.
|
| 122 |
-
notes : str or None
|
| 123 |
-
Optional human-readable notes.
|
| 124 |
-
risk_engine : RiskEngine
|
| 125 |
-
ARF risk engine instance (may be updated).
|
| 126 |
-
idempotency_key : str or None
|
| 127 |
-
Optional caller-provided idempotency token.
|
| 128 |
-
skill_id : str or None (v4.3.1)
|
| 129 |
-
Identifier of the procedural skill that guided the action.
|
| 130 |
-
skill_version : int or None (v4.3.1)
|
| 131 |
-
Version number of that skill.
|
| 132 |
-
skill_registry : SkillRegistry or None (v4.3.1)
|
| 133 |
-
Optional skill registry instance to update per‑skill reliability.
|
| 134 |
-
|
| 135 |
-
Returns
|
| 136 |
-
-------
|
| 137 |
-
OutcomeDB
|
| 138 |
-
The recorded outcome object.
|
| 139 |
-
|
| 140 |
-
Raises
|
| 141 |
-
------
|
| 142 |
-
ValueError
|
| 143 |
-
If intent not found or reconstruction fails fatally.
|
| 144 |
-
OutcomeConflictError
|
| 145 |
-
If a conflicting outcome already exists.
|
| 146 |
"""
|
| 147 |
-
# 1. Fetch the original intent record
|
| 148 |
intent = db.query(IntentDB).filter(
|
| 149 |
-
IntentDB.deterministic_id == deterministic_id
|
| 150 |
-
IntentDB.tenant_id == tenant_id,
|
| 151 |
-
).one_or_none()
|
| 152 |
if not intent:
|
| 153 |
raise ValueError(f"Intent not found: {deterministic_id}")
|
| 154 |
|
|
@@ -215,7 +163,7 @@ def record_outcome(
|
|
| 215 |
# ----------------------------------------------------------------
|
| 216 |
# PERSISTENCE: after updating the conjugate posterior, write it
|
| 217 |
# ----------------------------------------------------------------
|
| 218 |
-
_persist_beta_state(db,
|
| 219 |
|
| 220 |
except Exception as e:
|
| 221 |
logger.exception(
|
|
@@ -228,18 +176,4 @@ def record_outcome(
|
|
| 228 |
deterministic_id
|
| 229 |
)
|
| 230 |
|
| 231 |
-
# 6. v4.3.1: Update per‑skill reliability model if provenance is provided
|
| 232 |
-
if SKILL_REGISTRY_AVAILABLE and skill_registry is not None and skill_id is not None and skill_version is not None:
|
| 233 |
-
try:
|
| 234 |
-
skill_registry.observe_outcome(skill_id, skill_version, success)
|
| 235 |
-
logger.debug(
|
| 236 |
-
"Skill reliability updated for '%s' v%d (success=%s)",
|
| 237 |
-
skill_id, skill_version, success,
|
| 238 |
-
)
|
| 239 |
-
except Exception as e:
|
| 240 |
-
logger.warning(
|
| 241 |
-
"Failed to update skill reliability for '%s' v%d: %s",
|
| 242 |
-
skill_id, skill_version, e, exc_info=True,
|
| 243 |
-
)
|
| 244 |
-
|
| 245 |
return outcome
|
|
|
|
| 1 |
+
"""Outcome recording with idempotency, no dummy fallbacks, and timezone-aware timestamps."""
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import datetime
|
| 4 |
import logging
|
|
|
|
| 18 |
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
# ---------------------------------------------------------------------------
|
| 23 |
+
# NEW: small helper to persist the conjugate posterior state
|
| 24 |
# ---------------------------------------------------------------------------
|
| 25 |
+
def _persist_beta_state(db: Session, risk_engine: RiskEngine) -> None:
|
| 26 |
"""
|
| 27 |
Write the current Beta posterior parameters to the beta_state table.
|
| 28 |
This is called after every outcome update so that online learning
|
|
|
|
| 31 |
try:
|
| 32 |
state = risk_engine.beta_store.get_state()
|
| 33 |
for cat, (alpha, beta) in state.items():
|
| 34 |
+
# Upsert: if the category already exists, update it
|
| 35 |
+
db.merge(BetaStateDB(category=cat.value, alpha=alpha, beta=beta))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
db.commit()
|
| 37 |
logger.debug("Persisted Beta posterior parameters to database.")
|
| 38 |
except Exception as e:
|
|
|
|
| 62 |
|
| 63 |
def record_outcome(
|
| 64 |
db: Session,
|
|
|
|
| 65 |
deterministic_id: str,
|
| 66 |
success: bool,
|
| 67 |
recorded_by: Optional[str],
|
| 68 |
notes: Optional[str],
|
| 69 |
risk_engine: RiskEngine,
|
| 70 |
idempotency_key: Optional[str] = None,
|
|
|
|
|
|
|
|
|
|
| 71 |
) -> OutcomeDB:
|
| 72 |
"""
|
| 73 |
Record an outcome for a previously evaluated intent.
|
|
|
|
| 78 |
No dummy intents are created. If the OSS intent cannot be reconstructed, the risk engine
|
| 79 |
is NOT updated – we log an error and still record the outcome.
|
| 80 |
|
| 81 |
+
Args:
|
| 82 |
+
db: SQLAlchemy session.
|
| 83 |
+
deterministic_id: Unique identifier of the original intent.
|
| 84 |
+
success: Whether the action succeeded (True) or failed (False).
|
| 85 |
+
recorded_by: Optional user or system identifier.
|
| 86 |
+
notes: Optional human-readable notes.
|
| 87 |
+
risk_engine: ARF risk engine instance (may be updated).
|
| 88 |
+
idempotency_key: Optional caller-provided idempotency token.
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
The recorded OutcomeDB object.
|
| 92 |
+
|
| 93 |
+
Raises:
|
| 94 |
+
ValueError: If intent not found or reconstruction fails fatally.
|
| 95 |
+
OutcomeConflictError: If a conflicting outcome already exists.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
"""
|
| 97 |
+
# 1. Fetch the original intent record
|
| 98 |
intent = db.query(IntentDB).filter(
|
| 99 |
+
IntentDB.deterministic_id == deterministic_id).one_or_none()
|
|
|
|
|
|
|
| 100 |
if not intent:
|
| 101 |
raise ValueError(f"Intent not found: {deterministic_id}")
|
| 102 |
|
|
|
|
| 163 |
# ----------------------------------------------------------------
|
| 164 |
# PERSISTENCE: after updating the conjugate posterior, write it
|
| 165 |
# ----------------------------------------------------------------
|
| 166 |
+
_persist_beta_state(db, risk_engine)
|
| 167 |
|
| 168 |
except Exception as e:
|
| 169 |
logger.exception(
|
|
|
|
| 176 |
deterministic_id
|
| 177 |
)
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
return outcome
|
app/services/risk_service.py
CHANGED
|
@@ -1,12 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
-
Risk service – integrates ARF
|
| 3 |
-
Deterministic, no random fallbacks, explicit error handling.
|
| 4 |
-
|
| 5 |
-
Version: 2026-
|
| 6 |
-
skill context injection, and full HealingIntent serialisation.
|
| 7 |
-
v4.3.1 – healing decision now optionally incorporates skill reliability
|
| 8 |
-
for Bayesian utility‑aware action selection.
|
| 9 |
-
v4.3.2 – passes criticality parameter for dynamic gate tuning (Feature 3).
|
| 10 |
"""
|
| 11 |
|
| 12 |
import json
|
|
@@ -23,14 +19,6 @@ from agentic_reliability_framework.core.decision.decision_engine import Decision
|
|
| 23 |
from agentic_reliability_framework.runtime.memory.rag_graph import RAGGraphMemory
|
| 24 |
from agentic_reliability_framework.core.research.eclipse_probe import compute_epistemic_risk
|
| 25 |
|
| 26 |
-
# ── Governance loop integration ──────────────────────────────
|
| 27 |
-
from agentic_reliability_framework.core.governance.governance_loop import GovernanceLoop
|
| 28 |
-
from agentic_reliability_framework.core.governance.cost_estimator import CostEstimator
|
| 29 |
-
from agentic_reliability_framework.core.governance.policies import PolicyEvaluator, allow_all
|
| 30 |
-
from agentic_reliability_framework.core.governance.stability_controller import LyapunovStabilityController
|
| 31 |
-
from agentic_reliability_framework.core.temporal_reliability import TemporalReliabilityMonitor
|
| 32 |
-
from agentic_reliability_framework.core.governance.healing_intent import HealingIntent
|
| 33 |
-
|
| 34 |
# ── optional tracing ─────────────────────────────────────────
|
| 35 |
try:
|
| 36 |
from opentelemetry import trace
|
|
@@ -75,6 +63,7 @@ if os.getenv("ARF_USE_RUST_ENFORCER", "false").lower() == "true":
|
|
| 75 |
pass
|
| 76 |
|
| 77 |
# Default OSS policy tree – mirrors the hard‑coded rules in the Python PolicyEvaluator
|
|
|
|
| 78 |
_OSS_POLICY_TREE_JSON = json.dumps({
|
| 79 |
"And": [
|
| 80 |
{"Atomic": {"RegionAllowed": {"allowed_regions": ["eastus"]}}},
|
|
@@ -87,7 +76,7 @@ _OSS_POLICY_TREE_JSON = json.dumps({
|
|
| 87 |
|
| 88 |
|
| 89 |
def _ensure_rust_evaluator() -> bool:
|
| 90 |
-
"""Lazy initialise the Rust policy evaluator.
|
| 91 |
global _rust_evaluator, _rust_policy_json
|
| 92 |
if _rust_evaluator is not None:
|
| 93 |
return True
|
|
@@ -109,29 +98,25 @@ def evaluate_intent(
|
|
| 109 |
engine: RiskEngine,
|
| 110 |
intent: InfrastructureIntent,
|
| 111 |
cost_estimate: Optional[float],
|
| 112 |
-
policy_violations: List[str]
|
| 113 |
-
tenant_id: Optional[str] = None,
|
| 114 |
) -> dict:
|
| 115 |
"""
|
| 116 |
Evaluate an infrastructure intent using the Bayesian risk engine.
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
|
| 122 |
Parameters
|
| 123 |
----------
|
| 124 |
engine : RiskEngine
|
| 125 |
-
Initialised ARF Bayesian risk engine
|
| 126 |
intent : InfrastructureIntent
|
| 127 |
The infrastructure request to evaluate.
|
| 128 |
cost_estimate : float or None
|
| 129 |
Estimated monthly cost (used by cost‑threshold policies).
|
| 130 |
policy_violations : list[str]
|
| 131 |
Pre‑computed policy violation strings (from the Python evaluator).
|
| 132 |
-
tenant_id : str, optional
|
| 133 |
-
Tenant UUID. If provided, the risk engine will use tenant‑specific
|
| 134 |
-
conjugate state. Required for multi‑tenant deployments.
|
| 135 |
|
| 136 |
Returns
|
| 137 |
-------
|
|
@@ -143,8 +128,6 @@ def evaluate_intent(
|
|
| 143 |
if OTEL_AVAILABLE and _tracer:
|
| 144 |
span = _tracer.start_span("risk_service.evaluate_intent")
|
| 145 |
span.set_attribute("intent_type", type(intent).__name__)
|
| 146 |
-
if tenant_id:
|
| 147 |
-
span.set_attribute("tenant_id", tenant_id)
|
| 148 |
|
| 149 |
# ── Shadow Rust enforcer (best‑effort, non‑blocking) ──────
|
| 150 |
if _RUST_ENFORCER_AVAILABLE and _ensure_rust_evaluator():
|
|
@@ -155,7 +138,6 @@ def evaluate_intent(
|
|
| 155 |
"region": getattr(intent, "region", None),
|
| 156 |
"resource_type": getattr(intent, "resource_type", None),
|
| 157 |
"permission_level": getattr(intent, "permission_level", None),
|
| 158 |
-
"tenant_id": tenant_id,
|
| 159 |
"extra": {}
|
| 160 |
}
|
| 161 |
rust_raw = _rust_evaluator.evaluate(
|
|
@@ -167,7 +149,7 @@ def evaluate_intent(
|
|
| 167 |
_RUST_AGREEMENT.labels(result="agreed" if agreed else "diverged").inc()
|
| 168 |
if not agreed:
|
| 169 |
msg = (
|
| 170 |
-
|
| 171 |
f"Rust={sorted(rust_violations)} Python={sorted(policy_violations)}"
|
| 172 |
)
|
| 173 |
logger.warning(msg)
|
|
@@ -180,14 +162,19 @@ def evaluate_intent(
|
|
| 180 |
logger.debug("Rust enforcer shadow evaluation failed: %s", exc)
|
| 181 |
|
| 182 |
# ── Core risk evaluation ──────────────────────────────────
|
| 183 |
-
try:
|
| 184 |
-
if hasattr(engine, "set_tenant"):
|
| 185 |
-
engine.set_tenant(tenant_id)
|
| 186 |
-
elif tenant_id:
|
| 187 |
-
logger.warning(
|
| 188 |
-
"RiskEngine does not yet support tenant_id; evaluations will be shared across tenants."
|
| 189 |
-
)
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
score, explanation, contributions = engine.calculate_risk(
|
| 192 |
intent=intent,
|
| 193 |
cost_estimate=cost_estimate,
|
|
@@ -216,174 +203,6 @@ def evaluate_intent(
|
|
| 216 |
}
|
| 217 |
|
| 218 |
|
| 219 |
-
def evaluate_intent_full(
|
| 220 |
-
intent: InfrastructureIntent,
|
| 221 |
-
*,
|
| 222 |
-
risk_engine: RiskEngine,
|
| 223 |
-
cost_estimator: Optional[CostEstimator] = None,
|
| 224 |
-
policy_evaluator: Optional[PolicyEvaluator] = None,
|
| 225 |
-
memory: Optional[RAGGraphMemory] = None,
|
| 226 |
-
enable_epistemic: bool = False,
|
| 227 |
-
hallucination_probe: Optional[Any] = None,
|
| 228 |
-
predictive_engine: Optional[Any] = None,
|
| 229 |
-
business_calculator: Optional[Any] = None,
|
| 230 |
-
use_rust_enforcer: bool = False,
|
| 231 |
-
stability_controller: Optional[LyapunovStabilityController] = None,
|
| 232 |
-
temporal_monitor: Optional[TemporalReliabilityMonitor] = None,
|
| 233 |
-
tenant_id: Optional[str] = None,
|
| 234 |
-
skill_id: Optional[str] = None,
|
| 235 |
-
skill_registry: Optional[Any] = None,
|
| 236 |
-
context_extra: Optional[Dict[str, Any]] = None,
|
| 237 |
-
criticality: Optional[float] = None, # v4.3.2
|
| 238 |
-
) -> Dict[str, Any]:
|
| 239 |
-
"""
|
| 240 |
-
Run the full governance loop and return a structured response containing
|
| 241 |
-
the serialised HealingIntent with Bayesian skill posterior parameters.
|
| 242 |
-
|
| 243 |
-
If stability_controller or temporal_monitor are None (the default),
|
| 244 |
-
the governance loop will simply skip those checks. Pass stateful
|
| 245 |
-
instances from the app state to accumulate cross‑request state.
|
| 246 |
-
|
| 247 |
-
Parameters
|
| 248 |
-
----------
|
| 249 |
-
intent : InfrastructureIntent
|
| 250 |
-
The original infrastructure request.
|
| 251 |
-
risk_engine : RiskEngine
|
| 252 |
-
Bayesian risk engine (tenant‑aware).
|
| 253 |
-
cost_estimator : CostEstimator, optional
|
| 254 |
-
Monthly cost estimator; a default instance is created if None.
|
| 255 |
-
policy_evaluator : PolicyEvaluator, optional
|
| 256 |
-
Policy tree evaluator; defaults to `allow_all` if None.
|
| 257 |
-
memory : RAGGraphMemory, optional
|
| 258 |
-
Semantic memory for similar‑incident retrieval.
|
| 259 |
-
enable_epistemic : bool
|
| 260 |
-
Whether to run the ECLIPSE hallucination probe and CUDL attribution.
|
| 261 |
-
hallucination_probe : HallucinationRisk, optional
|
| 262 |
-
Pre‑configured probe instance.
|
| 263 |
-
predictive_engine : SimplePredictiveEngine, optional
|
| 264 |
-
Time‑series forecasting engine.
|
| 265 |
-
business_calculator : BusinessImpactCalculator, optional
|
| 266 |
-
Revenue impact estimator.
|
| 267 |
-
use_rust_enforcer : bool
|
| 268 |
-
Whether to run the Rust policy evaluator in shadow mode.
|
| 269 |
-
stability_controller : LyapunovStabilityController, optional
|
| 270 |
-
Passive stability monitor; if None, stability checks are skipped.
|
| 271 |
-
temporal_monitor : TemporalReliabilityMonitor, optional
|
| 272 |
-
Drift detector; if None, drift detection is skipped.
|
| 273 |
-
tenant_id : str, optional
|
| 274 |
-
Tenant UUID for multi‑tenant state.
|
| 275 |
-
skill_id : str, optional
|
| 276 |
-
Skill identifier; if provided, the skill's current posterior
|
| 277 |
-
parameters are injected into the governance loop.
|
| 278 |
-
skill_registry : SkillRegistry, optional
|
| 279 |
-
Instance of the skill registry (required if skill_id is given).
|
| 280 |
-
context_extra : dict, optional
|
| 281 |
-
Additional key‑value pairs to merge into the loop context.
|
| 282 |
-
criticality : float, optional
|
| 283 |
-
Criticality of the operation (0 = low, 1 = critical). Passed to the
|
| 284 |
-
governance loop for dynamic gate threshold tuning (v4.3.2).
|
| 285 |
-
|
| 286 |
-
Returns
|
| 287 |
-
-------
|
| 288 |
-
dict
|
| 289 |
-
Keys:
|
| 290 |
-
- risk_score : float
|
| 291 |
-
- explanation : str
|
| 292 |
-
- contributions : dict (empty; full trace is in healing_intent)
|
| 293 |
-
- healing_intent : dict (serialised HealingIntent)
|
| 294 |
-
- recommended_action : str
|
| 295 |
-
- deterministic_id : str
|
| 296 |
-
"""
|
| 297 |
-
t0 = time.monotonic()
|
| 298 |
-
span = None
|
| 299 |
-
if OTEL_AVAILABLE and _tracer:
|
| 300 |
-
span = _tracer.start_span("risk_service.evaluate_intent_full")
|
| 301 |
-
span.set_attribute("intent_type", type(intent).__name__)
|
| 302 |
-
if tenant_id:
|
| 303 |
-
span.set_attribute("tenant_id", tenant_id)
|
| 304 |
-
|
| 305 |
-
# Default components if not provided
|
| 306 |
-
if policy_evaluator is None:
|
| 307 |
-
policy_evaluator = PolicyEvaluator(allow_all())
|
| 308 |
-
if cost_estimator is None:
|
| 309 |
-
cost_estimator = CostEstimator()
|
| 310 |
-
# stability_controller and temporal_monitor are NOT defaulted here;
|
| 311 |
-
# they remain None unless explicitly passed. The GovernanceLoop will skip
|
| 312 |
-
# those checks gracefully.
|
| 313 |
-
|
| 314 |
-
loop = GovernanceLoop(
|
| 315 |
-
policy_evaluator=policy_evaluator,
|
| 316 |
-
cost_estimator=cost_estimator,
|
| 317 |
-
risk_engine=risk_engine,
|
| 318 |
-
memory=memory,
|
| 319 |
-
enable_epistemic=enable_epistemic,
|
| 320 |
-
hallucination_probe=hallucination_probe,
|
| 321 |
-
predictive_engine=predictive_engine,
|
| 322 |
-
business_calculator=business_calculator,
|
| 323 |
-
use_rust_enforcer=use_rust_enforcer,
|
| 324 |
-
stability_controller=stability_controller,
|
| 325 |
-
temporal_monitor=temporal_monitor,
|
| 326 |
-
)
|
| 327 |
-
|
| 328 |
-
# ── Build context with skill posterior parameters ─────────
|
| 329 |
-
context: Dict[str, Any] = dict(context_extra) if context_extra else {}
|
| 330 |
-
if skill_id and skill_registry is not None:
|
| 331 |
-
try:
|
| 332 |
-
# Fetch the latest version for the skill
|
| 333 |
-
versions = skill_registry.list_skill_versions(skill_id)
|
| 334 |
-
version = versions[-1] if versions else 1
|
| 335 |
-
# Use public get_model() instead of direct _models access
|
| 336 |
-
model = skill_registry.get_model(skill_id, version)
|
| 337 |
-
if model is not None:
|
| 338 |
-
alpha = model.alpha
|
| 339 |
-
beta = model.beta
|
| 340 |
-
reliability = model.mean()
|
| 341 |
-
else:
|
| 342 |
-
# Use default prior if no model exists yet
|
| 343 |
-
alpha = skill_registry.default_prior_alpha
|
| 344 |
-
beta = skill_registry.default_prior_beta
|
| 345 |
-
reliability = alpha / (alpha + beta)
|
| 346 |
-
context.update({
|
| 347 |
-
"skill_id": skill_id,
|
| 348 |
-
"skill_version": version,
|
| 349 |
-
"skill_ate": skill_registry.get_ate(skill_id, version),
|
| 350 |
-
"skill_reliability_score": reliability,
|
| 351 |
-
"skill_alpha": alpha,
|
| 352 |
-
"skill_beta": beta,
|
| 353 |
-
})
|
| 354 |
-
except Exception as e:
|
| 355 |
-
logger.warning("Failed to inject skill context for '%s': %s", skill_id, e)
|
| 356 |
-
|
| 357 |
-
# v4.3.2: inject criticality into context for dynamic gate tuning
|
| 358 |
-
if criticality is not None:
|
| 359 |
-
context["criticality"] = criticality
|
| 360 |
-
|
| 361 |
-
# ── Execute governance loop ───────────────────────────────
|
| 362 |
-
healing_intent: HealingIntent = loop.run(intent, context=context)
|
| 363 |
-
healing_dict = healing_intent.to_dict(include_advisory_context=True)
|
| 364 |
-
|
| 365 |
-
risk_score = healing_intent.risk_score or 0.0
|
| 366 |
-
explanation = healing_intent.justification or ""
|
| 367 |
-
|
| 368 |
-
# ── Metrics & span finalisation ───────────────────────────
|
| 369 |
-
_EVAL_COUNTER.labels(engine="governance_loop", status="success").inc()
|
| 370 |
-
_EVAL_DURATION.labels(engine="governance_loop").observe(time.monotonic() - t0)
|
| 371 |
-
|
| 372 |
-
if span:
|
| 373 |
-
span.set_attribute("risk_score", risk_score)
|
| 374 |
-
span.set_attribute("recommended_action", healing_dict.get("recommended_action"))
|
| 375 |
-
span.end()
|
| 376 |
-
|
| 377 |
-
return {
|
| 378 |
-
"risk_score": risk_score,
|
| 379 |
-
"explanation": explanation,
|
| 380 |
-
"contributions": {}, # full trace is in healing_intent
|
| 381 |
-
"healing_intent": healing_dict,
|
| 382 |
-
"recommended_action": healing_dict.get("recommended_action"),
|
| 383 |
-
"deterministic_id": healing_intent.deterministic_id,
|
| 384 |
-
}
|
| 385 |
-
|
| 386 |
-
|
| 387 |
def evaluate_healing_decision(
|
| 388 |
event: ReliabilityEvent,
|
| 389 |
policy_engine: PolicyEngine,
|
|
@@ -391,26 +210,10 @@ def evaluate_healing_decision(
|
|
| 391 |
rag_graph: Optional[RAGGraphMemory] = None,
|
| 392 |
model=None,
|
| 393 |
tokenizer=None,
|
| 394 |
-
tenant_id: Optional[str] = None,
|
| 395 |
-
# ── v4.3.1: skill context ──────────────────────────────────
|
| 396 |
-
skill_id: Optional[str] = None,
|
| 397 |
-
skill_version: Optional[int] = None,
|
| 398 |
-
skill_registry: Optional[Any] = None,
|
| 399 |
) -> Dict[str, Any]:
|
| 400 |
"""
|
| 401 |
Evaluate healing actions for a given reliability event using decision‑theoretic selection.
|
| 402 |
-
Includes epistemic risk signals from the eclipse probe
|
| 403 |
-
information to bias the utility towards actions from trusted skills.
|
| 404 |
-
|
| 405 |
-
The utility of each candidate action a is extended with two additional terms:
|
| 406 |
-
|
| 407 |
-
U(a) = U_base(a) + w_skill · μ_skill − w_σ · σ_skill
|
| 408 |
-
|
| 409 |
-
where μ_skill = α/(α+β) is the posterior mean reliability of the skill that
|
| 410 |
-
authored the action, and σ_skill = sqrt(αβ / ((α+β)²(α+β+1))) is its
|
| 411 |
-
posterior standard deviation. These terms are computed from the conjugate
|
| 412 |
-
Beta posterior tracked by the SkillRegistry. When no skill context is
|
| 413 |
-
provided, the utility falls back to the original formulation.
|
| 414 |
|
| 415 |
Parameters
|
| 416 |
----------
|
|
@@ -419,43 +222,32 @@ def evaluate_healing_decision(
|
|
| 419 |
policy_engine : PolicyEngine
|
| 420 |
The ARF healing policy engine with configured policies.
|
| 421 |
decision_engine : DecisionEngine, optional
|
| 422 |
-
If omitted, a default instance is created.
|
| 423 |
-
(its internal skill registry is not modified).
|
| 424 |
rag_graph : RAGGraphMemory, optional
|
| 425 |
Semantic memory for similar incident retrieval.
|
| 426 |
model, tokenizer : optional
|
| 427 |
HuggingFace model and tokenizer for epistemic risk computation.
|
| 428 |
-
tenant_id : str, optional
|
| 429 |
-
Tenant UUID for logging and metrics.
|
| 430 |
-
skill_id : str, optional
|
| 431 |
-
Skill identifier to incorporate into utility.
|
| 432 |
-
skill_version : int, optional
|
| 433 |
-
Version of the skill.
|
| 434 |
-
skill_registry : SkillRegistry, optional
|
| 435 |
-
Registry to fetch the skill's posterior parameters.
|
| 436 |
|
| 437 |
Returns
|
| 438 |
-------
|
| 439 |
dict
|
| 440 |
Keys: risk_score, selected_action, expected_utility, alternatives,
|
| 441 |
-
explanation, epistemic_signals
|
| 442 |
"""
|
| 443 |
t0 = time.monotonic()
|
| 444 |
span = None
|
| 445 |
if OTEL_AVAILABLE and _tracer:
|
| 446 |
span = _tracer.start_span("risk_service.evaluate_healing")
|
| 447 |
span.set_attribute("component", event.component)
|
| 448 |
-
if tenant_id:
|
| 449 |
-
span.set_attribute("tenant_id", tenant_id)
|
| 450 |
|
| 451 |
# If decision_engine not provided, try to get from policy_engine
|
| 452 |
if decision_engine is None and hasattr(policy_engine, 'decision_engine'):
|
| 453 |
decision_engine = policy_engine.decision_engine
|
| 454 |
|
| 455 |
-
# If still None, create a minimal one (global stats only)
|
| 456 |
if decision_engine is None:
|
| 457 |
logger.debug("No DecisionEngine provided; creating default instance")
|
| 458 |
-
decision_engine = DecisionEngine(rag_graph=rag_graph
|
| 459 |
|
| 460 |
# Get raw candidate actions (by temporarily disabling decision engine)
|
| 461 |
orig_use = policy_engine.use_decision_engine
|
|
@@ -472,7 +264,7 @@ def evaluate_healing_decision(
|
|
| 472 |
span.end()
|
| 473 |
_EVAL_COUNTER.labels(engine="python", status="success").inc()
|
| 474 |
_EVAL_DURATION.labels(engine="python").observe(time.monotonic() - t0)
|
| 475 |
-
|
| 476 |
"risk_score": 0.0,
|
| 477 |
"selected_action": HealingAction.NO_ACTION.value,
|
| 478 |
"expected_utility": 0.0,
|
|
@@ -480,10 +272,6 @@ def evaluate_healing_decision(
|
|
| 480 |
"explanation": "No candidate actions triggered.",
|
| 481 |
"epistemic_signals": None,
|
| 482 |
}
|
| 483 |
-
if skill_id:
|
| 484 |
-
no_action_result["skill_id"] = skill_id
|
| 485 |
-
no_action_result["skill_version"] = skill_version
|
| 486 |
-
return no_action_result
|
| 487 |
|
| 488 |
# Build reasoning text from policies that triggered the actions
|
| 489 |
reasoning_parts = []
|
|
@@ -530,14 +318,10 @@ def evaluate_healing_decision(
|
|
| 530 |
"hallucination_risk": 0.0,
|
| 531 |
}
|
| 532 |
|
| 533 |
-
#
|
| 534 |
decision = decision_engine.select_optimal_action(
|
| 535 |
-
raw_actions,
|
| 536 |
-
|
| 537 |
-
component=event.component,
|
| 538 |
-
epistemic_signals=epistemic_signals,
|
| 539 |
-
skill_id=skill_id,
|
| 540 |
-
skill_version=skill_version,
|
| 541 |
)
|
| 542 |
|
| 543 |
# Extract risk of the selected action
|
|
@@ -570,7 +354,7 @@ def evaluate_healing_decision(
|
|
| 570 |
span.set_attribute("expected_utility", decision.expected_utility)
|
| 571 |
span.end()
|
| 572 |
|
| 573 |
-
|
| 574 |
"risk_score": risk_score,
|
| 575 |
"selected_action": decision.best_action.value,
|
| 576 |
"expected_utility": decision.expected_utility,
|
|
@@ -579,16 +363,13 @@ def evaluate_healing_decision(
|
|
| 579 |
"raw_decision": decision.raw_data,
|
| 580 |
"epistemic_signals": epistemic_signals,
|
| 581 |
}
|
| 582 |
-
if skill_id:
|
| 583 |
-
result["skill_id"] = skill_id
|
| 584 |
-
result["skill_version"] = skill_version
|
| 585 |
-
return result
|
| 586 |
|
| 587 |
|
| 588 |
def get_system_risk() -> float:
|
| 589 |
"""
|
| 590 |
Return an aggregated risk score across all monitored components.
|
| 591 |
-
This
|
|
|
|
| 592 |
"""
|
| 593 |
raise NotImplementedError(
|
| 594 |
"get_system_risk is deprecated. Use component‑level risk evaluation instead."
|
|
|
|
| 1 |
"""
|
| 2 |
+
Risk service – integrates ARF risk engine, policy engine, and decision engine.
|
| 3 |
+
Deterministic, no random fallbacks, explicit error handling.
|
| 4 |
+
|
| 5 |
+
Version: 2026-05-04 – added Prometheus metrics for observability.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import json
|
|
|
|
| 19 |
from agentic_reliability_framework.runtime.memory.rag_graph import RAGGraphMemory
|
| 20 |
from agentic_reliability_framework.core.research.eclipse_probe import compute_epistemic_risk
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# ── optional tracing ─────────────────────────────────────────
|
| 23 |
try:
|
| 24 |
from opentelemetry import trace
|
|
|
|
| 63 |
pass
|
| 64 |
|
| 65 |
# Default OSS policy tree – mirrors the hard‑coded rules in the Python PolicyEvaluator
|
| 66 |
+
# that check region, resource type, and max permission level.
|
| 67 |
_OSS_POLICY_TREE_JSON = json.dumps({
|
| 68 |
"And": [
|
| 69 |
{"Atomic": {"RegionAllowed": {"allowed_regions": ["eastus"]}}},
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
def _ensure_rust_evaluator() -> bool:
|
| 79 |
+
"""Lazy initialise the Rust policy evaluator. Returns True on success."""
|
| 80 |
global _rust_evaluator, _rust_policy_json
|
| 81 |
if _rust_evaluator is not None:
|
| 82 |
return True
|
|
|
|
| 98 |
engine: RiskEngine,
|
| 99 |
intent: InfrastructureIntent,
|
| 100 |
cost_estimate: Optional[float],
|
| 101 |
+
policy_violations: List[str]
|
|
|
|
| 102 |
) -> dict:
|
| 103 |
"""
|
| 104 |
Evaluate an infrastructure intent using the Bayesian risk engine.
|
| 105 |
|
| 106 |
+
Optionally shadows the policy evaluation with the Rust enforcer when
|
| 107 |
+
the environment variable ARF_USE_RUST_ENFORCER is set to "true".
|
| 108 |
+
Any divergence is logged and counted as a Prometheus metric.
|
| 109 |
|
| 110 |
Parameters
|
| 111 |
----------
|
| 112 |
engine : RiskEngine
|
| 113 |
+
Initialised ARF Bayesian risk engine.
|
| 114 |
intent : InfrastructureIntent
|
| 115 |
The infrastructure request to evaluate.
|
| 116 |
cost_estimate : float or None
|
| 117 |
Estimated monthly cost (used by cost‑threshold policies).
|
| 118 |
policy_violations : list[str]
|
| 119 |
Pre‑computed policy violation strings (from the Python evaluator).
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
Returns
|
| 122 |
-------
|
|
|
|
| 128 |
if OTEL_AVAILABLE and _tracer:
|
| 129 |
span = _tracer.start_span("risk_service.evaluate_intent")
|
| 130 |
span.set_attribute("intent_type", type(intent).__name__)
|
|
|
|
|
|
|
| 131 |
|
| 132 |
# ── Shadow Rust enforcer (best‑effort, non‑blocking) ──────
|
| 133 |
if _RUST_ENFORCER_AVAILABLE and _ensure_rust_evaluator():
|
|
|
|
| 138 |
"region": getattr(intent, "region", None),
|
| 139 |
"resource_type": getattr(intent, "resource_type", None),
|
| 140 |
"permission_level": getattr(intent, "permission_level", None),
|
|
|
|
| 141 |
"extra": {}
|
| 142 |
}
|
| 143 |
rust_raw = _rust_evaluator.evaluate(
|
|
|
|
| 149 |
_RUST_AGREEMENT.labels(result="agreed" if agreed else "diverged").inc()
|
| 150 |
if not agreed:
|
| 151 |
msg = (
|
| 152 |
+
"Rust enforcer divergence: "
|
| 153 |
f"Rust={sorted(rust_violations)} Python={sorted(policy_violations)}"
|
| 154 |
)
|
| 155 |
logger.warning(msg)
|
|
|
|
| 162 |
logger.debug("Rust enforcer shadow evaluation failed: %s", exc)
|
| 163 |
|
| 164 |
# ── Core risk evaluation ──────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
+
# ── Automated canary promotion ──────────────────────────
|
| 167 |
+
if _RUST_ENFORCER_AVAILABLE and os.getenv("ARF_RUST_CANARY", "false").lower() == "true":
|
| 168 |
+
try:
|
| 169 |
+
from prometheus_client import REGISTRY
|
| 170 |
+
lower = REGISTRY.get_sample_value("arf_rust_agreement_lower_bound", {})
|
| 171 |
+
if lower is not None and lower > 0.9999:
|
| 172 |
+
policy_violations = rust_violations
|
| 173 |
+
if span:
|
| 174 |
+
span.set_attribute("rust_enforcer_active", True)
|
| 175 |
+
except Exception:
|
| 176 |
+
pass
|
| 177 |
+
try:
|
| 178 |
score, explanation, contributions = engine.calculate_risk(
|
| 179 |
intent=intent,
|
| 180 |
cost_estimate=cost_estimate,
|
|
|
|
| 203 |
}
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
def evaluate_healing_decision(
|
| 207 |
event: ReliabilityEvent,
|
| 208 |
policy_engine: PolicyEngine,
|
|
|
|
| 210 |
rag_graph: Optional[RAGGraphMemory] = None,
|
| 211 |
model=None,
|
| 212 |
tokenizer=None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
) -> Dict[str, Any]:
|
| 214 |
"""
|
| 215 |
Evaluate healing actions for a given reliability event using decision‑theoretic selection.
|
| 216 |
+
Includes epistemic risk signals from the eclipse probe.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
Parameters
|
| 219 |
----------
|
|
|
|
| 222 |
policy_engine : PolicyEngine
|
| 223 |
The ARF healing policy engine with configured policies.
|
| 224 |
decision_engine : DecisionEngine, optional
|
| 225 |
+
If omitted, a default instance is created.
|
|
|
|
| 226 |
rag_graph : RAGGraphMemory, optional
|
| 227 |
Semantic memory for similar incident retrieval.
|
| 228 |
model, tokenizer : optional
|
| 229 |
HuggingFace model and tokenizer for epistemic risk computation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
Returns
|
| 232 |
-------
|
| 233 |
dict
|
| 234 |
Keys: risk_score, selected_action, expected_utility, alternatives,
|
| 235 |
+
explanation, epistemic_signals.
|
| 236 |
"""
|
| 237 |
t0 = time.monotonic()
|
| 238 |
span = None
|
| 239 |
if OTEL_AVAILABLE and _tracer:
|
| 240 |
span = _tracer.start_span("risk_service.evaluate_healing")
|
| 241 |
span.set_attribute("component", event.component)
|
|
|
|
|
|
|
| 242 |
|
| 243 |
# If decision_engine not provided, try to get from policy_engine
|
| 244 |
if decision_engine is None and hasattr(policy_engine, 'decision_engine'):
|
| 245 |
decision_engine = policy_engine.decision_engine
|
| 246 |
|
| 247 |
+
# If still None, create a minimal one (global stats only)
|
| 248 |
if decision_engine is None:
|
| 249 |
logger.debug("No DecisionEngine provided; creating default instance")
|
| 250 |
+
decision_engine = DecisionEngine(rag_graph=rag_graph)
|
| 251 |
|
| 252 |
# Get raw candidate actions (by temporarily disabling decision engine)
|
| 253 |
orig_use = policy_engine.use_decision_engine
|
|
|
|
| 264 |
span.end()
|
| 265 |
_EVAL_COUNTER.labels(engine="python", status="success").inc()
|
| 266 |
_EVAL_DURATION.labels(engine="python").observe(time.monotonic() - t0)
|
| 267 |
+
return {
|
| 268 |
"risk_score": 0.0,
|
| 269 |
"selected_action": HealingAction.NO_ACTION.value,
|
| 270 |
"expected_utility": 0.0,
|
|
|
|
| 272 |
"explanation": "No candidate actions triggered.",
|
| 273 |
"epistemic_signals": None,
|
| 274 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
# Build reasoning text from policies that triggered the actions
|
| 277 |
reasoning_parts = []
|
|
|
|
| 318 |
"hallucination_risk": 0.0,
|
| 319 |
}
|
| 320 |
|
| 321 |
+
# Run decision engine to get best action and alternatives
|
| 322 |
decision = decision_engine.select_optimal_action(
|
| 323 |
+
raw_actions, event, component=event.component,
|
| 324 |
+
epistemic_signals=epistemic_signals
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
)
|
| 326 |
|
| 327 |
# Extract risk of the selected action
|
|
|
|
| 354 |
span.set_attribute("expected_utility", decision.expected_utility)
|
| 355 |
span.end()
|
| 356 |
|
| 357 |
+
return {
|
| 358 |
"risk_score": risk_score,
|
| 359 |
"selected_action": decision.best_action.value,
|
| 360 |
"expected_utility": decision.expected_utility,
|
|
|
|
| 363 |
"raw_decision": decision.raw_data,
|
| 364 |
"epistemic_signals": epistemic_signals,
|
| 365 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
|
| 367 |
|
| 368 |
def get_system_risk() -> float:
|
| 369 |
"""
|
| 370 |
Return an aggregated risk score across all monitored components.
|
| 371 |
+
This is a placeholder – the endpoint is deprecated.
|
| 372 |
+
Raises NotImplementedError to avoid random fallback.
|
| 373 |
"""
|
| 374 |
raise NotImplementedError(
|
| 375 |
"get_system_risk is deprecated. Use component‑level risk evaluation instead."
|
deploy/kubernetes/arf-api/configmap.yaml
DELETED
|
@@ -1,11 +0,0 @@
|
|
| 1 |
-
apiVersion: v1
|
| 2 |
-
kind: ConfigMap
|
| 3 |
-
metadata:
|
| 4 |
-
name: arf-api-config
|
| 5 |
-
namespace: arf-system
|
| 6 |
-
data:
|
| 7 |
-
ARF_HMC_MODEL: "models/hmc_model.json"
|
| 8 |
-
ARF_USE_HYPERPRIORS: "false"
|
| 9 |
-
ARF_USAGE_TRACKING: "true"
|
| 10 |
-
ARF_USE_RUST_ENFORCER: "false"
|
| 11 |
-
EPISTEMIC_MODEL: ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/kubernetes/arf-api/deployment.yaml
DELETED
|
@@ -1,65 +0,0 @@
|
|
| 1 |
-
apiVersion: apps/v1
|
| 2 |
-
kind: Deployment
|
| 3 |
-
metadata:
|
| 4 |
-
name: arf-api
|
| 5 |
-
namespace: arf-system
|
| 6 |
-
labels:
|
| 7 |
-
app: arf-api
|
| 8 |
-
version: v4.3.2
|
| 9 |
-
spec:
|
| 10 |
-
replicas: 3
|
| 11 |
-
strategy:
|
| 12 |
-
type: RollingUpdate
|
| 13 |
-
rollingUpdate:
|
| 14 |
-
maxUnavailable: 1
|
| 15 |
-
maxSurge: 1
|
| 16 |
-
selector:
|
| 17 |
-
matchLabels:
|
| 18 |
-
app: arf-api
|
| 19 |
-
template:
|
| 20 |
-
metadata:
|
| 21 |
-
labels:
|
| 22 |
-
app: arf-api
|
| 23 |
-
version: v4.3.2
|
| 24 |
-
spec:
|
| 25 |
-
serviceAccountName: arf-api
|
| 26 |
-
securityContext:
|
| 27 |
-
runAsNonRoot: true
|
| 28 |
-
runAsUser: 1000
|
| 29 |
-
fsGroup: 1000
|
| 30 |
-
containers:
|
| 31 |
-
- name: arf-api
|
| 32 |
-
image: arf-api:latest # Replace with specific tag in production
|
| 33 |
-
imagePullPolicy: Always
|
| 34 |
-
ports:
|
| 35 |
-
- containerPort: 8000
|
| 36 |
-
protocol: TCP
|
| 37 |
-
envFrom:
|
| 38 |
-
- configMapRef:
|
| 39 |
-
name: arf-api-config
|
| 40 |
-
- secretRef:
|
| 41 |
-
name: arf-api-secrets
|
| 42 |
-
resources:
|
| 43 |
-
requests:
|
| 44 |
-
cpu: 500m
|
| 45 |
-
memory: 512Mi
|
| 46 |
-
limits:
|
| 47 |
-
cpu: 2000m
|
| 48 |
-
memory: 2Gi
|
| 49 |
-
livenessProbe:
|
| 50 |
-
httpGet:
|
| 51 |
-
path: /health
|
| 52 |
-
port: 8000
|
| 53 |
-
initialDelaySeconds: 30
|
| 54 |
-
periodSeconds: 10
|
| 55 |
-
timeoutSeconds: 5
|
| 56 |
-
failureThreshold: 3
|
| 57 |
-
readinessProbe:
|
| 58 |
-
httpGet:
|
| 59 |
-
path: /health
|
| 60 |
-
port: 8000
|
| 61 |
-
initialDelaySeconds: 10
|
| 62 |
-
periodSeconds: 5
|
| 63 |
-
timeoutSeconds: 3
|
| 64 |
-
failureThreshold: 2
|
| 65 |
-
terminationGracePeriodSeconds: 30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/kubernetes/arf-api/hpa.yaml
DELETED
|
@@ -1,25 +0,0 @@
|
|
| 1 |
-
apiVersion: autoscaling/v2
|
| 2 |
-
kind: HorizontalPodAutoscaler
|
| 3 |
-
metadata:
|
| 4 |
-
name: arf-api-hpa
|
| 5 |
-
namespace: arf-system
|
| 6 |
-
spec:
|
| 7 |
-
scaleTargetRef:
|
| 8 |
-
apiVersion: apps/v1
|
| 9 |
-
kind: Deployment
|
| 10 |
-
name: arf-api
|
| 11 |
-
minReplicas: 3
|
| 12 |
-
maxReplicas: 10
|
| 13 |
-
metrics:
|
| 14 |
-
- type: Resource
|
| 15 |
-
resource:
|
| 16 |
-
name: cpu
|
| 17 |
-
target:
|
| 18 |
-
type: Utilization
|
| 19 |
-
averageUtilization: 70
|
| 20 |
-
- type: Resource
|
| 21 |
-
resource:
|
| 22 |
-
name: memory
|
| 23 |
-
target:
|
| 24 |
-
type: Utilization
|
| 25 |
-
averageUtilization: 80
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/kubernetes/arf-api/networkpolicy.yaml
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
apiVersion: networking.k8s.io/v1
|
| 2 |
-
kind: NetworkPolicy
|
| 3 |
-
metadata:
|
| 4 |
-
name: arf-api-ingress
|
| 5 |
-
namespace: arf-system
|
| 6 |
-
spec:
|
| 7 |
-
podSelector:
|
| 8 |
-
matchLabels:
|
| 9 |
-
app: arf-api
|
| 10 |
-
policyTypes:
|
| 11 |
-
- Ingress
|
| 12 |
-
ingress:
|
| 13 |
-
# Allow traffic only from the gateway pods on port 8000
|
| 14 |
-
- from:
|
| 15 |
-
- podSelector:
|
| 16 |
-
matchLabels:
|
| 17 |
-
app: arf-gateway
|
| 18 |
-
ports:
|
| 19 |
-
- port: 8000
|
| 20 |
-
protocol: TCP
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/kubernetes/arf-api/secret.yaml
DELETED
|
@@ -1,25 +0,0 @@
|
|
| 1 |
-
# DO NOT apply this file as-is. Every value below is a placeholder, not a
|
| 2 |
-
# real secret -- ARF_INTERNAL_API_KEY in particular is a fixed, publicly
|
| 3 |
-
# visible string in this repo's git history. Applying it unmodified means
|
| 4 |
-
# the "secret" is a known value, not a secret.
|
| 5 |
-
#
|
| 6 |
-
# Generate real values instead, e.g.:
|
| 7 |
-
# kubectl create secret generic arf-api-secrets -n arf-system \
|
| 8 |
-
# --from-literal=DATABASE_URL=... \
|
| 9 |
-
# --from-literal=ARF_INTERNAL_API_KEY=$(openssl rand -hex 32) \
|
| 10 |
-
# --from-literal=ARF_API_KEYS='{}' \
|
| 11 |
-
# --from-literal=ARF_REDIS_URL=...
|
| 12 |
-
# or manage this via a secrets operator (External Secrets, Sealed Secrets,
|
| 13 |
-
# SOPS) rather than a plain committed manifest.
|
| 14 |
-
apiVersion: v1
|
| 15 |
-
kind: Secret
|
| 16 |
-
metadata:
|
| 17 |
-
name: arf-api-secrets
|
| 18 |
-
namespace: arf-system
|
| 19 |
-
type: Opaque
|
| 20 |
-
stringData:
|
| 21 |
-
# Placeholders only -- see warning above. Replace before applying.
|
| 22 |
-
DATABASE_URL: "postgresql://user:password@host:5432/arf"
|
| 23 |
-
ARF_INTERNAL_API_KEY: "change-me-to-a-strong-random-key"
|
| 24 |
-
ARF_API_KEYS: '{}'
|
| 25 |
-
ARF_REDIS_URL: ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/kubernetes/arf-api/service.yaml
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 1 |
-
apiVersion: v1
|
| 2 |
-
kind: Service
|
| 3 |
-
metadata:
|
| 4 |
-
name: arf-api
|
| 5 |
-
namespace: arf-system
|
| 6 |
-
labels:
|
| 7 |
-
app: arf-api
|
| 8 |
-
spec:
|
| 9 |
-
type: ClusterIP
|
| 10 |
-
ports:
|
| 11 |
-
- port: 8000
|
| 12 |
-
targetPort: 8000
|
| 13 |
-
protocol: TCP
|
| 14 |
-
name: http
|
| 15 |
-
selector:
|
| 16 |
-
app: arf-api
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/authentication.md
CHANGED
|
@@ -4,48 +4,22 @@ This page describes how to authenticate with the ARF API.
|
|
| 4 |
|
| 5 |
Current status
|
| 6 |
|
| 7 |
-
-
|
| 8 |
-
`routes_memory.py`: the entire router requires the `X-Internal-Key` header, verified against
|
| 9 |
-
`ARF_INTERNAL_API_KEY` (`app/api/deps.py::verify_internal_key`). This fails closed — requests
|
| 10 |
-
are rejected with 401 if the env var is unset, if the header is missing, or if it doesn't
|
| 11 |
-
match (constant-time comparison). This is the header arf-gateway injects when proxying to
|
| 12 |
-
this service. The last four were unauthenticated until this was fixed — see
|
| 13 |
-
`tests/test_deps.py` for the tests that verify the dependency itself actually rejects what
|
| 14 |
-
it should, not just that it's wired in.
|
| 15 |
-
- `routes_incidents.py`'s `POST /report_incident` requires the same `X-Internal-Key`
|
| 16 |
-
dependency as above — it did not until a later audit found it had none at all, unlike every
|
| 17 |
-
other route in this file, despite its own docstring saying it's meant for internal monitoring
|
| 18 |
-
tools only. Anyone could previously write arbitrary events into the incident history that
|
| 19 |
-
feeds the causal explainer and `GET /history`. That history is now also a bounded
|
| 20 |
-
`deque(maxlen=10_000)` (`app/core/storage.py`), not an unbounded list — the same audit found
|
| 21 |
-
`POST /report_incident` and `GET /history` were reading/writing two _different_ Python lists
|
| 22 |
-
with the same name, so `GET /history` had in fact always returned empty regardless of what was
|
| 23 |
-
reported; both routers now share the one list in `app.core.storage`.
|
| 24 |
-
- `routes_admin.py`: individual `/admin/*` endpoints require an `admin_key` query parameter,
|
| 25 |
-
verified against `ARF_ADMIN_API_KEY` (`app/api/deps.py`, or the local `verify_admin`
|
| 26 |
-
dependency in that router). Also fails closed if unset. Includes
|
| 27 |
-
`POST /admin/keys/{key_id}/rotate` — deactivates a key and issues a new one on the same
|
| 28 |
-
tenant/tier in one transaction, for when a key needs to be revoked without losing the
|
| 29 |
-
tenant's identity or history. `api_keys` itself lives in Postgres (`DATABASE_URL`), shared
|
| 30 |
-
with arf-gateway (both must use the identical `ARF_KEY_PEPPER`) — see the comments on those
|
| 31 |
-
two variables in `.env.example` for why.
|
| 32 |
-
- `routes_pricing.py`: individual `/pricing/*` endpoints require a real per-customer API key
|
| 33 |
-
(`Authorization: Bearer <key>` or `?api_key=`), verified against the tracked/tenant-scoped
|
| 34 |
-
`enforce_quota` dependency (`app/core/usage_tracker.py`) — a different mechanism from
|
| 35 |
-
`X-Internal-Key`, since pricing estimates are meant to be reachable by a customer directly,
|
| 36 |
-
not only via the gateway.
|
| 37 |
|
| 38 |
What the code provides
|
| 39 |
|
| 40 |
-
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
Notes
|
| 46 |
|
| 47 |
-
- Tests
|
| 48 |
-
- `tests/conftest.py` globally overrides `verify_internal_key` for the test suite (so routes
|
| 49 |
-
behind it can be exercised without the gateway-injected header) — this means the app-level
|
| 50 |
-
test suite alone can't confirm the dependency actually fails closed. `tests/test_deps.py`
|
| 51 |
-
calls it directly, bypassing that override, specifically to verify that.
|
|
|
|
| 4 |
|
| 5 |
Current status
|
| 6 |
|
| 7 |
+
- There is no route-level or global authentication enforced by the API code in this repository. The API routes (including governance endpoints) do not validate API keys, tokens, or other credentials.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
What the code provides
|
| 10 |
|
| 11 |
+
- The configuration model (app/core/config.py) exposes an optional `api_key` setting. This can be provided via environment variables or a `.env` file (the BaseSettings `env_file` is configured to read `.env`).
|
| 12 |
+
|
| 13 |
+
What this means for you
|
| 14 |
+
|
| 15 |
+
- Setting `API_KEY` in a `.env` file or environment variable will populate the `settings.api_key`, but the current route implementations do not check this value.
|
| 16 |
+
- If you require authentication, add a FastAPI dependency or middleware that checks `settings.api_key` (or another auth mechanism) and then apply it to routes or include it in a dependency override.
|
| 17 |
+
|
| 18 |
+
Suggested minimal approach to enable API key checking
|
| 19 |
+
|
| 20 |
+
- Implement a dependency in `app.api.deps` (e.g., `get_api_key`) that compares a header value to `settings.api_key` and raise `HTTPException(401)` when missing/invalid.
|
| 21 |
+
- Add that dependency to routers or individual endpoints where auth is required.
|
| 22 |
|
| 23 |
Notes
|
| 24 |
|
| 25 |
+
- Tests and example code in this repo currently run without auth.
|
|
|
|
|
|
|
|
|
|
|
|
docs/development.md
CHANGED
|
@@ -20,11 +20,10 @@ Quick start
|
|
| 20 |
python -m venv .venv
|
| 21 |
source .venv/bin/activate # or .\.venv\Scripts\activate on Windows
|
| 22 |
pip install -r requirements.txt
|
| 23 |
-
pip install -r requirements-dev.txt # needed to run the test suite (pytest, etc.)
|
| 24 |
|
| 25 |
3. Configure environment variables (optional):
|
| 26 |
|
| 27 |
-
- The project uses pydantic-settings with `env_file = ".env"` (see `app/core/config.py`).
|
| 28 |
|
| 29 |
Relevant environment variables used by the code:
|
| 30 |
- ARF_HMC_MODEL (default: `models/hmc_model.json`) — path to HMC model JSON used by RiskEngine.
|
|
|
|
| 20 |
python -m venv .venv
|
| 21 |
source .venv/bin/activate # or .\.venv\Scripts\activate on Windows
|
| 22 |
pip install -r requirements.txt
|
|
|
|
| 23 |
|
| 24 |
3. Configure environment variables (optional):
|
| 25 |
|
| 26 |
+
- The project uses pydantic-settings with `env_file = ".env"` (see `app/core/config.py`). Create a `.env` file to set values locally.
|
| 27 |
|
| 28 |
Relevant environment variables used by the code:
|
| 29 |
- ARF_HMC_MODEL (default: `models/hmc_model.json`) — path to HMC model JSON used by RiskEngine.
|
render.yaml
CHANGED
|
@@ -11,8 +11,6 @@ services:
|
|
| 11 |
property: connectionString
|
| 12 |
- key: API_KEY
|
| 13 |
sync: false
|
| 14 |
-
- key: ARF_KEY_PEPPER
|
| 15 |
-
sync: false
|
| 16 |
- key: ENVIRONMENT
|
| 17 |
value: production
|
| 18 |
databases:
|
|
|
|
| 11 |
property: connectionString
|
| 12 |
- key: API_KEY
|
| 13 |
sync: false
|
|
|
|
|
|
|
| 14 |
- key: ENVIRONMENT
|
| 15 |
value: production
|
| 16 |
databases:
|
requirements-dev.txt
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
-
pytest>=9.0.3
|
| 2 |
pytest-cov>=7.0.0
|
| 3 |
jsonschema>=4.0.0
|
| 4 |
pytest-asyncio>=0.24.0
|
| 5 |
-
pytest-timeout>=2.3.1
|
|
|
|
|
|
|
| 1 |
pytest-cov>=7.0.0
|
| 2 |
jsonschema>=4.0.0
|
| 3 |
pytest-asyncio>=0.24.0
|
|
|
requirements.txt
CHANGED
|
@@ -1,22 +1,19 @@
|
|
| 1 |
fastapi==0.115.12
|
| 2 |
uvicorn[standard]==0.34.0
|
| 3 |
pydantic>=2.13.2
|
| 4 |
-
agentic-reliability-framework @ git+https://github.com/arf-foundation/
|
| 5 |
arf-pricing-calculator @ git+https://github.com/arf-foundation/ARF-Bayesian-Pricing-Calculator@main
|
|
|
|
|
|
|
| 6 |
httpx==0.28.1
|
| 7 |
alembic
|
| 8 |
pydantic-settings
|
| 9 |
sqlalchemy
|
| 10 |
psycopg2-binary==2.9.10
|
| 11 |
slowapi==0.1.9
|
| 12 |
-
limits==3.3.1 # slowapi 0.1.9's own poetry.lock pins this; left unpinned it resolves
|
| 13 |
-
# to a much newer major version whose internal API to slowapi breaks
|
| 14 |
-
# (Limiter._check_request_limit raises a plain ValueError instead of
|
| 15 |
-
# RateLimitExceeded, which slowapi's middleware mishandles as an
|
| 16 |
-
# unhandled 500 on every request touching a rate limit)
|
| 17 |
prometheus-fastapi-instrumentator==7.1.0
|
| 18 |
flake8==7.2.0
|
| 19 |
-
cryptography==
|
| 20 |
sentence-transformers>=2.2.0
|
| 21 |
scikit-learn
|
| 22 |
redis>=4.0.0 # optional, for faster counters
|
|
|
|
| 1 |
fastapi==0.115.12
|
| 2 |
uvicorn[standard]==0.34.0
|
| 3 |
pydantic>=2.13.2
|
| 4 |
+
agentic-reliability-framework @ git+https://github.com/arf-foundation/agentic-reliability-framework@main
|
| 5 |
arf-pricing-calculator @ git+https://github.com/arf-foundation/ARF-Bayesian-Pricing-Calculator@main
|
| 6 |
+
pytest==8.3.5
|
| 7 |
+
pytest==8.3.5
|
| 8 |
httpx==0.28.1
|
| 9 |
alembic
|
| 10 |
pydantic-settings
|
| 11 |
sqlalchemy
|
| 12 |
psycopg2-binary==2.9.10
|
| 13 |
slowapi==0.1.9
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
prometheus-fastapi-instrumentator==7.1.0
|
| 15 |
flake8==7.2.0
|
| 16 |
+
cryptography==47.0.0
|
| 17 |
sentence-transformers>=2.2.0
|
| 18 |
scikit-learn
|
| 19 |
redis>=4.0.0 # optional, for faster counters
|
tests/conftest.py
CHANGED
|
@@ -3,13 +3,11 @@ pytest configuration and fixtures for ARF API tests.
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from app.core.usage_tracker import enforce_quota, Tier
|
| 6 |
-
from app.api.deps import get_db
|
| 7 |
from app.database.base import Base
|
| 8 |
-
from app.database.models_intents import IntentDB, TenantDB, BetaStateDB, DecisionAuditLogDB # noqa: E501,F401 -- imported for their side effect of registering these tables on Base.metadata
|
| 9 |
from app.main import app as fastapi_app
|
| 10 |
from sqlalchemy.orm import sessionmaker
|
| 11 |
from sqlalchemy import create_engine
|
| 12 |
-
from fastapi import Request
|
| 13 |
from fastapi.testclient import TestClient
|
| 14 |
import app.core.usage_tracker
|
| 15 |
import os
|
|
@@ -18,10 +16,6 @@ import pytest
|
|
| 18 |
# ===== STEP 1: Set environment variables BEFORE any app imports =====
|
| 19 |
os.environ["ARF_USAGE_TRACKING"] = "false"
|
| 20 |
|
| 21 |
-
# UsageTracker now requires a real pepper to hash/verify API keys (H-2 fix);
|
| 22 |
-
# tests never touch a production key, so a fixed test-only value is fine.
|
| 23 |
-
os.environ.setdefault("ARF_KEY_PEPPER", "test-only-pepper-not-for-production-use-32chars")
|
| 24 |
-
|
| 25 |
# Force the correct database URL for tests
|
| 26 |
os.environ["DATABASE_URL"] = "postgresql://postgres:postgres@localhost:5432/testdb"
|
| 27 |
os.environ["TEST_DATABASE_URL"] = "postgresql://postgres:postgres@localhost:5432/testdb"
|
|
@@ -46,9 +40,6 @@ class MockTracker:
|
|
| 46 |
|
| 47 |
return 1000
|
| 48 |
|
| 49 |
-
def get_tenant_id(self, api_key):
|
| 50 |
-
return "test-tenant"
|
| 51 |
-
|
| 52 |
def consume_quota_and_log(self, record, idempotency_key=None):
|
| 53 |
|
| 54 |
return (True, None)
|
|
@@ -107,19 +98,10 @@ fastapi_app.dependency_overrides[get_db] = override_get_db
|
|
| 107 |
# Override enforce_quota dependency
|
| 108 |
|
| 109 |
|
| 110 |
-
async def mock_enforce_quota(request
|
| 111 |
-
return {"api_key": "test_key", "tier": Tier.PRO, "
|
| 112 |
fastapi_app.dependency_overrides[enforce_quota] = mock_enforce_quota
|
| 113 |
|
| 114 |
-
# Override verify_internal_key: production fails closed when
|
| 115 |
-
# ARF_INTERNAL_API_KEY is unset, but tests exercise routes directly without
|
| 116 |
-
# the gateway-injected X-Internal-Key header.
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
async def mock_verify_internal_key():
|
| 120 |
-
return None
|
| 121 |
-
fastapi_app.dependency_overrides[verify_internal_key] = mock_verify_internal_key
|
| 122 |
-
|
| 123 |
|
| 124 |
@pytest.fixture(scope="session", autouse=True)
|
| 125 |
def setup_database():
|
|
@@ -137,15 +119,10 @@ def client():
|
|
| 137 |
|
| 138 |
@pytest.fixture(scope="function")
|
| 139 |
def db_session():
|
| 140 |
-
"""Provide a database session for each test.
|
| 141 |
-
|
| 142 |
-
Schema lifecycle is owned entirely by the session-scoped
|
| 143 |
-
`setup_database` fixture. Dropping tables here would blow away the
|
| 144 |
-
shared schema for any test that runs afterwards without itself
|
| 145 |
-
depending on `db_session` (e.g. tests that build their own bare
|
| 146 |
-
TestClient), leaving them with a database that has no tables at all.
|
| 147 |
-
"""
|
| 148 |
session = TestingSessionLocal()
|
| 149 |
yield session
|
| 150 |
session.rollback()
|
| 151 |
session.close()
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from app.core.usage_tracker import enforce_quota, Tier
|
| 6 |
+
from app.api.deps import get_db
|
| 7 |
from app.database.base import Base
|
|
|
|
| 8 |
from app.main import app as fastapi_app
|
| 9 |
from sqlalchemy.orm import sessionmaker
|
| 10 |
from sqlalchemy import create_engine
|
|
|
|
| 11 |
from fastapi.testclient import TestClient
|
| 12 |
import app.core.usage_tracker
|
| 13 |
import os
|
|
|
|
| 16 |
# ===== STEP 1: Set environment variables BEFORE any app imports =====
|
| 17 |
os.environ["ARF_USAGE_TRACKING"] = "false"
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# Force the correct database URL for tests
|
| 20 |
os.environ["DATABASE_URL"] = "postgresql://postgres:postgres@localhost:5432/testdb"
|
| 21 |
os.environ["TEST_DATABASE_URL"] = "postgresql://postgres:postgres@localhost:5432/testdb"
|
|
|
|
| 40 |
|
| 41 |
return 1000
|
| 42 |
|
|
|
|
|
|
|
|
|
|
| 43 |
def consume_quota_and_log(self, record, idempotency_key=None):
|
| 44 |
|
| 45 |
return (True, None)
|
|
|
|
| 98 |
# Override enforce_quota dependency
|
| 99 |
|
| 100 |
|
| 101 |
+
async def mock_enforce_quota(request, api_key=None):
|
| 102 |
+
return {"api_key": "test_key", "tier": Tier.PRO, "remaining": 1000}
|
| 103 |
fastapi_app.dependency_overrides[enforce_quota] = mock_enforce_quota
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
@pytest.fixture(scope="session", autouse=True)
|
| 107 |
def setup_database():
|
|
|
|
| 119 |
|
| 120 |
@pytest.fixture(scope="function")
|
| 121 |
def db_session():
|
| 122 |
+
"""Provide a clean database session for each test."""
|
| 123 |
+
Base.metadata.create_all(bind=engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
session = TestingSessionLocal()
|
| 125 |
yield session
|
| 126 |
session.rollback()
|
| 127 |
session.close()
|
| 128 |
+
Base.metadata.drop_all(bind=engine)
|
tests/test_deps.py
CHANGED
|
@@ -1,10 +1,5 @@
|
|
| 1 |
-
import importlib
|
| 2 |
-
from unittest.mock import MagicMock, patch
|
| 3 |
-
|
| 4 |
import pytest
|
| 5 |
-
from
|
| 6 |
-
|
| 7 |
-
import app.api.deps as deps
|
| 8 |
from app.api.deps import get_db
|
| 9 |
|
| 10 |
|
|
@@ -18,72 +13,3 @@ def test_get_db_closes_session():
|
|
| 18 |
with pytest.raises(Exception):
|
| 19 |
db_gen.throw(Exception("test error"))
|
| 20 |
mock_session.close.assert_called_once()
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
# verify_internal_key tests below are called directly, not through
|
| 24 |
-
# TestClient: tests/conftest.py globally overrides verify_internal_key
|
| 25 |
-
# (`fastapi_app.dependency_overrides[verify_internal_key] = mock_verify_internal_key`)
|
| 26 |
-
# so that already-protected routes (routes_governance.py) can be exercised
|
| 27 |
-
# in tests without the gateway-injected X-Internal-Key header. That override
|
| 28 |
-
# makes the real fail-closed behavior untestable through the app for any
|
| 29 |
-
# router that uses it -- this is the only place it's actually verified to
|
| 30 |
-
# reject what it should reject, rather than just trusted to work because
|
| 31 |
-
# it's wired in.
|
| 32 |
-
#
|
| 33 |
-
# Newly relevant as of the auth fix to routes_risk.py, routes_intents.py,
|
| 34 |
-
# routes_history.py, routes_memory.py (see docs/authentication.md) -- those
|
| 35 |
-
# four routers now depend on this function passing correctly.
|
| 36 |
-
|
| 37 |
-
_NEWLY_PROTECTED_ROUTER_MODULES = [
|
| 38 |
-
"app.api.routes_risk",
|
| 39 |
-
"app.api.routes_intents",
|
| 40 |
-
"app.api.routes_history",
|
| 41 |
-
"app.api.routes_memory",
|
| 42 |
-
]
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
@pytest.mark.asyncio
|
| 46 |
-
async def test_verify_internal_key_rejects_missing_header(monkeypatch):
|
| 47 |
-
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret")
|
| 48 |
-
with pytest.raises(HTTPException) as exc_info:
|
| 49 |
-
await deps.verify_internal_key(x_internal_key=None)
|
| 50 |
-
assert exc_info.value.status_code == 401
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
@pytest.mark.asyncio
|
| 54 |
-
async def test_verify_internal_key_rejects_wrong_key(monkeypatch):
|
| 55 |
-
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret")
|
| 56 |
-
with pytest.raises(HTTPException) as exc_info:
|
| 57 |
-
await deps.verify_internal_key(x_internal_key="wrong-key")
|
| 58 |
-
assert exc_info.value.status_code == 401
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
@pytest.mark.asyncio
|
| 62 |
-
async def test_verify_internal_key_fails_closed_when_unset(monkeypatch):
|
| 63 |
-
"""The env var being unset must reject every request, not let them
|
| 64 |
-
through -- this is the specific property that makes this safe to add
|
| 65 |
-
to a router without also needing to guarantee the env var is always
|
| 66 |
-
set."""
|
| 67 |
-
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "")
|
| 68 |
-
with pytest.raises(HTTPException) as exc_info:
|
| 69 |
-
await deps.verify_internal_key(x_internal_key="anything")
|
| 70 |
-
assert exc_info.value.status_code == 401
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
@pytest.mark.asyncio
|
| 74 |
-
async def test_verify_internal_key_accepts_correct_key(monkeypatch):
|
| 75 |
-
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret")
|
| 76 |
-
result = await deps.verify_internal_key(x_internal_key="real-secret")
|
| 77 |
-
assert result is None
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
@pytest.mark.parametrize("router_module_name", _NEWLY_PROTECTED_ROUTER_MODULES)
|
| 81 |
-
def test_router_requires_verify_internal_key(router_module_name):
|
| 82 |
-
"""Structural check, independent of the function-level tests above:
|
| 83 |
-
proves each router actually declares verify_internal_key as a
|
| 84 |
-
router-level dependency, not just that the function itself works in
|
| 85 |
-
isolation. Mirrors the pattern routes_governance.py already uses
|
| 86 |
-
(`APIRouter(dependencies=[Depends(verify_internal_key)])`)."""
|
| 87 |
-
module = importlib.import_module(router_module_name)
|
| 88 |
-
dependency_callables = [d.dependency for d in module.router.dependencies]
|
| 89 |
-
assert deps.verify_internal_key in dependency_callables
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
+
from unittest.mock import patch, MagicMock
|
|
|
|
|
|
|
| 3 |
from app.api.deps import get_db
|
| 4 |
|
| 5 |
|
|
|
|
| 13 |
with pytest.raises(Exception):
|
| 14 |
db_gen.throw(Exception("test error"))
|
| 15 |
mock_session.close.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_governance.py
CHANGED
|
@@ -1,21 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
Tests for governance endpoints: /api/v1/intents/evaluate
|
| 3 |
"""
|
| 4 |
-
import tempfile
|
| 5 |
-
|
| 6 |
-
import pytest
|
| 7 |
-
import app.core.usage_tracker as usage_tracker_module
|
| 8 |
-
from app.core.usage_tracker import UsageTracker
|
| 9 |
-
from app.database.models_intents import TenantDB
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
@pytest.fixture(autouse=True)
|
| 13 |
-
def seed_tenant(db_session):
|
| 14 |
-
"""Ensure the tenant 'test-tenant' exists before each test."""
|
| 15 |
-
tenant = db_session.query(TenantDB).filter_by(id="test-tenant").first()
|
| 16 |
-
if not tenant:
|
| 17 |
-
db_session.add(TenantDB(id="test-tenant", name="Test Tenant"))
|
| 18 |
-
db_session.commit()
|
| 19 |
|
| 20 |
|
| 21 |
def test_evaluate_provision_intent(client):
|
|
@@ -31,8 +16,7 @@ def test_evaluate_provision_intent(client):
|
|
| 31 |
"provenance": {},
|
| 32 |
"configuration": {}
|
| 33 |
}
|
| 34 |
-
response = client.post("/api/v1/intents/evaluate", json=payload
|
| 35 |
-
headers={"X-Tenant-ID": "test-tenant"})
|
| 36 |
assert response.status_code == 200, response.text
|
| 37 |
data = response.json()
|
| 38 |
assert "risk_score" in data
|
|
@@ -51,8 +35,7 @@ def test_evaluate_grant_access(client):
|
|
| 51 |
"provenance": {},
|
| 52 |
"justification": "test"
|
| 53 |
}
|
| 54 |
-
response = client.post("/api/v1/intents/evaluate", json=payload
|
| 55 |
-
headers={"X-Tenant-ID": "test-tenant"})
|
| 56 |
assert response.status_code == 200, response.text
|
| 57 |
data = response.json()
|
| 58 |
assert "risk_score" in data
|
|
@@ -71,8 +54,7 @@ def test_evaluate_deploy_config(client):
|
|
| 71 |
"provenance": {},
|
| 72 |
"configuration": {}
|
| 73 |
}
|
| 74 |
-
response = client.post("/api/v1/intents/evaluate", json=payload
|
| 75 |
-
headers={"X-Tenant-ID": "test-tenant"})
|
| 76 |
assert response.status_code == 200, response.text
|
| 77 |
data = response.json()
|
| 78 |
assert "risk_score" in data
|
|
@@ -85,68 +67,5 @@ def test_invalid_intent_type(client):
|
|
| 85 |
"requester": "alice",
|
| 86 |
"provenance": {}
|
| 87 |
}
|
| 88 |
-
response = client.post("/api/v1/intents/evaluate", json=payload
|
| 89 |
-
headers={"X-Tenant-ID": "test-tenant"})
|
| 90 |
assert response.status_code == 422
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def test_evaluate_with_criticality(client):
|
| 94 |
-
"""v4.3.2: criticality is accepted and a context_hash is generated."""
|
| 95 |
-
payload = {
|
| 96 |
-
"intent_type": "provision_resource",
|
| 97 |
-
"environment": "prod",
|
| 98 |
-
"resource_type": "database",
|
| 99 |
-
"region": "eastus",
|
| 100 |
-
"size": "Standard",
|
| 101 |
-
"estimated_cost": 1200,
|
| 102 |
-
"policy_violations": [],
|
| 103 |
-
"requester": "alice",
|
| 104 |
-
"provenance": {},
|
| 105 |
-
"configuration": {},
|
| 106 |
-
"criticality": 0.85
|
| 107 |
-
}
|
| 108 |
-
response = client.post("/api/v1/intents/evaluate", json=payload,
|
| 109 |
-
headers={"X-Tenant-ID": "test-tenant"})
|
| 110 |
-
assert response.status_code == 200, response.text
|
| 111 |
-
data = response.json()
|
| 112 |
-
assert "risk_score" in data
|
| 113 |
-
# The healing_intent dict should contain the new fields.
|
| 114 |
-
healing = data.get("healing_intent", {})
|
| 115 |
-
# criticality is passed through
|
| 116 |
-
assert healing.get("criticality") == 0.85
|
| 117 |
-
# context_hash is computed by the governance loop (a 64‑char hex string)
|
| 118 |
-
ctx_hash = healing.get("context_hash")
|
| 119 |
-
assert isinstance(ctx_hash, str) and len(ctx_hash) == 64
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def test_evaluate_intent_against_real_tracker_does_not_crash(client, monkeypatch):
|
| 123 |
-
"""Every other test in this file runs against tests/conftest.py's
|
| 124 |
-
MockTracker, whose consume_quota_and_log ignores record.tier entirely
|
| 125 |
-
and always returns (True, None) -- so none of them would have noticed
|
| 126 |
-
that this endpoint hardcoded tier=None instead of using quota["tier"]
|
| 127 |
-
(already resolved by the enforce_quota dependency). Against the real
|
| 128 |
-
UsageTracker, consume_quota_and_log evaluates tier.monthly_evaluation_limit
|
| 129 |
-
unconditionally, so a None tier raised AttributeError on every real
|
| 130 |
-
call, outside any try/except, surfacing as a raw 500. This test swaps
|
| 131 |
-
in a real UsageTracker (a throwaway SQLite file; the same real
|
| 132 |
-
Postgres the CI service provides backs api_keys/monthly_counts) to
|
| 133 |
-
prove the endpoint no longer crashes."""
|
| 134 |
-
with tempfile.NamedTemporaryFile(suffix=".db") as tmp:
|
| 135 |
-
monkeypatch.setattr(usage_tracker_module, "tracker", UsageTracker(db_path=tmp.name))
|
| 136 |
-
|
| 137 |
-
payload = {
|
| 138 |
-
"intent_type": "provision_resource",
|
| 139 |
-
"environment": "prod",
|
| 140 |
-
"resource_type": "database",
|
| 141 |
-
"region": "eastus",
|
| 142 |
-
"size": "Standard",
|
| 143 |
-
"estimated_cost": 1200,
|
| 144 |
-
"policy_violations": [],
|
| 145 |
-
"requester": "alice",
|
| 146 |
-
"provenance": {},
|
| 147 |
-
"configuration": {}
|
| 148 |
-
}
|
| 149 |
-
response = client.post("/api/v1/intents/evaluate", json=payload,
|
| 150 |
-
headers={"X-Tenant-ID": "test-tenant"})
|
| 151 |
-
assert response.status_code == 200, response.text
|
| 152 |
-
assert "risk_score" in response.json()
|
|
|
|
| 1 |
"""
|
| 2 |
Tests for governance endpoints: /api/v1/intents/evaluate
|
| 3 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
def test_evaluate_provision_intent(client):
|
|
|
|
| 16 |
"provenance": {},
|
| 17 |
"configuration": {}
|
| 18 |
}
|
| 19 |
+
response = client.post("/api/v1/intents/evaluate", json=payload)
|
|
|
|
| 20 |
assert response.status_code == 200, response.text
|
| 21 |
data = response.json()
|
| 22 |
assert "risk_score" in data
|
|
|
|
| 35 |
"provenance": {},
|
| 36 |
"justification": "test"
|
| 37 |
}
|
| 38 |
+
response = client.post("/api/v1/intents/evaluate", json=payload)
|
|
|
|
| 39 |
assert response.status_code == 200, response.text
|
| 40 |
data = response.json()
|
| 41 |
assert "risk_score" in data
|
|
|
|
| 54 |
"provenance": {},
|
| 55 |
"configuration": {}
|
| 56 |
}
|
| 57 |
+
response = client.post("/api/v1/intents/evaluate", json=payload)
|
|
|
|
| 58 |
assert response.status_code == 200, response.text
|
| 59 |
data = response.json()
|
| 60 |
assert "risk_score" in data
|
|
|
|
| 67 |
"requester": "alice",
|
| 68 |
"provenance": {}
|
| 69 |
}
|
| 70 |
+
response = client.post("/api/v1/intents/evaluate", json=payload)
|
|
|
|
| 71 |
assert response.status_code == 422
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_healing_endpoint.py
CHANGED
|
@@ -1,9 +1,5 @@
|
|
| 1 |
-
import tempfile
|
| 2 |
-
|
| 3 |
from fastapi.testclient import TestClient
|
| 4 |
from app.main import app
|
| 5 |
-
import app.core.usage_tracker as usage_tracker_module
|
| 6 |
-
from app.core.usage_tracker import UsageTracker
|
| 7 |
|
| 8 |
client = TestClient(app)
|
| 9 |
|
|
@@ -19,30 +15,7 @@ def test_healing_evaluate_endpoint():
|
|
| 19 |
"memory_util": 0.90
|
| 20 |
}
|
| 21 |
}
|
| 22 |
-
response = client.post("/api/v1/healing/evaluate", json=payload
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def test_healing_evaluate_against_real_tracker_does_not_crash(monkeypatch):
|
| 28 |
-
"""Same regression as test_governance.py's equivalent test, for this
|
| 29 |
-
endpoint's own hardcoded tier=None (routes_governance.py's
|
| 30 |
-
/healing/evaluate handler) -- see that test's docstring for the full
|
| 31 |
-
explanation. Swaps in a real UsageTracker to prove
|
| 32 |
-
consume_quota_and_log no longer crashes on a None tier here either."""
|
| 33 |
-
payload = {
|
| 34 |
-
"event": {
|
| 35 |
-
"component": "my-service",
|
| 36 |
-
"latency_p99": 450.0,
|
| 37 |
-
"error_rate": 0.25,
|
| 38 |
-
"service_mesh": "default",
|
| 39 |
-
"cpu_util": 0.85,
|
| 40 |
-
"memory_util": 0.90
|
| 41 |
-
}
|
| 42 |
-
}
|
| 43 |
-
with tempfile.NamedTemporaryFile(suffix=".db") as tmp:
|
| 44 |
-
monkeypatch.setattr(usage_tracker_module, "tracker", UsageTracker(db_path=tmp.name))
|
| 45 |
-
|
| 46 |
-
response = client.post("/api/v1/healing/evaluate", json=payload,
|
| 47 |
-
headers={"X-Tenant-ID": "test-tenant"})
|
| 48 |
-
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
|
|
|
|
|
|
|
|
|
|
| 1 |
from fastapi.testclient import TestClient
|
| 2 |
from app.main import app
|
|
|
|
|
|
|
| 3 |
|
| 4 |
client = TestClient(app)
|
| 5 |
|
|
|
|
| 15 |
"memory_util": 0.90
|
| 16 |
}
|
| 17 |
}
|
| 18 |
+
response = client.post("/api/v1/healing/evaluate", json=payload)
|
| 19 |
+
assert response.status_code == 200, f"Expected 200, got {
|
| 20 |
+
response.status_code}: {
|
| 21 |
+
response.text}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_history.py
CHANGED
|
@@ -8,5 +8,9 @@ def test_history():
|
|
| 8 |
response = client.get("/api/v1/history")
|
| 9 |
assert response.status_code == 200
|
| 10 |
data = response.json()
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
response = client.get("/api/v1/history")
|
| 9 |
assert response.status_code == 200
|
| 10 |
data = response.json()
|
| 11 |
+
# The endpoint returns a list of risk points, not an object with an
|
| 12 |
+
# "incidents" key
|
| 13 |
+
assert isinstance(data, list)
|
| 14 |
+
if data: # if not empty, verify the structure of the first item
|
| 15 |
+
assert "risk" in data[0]
|
| 16 |
+
assert "time" in data[0]
|
tests/test_integration.py
DELETED
|
@@ -1,305 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
End‑to‑end integration tests for the ARF governance pipeline.
|
| 3 |
-
|
| 4 |
-
These tests exercise the full path from HTTP request to HealingIntent
|
| 5 |
-
response, validating that every layer – API, governance loop, policy
|
| 6 |
-
engine, risk engine, audit log, and optional skill/criticality features –
|
| 7 |
-
behaves correctly under realistic conditions.
|
| 8 |
-
|
| 9 |
-
v4.3.2: Covers basic evaluation, skill context, criticality, and audit
|
| 10 |
-
trace verification.
|
| 11 |
-
"""
|
| 12 |
-
import pytest
|
| 13 |
-
import time
|
| 14 |
-
from app.database.models_intents import TenantDB, DecisionAuditLogDB
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
@pytest.fixture(autouse=True)
|
| 18 |
-
def seed_tenant(db_session):
|
| 19 |
-
"""Ensure the tenant 'test-tenant' exists before each test."""
|
| 20 |
-
tenant = db_session.query(TenantDB).filter_by(id="test-tenant").first()
|
| 21 |
-
if not tenant:
|
| 22 |
-
db_session.add(TenantDB(id="test-tenant", name="Test Tenant"))
|
| 23 |
-
db_session.commit()
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
class TestFullPipeline:
|
| 27 |
-
"""End‑to‑end tests for the /intents/evaluate endpoint."""
|
| 28 |
-
|
| 29 |
-
def test_basic_provision_evaluation(self, client):
|
| 30 |
-
"""A minimal valid request returns 200 and a well‑formed HealingIntent."""
|
| 31 |
-
payload = {
|
| 32 |
-
"intent_type": "provision_resource",
|
| 33 |
-
"environment": "prod",
|
| 34 |
-
"resource_type": "database",
|
| 35 |
-
"region": "eastus",
|
| 36 |
-
"size": "Standard",
|
| 37 |
-
"estimated_cost": 1200,
|
| 38 |
-
"policy_violations": [],
|
| 39 |
-
"requester": "alice",
|
| 40 |
-
"provenance": {},
|
| 41 |
-
"configuration": {}
|
| 42 |
-
}
|
| 43 |
-
response = client.post(
|
| 44 |
-
"/api/v1/intents/evaluate",
|
| 45 |
-
json=payload,
|
| 46 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 47 |
-
)
|
| 48 |
-
assert response.status_code == 200, response.text
|
| 49 |
-
data = response.json()
|
| 50 |
-
# Top‑level fields
|
| 51 |
-
assert "risk_score" in data
|
| 52 |
-
assert "explanation" in data
|
| 53 |
-
assert "deterministic_id" in data
|
| 54 |
-
assert "recommended_action" in data
|
| 55 |
-
assert isinstance(data["risk_score"], float)
|
| 56 |
-
assert 0.0 <= data["risk_score"] <= 1.0
|
| 57 |
-
# HealingIntent contract
|
| 58 |
-
healing = data.get("healing_intent", {})
|
| 59 |
-
assert healing.get("action") is not None
|
| 60 |
-
assert healing.get("component") is not None
|
| 61 |
-
assert "justification" in healing
|
| 62 |
-
assert "confidence" in healing
|
| 63 |
-
assert "version" in healing
|
| 64 |
-
assert healing["version"] == "2.6.0"
|
| 65 |
-
# v4.3.2: context_hash must be present (64 hex chars)
|
| 66 |
-
ctx_hash = healing.get("context_hash")
|
| 67 |
-
assert isinstance(ctx_hash, str) and len(ctx_hash) == 64, (
|
| 68 |
-
f"context_hash missing or invalid: {ctx_hash}"
|
| 69 |
-
)
|
| 70 |
-
|
| 71 |
-
def test_audit_log_written(self, client, db_session):
|
| 72 |
-
"""A successful evaluation writes a row to the decision audit log.
|
| 73 |
-
|
| 74 |
-
deterministic_id is a hash of (action, component, parameters,
|
| 75 |
-
incident_id, oss_edition) only -- and provision_resource requests
|
| 76 |
-
have no field that varies component or parameters -- so a minimal
|
| 77 |
-
provision_resource payload here would collide with the identical
|
| 78 |
-
one in test_basic_provision_evaluation and hit the audit log's
|
| 79 |
-
idempotency skip. Checking for the specific row by the response's
|
| 80 |
-
own deterministic_id (rather than a tenant-wide count delta) tests
|
| 81 |
-
the actual claim -- "this decision got audited" -- without being
|
| 82 |
-
sensitive to what other tests already wrote for the same decision.
|
| 83 |
-
"""
|
| 84 |
-
payload = {
|
| 85 |
-
"intent_type": "provision_resource",
|
| 86 |
-
"environment": "prod",
|
| 87 |
-
"resource_type": "database",
|
| 88 |
-
"region": "eastus",
|
| 89 |
-
"size": "Standard",
|
| 90 |
-
"estimated_cost": 1200,
|
| 91 |
-
"policy_violations": [],
|
| 92 |
-
"requester": "alice",
|
| 93 |
-
"provenance": {},
|
| 94 |
-
"configuration": {}
|
| 95 |
-
}
|
| 96 |
-
response = client.post(
|
| 97 |
-
"/api/v1/intents/evaluate",
|
| 98 |
-
json=payload,
|
| 99 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 100 |
-
)
|
| 101 |
-
assert response.status_code == 200
|
| 102 |
-
deterministic_id = response.json()["deterministic_id"]
|
| 103 |
-
# The write_audit_log runs as a background task; give it a moment.
|
| 104 |
-
time.sleep(0.5)
|
| 105 |
-
entry = (
|
| 106 |
-
db_session.query(DecisionAuditLogDB)
|
| 107 |
-
.filter_by(tenant_id="test-tenant", deterministic_id=deterministic_id)
|
| 108 |
-
.first()
|
| 109 |
-
)
|
| 110 |
-
assert entry is not None, (
|
| 111 |
-
f"Expected an audit log entry for deterministic_id={deterministic_id}"
|
| 112 |
-
)
|
| 113 |
-
|
| 114 |
-
def test_skill_context_injection(self, client):
|
| 115 |
-
"""When skill_id is provided, the response includes skill posterior data."""
|
| 116 |
-
payload = {
|
| 117 |
-
"intent_type": "provision_resource",
|
| 118 |
-
"environment": "prod",
|
| 119 |
-
"resource_type": "database",
|
| 120 |
-
"region": "eastus",
|
| 121 |
-
"size": "Standard",
|
| 122 |
-
"estimated_cost": 1200,
|
| 123 |
-
"policy_violations": [],
|
| 124 |
-
"requester": "alice",
|
| 125 |
-
"provenance": {},
|
| 126 |
-
"configuration": {},
|
| 127 |
-
"skill_id": "pdf-skill",
|
| 128 |
-
}
|
| 129 |
-
response = client.post(
|
| 130 |
-
"/api/v1/intents/evaluate",
|
| 131 |
-
json=payload,
|
| 132 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 133 |
-
)
|
| 134 |
-
assert response.status_code == 200
|
| 135 |
-
healing = response.json().get("healing_intent", {})
|
| 136 |
-
# Skill fields should be present in the HealingIntent
|
| 137 |
-
assert "skill_id" in healing
|
| 138 |
-
assert healing["skill_id"] == "pdf-skill"
|
| 139 |
-
# Because the skill registry is a singleton, the skill may or may not
|
| 140 |
-
# already exist. In either case, the fields are populated with either
|
| 141 |
-
# the posterior or the default prior.
|
| 142 |
-
assert "skill_alpha" in healing
|
| 143 |
-
assert "skill_beta" in healing
|
| 144 |
-
assert "skill_reliability_score" in healing
|
| 145 |
-
assert "skill_version" in healing
|
| 146 |
-
|
| 147 |
-
def test_criticality_parameter(self, client):
|
| 148 |
-
"""The criticality field is accepted and flows into the HealingIntent."""
|
| 149 |
-
payload = {
|
| 150 |
-
"intent_type": "provision_resource",
|
| 151 |
-
"environment": "prod",
|
| 152 |
-
"resource_type": "database",
|
| 153 |
-
"region": "eastus",
|
| 154 |
-
"size": "Standard",
|
| 155 |
-
"estimated_cost": 1200,
|
| 156 |
-
"policy_violations": [],
|
| 157 |
-
"requester": "alice",
|
| 158 |
-
"provenance": {},
|
| 159 |
-
"configuration": {},
|
| 160 |
-
"criticality": 0.85,
|
| 161 |
-
}
|
| 162 |
-
response = client.post(
|
| 163 |
-
"/api/v1/intents/evaluate",
|
| 164 |
-
json=payload,
|
| 165 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 166 |
-
)
|
| 167 |
-
assert response.status_code == 200
|
| 168 |
-
healing = response.json().get("healing_intent", {})
|
| 169 |
-
assert healing.get("criticality") == 0.85, (
|
| 170 |
-
f"criticality should be 0.85, got {healing.get('criticality')}"
|
| 171 |
-
)
|
| 172 |
-
|
| 173 |
-
def test_policy_violation_denial(self, client):
|
| 174 |
-
"""An intent with a policy violation returns DENY."""
|
| 175 |
-
payload = {
|
| 176 |
-
"intent_type": "provision_resource",
|
| 177 |
-
"environment": "prod",
|
| 178 |
-
"resource_type": "database",
|
| 179 |
-
"region": "westus", # not in default allowed set
|
| 180 |
-
"size": "Standard",
|
| 181 |
-
"estimated_cost": 1200,
|
| 182 |
-
"policy_violations": ["Region 'westus' not allowed"], # pre‑computed
|
| 183 |
-
"requester": "alice",
|
| 184 |
-
"provenance": {},
|
| 185 |
-
"configuration": {}
|
| 186 |
-
}
|
| 187 |
-
response = client.post(
|
| 188 |
-
"/api/v1/intents/evaluate",
|
| 189 |
-
json=payload,
|
| 190 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 191 |
-
)
|
| 192 |
-
assert response.status_code == 200
|
| 193 |
-
data = response.json()
|
| 194 |
-
assert data.get("recommended_action") == "deny", (
|
| 195 |
-
f"Expected action=deny, got {data.get('recommended_action')}"
|
| 196 |
-
)
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
class TestHealingPipeline:
|
| 200 |
-
"""End‑to‑end tests for the /healing/evaluate endpoint."""
|
| 201 |
-
|
| 202 |
-
def test_basic_healing_evaluation(self, client):
|
| 203 |
-
"""A reliability event triggers candidate healing actions."""
|
| 204 |
-
payload = {
|
| 205 |
-
"event": {
|
| 206 |
-
"component": "checkout-service",
|
| 207 |
-
"latency_p99": 600.0,
|
| 208 |
-
"error_rate": 0.25,
|
| 209 |
-
"service_mesh": "default",
|
| 210 |
-
"cpu_util": 0.85,
|
| 211 |
-
"memory_util": 0.90,
|
| 212 |
-
}
|
| 213 |
-
}
|
| 214 |
-
response = client.post(
|
| 215 |
-
"/api/v1/healing/evaluate",
|
| 216 |
-
json=payload,
|
| 217 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 218 |
-
)
|
| 219 |
-
assert response.status_code == 200
|
| 220 |
-
data = response.json()
|
| 221 |
-
assert "selected_action" in data
|
| 222 |
-
assert data["selected_action"] != "NO_ACTION", (
|
| 223 |
-
"Expected at least one healing action to be triggered"
|
| 224 |
-
)
|
| 225 |
-
|
| 226 |
-
def test_healing_with_skill_context(self, client):
|
| 227 |
-
"""Skill context biases the healing decision utility."""
|
| 228 |
-
payload = {
|
| 229 |
-
"event": {
|
| 230 |
-
"component": "checkout-service",
|
| 231 |
-
"latency_p99": 600.0,
|
| 232 |
-
"error_rate": 0.25,
|
| 233 |
-
"service_mesh": "default",
|
| 234 |
-
"cpu_util": 0.85,
|
| 235 |
-
"memory_util": 0.90,
|
| 236 |
-
},
|
| 237 |
-
"skill_id": "pdf-skill",
|
| 238 |
-
"skill_version": 1,
|
| 239 |
-
}
|
| 240 |
-
response = client.post(
|
| 241 |
-
"/api/v1/healing/evaluate",
|
| 242 |
-
json=payload,
|
| 243 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 244 |
-
)
|
| 245 |
-
assert response.status_code == 200
|
| 246 |
-
data = response.json()
|
| 247 |
-
# The response should echo the skill context back
|
| 248 |
-
assert data.get("skill_id") == "pdf-skill"
|
| 249 |
-
assert data.get("skill_version") == 1
|
| 250 |
-
assert "selected_action" in data
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
class TestOutcomeRecording:
|
| 254 |
-
"""End‑to‑end tests for the /intents/outcome endpoint."""
|
| 255 |
-
|
| 256 |
-
def test_record_outcome_updates_risk_engine(self, client, db_session):
|
| 257 |
-
"""Recording a successful outcome for a previously evaluated intent
|
| 258 |
-
updates the conjugate posterior and skill registry."""
|
| 259 |
-
# Step 1: evaluate an intent to create a record
|
| 260 |
-
payload = {
|
| 261 |
-
"intent_type": "provision_resource",
|
| 262 |
-
"environment": "prod",
|
| 263 |
-
"resource_type": "database",
|
| 264 |
-
"region": "eastus",
|
| 265 |
-
"size": "Standard",
|
| 266 |
-
"estimated_cost": 1200,
|
| 267 |
-
"policy_violations": [],
|
| 268 |
-
"requester": "alice",
|
| 269 |
-
"provenance": {},
|
| 270 |
-
"configuration": {},
|
| 271 |
-
}
|
| 272 |
-
eval_resp = client.post(
|
| 273 |
-
"/api/v1/intents/evaluate",
|
| 274 |
-
json=payload,
|
| 275 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 276 |
-
)
|
| 277 |
-
assert eval_resp.status_code == 200
|
| 278 |
-
deterministic_id = eval_resp.json()["deterministic_id"]
|
| 279 |
-
|
| 280 |
-
# Step 2: record a successful outcome
|
| 281 |
-
outcome_payload = {
|
| 282 |
-
"deterministic_id": deterministic_id,
|
| 283 |
-
"success": True,
|
| 284 |
-
"recorded_by": "tester",
|
| 285 |
-
"notes": "integration test",
|
| 286 |
-
}
|
| 287 |
-
outcome_resp = client.post(
|
| 288 |
-
"/api/v1/intents/outcome",
|
| 289 |
-
json=outcome_payload,
|
| 290 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 291 |
-
)
|
| 292 |
-
assert outcome_resp.status_code == 200
|
| 293 |
-
assert "outcome_id" in outcome_resp.json()
|
| 294 |
-
|
| 295 |
-
# Step 3: verify that the outcome row exists in the database
|
| 296 |
-
from app.database.models_intents import OutcomeDB
|
| 297 |
-
outcome = (
|
| 298 |
-
db_session.query(OutcomeDB)
|
| 299 |
-
.filter_by(idempotency_key=None) # we didn't send one
|
| 300 |
-
.order_by(OutcomeDB.id.desc())
|
| 301 |
-
.first()
|
| 302 |
-
)
|
| 303 |
-
assert outcome is not None
|
| 304 |
-
assert outcome.success is True
|
| 305 |
-
assert outcome.recorded_by == "tester"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_intent_store.py
CHANGED
|
@@ -21,12 +21,11 @@ def test_save_intent(db_session):
|
|
| 21 |
saved = save_evaluated_intent(
|
| 22 |
db=db_session,
|
| 23 |
deterministic_id=det_id,
|
| 24 |
-
tenant_id="test-tenant",
|
| 25 |
intent_type="ProvisionResourceIntent",
|
| 26 |
api_payload={"foo": "bar"},
|
| 27 |
oss_payload={"intent_type": "provision_resource"},
|
| 28 |
environment="prod",
|
| 29 |
-
risk_score=0.42
|
| 30 |
)
|
| 31 |
assert saved.deterministic_id == det_id
|
| 32 |
assert saved.risk_score == "0.42"
|
|
@@ -38,9 +37,9 @@ def test_save_intent(db_session):
|
|
| 38 |
|
| 39 |
def test_update_existing_intent(db_session):
|
| 40 |
det_id = "intent_123"
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
assert updated.risk_score == "0.7"
|
| 45 |
count = db_session.query(IntentDB).filter(
|
| 46 |
IntentDB.deterministic_id == det_id).count()
|
|
|
|
| 21 |
saved = save_evaluated_intent(
|
| 22 |
db=db_session,
|
| 23 |
deterministic_id=det_id,
|
|
|
|
| 24 |
intent_type="ProvisionResourceIntent",
|
| 25 |
api_payload={"foo": "bar"},
|
| 26 |
oss_payload={"intent_type": "provision_resource"},
|
| 27 |
environment="prod",
|
| 28 |
+
risk_score=0.42
|
| 29 |
)
|
| 30 |
assert saved.deterministic_id == det_id
|
| 31 |
assert saved.risk_score == "0.42"
|
|
|
|
| 37 |
|
| 38 |
def test_update_existing_intent(db_session):
|
| 39 |
det_id = "intent_123"
|
| 40 |
+
save_evaluated_intent(db_session, det_id, "Type", {}, {}, "prod", 0.5)
|
| 41 |
+
updated = save_evaluated_intent(
|
| 42 |
+
db_session, det_id, "Type", {}, {}, "prod", 0.7)
|
| 43 |
assert updated.risk_score == "0.7"
|
| 44 |
count = db_session.query(IntentDB).filter(
|
| 45 |
IntentDB.deterministic_id == det_id).count()
|
tests/test_outcome_service.py
CHANGED
|
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock
|
|
| 4 |
from sqlalchemy import create_engine
|
| 5 |
from sqlalchemy.orm import sessionmaker
|
| 6 |
from app.database.base import Base
|
| 7 |
-
from app.database.models_intents import IntentDB
|
| 8 |
from app.services.outcome_service import record_outcome, OutcomeConflictError
|
| 9 |
from agentic_reliability_framework.core.governance.intents import (
|
| 10 |
ProvisionResourceIntent,
|
|
@@ -18,10 +18,6 @@ def db_session():
|
|
| 18 |
TestingSessionLocal = sessionmaker(bind=engine, future=True)
|
| 19 |
Base.metadata.create_all(bind=engine)
|
| 20 |
sess = TestingSessionLocal()
|
| 21 |
-
# Ensure a tenant exists for foreign key constraints
|
| 22 |
-
if not sess.query(TenantDB).filter_by(id="test-tenant").first():
|
| 23 |
-
sess.add(TenantDB(id="test-tenant", name="Test Tenant"))
|
| 24 |
-
sess.commit()
|
| 25 |
yield sess
|
| 26 |
sess.close()
|
| 27 |
|
|
@@ -46,7 +42,6 @@ def test_record_outcome_creates_row_and_updates_engine(
|
|
| 46 |
|
| 47 |
intent = IntentDB(
|
| 48 |
deterministic_id="intent_abc",
|
| 49 |
-
tenant_id="test-tenant", # <-- required
|
| 50 |
intent_type="ProvisionResourceIntent",
|
| 51 |
payload={},
|
| 52 |
oss_payload=oss_payload,
|
|
@@ -58,7 +53,6 @@ def test_record_outcome_creates_row_and_updates_engine(
|
|
| 58 |
|
| 59 |
outcome = record_outcome(
|
| 60 |
db=db_session,
|
| 61 |
-
tenant_id="test-tenant",
|
| 62 |
deterministic_id="intent_abc",
|
| 63 |
success=True,
|
| 64 |
recorded_by="tester",
|
|
@@ -75,7 +69,6 @@ def test_record_outcome_creates_row_and_updates_engine(
|
|
| 75 |
# call engine again
|
| 76 |
outcome2 = record_outcome(
|
| 77 |
db=db_session,
|
| 78 |
-
tenant_id="test-tenant",
|
| 79 |
deterministic_id="intent_abc",
|
| 80 |
success=True,
|
| 81 |
recorded_by="tester",
|
|
@@ -90,7 +83,6 @@ def test_record_outcome_creates_row_and_updates_engine(
|
|
| 90 |
def test_conflict_different_result(db_session, mock_risk_engine):
|
| 91 |
intent = IntentDB(
|
| 92 |
deterministic_id="intent_def",
|
| 93 |
-
tenant_id="test-tenant", # <-- required
|
| 94 |
intent_type="ProvisionResourceIntent",
|
| 95 |
payload={},
|
| 96 |
created_at=datetime.datetime.utcnow()
|
|
@@ -100,7 +92,6 @@ def test_conflict_different_result(db_session, mock_risk_engine):
|
|
| 100 |
|
| 101 |
record_outcome(
|
| 102 |
db_session,
|
| 103 |
-
"test-tenant",
|
| 104 |
"intent_def",
|
| 105 |
True,
|
| 106 |
None,
|
|
@@ -109,7 +100,6 @@ def test_conflict_different_result(db_session, mock_risk_engine):
|
|
| 109 |
with pytest.raises(OutcomeConflictError):
|
| 110 |
record_outcome(
|
| 111 |
db_session,
|
| 112 |
-
"test-tenant",
|
| 113 |
"intent_def",
|
| 114 |
False,
|
| 115 |
None,
|
|
@@ -121,7 +111,6 @@ def test_nonexistent_intent(db_session, mock_risk_engine):
|
|
| 121 |
with pytest.raises(ValueError):
|
| 122 |
record_outcome(
|
| 123 |
db_session,
|
| 124 |
-
"test-tenant",
|
| 125 |
"missing",
|
| 126 |
True,
|
| 127 |
None,
|
|
@@ -134,7 +123,6 @@ def test_record_outcome_reconstruction_failure_does_not_update_engine(
|
|
| 134 |
# Create an intent with invalid oss_payload (missing required fields)
|
| 135 |
intent = IntentDB(
|
| 136 |
deterministic_id="intent_bad",
|
| 137 |
-
tenant_id="test-tenant", # <-- required
|
| 138 |
intent_type="ProvisionResourceIntent",
|
| 139 |
payload={},
|
| 140 |
oss_payload={"intent_type": "provision_resource"}, # missing fields
|
|
@@ -146,7 +134,6 @@ def test_record_outcome_reconstruction_failure_does_not_update_engine(
|
|
| 146 |
# This should NOT call risk_engine.update_outcome (no dummy fallback)
|
| 147 |
outcome = record_outcome(
|
| 148 |
db=db_session,
|
| 149 |
-
tenant_id="test-tenant",
|
| 150 |
deterministic_id="intent_bad",
|
| 151 |
success=True,
|
| 152 |
recorded_by="tester",
|
|
|
|
| 4 |
from sqlalchemy import create_engine
|
| 5 |
from sqlalchemy.orm import sessionmaker
|
| 6 |
from app.database.base import Base
|
| 7 |
+
from app.database.models_intents import IntentDB
|
| 8 |
from app.services.outcome_service import record_outcome, OutcomeConflictError
|
| 9 |
from agentic_reliability_framework.core.governance.intents import (
|
| 10 |
ProvisionResourceIntent,
|
|
|
|
| 18 |
TestingSessionLocal = sessionmaker(bind=engine, future=True)
|
| 19 |
Base.metadata.create_all(bind=engine)
|
| 20 |
sess = TestingSessionLocal()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
yield sess
|
| 22 |
sess.close()
|
| 23 |
|
|
|
|
| 42 |
|
| 43 |
intent = IntentDB(
|
| 44 |
deterministic_id="intent_abc",
|
|
|
|
| 45 |
intent_type="ProvisionResourceIntent",
|
| 46 |
payload={},
|
| 47 |
oss_payload=oss_payload,
|
|
|
|
| 53 |
|
| 54 |
outcome = record_outcome(
|
| 55 |
db=db_session,
|
|
|
|
| 56 |
deterministic_id="intent_abc",
|
| 57 |
success=True,
|
| 58 |
recorded_by="tester",
|
|
|
|
| 69 |
# call engine again
|
| 70 |
outcome2 = record_outcome(
|
| 71 |
db=db_session,
|
|
|
|
| 72 |
deterministic_id="intent_abc",
|
| 73 |
success=True,
|
| 74 |
recorded_by="tester",
|
|
|
|
| 83 |
def test_conflict_different_result(db_session, mock_risk_engine):
|
| 84 |
intent = IntentDB(
|
| 85 |
deterministic_id="intent_def",
|
|
|
|
| 86 |
intent_type="ProvisionResourceIntent",
|
| 87 |
payload={},
|
| 88 |
created_at=datetime.datetime.utcnow()
|
|
|
|
| 92 |
|
| 93 |
record_outcome(
|
| 94 |
db_session,
|
|
|
|
| 95 |
"intent_def",
|
| 96 |
True,
|
| 97 |
None,
|
|
|
|
| 100 |
with pytest.raises(OutcomeConflictError):
|
| 101 |
record_outcome(
|
| 102 |
db_session,
|
|
|
|
| 103 |
"intent_def",
|
| 104 |
False,
|
| 105 |
None,
|
|
|
|
| 111 |
with pytest.raises(ValueError):
|
| 112 |
record_outcome(
|
| 113 |
db_session,
|
|
|
|
| 114 |
"missing",
|
| 115 |
True,
|
| 116 |
None,
|
|
|
|
| 123 |
# Create an intent with invalid oss_payload (missing required fields)
|
| 124 |
intent = IntentDB(
|
| 125 |
deterministic_id="intent_bad",
|
|
|
|
| 126 |
intent_type="ProvisionResourceIntent",
|
| 127 |
payload={},
|
| 128 |
oss_payload={"intent_type": "provision_resource"}, # missing fields
|
|
|
|
| 134 |
# This should NOT call risk_engine.update_outcome (no dummy fallback)
|
| 135 |
outcome = record_outcome(
|
| 136 |
db=db_session,
|
|
|
|
| 137 |
deterministic_id="intent_bad",
|
| 138 |
success=True,
|
| 139 |
recorded_by="tester",
|
tests/test_payments.py
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
from unittest.mock import patch, MagicMock
|
| 3 |
from fastapi.testclient import TestClient
|
| 4 |
from app.main import app
|
| 5 |
|
| 6 |
-
# Every Stripe call in this module is mocked -- nothing here reaches the
|
| 7 |
-
# network, and no real credentials are involved. This module used to skip
|
| 8 |
-
# entirely whenever STRIPE_SECRET_KEY was unset (always, in CI), so none of
|
| 9 |
-
# it ran; stripe.api_key is patched below instead of relying on env vars,
|
| 10 |
-
# since routes_payments.py reads it at import time.
|
| 11 |
client = TestClient(app)
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
@pytest.fixture
|
|
@@ -23,13 +21,11 @@ def mock_stripe():
|
|
| 23 |
|
| 24 |
|
| 25 |
def test_create_checkout_session_missing_stripe_key(monkeypatch):
|
| 26 |
-
|
| 27 |
-
# time and checks `stripe.api_key`, so setenv here would be a no-op.
|
| 28 |
-
monkeypatch.setattr("stripe.api_key", None)
|
| 29 |
response = client.post(
|
| 30 |
"/api/v1/payments/create-checkout-session",
|
| 31 |
-
headers={"Authorization": "Bearer test_key"},
|
| 32 |
json={
|
|
|
|
| 33 |
"success_url": "https://example.com/success",
|
| 34 |
"cancel_url": "https://example.com/cancel"})
|
| 35 |
assert response.status_code == 500
|
|
@@ -40,13 +36,12 @@ def test_create_checkout_session_free_key(mock_stripe):
|
|
| 40 |
# Mock tracker.get_tier to return Tier.FREE
|
| 41 |
with patch("app.core.usage_tracker.tracker") as mock_tracker:
|
| 42 |
mock_tracker.get_tier.return_value = "free"
|
| 43 |
-
mock_tracker.get_tenant_id.return_value = "tenant_test_123"
|
| 44 |
mock_stripe.return_value = MagicMock(
|
| 45 |
id="cs_test_123", url="https://checkout.stripe.com/pay")
|
| 46 |
response = client.post(
|
| 47 |
"/api/v1/payments/create-checkout-session",
|
| 48 |
-
headers={"Authorization": "Bearer test_key"},
|
| 49 |
json={
|
|
|
|
| 50 |
"success_url": "https://example.com/success",
|
| 51 |
"cancel_url": "https://example.com/cancel"})
|
| 52 |
assert response.status_code == 200
|
|
@@ -60,39 +55,10 @@ def test_create_checkout_session_pro_key():
|
|
| 60 |
mock_tracker.get_tier.return_value = "pro"
|
| 61 |
response = client.post(
|
| 62 |
"/api/v1/payments/create-checkout-session",
|
| 63 |
-
headers={"Authorization": "Bearer test_key"},
|
| 64 |
json={
|
|
|
|
| 65 |
"success_url": "https://example.com/success",
|
| 66 |
"cancel_url": "https://example.com/cancel"})
|
| 67 |
assert response.status_code == 400
|
| 68 |
assert "Only free tier keys can be upgraded" in response.json()[
|
| 69 |
"detail"]
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def test_create_checkout_session_requires_authentication(mock_stripe):
|
| 73 |
-
# Regression test: this endpoint used to take `api_key` as a plain JSON
|
| 74 |
-
# body field with no Depends() gate at all, so any caller could request
|
| 75 |
-
# a session for any tenant_id-bearing key string without proving they
|
| 76 |
-
# held it. No Authorization header (and no api_key in the body -- the
|
| 77 |
-
# field no longer exists on CheckoutRequest) must be rejected before
|
| 78 |
-
# ever reaching Stripe.
|
| 79 |
-
response = client.post(
|
| 80 |
-
"/api/v1/payments/create-checkout-session",
|
| 81 |
-
json={
|
| 82 |
-
"success_url": "https://example.com/success",
|
| 83 |
-
"cancel_url": "https://example.com/cancel"})
|
| 84 |
-
assert response.status_code == 401
|
| 85 |
-
mock_stripe.assert_not_called()
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
def test_create_checkout_session_rejects_invalid_key(mock_stripe):
|
| 89 |
-
with patch("app.core.usage_tracker.tracker") as mock_tracker:
|
| 90 |
-
mock_tracker.get_tier.return_value = None
|
| 91 |
-
response = client.post(
|
| 92 |
-
"/api/v1/payments/create-checkout-session",
|
| 93 |
-
headers={"Authorization": "Bearer not-a-real-key"},
|
| 94 |
-
json={
|
| 95 |
-
"success_url": "https://example.com/success",
|
| 96 |
-
"cancel_url": "https://example.com/cancel"})
|
| 97 |
-
assert response.status_code == 403
|
| 98 |
-
mock_stripe.assert_not_called()
|
|
|
|
| 1 |
+
import os
|
| 2 |
import pytest
|
| 3 |
from unittest.mock import patch, MagicMock
|
| 4 |
from fastapi.testclient import TestClient
|
| 5 |
from app.main import app
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
client = TestClient(app)
|
| 8 |
|
| 9 |
+
# Skip all tests in this module if Stripe secret key is not set
|
| 10 |
+
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
|
| 11 |
+
if not STRIPE_SECRET_KEY:
|
| 12 |
+
pytest.skip(
|
| 13 |
+
"Stripe not configured – skipping payment tests",
|
| 14 |
+
allow_module_level=True)
|
| 15 |
|
| 16 |
|
| 17 |
@pytest.fixture
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def test_create_checkout_session_missing_stripe_key(monkeypatch):
|
| 24 |
+
monkeypatch.setenv("STRIPE_SECRET_KEY", "")
|
|
|
|
|
|
|
| 25 |
response = client.post(
|
| 26 |
"/api/v1/payments/create-checkout-session",
|
|
|
|
| 27 |
json={
|
| 28 |
+
"api_key": "test_key",
|
| 29 |
"success_url": "https://example.com/success",
|
| 30 |
"cancel_url": "https://example.com/cancel"})
|
| 31 |
assert response.status_code == 500
|
|
|
|
| 36 |
# Mock tracker.get_tier to return Tier.FREE
|
| 37 |
with patch("app.core.usage_tracker.tracker") as mock_tracker:
|
| 38 |
mock_tracker.get_tier.return_value = "free"
|
|
|
|
| 39 |
mock_stripe.return_value = MagicMock(
|
| 40 |
id="cs_test_123", url="https://checkout.stripe.com/pay")
|
| 41 |
response = client.post(
|
| 42 |
"/api/v1/payments/create-checkout-session",
|
|
|
|
| 43 |
json={
|
| 44 |
+
"api_key": "test_key",
|
| 45 |
"success_url": "https://example.com/success",
|
| 46 |
"cancel_url": "https://example.com/cancel"})
|
| 47 |
assert response.status_code == 200
|
|
|
|
| 55 |
mock_tracker.get_tier.return_value = "pro"
|
| 56 |
response = client.post(
|
| 57 |
"/api/v1/payments/create-checkout-session",
|
|
|
|
| 58 |
json={
|
| 59 |
+
"api_key": "test_key",
|
| 60 |
"success_url": "https://example.com/success",
|
| 61 |
"cancel_url": "https://example.com/cancel"})
|
| 62 |
assert response.status_code == 400
|
| 63 |
assert "Only free tier keys can be upgraded" in response.json()[
|
| 64 |
"detail"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_performance.py
DELETED
|
@@ -1,100 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Performance benchmarks for the ARF governance pipeline.
|
| 3 |
-
|
| 4 |
-
These tests measure the latency of key operations and assert that
|
| 5 |
-
they remain within the target thresholds for pilot readiness.
|
| 6 |
-
|
| 7 |
-
Targets (v4.3.2):
|
| 8 |
-
- Full governance loop (single intent): p50 < 50 ms, p99 < 100 ms
|
| 9 |
-
- Policy evaluation alone: p50 < 1 ms
|
| 10 |
-
- Conjugate update: p50 < 0.1 ms
|
| 11 |
-
- HealingIntent serialization: p50 < 5 ms
|
| 12 |
-
"""
|
| 13 |
-
import time
|
| 14 |
-
import pytest
|
| 15 |
-
import numpy as np
|
| 16 |
-
|
| 17 |
-
from agentic_reliability_framework.core.governance.governance_loop import GovernanceLoop
|
| 18 |
-
from agentic_reliability_framework.core.governance.intents import (
|
| 19 |
-
ProvisionResourceIntent,
|
| 20 |
-
ResourceType,
|
| 21 |
-
)
|
| 22 |
-
from agentic_reliability_framework.core.governance.policies import PolicyEvaluator, allow_all
|
| 23 |
-
from agentic_reliability_framework.core.governance.cost_estimator import CostEstimator
|
| 24 |
-
from agentic_reliability_framework.core.governance.risk_engine import RiskEngine
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
# Number of warmup iterations and measured iterations
|
| 28 |
-
WARMUP = 10
|
| 29 |
-
MEASURED = 50
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def _measure_latency(fn, *args, **kwargs):
|
| 33 |
-
"""Run fn MEASURED times after WARMUP warmups, return (p50, p99, p100) in seconds."""
|
| 34 |
-
times = []
|
| 35 |
-
for _ in range(WARMUP):
|
| 36 |
-
fn(*args, **kwargs)
|
| 37 |
-
for _ in range(MEASURED):
|
| 38 |
-
t0 = time.perf_counter()
|
| 39 |
-
fn(*args, **kwargs)
|
| 40 |
-
times.append(time.perf_counter() - t0)
|
| 41 |
-
arr = np.array(times) * 1000 # convert to milliseconds
|
| 42 |
-
return np.percentile(arr, 50), np.percentile(arr, 99), arr.max()
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
@pytest.fixture(scope="module")
|
| 46 |
-
def sample_intent():
|
| 47 |
-
return ProvisionResourceIntent(
|
| 48 |
-
resource_type=ResourceType.VM,
|
| 49 |
-
region="eastus",
|
| 50 |
-
size="Standard_D2s_v3",
|
| 51 |
-
requester="perf-test",
|
| 52 |
-
environment="dev",
|
| 53 |
-
)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
@pytest.fixture(scope="module")
|
| 57 |
-
def governance_loop():
|
| 58 |
-
return GovernanceLoop(
|
| 59 |
-
policy_evaluator=PolicyEvaluator(allow_all()),
|
| 60 |
-
cost_estimator=CostEstimator(),
|
| 61 |
-
risk_engine=RiskEngine(),
|
| 62 |
-
enable_epistemic=False,
|
| 63 |
-
)
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
class TestGovernanceLoopPerformance:
|
| 67 |
-
"""Latency benchmarks for the full governance loop."""
|
| 68 |
-
|
| 69 |
-
def test_full_loop_latency(self, governance_loop, sample_intent):
|
| 70 |
-
"""The full loop should complete within 100 ms at p99."""
|
| 71 |
-
p50, p99, p100 = _measure_latency(
|
| 72 |
-
governance_loop.run, sample_intent, context={"service_name": "perf-svc"}
|
| 73 |
-
)
|
| 74 |
-
assert p50 < 100, f"p50 latency {p50:.1f} ms exceeds 100 ms target"
|
| 75 |
-
assert p99 < 200, f"p99 latency {p99:.1f} ms exceeds 200 ms target"
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
class TestHealingIntentSerialization:
|
| 79 |
-
"""Serialization performance."""
|
| 80 |
-
|
| 81 |
-
def test_to_enterprise_request_latency(self, governance_loop, sample_intent):
|
| 82 |
-
"""Serializing a HealingIntent to the enterprise request dict should be fast."""
|
| 83 |
-
intent = governance_loop.run(sample_intent, context={"service_name": "perf-svc"})
|
| 84 |
-
p50, p99, p100 = _measure_latency(intent.to_enterprise_request)
|
| 85 |
-
assert p50 < 10, f"p50 serialization latency {p50:.1f} ms exceeds 10 ms target"
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
class TestRiskEnginePerformance:
|
| 89 |
-
"""Conjugate update latency."""
|
| 90 |
-
|
| 91 |
-
def test_risk_calculation_latency(self, governance_loop, sample_intent):
|
| 92 |
-
"""A single risk calculation should be sub‑millisecond."""
|
| 93 |
-
engine = governance_loop.risk_engine
|
| 94 |
-
p50, p99, p100 = _measure_latency(
|
| 95 |
-
engine.calculate_risk,
|
| 96 |
-
intent=sample_intent,
|
| 97 |
-
cost_estimate=None,
|
| 98 |
-
policy_violations=[],
|
| 99 |
-
)
|
| 100 |
-
assert p50 < 10, f"p50 risk calculation latency {p50:.1f} ms exceeds 10 ms target"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_risk.py
CHANGED
|
@@ -31,8 +31,4 @@ def test_get_risk_internal_error(client, monkeypatch):
|
|
| 31 |
"X-API-Key": "test-key"})
|
| 32 |
assert response.status_code == 500
|
| 33 |
data = response.json()
|
| 34 |
-
|
| 35 |
-
# logs the real exception server-side and returns a generic detail
|
| 36 |
-
# instead (information-disclosure fix from this session's audit).
|
| 37 |
-
assert data.get("detail") == "Internal server error"
|
| 38 |
-
assert "test error" not in data.get("detail", "")
|
|
|
|
| 31 |
"X-API-Key": "test-key"})
|
| 32 |
assert response.status_code == 500
|
| 33 |
data = response.json()
|
| 34 |
+
assert "test error" in data.get("detail", "")
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_routes_admin.py
DELETED
|
@@ -1,126 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Route-level tests for the /admin/keys endpoints against the real,
|
| 3 |
-
Postgres-backed UsageTracker -- these routes are normally exercised through
|
| 4 |
-
the module-level `tracker` singleton (conftest.py replaces it globally with
|
| 5 |
-
MockTracker for every other test), so `tracker` is patched on
|
| 6 |
-
`app.core.usage_tracker` and `ADMIN_API_KEY` on `app.api.routes_admin` for
|
| 7 |
-
the duration of this module.
|
| 8 |
-
|
| 9 |
-
`tracker` is patched on the *defining* module, not on routes_admin, because
|
| 10 |
-
routes_admin no longer holds its own binding: it reaches the singleton
|
| 11 |
-
through `usage_tracker.tracker` so that init_tracker()'s rebinding is
|
| 12 |
-
visible to it. That is also what makes one patch here sufficient where five
|
| 13 |
-
would otherwise be needed.
|
| 14 |
-
|
| 15 |
-
This is the main regression coverage for the api_keys-on-Postgres
|
| 16 |
-
migration: it exercises the exact raw SQL routes_admin.py runs against
|
| 17 |
-
`usage_tracker.tracker._get_pg_conn()`.
|
| 18 |
-
"""
|
| 19 |
-
import hashlib
|
| 20 |
-
import hmac
|
| 21 |
-
import os
|
| 22 |
-
|
| 23 |
-
import pytest
|
| 24 |
-
|
| 25 |
-
from app.core import usage_tracker
|
| 26 |
-
from app.core.usage_tracker import UsageTracker
|
| 27 |
-
import app.api.routes_admin as routes_admin
|
| 28 |
-
|
| 29 |
-
TEST_ADMIN_KEY = "test-admin-key-for-routes-admin-tests"
|
| 30 |
-
TEST_PEPPER = os.environ["ARF_KEY_PEPPER"] # set in conftest.py before app import
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def _key_id(raw_key: str) -> str:
|
| 34 |
-
"""Reproduce UsageTracker._lookup_hash without a tracker instance, so
|
| 35 |
-
tests can locate the row created for a given raw key deterministically."""
|
| 36 |
-
return hmac.new(TEST_PEPPER.encode(), raw_key.encode(), hashlib.sha256).hexdigest()
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
@pytest.fixture(autouse=True)
|
| 40 |
-
def real_tracker(monkeypatch):
|
| 41 |
-
real = UsageTracker(db_path=":memory:")
|
| 42 |
-
monkeypatch.setattr(usage_tracker, "tracker", real)
|
| 43 |
-
monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY)
|
| 44 |
-
yield real
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def test_create_list_update_deactivate_key(client):
|
| 48 |
-
create_resp = client.post(
|
| 49 |
-
"/api/v1/admin/keys",
|
| 50 |
-
params={"admin_key": TEST_ADMIN_KEY},
|
| 51 |
-
json={"tier": "free", "org_name": "Test Org"},
|
| 52 |
-
)
|
| 53 |
-
assert create_resp.status_code == 200
|
| 54 |
-
body = create_resp.json()
|
| 55 |
-
api_key = body["api_key"]
|
| 56 |
-
assert body["tier"] == "free"
|
| 57 |
-
key_id = _key_id(api_key)
|
| 58 |
-
|
| 59 |
-
list_resp = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 60 |
-
assert list_resp.status_code == 200
|
| 61 |
-
keys_by_id = {row["key_id"]: row for row in list_resp.json()["keys"]}
|
| 62 |
-
assert key_id in keys_by_id
|
| 63 |
-
assert keys_by_id[key_id]["tier"] == "free"
|
| 64 |
-
assert keys_by_id[key_id]["is_active"] is True
|
| 65 |
-
|
| 66 |
-
patch_resp = client.patch(
|
| 67 |
-
f"/api/v1/admin/keys/{key_id}/tier",
|
| 68 |
-
params={"admin_key": TEST_ADMIN_KEY},
|
| 69 |
-
json={"tier": "pro"},
|
| 70 |
-
)
|
| 71 |
-
assert patch_resp.status_code == 200
|
| 72 |
-
|
| 73 |
-
list_resp2 = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 74 |
-
assert list_resp2.json()["keys"][0] # non-empty, sanity check
|
| 75 |
-
keys_by_id2 = {row["key_id"]: row for row in list_resp2.json()["keys"]}
|
| 76 |
-
assert keys_by_id2[key_id]["tier"] == "pro"
|
| 77 |
-
|
| 78 |
-
delete_resp = client.delete(f"/api/v1/admin/keys/{key_id}", params={"admin_key": TEST_ADMIN_KEY})
|
| 79 |
-
assert delete_resp.status_code == 200
|
| 80 |
-
|
| 81 |
-
list_resp3 = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 82 |
-
keys_by_id3 = {row["key_id"]: row for row in list_resp3.json()["keys"]}
|
| 83 |
-
assert keys_by_id3[key_id]["is_active"] is False
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def test_update_nonexistent_key_returns_404(client):
|
| 87 |
-
resp = client.patch(
|
| 88 |
-
"/api/v1/admin/keys/does-not-exist/tier",
|
| 89 |
-
params={"admin_key": TEST_ADMIN_KEY},
|
| 90 |
-
json={"tier": "pro"},
|
| 91 |
-
)
|
| 92 |
-
assert resp.status_code == 404
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def test_rotate_key_deactivates_old_and_creates_new_on_same_tenant(client):
|
| 96 |
-
create_resp = client.post(
|
| 97 |
-
"/api/v1/admin/keys",
|
| 98 |
-
params={"admin_key": TEST_ADMIN_KEY},
|
| 99 |
-
json={"tier": "pro", "org_name": "Rotate Test Org"},
|
| 100 |
-
)
|
| 101 |
-
assert create_resp.status_code == 200
|
| 102 |
-
old_body = create_resp.json()
|
| 103 |
-
old_key_id = _key_id(old_body["api_key"])
|
| 104 |
-
tenant_id = old_body["tenant_id"]
|
| 105 |
-
|
| 106 |
-
rotate_resp = client.post(
|
| 107 |
-
f"/api/v1/admin/keys/{old_key_id}/rotate", params={"admin_key": TEST_ADMIN_KEY})
|
| 108 |
-
assert rotate_resp.status_code == 200
|
| 109 |
-
rotated = rotate_resp.json()
|
| 110 |
-
assert rotated["tenant_id"] == tenant_id
|
| 111 |
-
assert rotated["tier"] == "pro"
|
| 112 |
-
assert rotated["deactivated_key_id"] == old_key_id
|
| 113 |
-
new_key_id = _key_id(rotated["api_key"])
|
| 114 |
-
assert new_key_id != old_key_id
|
| 115 |
-
|
| 116 |
-
list_resp = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 117 |
-
keys_by_id = {row["key_id"]: row for row in list_resp.json()["keys"]}
|
| 118 |
-
assert keys_by_id[old_key_id]["is_active"] is False
|
| 119 |
-
assert keys_by_id[new_key_id]["is_active"] is True
|
| 120 |
-
assert keys_by_id[new_key_id]["tier"] == "pro"
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def test_rotate_nonexistent_key_returns_404(client):
|
| 124 |
-
resp = client.post(
|
| 125 |
-
"/api/v1/admin/keys/does-not-exist/rotate", params={"admin_key": TEST_ADMIN_KEY})
|
| 126 |
-
assert resp.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|