petter2025 commited on
Commit
3b45581
Β·
1 Parent(s): 30bcdd5

Upload folder using huggingface_hub

Browse files
app/api/routes_admin.py CHANGED
@@ -11,7 +11,8 @@ import secrets
11
  import uuid
12
  from sqlalchemy.orm import Session
13
  from app.api.deps import get_db
14
- from app.core.usage_tracker import tracker, Tier
 
15
  from app.database.models_intents import TenantDB
16
 
17
  router = APIRouter(prefix="/admin", tags=["admin"])
@@ -67,7 +68,7 @@ async def create_api_key(req: CreateKeyRequest, db: Session = Depends(get_db)):
67
  db.commit()
68
 
69
  new_key = f"sk_live_{uuid.uuid4().hex[:24]}"
70
- tracker.get_or_create_api_key(new_key, tenant_id=tenant_id, tier=tier_enum)
71
  return {"api_key": new_key, "tenant_id": tenant_id, "tier": req.tier}
72
 
73
 
@@ -82,8 +83,8 @@ async def list_api_keys(limit: int = 100, offset: int = 0):
82
  longer has -- query `/admin/keys/{api_key}/audit` with the real key for
83
  per-key usage/audit history instead.
84
  """
85
- with tracker._get_pg_conn() as conn:
86
- rows = tracker._pg_execute(
87
  conn,
88
  "SELECT lookup_hash, tier, created_at, last_used_at, is_active FROM api_keys "
89
  "ORDER BY created_at DESC LIMIT %s OFFSET %s",
@@ -111,13 +112,13 @@ async def update_key_tier(
111
  if req.tier not in [t.value for t in Tier]:
112
  raise HTTPException(
113
  status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}")
114
- with tracker._get_pg_conn() as conn:
115
- row = tracker._pg_execute(
116
  conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (key_id,)).fetchone()
117
  if not row:
118
  conn.rollback()
119
  raise HTTPException(status_code=404, detail="API key not found")
120
- tracker._pg_execute(
121
  conn, "UPDATE api_keys SET tier = %s WHERE lookup_hash = %s", (req.tier, key_id))
122
  conn.commit()
123
  return {"message": f"Tier updated to {req.tier}"}
@@ -133,8 +134,8 @@ async def rotate_api_key(
133
  before this endpoint existed) risks losing track of the tenant_id
134
  partway through, or leaving the old key active if a later step fails.
135
  The new plaintext key is returned exactly once, like create_api_key's."""
136
- with tracker._get_pg_conn() as conn:
137
- old_row = tracker._pg_execute(
138
  conn, "SELECT tenant_id, tier FROM api_keys WHERE lookup_hash = %s", (key_id,)
139
  ).fetchone()
140
  if not old_row:
