Upload folder using huggingface_hub

#24
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. Dockerfile +3 -60
  2. README.md +10 -24
  3. alembic/versions/a1f3c9d2e6b7_create_api_keys_table.py +0 -58
  4. alembic/versions/e4a7c1f9b3d2_create_onchain_rationales_table.py +0 -62
  5. app/api/deps.py +3 -74
  6. app/api/routes_admin.py +52 -205
  7. app/api/routes_governance.py +118 -399
  8. app/api/routes_history.py +3 -4
  9. app/api/routes_incidents.py +13 -15
  10. app/api/routes_intents.py +4 -10
  11. app/api/routes_memory.py +2 -3
  12. app/api/routes_onchain.py +0 -149
  13. app/api/routes_payments.py +13 -47
  14. app/api/routes_pricing.py +44 -22
  15. app/api/routes_risk.py +14 -10
  16. app/api/routes_users.py +3 -4
  17. app/api/webhooks.py +13 -34
  18. app/core/config.py +0 -1
  19. app/core/storage.py +2 -16
  20. app/core/usage_tracker.py +64 -429
  21. app/database/models_intents.py +65 -6
  22. app/database/models_onchain.py +0 -54
  23. app/main.py +13 -145
  24. app/models/infrastructure_intents.py +0 -4
  25. app/services/outcome_service.py +23 -89
  26. app/services/risk_service.py +19 -229
  27. deploy/kubernetes/arf-api/configmap.yaml +0 -11
  28. deploy/kubernetes/arf-api/deployment.yaml +0 -65
  29. deploy/kubernetes/arf-api/hpa.yaml +0 -25
  30. deploy/kubernetes/arf-api/networkpolicy.yaml +0 -20
  31. deploy/kubernetes/arf-api/secret.yaml +0 -25
  32. deploy/kubernetes/arf-api/service.yaml +0 -16
  33. docs/authentication.md +13 -39
  34. docs/development.md +1 -2
  35. render.yaml +0 -2
  36. requirements-dev.txt +0 -2
  37. requirements.txt +4 -7
  38. tests/conftest.py +6 -29
  39. tests/test_deps.py +1 -75
  40. tests/test_governance.py +0 -77
  41. tests/test_healing_endpoint.py +0 -28
  42. tests/test_history.py +6 -2
  43. tests/test_integration.py +0 -305
  44. tests/test_intent_store.py +4 -4
  45. tests/test_outcome_service.py +1 -14
  46. tests/test_payments.py +11 -45
  47. tests/test_performance.py +0 -100
  48. tests/test_risk.py +1 -5
  49. tests/test_routes_admin.py +0 -126
  50. tests/test_routes_governance_execute.py +0 -352
Dockerfile CHANGED
@@ -1,64 +1,7 @@
1
- # syntax=docker/dockerfile:1.2
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
- # torch has no explicit pin anywhere in this dependency tree -- it's pulled in
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
- # Shell form (not exec/JSON-array form) deliberately -- ${PORT:-7860} only
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
- **Render is the primary deployment target** (custom domain, real scaling, standard secrets
17
- management -- the multi-stage Docker build in this repo was purpose-built for it). The Hugging
18
- Face Space below is a secondary, publicly-browsable mirror of the same code, not the primary
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 – dead setting, not read by any current route (see docs/authentication.md)
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
@@ -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": ["Using heuristic causal model (no fitted SCM)."]
 
 
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 run against a live Postgres connection (`tests/conftest.py`), matching CI's `postgres` service — not a temporary SQLite DB.
130
 
131
  Notes
132
  -----
133
 
134
  - The governance endpoints use an in-process `RiskEngine` initialized at startup.
135
- - Outcomes are recorded via `POST /api/v1/intents/outcome` (`app/api/routes_governance.py`), tenant-scoped and auth-protected.
 
 
 
 
 
 
 
 
 
 
 
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
 
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
@@ -3,23 +3,17 @@ 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
@@ -28,14 +22,6 @@ from agentic_reliability_framework.core.governance.causal_effect_estimator impor
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
@@ -64,45 +50,6 @@ limiter = Limiter(
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
- async def verify_internal_key(x_internal_key: str = Header(default=None, alias="X-Internal-Key")):
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
  # ---------------------------------------------------------------------------
@@ -112,7 +59,6 @@ _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:
@@ -211,20 +157,3 @@ def get_causal_explainer() -> CausalEffectEstimator:
211
  if _causal_explainer is None:
212
  _causal_explainer = CausalEffectEstimator()
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
 
3
 
4
  Provides FastAPI dependencies for database sessions, rate limiting, and
5
  singleton instances of the core ARF engines (RiskEngine, DecisionEngine,
6
+ LyapunovStabilityController, CausalEffectEstimator, and RAGGraphMemory).
7
+ All engine dependencies are lazily initialised and cached for the lifetime
8
+ of the application process.
 
 
9
  """
10
 
 
11
  import sys
 
12
  from app.database.session import SessionLocal
13
  from slowapi import Limiter
14
  from slowapi.util import get_remote_address
15
  from app.core.config import settings
16
 
 
 
17
  # ARF core engine imports
18
  from agentic_reliability_framework.core.governance.risk_engine import RiskEngine
19
  from agentic_reliability_framework.core.decision.decision_engine import DecisionEngine
 
22
  from agentic_reliability_framework.runtime.memory.rag_graph import RAGGraphMemory
23
  from agentic_reliability_framework.core.models.event import ReliabilityEvent, HealingAction
24
 
 
 
 
 
 
 
 
 
25
 
26
  # ---------------------------------------------------------------------------
27
  # Database dependency
 
50
  )
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  # ---------------------------------------------------------------------------
54
  # Singleton engine instances (lazy, cached)
55
  # ---------------------------------------------------------------------------
 
59
  _stability_controller = None
60
  _causal_explainer = None
61
  _rag_graph = None
 
62
 
63
 
64
  def _seed_rag_graph(rag: RAGGraphMemory) -> None:
 
157
  if _causal_explainer is None:
158
  _causal_explainer = CausalEffectEstimator()
159
  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, Request
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 sqlalchemy.orm import Session
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
- # Admin key must be supplied via environment; there is no default. Fail closed
20
- # if it is not configured, rather than falling back to a guessable secret.
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 not ADMIN_API_KEY:
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, db: Session = Depends(get_db)):
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
- usage_tracker.tracker.get_or_create_api_key(new_key, tenant_id=tenant_id, tier=tier_enum)
72
- return {"api_key": new_key, "tenant_id": tenant_id, "tier": req.tier}
 
73
 
74
 
75
- @router.get("/keys", dependencies=[Depends(verify_admin)])
76
  async def list_api_keys(limit: int = 100, offset: int = 0):
77
- """Lists keys by a non-secret `key_id` (the key's pepper-HMAC lookup
78
- hash), never the plaintext key -- there is no plaintext key to show
79
- since the H-2 fix (api_keys are hashed at rest). Use `key_id` in the
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
- conn.commit()
94
- keys = [
95
- {
96
- "key_id": row["lookup_hash"],
97
- "tier": row["tier"],
98
- "created_at": row["created_at"].isoformat(),
99
- "last_used_at": row["last_used_at"].isoformat() if row["last_used_at"] else None,
100
- "is_active": bool(row["is_active"]),
101
- }
102
- for row in rows
103
- ]
 
 
 
 
 
 
 
 
 
104
  return {"keys": keys, "total": len(keys)}
105
 
106
 
107
- @router.patch("/keys/{key_id}/tier", dependencies=[Depends(verify_admin)])
108
  async def update_key_tier(
109
- key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)"),
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 usage_tracker.tracker._get_pg_conn() as conn:
116
- row = usage_tracker.tracker._pg_execute(
117
- conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (key_id,)).fetchone()
118
  if not row:
119
- conn.rollback()
120
  raise HTTPException(status_code=404, detail="API key not found")
121
- usage_tracker.tracker._pg_execute(
122
- conn, "UPDATE api_keys SET tier = %s WHERE lookup_hash = %s", (req.tier, key_id))
123
  conn.commit()
124
  return {"message": f"Tier updated to {req.tier}"}
125
 
126
 
127
- @router.post("/keys/{key_id}/rotate", dependencies=[Depends(verify_admin)])
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
- key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)")):
170
- with usage_tracker.tracker._get_pg_conn() as conn:
171
- row = usage_tracker.tracker._pg_execute(
172
- conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (key_id,)).fetchone()
173
  if not row:
174
- conn.rollback()
175
  raise HTTPException(status_code=404, detail="API key not found")
