2525from typing import Any , Literal
2626
2727from fastapi import FastAPI
28- from fastapi .responses import PlainTextResponse
28+ from fastapi .responses import JSONResponse , PlainTextResponse
2929from pydantic import BaseModel , ConfigDict , Field
3030
3131from agentmesh .governance .policy import PolicyEngine as _PolicyEngine
@@ -59,24 +59,30 @@ async def health() -> dict[str, str]:
5959 """Liveness probe."""
6060 return {"status" : "ok" , "component" : "governance-sidecar" }
6161
62- @app .get ("/ready" , tags = ["health" ])
63- async def ready () -> dict [str , Any ]:
64- """Readiness probe. Reports loaded policy count."""
65- return {
66- "status" : "ready" ,
62+ def _readiness_response () -> JSONResponse :
63+ generation = _policy_state [1 ]
64+ payload : dict [str , Any ] = {
65+ "status" : "ready" if generation .effective_rules > 0 else "not-ready" ,
6766 "component" : "governance-sidecar" ,
68- ** _policy_state [ 1 ] .model_dump (exclude = {"files" }),
67+ ** generation .model_dump (exclude = {"files" }),
6968 }
69+ status_code = 200 if generation .effective_rules > 0 else 503
70+ return JSONResponse (status_code = status_code , content = payload )
71+
72+ @app .get ("/ready" , tags = ["health" ], response_model = None )
73+ async def ready () -> JSONResponse :
74+ """Readiness probe. Reports loaded policy count."""
75+ return _readiness_response ()
7076
7177 @app .get ("/healthz" , tags = ["health" ])
7278 async def healthz () -> dict [str , str ]:
7379 """Kubernetes-style liveness probe."""
7480 return {"status" : "ok" , "component" : "governance-sidecar" }
7581
76- @app .get ("/readyz" , tags = ["health" ])
77- async def readyz () -> dict [ str , str ] :
82+ @app .get ("/readyz" , tags = ["health" ], response_model = None )
83+ async def readyz () -> JSONResponse :
7884 """Kubernetes-style readiness probe."""
79- return { "status" : "ready" , "component" : "governance-sidecar" }
85+ return _readiness_response ()
8086
8187 # ── Metrics endpoint ─────────────────────────────────────────────
8288
@@ -203,17 +209,18 @@ class PolicyFileLoad(BaseModel):
203209
204210
205211class PolicyLoadGeneration (BaseModel ):
206- """Immutable manifest of a completed load; counts refer to files, not unique names ."""
212+ """Immutable manifest of a completed load."""
207213
208214 model_config = ConfigDict (frozen = True )
209215 policy_set_id : str
210216 policy_set_status : Literal ["complete" , "degraded" , "rejected" , "not_loaded" ]
211217 policies_discovered : int
212218 policies_loaded : int
219+ effective_rules : int = 0
213220 policies_failed : int
214221 directory_status : Literal ["available" , "unavailable" , "not_loaded" ]
215222 files : tuple [PolicyFileLoad , ...]
216- startup_warnings : tuple [str , ...] = ()
223+ load_warnings : tuple [str , ...] = ()
217224
218225
219226# ── Internal state ───────────────────────────────────────────────────
@@ -226,6 +233,7 @@ class PolicyLoadGeneration(BaseModel):
226233 policy_set_status = "not_loaded" ,
227234 policies_discovered = 0 ,
228235 policies_loaded = 0 ,
236+ effective_rules = 0 ,
229237 policies_failed = 0 ,
230238 directory_status = "not_loaded" ,
231239 files = (),
@@ -241,6 +249,7 @@ def _load_policies() -> PolicyLoadGeneration:
241249
242250 _policy_dir = os .getenv ("AGT_POLICY_DIR" , "/etc/agt/policies" )
243251 engine = PolicyEngine ()
252+ loaded_policies : dict [str , Any ] = {}
244253
245254 policy_path = Path (_policy_dir )
246255 files = []
@@ -261,7 +270,8 @@ def _load_policies() -> PolicyLoadGeneration:
261270 try :
262271 content = f .read_bytes ()
263272 digest = hashlib .sha256 (content ).hexdigest ()
264- loader (content .decode ("utf-8" ))
273+ policy = loader (content .decode ("utf-8" ))
274+ loaded_policies [policy .name ] = policy
265275 except Exception as exc :
266276 error_type = type (exc ).__name__
267277 logger .warning ("Skipped policy %r: %s" , f .name , error_type )
@@ -280,6 +290,10 @@ def _load_policies() -> PolicyLoadGeneration:
280290 }
281291 canonical = json .dumps (manifest , sort_keys = True , separators = ("," , ":" ))
282292 failed = sum (entry .status == "failed" for entry in files )
293+ effective_rules = sum (
294+ sum (rule .enabled for rule in policy .rules )
295+ for policy in loaded_policies .values ()
296+ )
283297
284298 # Fail-closed (#3536 review): when files fail, publish the generation
285299 # as 'degraded' so evaluate_policy can deny based on policies_failed.
@@ -296,24 +310,25 @@ def _load_policies() -> PolicyLoadGeneration:
296310 if failed or directory_status == "unavailable" :
297311 policy_set_status = "degraded"
298312
299- startup_warnings : tuple [str , ...] = ()
300- if len ( files ) - failed == 0 :
313+ load_warnings : tuple [str , ...] = ()
314+ if effective_rules == 0 :
301315 warning = (
302- f"Startup validation: no policies loaded from { _policy_dir } ; "
303- "all evaluations will be denied by default until policies are loaded."
316+ f"Policy load validation: no effective rules loaded from { _policy_dir } ; "
317+ "readiness remains blocked until an enabled policy rule is loaded."
304318 )
305319 logger .warning (warning )
306- startup_warnings = (warning ,)
320+ load_warnings = (warning ,)
307321
308322 generation = PolicyLoadGeneration (
309323 policy_set_id = "sha256:" + hashlib .sha256 (canonical .encode ("utf-8" )).hexdigest (),
310324 policy_set_status = policy_set_status ,
311325 policies_discovered = len (files ),
312326 policies_loaded = len (files ) - failed ,
327+ effective_rules = effective_rules ,
313328 policies_failed = failed ,
314329 directory_status = directory_status ,
315330 files = tuple (files ),
316- startup_warnings = startup_warnings ,
331+ load_warnings = load_warnings ,
317332 )
318333 serialized = generation .model_dump_json ()
319334 _policy_state = (engine , generation )
0 commit comments