@@ -143,15 +144,15 @@ async def rotate_api_key(
143
 
144
  new_key = f"sk_live_{uuid.uuid4().hex[:24]}"
145
  salt = secrets.token_hex(16)
146
- tracker._pg_execute(
147
  conn, "UPDATE api_keys SET is_active = false WHERE lookup_hash = %s", (key_id,))
148
- tracker._pg_execute(
149
  conn,
150
  "INSERT INTO api_keys "
151
  "(tenant_id, tier, created_at, is_active, salt, key_hash, lookup_hash) "
152
  "VALUES (%s, %s, %s, %s, %s, %s, %s)",
153
  (old_row["tenant_id"], old_row["tier"], datetime.utcnow(), True,
154
- salt, tracker._salted_hash(new_key, salt), tracker._lookup_hash(new_key)),
155
  )
156
  conn.commit()
157
 
@@ -166,13 +167,13 @@ async def rotate_api_key(
166
  @router.delete("/keys/{key_id}", dependencies=[Depends(verify_admin)])
167
  async def deactivate_api_key(
168
  key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)")):
169
- with tracker._get_pg_conn() as conn:
170
- row = tracker._pg_execute(
171
  conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (key_id,)).fetchone()
172
  if not row:
173
  conn.rollback()
174
  raise HTTPException(status_code=404, detail="API key not found")
175
- tracker._pg_execute(
176
  conn, "UPDATE api_keys SET is_active = false WHERE lookup_hash = %s", (key_id,))
177
  conn.commit()
178
  return {"message": "API key deactivated"}
@@ -187,23 +188,23 @@ async def get_audit_logs(
187
  ):
188
  start = datetime.fromisoformat(start_date) if start_date else None
189
  end = datetime.fromisoformat(end_date) if end_date else None
190
- logs = tracker.get_audit_logs(api_key, start, end, limit)
191
  return {"api_key": api_key, "logs": logs}
192
 
193
 
194
  @router.get("/stats", dependencies=[Depends(verify_admin)])
195
  async def get_global_stats():
196
- with tracker._get_pg_conn() as pg_conn:
197
- total_keys = tracker._pg_execute(
198
  pg_conn, "SELECT COUNT(*) FROM api_keys WHERE is_active = true").fetchone()["count"]
199
  pg_conn.commit()
200
- with tracker._get_conn() as conn:
201
  total_requests = conn.execute(
202
  "SELECT COUNT(*) FROM usage_log").fetchone()[0]
203
  by_tier = conn.execute(
204
  "SELECT tier, COUNT(*) as count FROM usage_log GROUP BY tier"
205
  ).fetchall()
206
- month = tracker._get_month_key()
207
  current_month_requests = conn.execute(
208
  "SELECT SUM(count) FROM monthly_counts WHERE year_month = ?", (month,)
209
  ).fetchone()[0] or 0
 
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"])
 
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
 
 
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",
 
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}"}
 
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:
 
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
 
 
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"}
 
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
app/api/routes_governance.py CHANGED
@@ -81,6 +81,7 @@ except ImportError:
81
  try:
82
  from arf_enterprise.executor import EnterpriseExecutor
83
  from arf_enterprise.actuators.fake import FakeCloudActuator
 
84
  from arf_enterprise.store import ApprovalStore, PostgresStore
85
  from arf_enterprise.exceptions import (
86
  ExecutionError as EnterpriseExecutionError,
@@ -92,12 +93,31 @@ except ImportError:
92
  ENTERPRISE_EXECUTOR_AVAILABLE = False
93
  EnterpriseExecutor = None
94
  FakeCloudActuator = None
 
95
  ApprovalStore = None
96
  PostgresStore = None
97
  EnterpriseExecutionError = None
98
  PendingApprovalError = None
99
  EnterpriseSafetyError = None
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  ARF_ENABLE_EXECUTION = os.getenv("ARF_ENABLE_EXECUTION", "false").lower() == "true"
102
 
103
  # ===== OPEN TELEMETRY =====
@@ -501,7 +521,27 @@ async def execute_intent_endpoint(
501
  # mode is the documented fallback (see EnterpriseExecutor.execute).
502
  approval_store = getattr(request.app.state, "approval_store", None)
503
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
  executor = EnterpriseExecutor(
 
505
  actuator=FakeCloudActuator(),
506
  approval_store=approval_store,
507
  on_verified_outcome=_on_verified_outcome,
 
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,
 
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 =====
 
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,
app/api/routes_incidents.py CHANGED
@@ -33,7 +33,8 @@ from agentic_reliability_framework.core.models.event import (
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.usage_tracker import UsageRecord, enforce_quota, tracker
 
37
 
38
  logger = logging.getLogger(__name__)
39
 
@@ -227,7 +228,7 @@ async def evaluate_incident(
227
  # ------------------------------------------------------------------
228
  # Asynchronous usage logging
229
  # ------------------------------------------------------------------
230
- if tracker:
231
  record = UsageRecord(
232
  api_key=api_key,
233
  tier=tier,
@@ -237,7 +238,7 @@ async def evaluate_incident(
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",
@@ -250,7 +251,7 @@ async def evaluate_incident(
250
  except Exception as exc:
251
  error_msg = str(exc)
252
  logger.exception("Error in evaluate_incident (deprecated endpoint)")
253
- if tracker:
254
  record = UsageRecord(
255
  api_key=api_key,
256
  tier=tier,
@@ -260,5 +261,5 @@ async def evaluate_incident(
260
  error=error_msg,
261
  processing_ms=(time.time() - start_time) * 1000,
262
  )
263
- await tracker.increment_usage_async(record, background_tasks)
264
  raise HTTPException(status_code=500, detail="Internal server error")
 
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
 
 
228
  # ------------------------------------------------------------------
229
  # Asynchronous usage logging
230
  # ------------------------------------------------------------------
231
+ if usage_tracker.tracker:
232
  record = UsageRecord(
233
  api_key=api_key,
234
  tier=tier,
 
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",
 
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
  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")
app/api/routes_payments.py CHANGED
@@ -8,7 +8,8 @@ import stripe
8
  from fastapi import APIRouter, HTTPException
9
  from pydantic import BaseModel
10
 
11
- from app.core.usage_tracker import tracker, Tier
 
12
 
13
  logger = logging.getLogger(__name__)
14
 
@@ -31,11 +32,11 @@ async def create_checkout_session(req: CheckoutRequest):
31
  """Create a Stripe Checkout session for the Pro tier."""
32
  if not stripe.api_key:
33
  raise HTTPException(status_code=500, detail="Stripe not configured")
34
- if not tracker:
35
  raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
36
 
37
  # Verify the API key exists and is free tier
38
- tier = tracker.get_tier(req.api_key)
39
  if tier != Tier.FREE:
40
  raise HTTPException(status_code=400,
41
  detail="Only free tier keys can be upgraded")
@@ -47,7 +48,7 @@ async def create_checkout_session(req: CheckoutRequest):
47
  # third party with no reason to ever see it. tenant_id is an opaque,
48
  # non-secret row identifier and is exactly what the webhook needs to
49
  # look up which tenant's keys to retier.
50
- tenant_id = tracker.get_tenant_id(req.api_key)
51
 
52
  try:
53
  checkout_session = stripe.checkout.Session.create(
 
8
  from fastapi import APIRouter, HTTPException
9
  from pydantic import BaseModel
10
 
11
+ from app.core import usage_tracker
12
+ from app.core.usage_tracker import Tier
13
 
14
  logger = logging.getLogger(__name__)
15
 
 
32
  """Create a Stripe Checkout session for the Pro tier."""
33
  if not stripe.api_key:
34
  raise HTTPException(status_code=500, detail="Stripe not configured")
35
+ if not usage_tracker.tracker:
36
  raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
37
 
38
  # Verify the API key exists and is free tier
39
+ tier = usage_tracker.tracker.get_tier(req.api_key)
40
  if tier != Tier.FREE:
41
  raise HTTPException(status_code=400,
42
  detail="Only free tier keys can be upgraded")
 
48
  # third party with no reason to ever see it. tenant_id is an opaque,
49
  # non-secret row identifier and is exactly what the webhook needs to
50
  # look up which tenant's keys to retier.
51
+ tenant_id = usage_tracker.tracker.get_tenant_id(req.api_key)
52
 
53
  try:
54
  checkout_session = stripe.checkout.Session.create(
app/api/routes_users.py CHANGED
@@ -9,7 +9,8 @@ from sqlalchemy.orm import Session
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,7 +31,7 @@ async def register_user(
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,7 +49,7 @@ async def register_user(
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)
 
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
  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
 
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)
app/core/usage_tracker.py CHANGED
@@ -629,6 +629,17 @@ class UsageTracker:
629
  # --------------------------------------------------------------------------
630
  # Global instance and FastAPI dependency
631
  # --------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
632
  tracker: Optional[UsageTracker] = None
633
 
634
 
 
629
  # --------------------------------------------------------------------------
630
  # Global instance and FastAPI dependency
631
  # --------------------------------------------------------------------------
632
+ # Rebound by init_tracker() during the app lifespan, which means consumers
633
+ # MUST reach it through the module -- `from app.core import usage_tracker`,
634
+ # then `usage_tracker.tracker`. Never `from app.core.usage_tracker import
635
+ # tracker`: that copies the *binding* (None) at import time, and init_tracker
636
+ # rebinding this global does not update the importer's copy. Five modules
637
+ # did exactly that (main, routes_admin, routes_incidents, routes_payments,
638
+ # routes_users) and every one of them saw None forever -- silently skipping
639
+ # metering and disabling signup/checkout, and crashing the Render deploy
640
+ # outright once main.py called a method on it. Functions defined *in* this
641
+ # module (enforce_quota, update_key_tier*) are safe to import by name: they
642
+ # resolve `tracker` here, at call time.
643
  tracker: Optional[UsageTracker] = None
644
 
645
 
app/main.py CHANGED
@@ -83,7 +83,8 @@ from agentic_reliability_framework.core.temporal_reliability import (
83
  )
84
 
85
  # ── Usage tracker ────────────────────────────────────────────
86
- from app.core.usage_tracker import init_tracker, tracker, Tier
 
87
 
88
  from app.api import (
89
  routes_governance,
@@ -285,8 +286,8 @@ async def lifespan(app: FastAPI):
285
  # the health endpoint answers, the deploy succeeds, and API
286
  # requests get a clean 503 from enforce_quota until the database
287
  # is reachable -- at which point they recover with no redeploy.
288
- postgres_ready = tracker.warm_up() if tracker else False
289
- if tracker and not postgres_ready:
290
  logger.error(
291
  "Usage tracker started WITHOUT a Postgres connection: api_keys "
292
  "is unreachable, so API-key validation and quota enforcement "
@@ -302,7 +303,7 @@ async def lifespan(app: FastAPI):
302
  # kills the process -- reintroducing the crash loop the warm-up
303
  # above exists to prevent.
304
  api_keys_json = os.getenv("ARF_API_KEYS", "{}")
305
- if tracker and not postgres_ready and api_keys_json not in ("", "{}"):
306
  logger.warning(
307
  "Skipping ARF_API_KEYS seeding: Postgres is unreachable. "
308
  "Seeded keys will not exist until the database recovers "
@@ -311,41 +312,38 @@ async def lifespan(app: FastAPI):
311
  api_keys_json = "{}"
312
  try:
313
  api_keys = json.loads(api_keys_json)
314
- if tracker:
315
- for key, tier_str in api_keys.items():
316
- try:
317
- tier = Tier(tier_str.lower())
318
- # Previously called get_or_create_api_key(key, tier)
319
- # -- tier was silently accepted as tenant_id, so
320
- # every seeded key of the same tier collided onto
321
- # one bogus tenant_id. These are demo/env-seeded
322
- # keys with no real TenantDB row, but each still
323
- # needs its own tenant_id to avoid cross-key
324
- # contamination in tenant-scoped state elsewhere
325
- # (BetaStateDB, IntentDB, decision audit log). A
326
- # fixed-length key prefix isn't safe here: keys
327
- # generated elsewhere in this codebase share an
328
- # 8-char prefix ("sk_live_"/"sk_free_"), so a
329
- # prefix-based id would collide the same way the
330
- # original bug did. Hash the whole key instead.
331
- tenant_id = "env-seed-" + hashlib.sha256(key.encode()).hexdigest()[:16]
332
- tracker.get_or_create_api_key(key, tenant_id=tenant_id, tier=tier)
333
- logger.info(f"Seeded API key for tier {tier.value}")
334
- except ValueError:
335
- logger.warning(
336
- f"Invalid tier '{tier_str}' for key {key}, skipping"
337
- )
338
  except json.JSONDecodeError:
339
  logger.warning(
340
  "ARF_API_KEYS environment variable is not valid JSON; skipping seeding."
341
  )
342
- app.state.usage_tracker = tracker
343
- if tracker and postgres_ready:
344
  logger.info("βœ… Usage tracker ready.")
345
- elif tracker:
346
- logger.warning("⚠️ Usage tracker started in degraded mode (no Postgres).")
347
  else:
348
- logger.error("❌ Usage tracker is None; API-key validation and quota enforcement will not work.")
349
  except Exception as e:
350
  # Still fail closed on genuine configuration errors -- a missing
351
  # or too-short ARF_KEY_PEPPER, an unset DATABASE_URL. Those never
 
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,
 
286
  # the health endpoint answers, the deploy succeeds, and API
287
  # requests get a clean 503 from enforce_quota until the database
288
  # is reachable -- at which point they recover with no redeploy.
289
+ postgres_ready = usage_tracker.tracker.warm_up()
290
+ if not postgres_ready:
291
  logger.error(
292
  "Usage tracker started WITHOUT a Postgres connection: api_keys "
293
  "is unreachable, so API-key validation and quota enforcement "
 
303
  # kills the process -- reintroducing the crash loop the warm-up
304
  # above exists to prevent.
305
  api_keys_json = os.getenv("ARF_API_KEYS", "{}")
306
+ if not postgres_ready and api_keys_json not in ("", "{}"):
307
  logger.warning(
308
  "Skipping ARF_API_KEYS seeding: Postgres is unreachable. "
309
  "Seeded keys will not exist until the database recovers "
 
312
  api_keys_json = "{}"
313
  try:
314
  api_keys = json.loads(api_keys_json)
315
+ for key, tier_str in api_keys.items():
316
+ try:
317
+ tier = Tier(tier_str.lower())
318
+ # Previously called get_or_create_api_key(key, tier)
319
+ # -- tier was silently accepted as tenant_id, so
320
+ # every seeded key of the same tier collided onto
321
+ # one bogus tenant_id. These are demo/env-seeded
322
+ # keys with no real TenantDB row, but each still
323
+ # needs its own tenant_id to avoid cross-key
324
+ # contamination in tenant-scoped state elsewhere
325
+ # (BetaStateDB, IntentDB, decision audit log). A
326
+ # fixed-length key prefix isn't safe here: keys
327
+ # generated elsewhere in this codebase share an
328
+ # 8-char prefix ("sk_live_"/"sk_free_"), so a
329
+ # prefix-based id would collide the same way the
330
+ # original bug did. Hash the whole key instead.
331
+ tenant_id = "env-seed-" + hashlib.sha256(key.encode()).hexdigest()[:16]
332
+ usage_tracker.tracker.get_or_create_api_key(key, tenant_id=tenant_id, tier=tier)
333
+ logger.info(f"Seeded API key for tier {tier.value}")
334
+ except ValueError:
335
+ logger.warning(
336
+ f"Invalid tier '{tier_str}' for key {key}, skipping"
337
+ )
 
338
  except json.JSONDecodeError:
339
  logger.warning(
340
  "ARF_API_KEYS environment variable is not valid JSON; skipping seeding."
341
  )
342
+ app.state.usage_tracker = usage_tracker.tracker
343
+ if postgres_ready:
344
  logger.info("βœ… Usage tracker ready.")
 
 
345
  else:
346
+ logger.warning("⚠️ Usage tracker started in degraded mode (no Postgres).")
347
  except Exception as e:
348
  # Still fail closed on genuine configuration errors -- a missing
349
  # or too-short ARF_KEY_PEPPER, an unset DATABASE_URL. Those never
tests/test_tracker_binding.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Guard against re-introducing the stale `tracker` from-import.
2
+
3
+ `app.core.usage_tracker.tracker` starts as None and is rebound by
4
+ `init_tracker()` during the lifespan. `from app.core.usage_tracker import
5
+ tracker` copies the *binding* -- None -- at import time, and the later
6
+ rebinding never reaches the importer. Five modules did this: main.py
7
+ crashed the Render deploy with "'NoneType' object has no attribute
8
+ 'warm_up'", routes_admin's 20 unguarded uses would have 500'd, and
9
+ routes_incidents/payments/users each had a `if tracker` guard that could
10
+ only ever take the None branch -- so usage went unmetered and signup and
11
+ checkout were permanently disabled, silently.
12
+
13
+ This is a source-level check on purpose. Reproducing the bug at runtime
14
+ needs a real UsageTracker (Postgres, pepper) and would only cover the
15
+ modules the test happened to import; parsing every module catches the
16
+ next one too, and costs nothing.
17
+ """
18
+
19
+ import ast
20
+ import pathlib
21
+
22
+ import pytest
23
+
24
+ APP = pathlib.Path(__file__).resolve().parent.parent / "app"
25
+
26
+ # Names safe to import directly: functions defined in usage_tracker.py
27
+ # resolve the module global at call time, so they always see the live
28
+ # instance. Only the mutable module-level object itself is unsafe.
29
+ UNSAFE_NAMES = {"tracker"}
30
+
31
+
32
+ def _source_files():
33
+ return sorted(p for p in APP.rglob("*.py"))
34
+
35
+
36
+ @pytest.mark.parametrize("path", _source_files(), ids=lambda p: p.name)
37
+ def test_tracker_is_never_imported_by_name(path):
38
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
39
+ for node in ast.walk(tree):
40
+ if not isinstance(node, ast.ImportFrom):
41
+ continue
42
+ if node.module != "app.core.usage_tracker":
43
+ continue
44
+ offenders = sorted(
45
+ {a.name for a in node.names} & UNSAFE_NAMES
46
+ )
47
+ assert not offenders, (
48
+ f"{path.name}:{node.lineno} imports {offenders} by name from "
49
+ "app.core.usage_tracker. That binding is None at import time and "
50
+ "init_tracker() will not update it. Use `from app.core import "
51
+ "usage_tracker` and reference `usage_tracker.tracker` instead."
52
+ )