176
- usage_tracker.tracker._pg_execute(
177
- conn, "UPDATE api_keys SET is_active = false WHERE lookup_hash = %s", (key_id,))
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 = usage_tracker.tracker.get_audit_logs(api_key, start, end, limit)
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 usage_tracker.tracker._get_pg_conn() as pg_conn:
198
- total_keys = usage_tracker.tracker._pg_execute(
199
- pg_conn, "SELECT COUNT(*) FROM api_keys WHERE is_active = true").fetchone()["count"]
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 = usage_tracker.tracker._get_month_key()
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
@@ -5,49 +5,31 @@ 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 datetime
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
@@ -69,57 +51,6 @@ 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
@@ -131,9 +62,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,38 +70,17 @@ 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],
@@ -182,90 +90,67 @@ async def write_audit_log(
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
  # --------------------------------------------------------------------------
@@ -278,13 +163,9 @@ 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, tenant isolation,
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:
@@ -293,10 +174,19 @@ async def evaluate_intent_endpoint(
293
  span.set_attribute("environment", str(intent_req.environment))
294
 
295
  start_time = time.time()
296
- # api_key/tenant_id are resolved server-side by enforce_quota from the
297
- # authenticated principal — never from a client-supplied header.
298
- api_key = quota["api_key"]
299
- tenant_id = quota["tenant_id"]
 
 
 
 
 
 
 
 
 
300
 
301
  current_tracker = app.core.usage_tracker.tracker
302
  if current_tracker is None:
@@ -307,7 +197,7 @@ async def evaluate_intent_endpoint(
307
 
308
  record = UsageRecord(
309
  api_key=api_key,
310
- tier=quota["tier"],
311
  timestamp=start_time,
312
  endpoint="/api/v1/intents/evaluate",
313
  request_body=intent_req.model_dump(),
@@ -330,62 +220,30 @@ async def evaluate_intent_endpoint(
330
  oss_intent = to_oss_intent(intent_req)
331
  risk_engine = request.app.state.risk_engine
332
 
333
- # Build the base policy evaluator from the app's policy engine (if available)
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
- risk_engine=risk_engine,
360
- policy_evaluator=policy_evaluator,
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 = result.get("deterministic_id", str(uuid.uuid4()))
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
@@ -395,6 +253,7 @@ async def evaluate_intent_endpoint(
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,
@@ -435,148 +294,11 @@ 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="Internal server error")
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(
@@ -584,25 +306,21 @@ async def record_outcome_endpoint(
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
- """Record an outcome for a previously evaluated intent."""
591
- tenant_id = quota["tenant_id"]
 
 
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:
@@ -619,27 +337,22 @@ async def record_outcome_endpoint(
619
  logger.warning(f"Failed to update pricing buffer for intent {outcome.deterministic_id}: {e}")
620
 
621
  return {"message": "Outcome recorded", "outcome_id": outcome_record.id}
622
- except Exception:
623
- logger.exception("Error recording outcome")
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, audit it, optionally enforce via Rust ladder,
642
- and now incorporate Bayesian skill reliability if skill context is provided.
643
  """
644
  span = None
645
  if OTEL_AVAILABLE and _tracer:
@@ -647,10 +360,19 @@ async def evaluate_healing_decision_endpoint(
647
  span.set_attribute("component", decision_req.event.component)
648
 
649
  start_time = time.time()
650
- # api_key/tenant_id are resolved server-side by enforce_quota from the
651
- # authenticated principal — never from a client-supplied header.
652
- api_key = quota["api_key"]
653
- tenant_id = quota["tenant_id"]
 
 
 
 
 
 
 
 
 
654
 
655
  current_tracker = app.core.usage_tracker.tracker
656
  if current_tracker is None:
@@ -661,7 +383,7 @@ async def evaluate_healing_decision_endpoint(
661
 
662
  record = UsageRecord(
663
  api_key=api_key,
664
- tier=quota["tier"],
665
  timestamp=start_time,
666
  endpoint="/api/v1/healing/evaluate",
667
  request_body=decision_req.model_dump(),
@@ -693,10 +415,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 ----
@@ -715,11 +433,12 @@ async def evaluate_healing_decision_endpoint(
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,
@@ -760,4 +479,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="Internal server error")
 
5
  intents and healing decisions. It integrates:
6
 
7
  - Idempotent quota consumption (usage tracker)
8
+ - Tenant isolation (tenant_id from request.state, with fallback to X-Tenant-ID header)
 
9
  - Auditable decision logging (DecisionAuditLogDB)
10
  - Pricing telemetry (optional, to arf‑pricing‑calculator)
11
  - OpenTelemetry tracing
12
  - Optional Rust execution ladder for mechanical enforcement
 
 
 
 
 
 
 
 
 
13
  """
14
 
15
  from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks, Header
16
  from fastapi.encoders import jsonable_encoder
 
17
  from sqlalchemy.orm import Session
18
  from pydantic import BaseModel
19
  import uuid
20
  import logging
 
21
  import time
22
+ from typing import Optional, Dict, Any
 
23
 
24
  from app.models.infrastructure_intents import InfrastructureIntentRequest
25
  from app.services.intent_adapter import to_oss_intent
26
+ from app.services.risk_service import evaluate_intent, evaluate_healing_decision
27
  from app.services.intent_store import save_evaluated_intent
28
  from app.services.outcome_service import record_outcome
29
+ from app.api.deps import get_db
30
+ from app.database.models_intents import DecisionAuditLogDB, TenantDB # <-- NEW
 
 
31
  from agentic_reliability_framework.core.models.event import ReliabilityEvent
32
+ from agentic_reliability_framework.core.governance.healing_intent import HealingIntent
 
 
 
33
 
34
  # ===== USAGE TRACKER =====
35
  import app.core.usage_tracker
 
51
  RUST_AVAILABLE = False
52
  ExecutionLadder = None
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  # ===== OPEN TELEMETRY =====
55
  try:
56
  from opentelemetry import trace
 
62
  _tracer = None
63
 
64
  logger = logging.getLogger(__name__)
65
+ router = APIRouter()
 
 
66
 
67
 
68
  class OutcomeRequest(BaseModel):
 
70
  success: bool
71
  recorded_by: str
72
  notes: str = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
 
75
  class HealingDecisionRequest(BaseModel):
76
  event: ReliabilityEvent
 
 
 
77
 
78
 
79
  # --------------------------------------------------------------------------
80
  # Helper: write audit log (idempotent)
81
  # --------------------------------------------------------------------------
82
  async def write_audit_log(
83
+ db: Session,
84
  tenant_id: str,
85
  deterministic_id: str,
86
  healing_intent: Dict[str, Any],
 
90
  """
91
  Store a governance decision in the immutable audit log.
92
  Idempotent on (tenant_id, deterministic_id) – if already exists, skip.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  """
94
+ # Check if already logged (idempotency)
95
+ existing = db.query(DecisionAuditLogDB).filter(
96
+ DecisionAuditLogDB.tenant_id == tenant_id,
97
+ DecisionAuditLogDB.deterministic_id == deterministic_id
98
+ ).first()
99
+ if existing:
100
+ logger.info(f"Audit log already exists for {deterministic_id}, skipping.")
101
+ return
102
+
103
+ # Extract fields from HealingIntent (or result dict)
104
+ risk_score = healing_intent.get("risk_score", 0.5)
105
+ action = healing_intent.get("recommended_action", "deny") # approve/deny/escalate
106
+ justification = healing_intent.get("justification", "")
107
+ confidence = healing_intent.get("confidence", 0.85)
108
+ confidence_dist = healing_intent.get("confidence_distribution", {})
109
+ confidence_lower = confidence_dist.get("p5", confidence - 0.1)
110
+ confidence_upper = confidence_dist.get("p95", confidence + 0.1)
111
+ cost_projection = healing_intent.get("cost_projection")
112
+ policy_violations = healing_intent.get("policy_violations", [])
113
+ source = healing_intent.get("source", "advisory_analysis")
114
+ parent_intent_id = healing_intent.get("parent_intent_id")
115
+ root_intent_id = healing_intent.get("root_intent_id")
116
+ ancestor_chain = healing_intent.get("ancestor_chain", [])
117
+
118
+ # Memory and causal fields (usually in metadata)
119
+ metadata = healing_intent.get("metadata", {})
120
+ memory_success_rate = metadata.get("memory_success_rate")
121
+ memory_weight = metadata.get("memory_weight")
122
+ counterfactual = metadata.get("counterfactual")
123
+ epistemic_uncertainty = metadata.get("epistemic_uncertainty")
124
+ causal_effect = metadata.get("causal_effect")
125
+
126
+ # Build audit entry
127
+ audit_entry = DecisionAuditLogDB(
128
+ tenant_id=tenant_id,
129
+ deterministic_id=deterministic_id,
130
+ timestamp=datetime.datetime.utcnow(),
131
+ risk_score=risk_score,
132
+ action=action,
133
+ justification=justification,
134
+ recommended_action=action, # same as action for now
135
+ confidence=confidence,
136
+ confidence_lower=confidence_lower,
137
+ confidence_upper=confidence_upper,
138
+ memory_success_rate=memory_success_rate,
139
+ memory_weight=memory_weight,
140
+ counterfactual=counterfactual,
141
+ epistemic_uncertainty=epistemic_uncertainty,
142
+ causal_effect=causal_effect,
143
+ cost_projection=cost_projection,
144
+ policy_violations=policy_violations,
145
+ source=source,
146
+ parent_intent_id=parent_intent_id,
147
+ root_intent_id=root_intent_id,
148
+ ancestor_chain=ancestor_chain,
149
+ trace_id=trace_id,
150
+ )
151
+ db.add(audit_entry)
152
+ db.commit()
153
+ logger.info(f"Audit log written for {deterministic_id}")
154
 
155
 
156
  # --------------------------------------------------------------------------
 
163
  background_tasks: BackgroundTasks,
164
  db: Session = Depends(get_db),
165
  idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
 
 
166
  ):
167
  """
168
+ Evaluate an infrastructure intent with idempotency, tenant isolation, and audit logging.
 
 
169
  """
170
  span = None
171
  if OTEL_AVAILABLE and _tracer:
 
174
  span.set_attribute("environment", str(intent_req.environment))
175
 
176
  start_time = time.time()
177
+ api_key = request.headers.get("Authorization", "").replace("Bearer ", "")
178
+ if not api_key:
179
+ api_key = request.query_params.get("api_key", "unknown")
180
+
181
+ # Get tenant_id from request.state or fallback to X-Tenant-ID header
182
+ tenant_id = getattr(request.state, "tenant_id", None)
183
+ if not tenant_id:
184
+ tenant_id = request.headers.get("X-Tenant-ID")
185
+ if not tenant_id:
186
+ if span:
187
+ span.set_status(Status(StatusCode.ERROR, "Missing tenant_id"))
188
+ span.end()
189
+ raise HTTPException(status_code=403, detail="Tenant not identified")
190
 
191
  current_tracker = app.core.usage_tracker.tracker
192
  if current_tracker is None:
 
197
 
198
  record = UsageRecord(
199
  api_key=api_key,
200
+ tier=None,
201
  timestamp=start_time,
202
  endpoint="/api/v1/intents/evaluate",
203
  request_body=intent_req.model_dump(),
 
220
  oss_intent = to_oss_intent(intent_req)
221
  risk_engine = request.app.state.risk_engine
222
 
223
+ result = evaluate_intent(
224
+ engine=risk_engine,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  intent=oss_intent,
226
+ cost_estimate=intent_req.estimated_cost,
227
+ policy_violations=intent_req.policy_violations,
 
 
 
 
 
 
 
 
 
 
228
  )
229
 
230
  if span:
231
  span.set_attribute("risk_score", result["risk_score"])
232
+ span.set_attribute("deterministic_id", str(uuid.uuid4()))
233
 
234
+ deterministic_id = str(uuid.uuid4())
235
  api_payload = jsonable_encoder(intent_req.model_dump())
236
  oss_payload = jsonable_encoder(oss_intent.model_dump())
237
 
238
  save_evaluated_intent(
239
  db=db,
240
  deterministic_id=deterministic_id,
 
241
  intent_type=intent_req.intent_type,
242
  api_payload=api_payload,
243
  oss_payload=oss_payload,
244
  environment=str(intent_req.environment),
245
  risk_score=result["risk_score"],
246
+ tenant_id=tenant_id,
247
  )
248
 
249
  result["intent_id"] = deterministic_id
 
253
  healing_intent_dict = result.get("healing_intent", result)
254
  background_tasks.add_task(
255
  write_audit_log,
256
+ db=db,
257
  tenant_id=tenant_id,
258
  deterministic_id=deterministic_id,
259
  healing_intent=healing_intent_dict,
 
294
  span.set_status(Status(StatusCode.ERROR, error_msg))
295
  span.record_exception(e)
296
  span.end()
297
+ raise HTTPException(status_code=500, detail=error_msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
 
299
 
300
  # --------------------------------------------------------------------------
301
+ # Endpoint: record outcome (idempotent, pricing)
302
  # --------------------------------------------------------------------------
303
  @router.post("/intents/outcome")
304
  async def record_outcome_endpoint(
 
306
  outcome: OutcomeRequest,
307
  db: Session = Depends(get_db),
308
  idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
 
 
309
  ):
310
+ """
311
+ Record an outcome for a previously evaluated intent.
312
+ Also updates the pricing calculator's calibration buffer if available.
313
+ """
314
  try:
315
  risk_engine = request.app.state.risk_engine
316
  outcome_record = record_outcome(
317
  db=db,
 
318
  deterministic_id=outcome.deterministic_id,
319
  success=outcome.success,
320
  recorded_by=outcome.recorded_by,
321
  notes=outcome.notes,
322
  risk_engine=risk_engine,
323
  idempotency_key=idempotency_key,
 
 
 
324
  )
325
 
326
  if PRICING_AVAILABLE and add_event is not None:
 
337
  logger.warning(f"Failed to update pricing buffer for intent {outcome.deterministic_id}: {e}")
338
 
339
  return {"message": "Outcome recorded", "outcome_id": outcome_record.id}
340
+ except Exception as e:
341
+ raise HTTPException(status_code=500, detail=str(e))
 
342
 
343
 
344
  # --------------------------------------------------------------------------
345
+ # Endpoint: evaluate healing decision (with optional Rust enforcement)
346
  # --------------------------------------------------------------------------
347
  @router.post("/healing/evaluate")
348
  async def evaluate_healing_decision_endpoint(
349
  request: Request,
350
  decision_req: HealingDecisionRequest,
351
  background_tasks: BackgroundTasks,
 
352
  idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
 
 
353
  ):
354
  """
355
+ Evaluate a healing decision, audit it, and optionally enforce via Rust ladder.
 
356
  """
357
  span = None
358
  if OTEL_AVAILABLE and _tracer:
 
360
  span.set_attribute("component", decision_req.event.component)
361
 
362
  start_time = time.time()
363
+ api_key = request.headers.get("Authorization", "").replace("Bearer ", "")
364
+ if not api_key:
365
+ api_key = request.query_params.get("api_key", "unknown")
366
+
367
+ # Get tenant_id from request.state or fallback to X-Tenant-ID header
368
+ tenant_id = getattr(request.state, "tenant_id", None)
369
+ if not tenant_id:
370
+ tenant_id = request.headers.get("X-Tenant-ID")
371
+ if not tenant_id:
372
+ if span:
373
+ span.set_status(Status(StatusCode.ERROR, "Missing tenant_id"))
374
+ span.end()
375
+ raise HTTPException(status_code=403, detail="Tenant not identified")
376
 
377
  current_tracker = app.core.usage_tracker.tracker
378
  if current_tracker is None:
 
383
 
384
  record = UsageRecord(
385
  api_key=api_key,
386
+ tier=None,
387
  timestamp=start_time,
388
  endpoint="/api/v1/healing/evaluate",
389
  request_body=decision_req.model_dump(),
 
415
  rag_graph=rag_graph,
416
  model=model,
417
  tokenizer=tokenizer,
 
 
 
 
418
  )
419
 
420
  # ---- Optional Rust enforcement ----
 
433
  except Exception as e:
434
  logger.warning(f"Rust enforcement failed: {e}")
435
 
436
+ # ---- Write audit log ----
437
  deterministic_id = response_data.get("intent_id", str(uuid.uuid4()))
438
  healing_intent_dict = response_data.get("healing_intent", response_data)
439
  background_tasks.add_task(
440
  write_audit_log,
441
+ db=db, # Note: the healing endpoint also needs a DB session, which it currently lacks!
442
  tenant_id=tenant_id,
443
  deterministic_id=deterministic_id,
444
  healing_intent=healing_intent_dict,
 
479
  span.set_status(Status(StatusCode.ERROR, error_msg))
480
  span.record_exception(e)
481
  span.end()
482
+ raise HTTPException(status_code=500, detail=error_msg)
app/api/routes_history.py CHANGED
@@ -1,10 +1,9 @@
1
- from fastapi import APIRouter, Depends
2
- from app.api.deps import verify_internal_key
3
  from app.core.storage import incident_history
4
 
5
- router = APIRouter(dependencies=[Depends(verify_internal_key)])
6
 
7
 
8
  @router.get("/history")
9
  async def get_history():
10
- return {"incidents": list(incident_history)}
 
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.storage import incident_history
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", dependencies=[Depends(verify_internal_key)])
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. Requires the same ``X-Internal-Key`` header every other
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
  ----------
@@ -228,7 +227,7 @@ async def evaluate_incident(
228
  # ------------------------------------------------------------------
229
  # Asynchronous usage logging
230
  # ------------------------------------------------------------------
231
- if usage_tracker.tracker:
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 usage_tracker.tracker.increment_usage_async(record, background_tasks)
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
- logger.exception("Error in evaluate_incident (deprecated endpoint)")
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 usage_tracker.tracker.increment_usage_async(record, background_tasks)
265
- raise HTTPException(status_code=500, detail="Internal server error")
 
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
  ----------
 
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 logging
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
- logger = logging.getLogger(__name__)
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
- logger.exception("simulate_intent failed")
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, Depends, Request
2
- from app.api.deps import verify_internal_key
3
 
4
- router = APIRouter(dependencies=[Depends(verify_internal_key)])
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, Depends, HTTPException
9
  from pydantic import BaseModel
10
 
11
- from app.core import usage_tracker
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
- req: CheckoutRequest,
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
- if identity["tier"] != Tier.FREE:
 
 
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={"tenant_id": tenant_id},
78
- client_reference_id=tenant_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
- logger.exception("create_checkout_session failed")
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. TEMPORARILY DISABLED.
61
-
62
- This endpoint used to persist each run's "outcome" as
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
- raise HTTPException(
76
- status_code=503,
77
- detail=(
78
- "This endpoint is temporarily disabled while a data-integrity issue is "
79
- "fixed. Use POST /api/v1/pricing/estimate for a single price estimate "
80
- "with no persisted learning in the meantime."
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 logging
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
- logger = logging.getLogger(__name__)
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
- logger.exception("get_risk failed")
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
@@ -9,8 +9,7 @@ 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
 
@@ -31,7 +30,7 @@ async def register_user(
31
  Public endpoint to create a new free‑tier API key and a new tenant.
32
  Rate‑limited to 5 requests per hour per IP address.
33
  """
34
- if usage_tracker.tracker is None:
35
  raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
36
 
37
  # 1. Create a new tenant in the main database
@@ -49,7 +48,7 @@ async def register_user(
49
 
50
  # 2. Generate a new API key for this tenant
51
  new_key = f"sk_free_{uuid.uuid4().hex[:24]}"
52
- success = usage_tracker.tracker.get_or_create_api_key(api_key=new_key, tenant_id=tenant_id, tier=Tier.FREE)
53
  if not success:
54
  # Rollback tenant creation if key creation fails
55
  db.delete(new_tenant)
 
9
  from slowapi import Limiter
10
  from slowapi.util import get_remote_address
11
 
12
+ from app.core.usage_tracker import tracker, enforce_quota, Tier
 
13
  from app.api.deps import get_db
14
  from app.database.models_intents import TenantDB # <-- NEW
15
 
 
30
  Public endpoint to create a new free‑tier API key and a new tenant.
31
  Rate‑limited to 5 requests per hour per IP address.
32
  """
33
+ if tracker is None:
34
  raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
35
 
36
  # 1. Create a new tenant in the main database
 
48
 
49
  # 2. Generate a new API key for this tenant
50
  new_key = f"sk_free_{uuid.uuid4().hex[:24]}"
51
+ success = tracker.get_or_create_api_key(api_key=new_key, tenant_id=tenant_id, tier=Tier.FREE)
52
  if not success:
53
  # Rollback tenant creation if key creation fails
54
  db.delete(new_tenant)
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 update_key_tier_by_tenant_id, Tier
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
- # tenant_id (not api_key) is what Checkout was given -- see
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
- # For card payments this is already "paid" by the time this event
47
- # fires; for delayed-notification payment methods (bank debits,
48
- # etc.) it can still be "unpaid" here. Upgrading on an unpaid
49
- # session would grant Pro access before payment actually clears.
50
- if session.get("payment_status") != "paid":
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
- tenant_id = subscription.get("metadata", {}).get("tenant_id")
63
- if not tenant_id:
64
- return {"status": "ok"}
65
- if event["type"] == "customer.subscription.deleted" or (
66
- subscription.get("status") in _DOWNGRADE_STATUSES
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
- """In-memory store for recent incident reports.
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
@@ -4,53 +4,15 @@ 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
- import hashlib
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
53
- from typing import Dict, Any, Optional, List, Tuple
54
  from enum import Enum
55
  from fastapi import BackgroundTasks, HTTPException, Request
56
 
@@ -106,51 +68,15 @@ 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
 
@@ -160,40 +86,9 @@ class UsageTracker:
160
  elif redis_url:
161
  raise ImportError("Redis client not installed. Run: pip install redis")
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 WAL and immediate transactions.
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 +96,20 @@ 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,
@@ -397,84 +152,65 @@ class UsageTracker:
397
  Register a new API key for a given tenant.
398
 
399
  Args:
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
- salt = secrets.token_hex(16)
420
- self._pg_execute(
421
- conn,
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._get_pg_conn() as conn:
434
- row = self._verify_key(conn, api_key)
435
- return Tier(row["tier"]) if row else None
 
 
 
 
 
436
 
437
  def get_tenant_id(self, api_key: str) -> Optional[str]:
438
  """Return the tenant ID associated with the API key, or None if key invalid."""
439
- with self._get_pg_conn() as conn:
440
- row = self._verify_key(conn, api_key)
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
- lookup_hash = self._lookup_hash(api_key)
446
- with self._get_pg_conn() as conn:
447
- row = self._pg_execute(
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
- self._pg_execute(
454
- conn, "UPDATE api_keys SET tier = %s WHERE lookup_hash = %s",
455
- (new_tier.value, lookup_hash))
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 (unchanged, but uses api_key which links to tenant)
480
  # --------------------------------------------------------------------------
@@ -535,34 +271,6 @@ class UsageTracker:
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 (unchanged)
568
  # --------------------------------------------------------------------------
@@ -597,18 +305,15 @@ class UsageTracker:
597
  if not quota_ok:
598
  return False, None
599
 
600
- self._record_pg_monthly_count(record.api_key, month)
601
-
602
  try:
603
  with self._get_conn() as conn:
604
  conn.execute(
605
  """INSERT INTO usage_log
606
- (api_key, tier, timestamp, endpoint, request_body, response, error,
607
- processing_ms, idempotency_key)
608
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
609
  (record.api_key, record.tier.value, record.timestamp, record.endpoint,
610
- json.dumps(record.request_body, default=str) if record.request_body else None,
611
- json.dumps(record.response, default=str) if record.response else None,
612
  record.error, record.processing_ms, idempotency_key)
613
  )
614
  conn.commit()
@@ -621,40 +326,6 @@ class UsageTracker:
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
  # --------------------------------------------------------------------------
@@ -722,17 +393,6 @@ class UsageTracker:
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
 
@@ -747,36 +407,20 @@ def update_key_tier(api_key: str, new_tier: Tier) -> bool:
747
  return tracker.update_api_key_tier(api_key, new_tier)
748
 
749
 
750
- def update_key_tier_by_tenant_id(tenant_id: str, new_tier: Tier) -> bool:
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
- FastAPI dependency that authenticates an API key and attaches tenant_id
768
- to request state, without enforcing monthly quota.
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(status_code=503, detail="Usage tracking service not initialised.")
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
 
@@ -784,6 +428,11 @@ async def resolve_api_key_identity(request: Request, api_key: str = None):
784
  if tier is None:
785
  raise HTTPException(status_code=403, detail="Invalid or inactive API key")
786
 
 
 
 
 
 
787
  tenant_id = tracker.get_tenant_id(api_key)
788
  if not tenant_id:
789
  raise HTTPException(status_code=403, detail="API key not associated with a tenant")
@@ -792,18 +441,4 @@ async def resolve_api_key_identity(request: Request, api_key: str = None):
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, detail="Monthly evaluation quota exceeded")
808
-
809
  return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id, "remaining": remaining}
 
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
  import json
 
 
 
9
  import sqlite3
10
  import threading
11
  import time
 
 
 
12
  from contextlib import contextmanager
13
  from datetime import datetime, timedelta
14
  from dataclasses import dataclass
15
+ from typing import Dict, Any, Optional, List, Tuple, Callable
16
  from enum import Enum
17
  from fastapi import BackgroundTasks, HTTPException, Request
18
 
 
68
  processing_ms: Optional[float] = None
69
 
70
 
 
 
 
 
 
 
 
 
 
 
71
  class UsageTracker:
72
  """
73
  Thread‑safe usage tracker with atomic quota consumption and idempotency.
74
  Extended to support tenant isolation: each API key is linked to a tenant.
75
  """
76
 
 
 
 
 
 
 
 
 
77
  def __init__(self, db_path: str = "arf_usage.db",
78
+ redis_url: Optional[str] = None):
 
79
  self.db_path = db_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  self._local = threading.local()
81
  self._init_db()
82
 
 
86
  elif redis_url:
87
  raise ImportError("Redis client not installed. Run: pip install redis")
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  @contextmanager
90
  def _get_conn(self):
91
+ """Get a thread‑local SQLite connection with WAL and immediate transactions."""
 
 
 
92
  if not hasattr(self._local, "conn"):
93
  self._local.conn = sqlite3.connect(
94
  self.db_path, check_same_thread=False, isolation_level=None)
 
96
  self._local.conn.execute("PRAGMA journal_mode=WAL")
97
  yield self._local.conn
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  def _init_db(self):
100
+ """Initialise SQLite tables with tenant_id support."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  with self._get_conn() as conn:
102
+ # Modified: api_keys now has tenant_id column
103
+ conn.execute("""
104
+ CREATE TABLE IF NOT EXISTS api_keys (
105
+ key TEXT PRIMARY KEY,
106
+ tenant_id TEXT NOT NULL,
107
+ tier TEXT NOT NULL,
108
+ created_at REAL NOT NULL,
109
+ last_used_at REAL,
110
+ is_active INTEGER DEFAULT 1
111
+ )
112
+ """)
113
  conn.execute("""
114
  CREATE TABLE IF NOT EXISTS usage_log (
115
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
152
  Register a new API key for a given tenant.
153
 
154
  Args:
155
+ key: The API key (plain text, will be hashed in production).
156
  tenant_id: UUID of the tenant (must already exist in main DB).
157
  tier: Initial tier for the key.
158
 
159
  Returns:
160
  True if key was created (or already exists for the same tenant).
161
  """
162
+ with self._get_conn() as conn:
163
+ row = conn.execute(
164
+ "SELECT key FROM api_keys WHERE key = ?", (key,)).fetchone()
 
 
165
  if row:
166
  # Key already exists – ensure it belongs to the same tenant
167
+ existing_tenant = conn.execute(
168
+ "SELECT tenant_id FROM api_keys WHERE key = ?", (key,)).fetchone()
169
+ if existing_tenant["tenant_id"] != tenant_id:
170
  raise ValueError(f"Key {key[:8]}... already belongs to a different tenant.")
 
171
  return True
172
+ conn.execute(
173
+ "INSERT INTO api_keys (key, tenant_id, tier, created_at, is_active) VALUES (?, ?, ?, ?, ?)",
174
+ (key, tenant_id, tier.value, time.time(), 1)
 
 
 
 
 
175
  )
176
  conn.commit()
177
  return True
178
 
179
  def get_tier(self, api_key: str) -> Optional[Tier]:
180
  """Return the tier for a given API key, or None if key invalid/inactive."""
181
+ with self._get_conn() as conn:
182
+ row = conn.execute(
183
+ "SELECT tier FROM api_keys WHERE key = ? AND is_active = 1",
184
+ (api_key,)
185
+ ).fetchone()
186
+ if not row:
187
+ return None
188
+ return Tier(row["tier"])
189
 
190
  def get_tenant_id(self, api_key: str) -> Optional[str]:
191
  """Return the tenant ID associated with the API key, or None if key invalid."""
192
+ with self._get_conn() as conn:
193
+ row = conn.execute(
194
+ "SELECT tenant_id FROM api_keys WHERE key = ? AND is_active = 1",
195
+ (api_key,)
196
+ ).fetchone()
197
+ if not row:
198
+ return None
199
+ return row["tenant_id"]
200
 
201
  def update_api_key_tier(self, api_key: str, new_tier: Tier) -> bool:
202
  """Update the tier of an existing API key. Returns True if successful."""
203
+ with self._get_conn() as conn:
204
+ row = conn.execute(
205
+ "SELECT key FROM api_keys WHERE key = ?", (api_key,)).fetchone()
 
 
206
  if not row:
 
207
  return False
208
+ conn.execute(
209
+ "UPDATE api_keys SET tier = ? WHERE key = ?",
210
+ (new_tier.value, api_key))
211
  conn.commit()
212
  return True
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  # --------------------------------------------------------------------------
215
  # Atomic quota consumption (unchanged, but uses api_key which links to tenant)
216
  # --------------------------------------------------------------------------
 
271
  result = self._redis_client.eval(lua_script, 1, redis_key, limit)
272
  return result == 1
273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  # --------------------------------------------------------------------------
275
  # Idempotency handling (unchanged)
276
  # --------------------------------------------------------------------------
 
305
  if not quota_ok:
306
  return False, None
307
 
 
 
308
  try:
309
  with self._get_conn() as conn:
310
  conn.execute(
311
  """INSERT INTO usage_log
312
+ (api_key, tier, timestamp, endpoint, request_body, response, error, processing_ms, idempotency_key)
 
313
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
314
  (record.api_key, record.tier.value, record.timestamp, record.endpoint,
315
+ json.dumps(record.request_body) if record.request_body else None,
316
+ json.dumps(record.response) if record.response else None,
317
  record.error, record.processing_ms, idempotency_key)
318
  )
319
  conn.commit()
 
326
  self._mark_idempotent_key_used(idempotency_key)
327
  return True, None
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  # --------------------------------------------------------------------------
330
  # Legacy interface (kept for compatibility)
331
  # --------------------------------------------------------------------------
 
393
  # --------------------------------------------------------------------------
394
  # Global instance and FastAPI dependency
395
  # --------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
396
  tracker: Optional[UsageTracker] = None
397
 
398
 
 
407
  return tracker.update_api_key_tier(api_key, new_tier)
408
 
409
 
410
+ async def enforce_quota(request: Request, api_key: str = None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  """
412
+ FastAPI dependency that enforces quota and attaches tenant_id to request state.
 
 
 
 
 
 
 
413
  """
414
  if tracker is None:
415
  raise HTTPException(status_code=503, detail="Usage tracking service not initialised.")
416
 
417
+ if api_key is None:
418
+ auth_header = request.headers.get("Authorization")
419
+ if auth_header and auth_header.startswith("Bearer "):
420
+ api_key = auth_header[7:]
421
+ else:
422
+ api_key = request.query_params.get("api_key")
423
+
424
  if not api_key:
425
  raise HTTPException(status_code=401, detail="Missing API key")
426
 
 
428
  if tier is None:
429
  raise HTTPException(status_code=403, detail="Invalid or inactive API key")
430
 
431
+ remaining = tracker.get_remaining_quota(api_key, tier)
432
+ if remaining is not None and remaining <= 0:
433
+ raise HTTPException(status_code=429, detail="Monthly evaluation quota exceeded")
434
+
435
+ # Retrieve tenant_id
436
  tenant_id = tracker.get_tenant_id(api_key)
437
  if not tenant_id:
438
  raise HTTPException(status_code=403, detail="API key not associated with a tenant")
 
441
  request.state.tier = tier
442
  request.state.tenant_id = tenant_id
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id, "remaining": remaining}
app/database/models_intents.py CHANGED
@@ -3,18 +3,14 @@ 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
@@ -50,11 +46,74 @@ class TenantDB(Base):
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
  # ============================================================================
 
3
 
4
  This module defines the SQLAlchemy ORM models for:
5
  - Tenants (multi‑tenant isolation root)
6
+ - API keys (per‑tenant, tier‑based)
7
+ - Usage logs (immutable records of API calls)
8
  - Intents (InfrastructureIntent evaluations)
9
  - Outcomes (recorded results of executed intents)
10
  - Beta state (conjugate Bayesian posteriors per tenant and category)
11
  - Audit logs (immutable decision records for compliance)
12
 
13
  All tables include a `tenant_id` column to enforce data partitioning.
 
 
 
 
 
 
14
  """
15
 
16
  import uuid
 
46
  created_by = Column(String(128), nullable=True)
47
 
48
  # Relationships
49
+ api_keys = relationship("APIKeyDB", back_populates="tenant", cascade="all, delete-orphan")
50
  intents = relationship("IntentDB", back_populates="tenant")
51
  beta_states = relationship("BetaStateDB", back_populates="tenant")
52
  audit_logs = relationship("DecisionAuditLogDB", back_populates="tenant")
53
 
54
 
55
+ # ============================================================================
56
+ # API keys (extended with tenant_id)
57
+ # ============================================================================
58
+
59
+ class APIKeyDB(Base):
60
+ """
61
+ Stores API keys for authentication and tiered quota. Each key belongs
62
+ to exactly one tenant. The `tier` determines monthly evaluation limits.
63
+
64
+ Attributes:
65
+ key (str): The hashed API key (primary key).
66
+ tenant_id (str): Foreign key to `tenants.id`.
67
+ tier (str): Tier enumeration value (free, pro, premium, enterprise).
68
+ created_at (datetime): UTC creation time.
69
+ last_used_at (datetime, optional): Timestamp of last successful request.
70
+ is_active (bool): Soft‑delete flag.
71
+ """
72
+ __tablename__ = "api_keys"
73
+
74
+ key = Column(String(256), primary_key=True, index=True)
75
+ tenant_id = Column(String(64), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
76
+ tier = Column(String(32), nullable=False)
77
+ created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
78
+ last_used_at = Column(DateTime, nullable=True)
79
+ is_active = Column(Boolean, default=True, nullable=False)
80
+
81
+ # Relationships
82
+ tenant = relationship("TenantDB", back_populates="api_keys")
83
+ usage_logs = relationship("UsageLogDB", back_populates="api_key_rel", cascade="all, delete-orphan")
84
+
85
+
86
+ # ============================================================================
87
+ # Usage logs – each API call
88
+ # ============================================================================
89
+
90
+ class UsageLogDB(Base):
91
+ """
92
+ Immutable record of each API call for quota tracking and billing.
93
+
94
+ Attributes:
95
+ id (int): Primary key.
96
+ api_key (str): Foreign key to `api_keys.key`.
97
+ tier (str): Tier at the time of the call.
98
+ timestamp (float): Unix timestamp of the request.
99
+ endpoint (str): URL or route of the endpoint hit.
100
+ request_body (JSON, optional): Request payload (sanitised).
101
+ response (JSON, optional): Response metadata (e.g., status code).
102
+ """
103
+ __tablename__ = "usage_logs"
104
+
105
+ id = Column(Integer, primary_key=True, index=True)
106
+ api_key = Column(String(256), ForeignKey("api_keys.key", ondelete="CASCADE"), nullable=False)
107
+ tier = Column(String(32), nullable=False)
108
+ timestamp = Column(Float, nullable=False)
109
+ endpoint = Column(String(512), nullable=True)
110
+ request_body = Column(JSON, nullable=True)
111
+ response = Column(JSON, nullable=True)
112
+
113
+ # Relationship back to API key
114
+ api_key_rel = relationship("APIKeyDB", back_populates="usage_logs")
115
+
116
+
117
  # ============================================================================
118
  # Intents (evaluations) – now tenant‑scoped
119
  # ============================================================================
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), epistemic models, and (new in v4.3.1)
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, Request
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 usage_tracker
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 per tenant.
126
  3. OpenTelemetry tracing (console exporter by default).
127
  4. Policy engine, RAG memory, and epistemic model.
128
- 5. Stability controller & temporal monitor (v4.3.1).
129
- 6. Usage tracker (SQLite / Redis).
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}")
@@ -241,27 +227,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, stability, drift disabled."
262
  )
263
 
264
- # ── 6. Usage tracker ──────────────────────────────────────
265
  usage_tracking_disabled = (
266
  os.getenv("ARF_USAGE_TRACKING", "true").lower() == "false"
267
  )
@@ -272,65 +243,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
- # Previously called get_or_create_api_key(key, tier)
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 +260,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 = usage_tracker.tracker
344
- if postgres_ready:
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
- # ── 6b. Enterprise execution approval ledger (optional) ───
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 +331,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 +363,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. **NEW: Load persisted conjugate posterior state per tenant**.
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}")
 
227
  )
228
  app.state.epistemic_model = None
229
  app.state.epistemic_tokenizer = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  else:
231
  logger.warning(
232
+ "agentic_reliability_framework not installed; risk engine, policy engine, RAG disabled."
233
  )
234
 
235
+ # ── 5. Usage tracker ──────────────────────────────────────
236
  usage_tracking_disabled = (
237
  os.getenv("ARF_USAGE_TRACKING", "true").lower() == "false"
238
  )
 
243
  db_path=os.getenv("ARF_USAGE_DB_PATH", "arf_usage.db"),
244
  redis_url=os.getenv("ARF_REDIS_URL"),
245
  )
246
+ # Seed initial API keys from environment variable (for testing / demo)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  api_keys_json = os.getenv("ARF_API_KEYS", "{}")
 
 
 
 
 
 
 
248
  try:
249
  api_keys = json.loads(api_keys_json)
250
  for key, tier_str in api_keys.items():
251
  try:
252
  tier = Tier(tier_str.lower())
253
+ tracker.get_or_create_api_key(key, tier)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  logger.info(f"Seeded API key for tier {tier.value}")
255
  except ValueError:
256
  logger.warning(
 
260
  logger.warning(
261
  "ARF_API_KEYS environment variable is not valid JSON; skipping seeding."
262
  )
263
+ app.state.usage_tracker = tracker
264
+ logger.info("✅ Usage tracker ready.")
 
 
 
265
  except Exception as e:
 
 
 
 
 
266
  logger.critical(f"Failed to initialise usage tracker: {e}")
267
  raise RuntimeError("Usage tracker initialisation failed") from e
268
  else:
269
  logger.info("Usage tracking disabled by ARF_USAGE_TRACKING=false.")
270
  app.state.usage_tracker = None
271
 
272
+ # ── 6. Wilson confidence monitor ──────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  try:
274
  from app.services.wilson_monitor import update as wilson_update
275
  from prometheus_client import REGISTRY
 
331
  )
332
  logger.debug("CORS middleware configured")
333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  # ── Rate limiter ──────────────────────────────────────────
335
  if SLOWAPI_AVAILABLE:
336
  app.state.limiter = limiter
 
363
  app.include_router(
364
  routes_governance.router, prefix="/api/v1", tags=["governance"]
365
  )
 
 
 
366
  app.include_router(
367
  routes_memory.router, prefix="/v1/memory", tags=["memory"]
368
  )
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/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
- # Helper: persist the conjugate posterior state
34
  # ---------------------------------------------------------------------------
35
- def _persist_beta_state(db: Session, tenant_id: str, risk_engine: RiskEngine) -> None:
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 on (tenant_id, category): merge() matches on primary key
45
- # only, and these rows are always constructed without an `id`,
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
- The intent lookup is scoped to `tenant_id` so a caller can only record outcomes for
107
- intents owned by their own tenant, even if they know or guess another tenant's
108
- deterministic_id.
109
-
110
- Parameters
111
- ----------
112
- db : Session
113
- SQLAlchemy session.
114
- tenant_id : str
115
- Tenant of the authenticated caller. Must match the intent's owning tenant.
116
- deterministic_id : str
117
- Unique identifier of the original intent.
118
- success : bool
119
- Whether the action succeeded (True) or failed (False).
120
- recorded_by : str or None
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, scoped to the caller's tenant
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, tenant_id, risk_engine)
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
@@ -2,11 +2,7 @@
2
  Risk service – integrates ARF Bayesian risk engine, policy engine, and decision engine.
3
  Deterministic, no random fallbacks, explicit error handling. Tenant‑aware.
4
 
5
- Version: 2026-07-06 – added evaluate_intent_full with GovernanceLoop integration,
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
@@ -110,7 +98,7 @@ def evaluate_intent(
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.
@@ -155,7 +143,7 @@ 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(
@@ -181,6 +169,10 @@ def evaluate_intent(
181
 
182
  # ── Core risk evaluation ──────────────────────────────────
183
  try:
 
 
 
 
184
  if hasattr(engine, "set_tenant"):
185
  engine.set_tenant(tenant_id)
186
  elif tenant_id:
@@ -216,174 +208,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 +215,11 @@ 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 and, optionally, skill reliability
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,26 +228,19 @@ 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. If provided, it is used as‑is
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, plus skill_id/skill_version if present.
442
  """
443
  t0 = time.monotonic()
444
  span = None
@@ -452,10 +254,10 @@ def evaluate_healing_decision(
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), passing skill registry if available
456
  if decision_engine is None:
457
  logger.debug("No DecisionEngine provided; creating default instance")
458
- decision_engine = DecisionEngine(rag_graph=rag_graph, skill_registry=skill_registry)
459
 
460
  # Get raw candidate actions (by temporarily disabling decision engine)
461
  orig_use = policy_engine.use_decision_engine
@@ -472,7 +274,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
- no_action_result = {
476
  "risk_score": 0.0,
477
  "selected_action": HealingAction.NO_ACTION.value,
478
  "expected_utility": 0.0,
@@ -480,10 +282,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 +328,10 @@ def evaluate_healing_decision(
530
  "hallucination_risk": 0.0,
531
  }
532
 
533
- # ── Decision with skill context ───────────────��──────────
534
  decision = decision_engine.select_optimal_action(
535
- raw_actions,
536
- event,
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 +364,7 @@ def evaluate_healing_decision(
570
  span.set_attribute("expected_utility", decision.expected_utility)
571
  span.end()
572
 
573
- result = {
574
  "risk_score": risk_score,
575
  "selected_action": decision.best_action.value,
576
  "expected_utility": decision.expected_utility,
@@ -579,10 +373,6 @@ 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:
 
2
  Risk service – integrates ARF Bayesian risk engine, policy engine, and decision engine.
3
  Deterministic, no random fallbacks, explicit error handling. Tenant‑aware.
4
 
5
+ Version: 2026-06-07 – added tenant_id propagation, improved Rust enforcer integration.
 
 
 
 
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
 
98
  intent: InfrastructureIntent,
99
  cost_estimate: Optional[float],
100
  policy_violations: List[str],
101
+ tenant_id: Optional[str] = None, # <-- NEW: tenant isolation
102
  ) -> dict:
103
  """
104
  Evaluate an infrastructure intent using the Bayesian risk engine.
 
143
  "region": getattr(intent, "region", None),
144
  "resource_type": getattr(intent, "resource_type", None),
145
  "permission_level": getattr(intent, "permission_level", None),
146
+ "tenant_id": tenant_id, # pass tenant for logging
147
  "extra": {}
148
  }
149
  rust_raw = _rust_evaluator.evaluate(
 
169
 
170
  # ── Core risk evaluation ──────────────────────────────────
171
  try:
172
+ # Note: The RiskEngine must be modified to accept tenant_id and use
173
+ # a per‑tenant BetaStore. This change is expected in the core engine.
174
+ # Here we pass the tenant_id as a keyword argument; the engine will
175
+ # ignore it if not yet implemented, but we log a warning.
176
  if hasattr(engine, "set_tenant"):
177
  engine.set_tenant(tenant_id)
178
  elif tenant_id:
 
208
  }
209
 
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  def evaluate_healing_decision(
212
  event: ReliabilityEvent,
213
  policy_engine: PolicyEngine,
 
215
  rag_graph: Optional[RAGGraphMemory] = None,
216
  model=None,
217
  tokenizer=None,
218
+ tenant_id: Optional[str] = None, # <-- NEW for audit context
 
 
 
 
219
  ) -> Dict[str, Any]:
220
  """
221
  Evaluate healing actions for a given reliability event using decision‑theoretic selection.
222
+ Includes epistemic risk signals from the eclipse probe.
 
 
 
 
 
 
 
 
 
 
 
223
 
224
  Parameters
225
  ----------
 
228
  policy_engine : PolicyEngine
229
  The ARF healing policy engine with configured policies.
230
  decision_engine : DecisionEngine, optional
231
+ If omitted, a default instance is created.
 
232
  rag_graph : RAGGraphMemory, optional
233
  Semantic memory for similar incident retrieval.
234
  model, tokenizer : optional
235
  HuggingFace model and tokenizer for epistemic risk computation.
236
  tenant_id : str, optional
237
+ Tenant UUID for logging and metrics (not used in core logic yet).
 
 
 
 
 
 
238
 
239
  Returns
240
  -------
241
  dict
242
  Keys: risk_score, selected_action, expected_utility, alternatives,
243
+ explanation, epistemic_signals.
244
  """
245
  t0 = time.monotonic()
246
  span = None
 
254
  if decision_engine is None and hasattr(policy_engine, 'decision_engine'):
255
  decision_engine = policy_engine.decision_engine
256
 
257
+ # If still None, create a minimal one (global stats only)
258
  if decision_engine is None:
259
  logger.debug("No DecisionEngine provided; creating default instance")
260
+ decision_engine = DecisionEngine(rag_graph=rag_graph)
261
 
262
  # Get raw candidate actions (by temporarily disabling decision engine)
263
  orig_use = policy_engine.use_decision_engine
 
274
  span.end()
275
  _EVAL_COUNTER.labels(engine="python", status="success").inc()
276
  _EVAL_DURATION.labels(engine="python").observe(time.monotonic() - t0)
277
+ return {
278
  "risk_score": 0.0,
279
  "selected_action": HealingAction.NO_ACTION.value,
280
  "expected_utility": 0.0,
 
282
  "explanation": "No candidate actions triggered.",
283
  "epistemic_signals": None,
284
  }
 
 
 
 
285
 
286
  # Build reasoning text from policies that triggered the actions
287
  reasoning_parts = []
 
328
  "hallucination_risk": 0.0,
329
  }
330
 
331
+ # Run decision engine to get best action and alternatives
332
  decision = decision_engine.select_optimal_action(
333
+ raw_actions, event, component=event.component,
334
+ epistemic_signals=epistemic_signals
 
 
 
 
335
  )
336
 
337
  # Extract risk of the selected action
 
364
  span.set_attribute("expected_utility", decision.expected_utility)
365
  span.end()
366
 
367
+ return {
368
  "risk_score": risk_score,
369
  "selected_action": decision.best_action.value,
370
  "expected_utility": decision.expected_utility,
 
373
  "raw_decision": decision.raw_data,
374
  "epistemic_signals": epistemic_signals,
375
  }
 
 
 
 
376
 
377
 
378
  def get_system_risk() -> float:
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
- - `routes_governance.py`, `routes_risk.py`, `routes_intents.py`, `routes_history.py`,
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
- - `app/core/config.py` exposes an `api_key` setting read from `.env`, but no current route
41
- checks it — it is not the mechanism in use. The real mechanisms are `X-Internal-Key`,
42
- `ARF_ADMIN_API_KEY`, and per-customer API keys (`enforce_quota`), all checked in
43
- `app/api/deps.py` / `app/core/usage_tracker.py`.
 
 
 
 
 
 
 
44
 
45
  Notes
46
 
47
- - Tests run against a real Postgres connection (`tests/conftest.py`), not SQLite; see the top-level README's Tests section.
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`). Copy `.env.example` to `.env` and fill in real values locally.
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/agentic_reliability_framework@main
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==50.0.0
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, verify_internal_key
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: Request, api_key: str = None):
111
- return {"api_key": "test_key", "tier": Tier.PRO, "tenant_id": "test-tenant", "remaining": 1000}
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 fastapi import HTTPException
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):
@@ -88,65 +73,3 @@ def test_invalid_intent_type(client):
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):
 
73
  response = client.post("/api/v1/intents/evaluate", json=payload,
74
  headers={"X-Tenant-ID": "test-tenant"})
75
  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
 
@@ -22,27 +18,3 @@ def test_healing_evaluate_endpoint():
22
  response = client.post("/api/v1/healing/evaluate", json=payload,
23
  headers={"X-Tenant-ID": "test-tenant"})
24
  assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
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
 
 
18
  response = client.post("/api/v1/healing/evaluate", json=payload,
19
  headers={"X-Tenant-ID": "test-tenant"})
20
  assert response.status_code == 200, f"Expected 200, got {response.status_code}: {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
- assert "incidents" in data
12
- assert isinstance(data["incidents"], list)
 
 
 
 
 
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,11 +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
@@ -38,9 +38,9 @@ def test_save_intent(db_session):
38
 
39
  def test_update_existing_intent(db_session):
40
  det_id = "intent_123"
41
- # Positional order: db, deterministic_id, tenant_id, intent_type, api_payload, oss_payload, environment, risk_score
42
- save_evaluated_intent(db_session, det_id, "test-tenant", "Type", {}, {}, "prod", 0.5)
43
- updated = save_evaluated_intent(db_session, det_id, "test-tenant", "Type", {}, {}, "prod", 0.7)
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
+ tenant_id="test-tenant",
29
  risk_score=0.42,
30
  )
31
  assert saved.deterministic_id == det_id
 
38
 
39
  def test_update_existing_intent(db_session):
40
  det_id = "intent_123"
41
+ # tenant_id is the 7th positional argument, risk_score is the 8th
42
+ save_evaluated_intent(db_session, det_id, "Type", {}, {}, "prod", "test-tenant", 0.5)
43
+ updated = save_evaluated_intent(db_session, det_id, "Type", {}, {}, "prod", "test-tenant", 0.7)
44
  assert updated.risk_score == "0.7"
45
  count = db_session.query(IntentDB).filter(
46
  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, TenantDB
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
- @pytest.fixture(autouse=True)
15
- def stripe_configured(monkeypatch):
16
- monkeypatch.setattr("stripe.api_key", "sk_test_fake")
 
 
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
- # routes_payments.py sets stripe.api_key from the environment at import
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
- # The raw exception message must NOT reach the caller -- routes_risk.py
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_routes_governance_execute.py DELETED
@@ -1,352 +0,0 @@
1
- """
2
- Tests for POST /intents/{id}/execute and POST /admin/executions/{id}/resolve.
3
-
4
- arf_enterprise is not installed in this test environment (by design -- it's
5
- an optional, proprietary package; see routes_governance.py's
6
- ENTERPRISE_EXECUTOR_AVAILABLE guard). The 501 "not available"/"not enabled"
7
- paths are real, unconditional behavior and tested as such. For the
8
- success/pending/error paths, the enterprise classes referenced in
9
- routes_governance.py (EnterpriseExecutor, FakeCloudActuator,
10
- PendingApprovalError, EnterpriseExecutionError, EnterpriseSafetyError) are
11
- monkeypatched with lightweight stand-ins -- this tests arf-api's own glue
12
- code (existence/tenant check, exception-to-HTTP-status mapping, response
13
- shaping), not EnterpriseExecutor's internals, which are already covered by
14
- the enterprise repo's own test suite.
15
- """
16
- import pytest
17
- from app.database.models_intents import TenantDB
18
- import app.api.routes_governance as routes_governance
19
- import app.api.routes_admin as routes_admin
20
-
21
- TENANT_ID = "test-tenant"
22
-
23
-
24
- @pytest.fixture(autouse=True)
25
- def seed_tenant(db_session):
26
- tenant = db_session.query(TenantDB).filter_by(id=TENANT_ID).first()
27
- if not tenant:
28
- db_session.add(TenantDB(id=TENANT_ID, name="Test Tenant"))
29
- db_session.commit()
30
-
31
-
32
- def _evaluate_intent(client):
33
- payload = {
34
- "intent_type": "provision_resource",
35
- "environment": "prod",
36
- "resource_type": "database",
37
- "region": "eastus",
38
- "size": "Standard",
39
- "estimated_cost": 1200,
40
- "policy_violations": [],
41
- "requester": "alice",
42
- "provenance": {},
43
- "configuration": {},
44
- }
45
- resp = client.post(
46
- "/api/v1/intents/evaluate", json=payload, headers={"X-Tenant-ID": TENANT_ID}
47
- )
48
- assert resp.status_code == 200, resp.text
49
- data = resp.json()
50
- # data["intent_id"] (top level, set by evaluate_intent_endpoint right
51
- # before returning: result["intent_id"] = deterministic_id) is what
52
- # save_evaluated_intent actually persisted to IntentDB.deterministic_id.
53
- # data["healing_intent"]["intent_id"] is a separate, independently
54
- # generated id belonging to the HealingIntent object itself -- using it
55
- # here instead was the exact bug that made every test in this file 404.
56
- return data["intent_id"], data["healing_intent"]
57
-
58
-
59
- class _FakePendingApprovalError(Exception):
60
- def __init__(self, message, level, approval_required, approval_id=None):
61
- super().__init__(message)
62
- self.level = level
63
- self.approval_required = approval_required
64
- self.approval_id = approval_id
65
-
66
-
67
- class _FakeExecutionError(Exception):
68
- pass
69
-
70
-
71
- class _FakeSafetyError(Exception):
72
- pass
73
-
74
-
75
- class _FakeConfig:
76
- """Stands in for EnterpriseConfig, which is None in the import fallback
77
- whenever arf_enterprise isn't installed -- as it isn't in CI, since it's
78
- a private-repo package deliberately kept out of requirements.txt.
79
-
80
- Records what it was constructed with so a test can assert that
81
- ARF_TRUSTED_SIGNING_KEYS actually reaches the executor. Without that
82
- the trust store could silently go back to empty and every signed intent
83
- would be rejected as "Untrusted signing key" with nothing failing here.
84
- """
85
- last_trusted_signing_keys = None
86
-
87
- def __init__(self, trusted_signing_keys=None, **kwargs):
88
- self.trusted_signing_keys = trusted_signing_keys
89
- type(self).last_trusted_signing_keys = trusted_signing_keys
90
-
91
-
92
- class _FakeExecutor:
93
- """Stands in for EnterpriseExecutor. Behavior is selected via a
94
- class-level `mode` set by each test before the request is made."""
95
- mode = "success"
96
- last_config = None
97
-
98
- def __init__(self, config=None, actuator=None, approval_store=None,
99
- on_verified_outcome=None):
100
- self._on_verified_outcome = on_verified_outcome
101
- type(self).last_config = config
102
-
103
- async def execute(self, intent, human_approved=False, admin_approved=False):
104
- if self.mode == "success":
105
- if self._on_verified_outcome:
106
- self._on_verified_outcome(intent, True, {"observed": {"status": "running"}})
107
- return {"status": "success", "verified": {"status": "running"}, "compensating_action": None}
108
- if self.mode == "pending":
109
- raise _FakePendingApprovalError(
110
- "needs human approval", level="HumanInLoop", approval_required="human",
111
- approval_id="appr_test123",
112
- )
113
- if self.mode == "execution_error":
114
- raise _FakeExecutionError("ladder denied this intent")
115
- if self.mode == "safety_error":
116
- raise _FakeSafetyError("blast radius exceeded")
117
- raise RuntimeError(f"unhandled test mode: {self.mode}")
118
-
119
-
120
- @pytest.fixture
121
- def enterprise_execution_enabled(monkeypatch):
122
- monkeypatch.setattr(routes_governance, "ENTERPRISE_EXECUTOR_AVAILABLE", True)
123
- monkeypatch.setattr(routes_governance, "ARF_ENABLE_EXECUTION", True)
124
- monkeypatch.setattr(routes_governance, "EnterpriseExecutor", _FakeExecutor)
125
- monkeypatch.setattr(routes_governance, "EnterpriseConfig", _FakeConfig)
126
- monkeypatch.setattr(routes_governance, "FakeCloudActuator", lambda: None)
127
- monkeypatch.setattr(routes_governance, "PendingApprovalError", _FakePendingApprovalError)
128
- monkeypatch.setattr(routes_governance, "EnterpriseExecutionError", _FakeExecutionError)
129
- monkeypatch.setattr(routes_governance, "EnterpriseSafetyError", _FakeSafetyError)
130
- _FakeExecutor.mode = "success"
131
- yield
132
- _FakeExecutor.mode = "success"
133
-
134
-
135
- def test_execute_returns_501_when_enterprise_package_not_available(client):
136
- resp = client.post(
137
- "/api/v1/intents/does-not-matter/execute",
138
- json={"healing_intent": {}},
139
- )
140
- assert resp.status_code == 501
141
- assert "not installed" in resp.json()["detail"]
142
-
143
-
144
- def test_execute_returns_501_when_not_enabled(client, monkeypatch):
145
- monkeypatch.setattr(routes_governance, "ENTERPRISE_EXECUTOR_AVAILABLE", True)
146
- monkeypatch.setattr(routes_governance, "ARF_ENABLE_EXECUTION", False)
147
- resp = client.post(
148
- "/api/v1/intents/does-not-matter/execute",
149
- json={"healing_intent": {}},
150
- )
151
- assert resp.status_code == 501
152
- assert "not enabled" in resp.json()["detail"]
153
-
154
-
155
- def test_execute_returns_404_for_unknown_intent(client, enterprise_execution_enabled):
156
- resp = client.post(
157
- "/api/v1/intents/does-not-exist-at-all/execute",
158
- json={"healing_intent": {}},
159
- )
160
- assert resp.status_code == 404
161
-
162
-
163
- def test_execute_success_path(client, enterprise_execution_enabled):
164
- deterministic_id, healing_intent = _evaluate_intent(client)
165
- resp = client.post(
166
- f"/api/v1/intents/{deterministic_id}/execute",
167
- json={"healing_intent": healing_intent, "human_approved": True},
168
- )
169
- assert resp.status_code == 200, resp.text
170
- assert resp.json()["status"] == "success"
171
-
172
-
173
- def test_trusted_signing_keys_reach_the_executor_split_not_raw(
174
- client, enterprise_execution_enabled, monkeypatch
175
- ):
176
- """ARF_TRUSTED_SIGNING_KEYS holds N comma-separated fingerprints.
177
- Passing the raw string through as a one-element list would register the
178
- literal "abc,def" as a single key -- trusting neither real one -- and
179
- would look identical from the outside, since both spellings produce a
180
- non-empty list and a 200 here."""
181
- monkeypatch.setenv("ARF_TRUSTED_SIGNING_KEYS", " abc123 , def456 ,, ")
182
- deterministic_id, healing_intent = _evaluate_intent(client)
183
- resp = client.post(
184
- f"/api/v1/intents/{deterministic_id}/execute",
185
- json={"healing_intent": healing_intent, "human_approved": True},
186
- )
187
- assert resp.status_code == 200, resp.text
188
- assert _FakeConfig.last_trusted_signing_keys == ["abc123", "def456"]
189
- assert _FakeExecutor.last_config is not None
190
-
191
-
192
- def test_unset_trusted_signing_keys_trusts_nothing_rather_than_the_empty_string(
193
- client, enterprise_execution_enabled, monkeypatch
194
- ):
195
- """Fail closed. `"".split(",")` is `[""]`, so the naive parse would
196
- register the empty string as a trusted fingerprint -- trusting a key
197
- nobody holds is harmless, but it makes "trusts nothing" and
198
- "misconfigured" indistinguishable in the logs."""
199
- monkeypatch.delenv("ARF_TRUSTED_SIGNING_KEYS", raising=False)
200
- deterministic_id, healing_intent = _evaluate_intent(client)
201
- resp = client.post(
202
- f"/api/v1/intents/{deterministic_id}/execute",
203
- json={"healing_intent": healing_intent, "human_approved": True},
204
- )
205
- assert resp.status_code == 200, resp.text
206
- assert _FakeConfig.last_trusted_signing_keys == []
207
-
208
-
209
- def test_execute_pending_approval_returns_202_with_approval_id(client, enterprise_execution_enabled):
210
- deterministic_id, healing_intent = _evaluate_intent(client)
211
- _FakeExecutor.mode = "pending"
212
- resp = client.post(
213
- f"/api/v1/intents/{deterministic_id}/execute",
214
- json={"healing_intent": healing_intent},
215
- )
216
- assert resp.status_code == 202
217
- body = resp.json()
218
- assert body["approval_id"] == "appr_test123"
219
- assert body["level"] == "HumanInLoop"
220
-
221
-
222
- def test_execute_execution_error_returns_422(client, enterprise_execution_enabled):
223
- deterministic_id, healing_intent = _evaluate_intent(client)
224
- _FakeExecutor.mode = "execution_error"
225
- resp = client.post(
226
- f"/api/v1/intents/{deterministic_id}/execute",
227
- json={"healing_intent": healing_intent, "human_approved": True},
228
- )
229
- assert resp.status_code == 422
230
- assert "ladder denied" in resp.json()["detail"]
231
-
232
-
233
- def test_execute_safety_error_returns_422(client, enterprise_execution_enabled):
234
- deterministic_id, healing_intent = _evaluate_intent(client)
235
- _FakeExecutor.mode = "safety_error"
236
- resp = client.post(
237
- f"/api/v1/intents/{deterministic_id}/execute",
238
- json={"healing_intent": healing_intent, "human_approved": True},
239
- )
240
- assert resp.status_code == 422
241
- assert "blast radius" in resp.json()["detail"]
242
-
243
-
244
- def test_execute_belongs_to_different_tenant_returns_404(client, enterprise_execution_enabled, db_session):
245
- """A deterministic_id that exists but under a different tenant must not
246
- be executable by this caller -- same tenant-scoping guarantee
247
- record_outcome already provides for /intents/outcome."""
248
- other_tenant = "other-tenant"
249
- if not db_session.query(TenantDB).filter_by(id=other_tenant).first():
250
- db_session.add(TenantDB(id=other_tenant, name="Other Tenant"))
251
- db_session.commit()
252
-
253
- from app.database.models_intents import IntentDB
254
- import datetime
255
- db_session.add(IntentDB(
256
- deterministic_id="belongs-to-other-tenant",
257
- tenant_id=other_tenant,
258
- intent_type="provision_resource",
259
- payload={},
260
- oss_payload={},
261
- environment="prod",
262
- evaluated_at=datetime.datetime.utcnow(),
263
- risk_score="0.1",
264
- ))
265
- db_session.commit()
266
-
267
- resp = client.post(
268
- "/api/v1/intents/belongs-to-other-tenant/execute",
269
- json={"healing_intent": {}},
270
- )
271
- assert resp.status_code == 404
272
-
273
-
274
- # ---------------------------------------------------------------------------
275
- # Admin resolve/list endpoints
276
- # ---------------------------------------------------------------------------
277
-
278
- TEST_ADMIN_KEY = "test-admin-key-for-governance-execute-tests"
279
-
280
-
281
- class _FakeApprovalRecord:
282
- def __init__(self, id, decision_id, intent_id, level, approval_required, requested_at):
283
- self.id = id
284
- self.decision_id = decision_id
285
- self.intent_id = intent_id
286
- self.level = level
287
- self.approval_required = approval_required
288
- self.requested_at = requested_at
289
-
290
-
291
- class _FakeApprovalStore:
292
- def __init__(self):
293
- import datetime
294
- self._pending = {
295
- "appr_1": _FakeApprovalRecord(
296
- "appr_1", "dec_1", "intent-1", "HumanInLoop", "human", datetime.datetime.utcnow()
297
- )
298
- }
299
- self.resolved = []
300
-
301
- def list_pending(self, limit=100, offset=0):
302
- return list(self._pending.values())[:limit]
303
-
304
- def resolve(self, approval_id, approved, resolved_by, note=None):
305
- if approval_id not in self._pending:
306
- return False
307
- del self._pending[approval_id]
308
- self.resolved.append((approval_id, approved, resolved_by, note))
309
- return True
310
-
311
-
312
- @pytest.fixture
313
- def admin_with_approval_store(monkeypatch, client):
314
- monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY)
315
- fake_store = _FakeApprovalStore()
316
- client.app.state.approval_store = fake_store
317
- yield fake_store
318
- client.app.state.approval_store = None
319
-
320
-
321
- def test_list_pending_executions_returns_501_without_store(client, monkeypatch):
322
- monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY)
323
- client.app.state.approval_store = None
324
- resp = client.get("/api/v1/admin/executions/pending", params={"admin_key": TEST_ADMIN_KEY})
325
- assert resp.status_code == 501
326
-
327
-
328
- def test_list_pending_executions(client, admin_with_approval_store):
329
- resp = client.get("/api/v1/admin/executions/pending", params={"admin_key": TEST_ADMIN_KEY})
330
- assert resp.status_code == 200
331
- body = resp.json()
332
- assert body["total"] == 1
333
- assert body["pending"][0]["approval_id"] == "appr_1"
334
-
335
-
336
- def test_resolve_execution_approval(client, admin_with_approval_store):
337
- resp = client.post(
338
- "/api/v1/admin/executions/appr_1/resolve",
339
- params={"admin_key": TEST_ADMIN_KEY},
340
- json={"approved": True, "note": "looks fine"},
341
- )
342
- assert resp.status_code == 200
343
- assert admin_with_approval_store.resolved == [("appr_1", True, "admin", "looks fine")]
344
-
345
-
346
- def test_resolve_unknown_approval_returns_404(client, admin_with_approval_store):
347
- resp = client.post(
348
- "/api/v1/admin/executions/does-not-exist/resolve",
349
- params={"admin_key": TEST_ADMIN_KEY},
350
- json={"approved": True},
351
- )
352
- assert resp.status_code == 404