#!/usr/bin/env python3 """JSON-shaped evaluators for the handoff acceptance harness. These rules cover the evidence that arrives as structured output — Kubernetes objects, forge pull requests, provider health files, and the two append-only evidence logs the platform keeps. They share the fail-closed contract of every other rule: an absent path, an unparsable payload, or an empty result set is ``NOT_RUN``, never a quiet pass. """ from __future__ import annotations import datetime as dt import math import re from typing import Any from hermes_handoff_exec import Outcome from hermes_handoff_model import FAIL, NOT_RUN, PASS, CheckSpec from hermes_handoff_rules import ( Evaluation, dotted, evaluator, exact_equal, is_list, missing_steps, parsed_step, strict_json, ) _ROUTE_PART_RE = re.compile(r"[a-z0-9][a-z0-9_.-]{0,63}\Z") _FLUX_NAME_RE = re.compile( r"[a-z0-9]([-a-z0-9.]*[a-z0-9])?/[a-z0-9]([-a-z0-9.]*[a-z0-9])?\Z" ) def _timestamp(value: Any) -> dt.datetime: if not isinstance(value, str) or not value.strip(): raise ValueError("timestamp is not a non-empty string") observed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) if observed.tzinfo is None: raise ValueError("timestamp has no timezone") return observed.astimezone(dt.timezone.utc) def _now(value: Any) -> dt.datetime: if not isinstance(value, dt.datetime) or value.tzinfo is None: raise ValueError("run clock is not timezone-aware") return value.astimezone(dt.timezone.utc) @evaluator("json_field") def evaluate_json_field(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation: """Compare dotted JSON paths of one step's output against expected values.""" blocked = missing_steps(spec, outcomes) if blocked: return blocked parsed = parsed_step(spec, outcomes) if isinstance(parsed, Evaluation): return parsed payload, key = parsed evidence: dict[str, Any] = {} mismatches: list[str] = [] for path, expected in spec.expect["fields"].items(): try: actual = dotted(payload, path) except (KeyError, ValueError, IndexError): return Evaluation( NOT_RUN, f"path {path} is absent from step {key}", evidence ) evidence[path] = actual allowed = expected if isinstance(expected, list) else [expected] if not any(exact_equal(actual, item) for item in allowed): mismatches.append(f"{path}={actual!r} expected one of {allowed!r}") if mismatches: return Evaluation(FAIL, "; ".join(mismatches), evidence) return Evaluation(PASS, "observed fields match the release contract", evidence) @evaluator("json_numeric") def evaluate_json_numeric(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation: """Compare numeric JSON paths against inclusive minimum/maximum bounds.""" blocked = missing_steps(spec, outcomes) if blocked: return blocked parsed = parsed_step(spec, outcomes) if isinstance(parsed, Evaluation): return parsed payload, key = parsed evidence: dict[str, Any] = {} problems: list[str] = [] for path, bounds in spec.expect["fields"].items(): try: actual = dotted(payload, path) except (KeyError, ValueError, IndexError): return Evaluation( NOT_RUN, f"path {path} is absent from step {key}", evidence ) if ( not isinstance(actual, (int, float)) or isinstance(actual, bool) or not math.isfinite(float(actual)) ): return Evaluation( NOT_RUN, f"path {path} is not numeric in step {key}", evidence ) evidence[path] = actual if "min" in bounds and actual < bounds["min"]: problems.append(f"{path}={actual} below minimum {bounds['min']}") if "max" in bounds and actual > bounds["max"]: problems.append(f"{path}={actual} above maximum {bounds['max']}") if problems: return Evaluation(FAIL, "; ".join(problems), evidence) return Evaluation(PASS, "numeric fields are within the expected bounds", evidence) @evaluator("json_recent") def evaluate_json_recent(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation: """Pass when a timestamp field is fresh enough to be evidence about now. Stale provider or routing evidence is worse than none: it describes a state the release no longer has. A timestamp older than the allowed age is a FAIL, and one the harness cannot parse is NOT_RUN. """ blocked = missing_steps(spec, outcomes) if blocked: return blocked parsed = parsed_step(spec, outcomes) if isinstance(parsed, Evaluation): return parsed payload, key = parsed try: now = _now(spec.expect["now"]) except (TypeError, ValueError) as exc: return Evaluation(NOT_RUN, f"invalid run clock: {exc}") evidence: dict[str, Any] = {} stale: list[str] = [] for path, max_age in spec.expect["fields"].items(): try: raw = dotted(payload, path) except (KeyError, ValueError, IndexError): return Evaluation( NOT_RUN, f"path {path} is absent from step {key}", evidence ) try: observed = _timestamp(raw) except (TypeError, ValueError): return Evaluation( NOT_RUN, f"path {path} is not a timezone-aware ISO timestamp", evidence ) age = (now - observed).total_seconds() evidence[path] = {"age_seconds": int(age)} if age < 0: stale.append(f"{path} is {abs(int(age))}s in the future") elif age > max_age: stale.append(f"{path} is {int(age)}s old, limit {max_age}s") if stale: return Evaluation(FAIL, "; ".join(stale), evidence) return Evaluation(PASS, "observed timestamps are fresh", evidence) @evaluator("all_of_field") def evaluate_all_of_field(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation: """Pass when every selected element of a JSON array matches expected fields.""" blocked = missing_steps(spec, outcomes) if blocked: return blocked parsed = parsed_step(spec, outcomes) if isinstance(parsed, Evaluation): return parsed payload, key = parsed try: elements = dotted(payload, spec.expect.get("array", "")) except (KeyError, ValueError, IndexError): return Evaluation(NOT_RUN, f"array path is absent from step {key}") if not is_list(elements): return Evaluation(NOT_RUN, f"step {key} did not return an array") key_field = spec.expect.get("key_field", "") wanted = spec.expect.get("keys") exempt = set(spec.expect.get("exempt_keys", ())) seen: list[Any] = [] problems: list[str] = [] evidence: dict[str, Any] = {} for element in elements: identity = dotted(element, key_field) if key_field else None if wanted is not None and identity not in wanted: continue if identity in exempt: continue seen.append(identity) for path in spec.expect.get("non_empty", ()): try: value = dotted(element, path) except (KeyError, ValueError, IndexError): value = None if value in (None, "", [], {}): problems.append(f"{identity}: {path} is empty") for path, expected in spec.expect.get("fields", {}).items(): try: actual = dotted(element, path) except (KeyError, ValueError, IndexError): actual = None allowed = expected if isinstance(expected, list) else [expected] if not any(exact_equal(actual, item) for item in allowed): problems.append( f"{identity}: {path}={actual!r} expected one of {allowed!r}" ) evidence["examined"] = seen[:32] if wanted is not None: absent = [item for item in wanted if item not in seen] if absent: return Evaluation( NOT_RUN, f"expected keys absent from step {key}: {absent}", evidence ) elif not seen: return Evaluation(NOT_RUN, f"step {key} returned nothing to examine", evidence) if problems: return Evaluation(FAIL, "; ".join(problems[:8]), evidence) return Evaluation( PASS, f"{len(seen)} element(s) match the release contract", evidence ) @evaluator("json_record") def evaluate_json_record(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation: """Capture named JSON paths as evidence; pass when all of them resolve. Recording is a release requirement in its own right — the runbook has to name the exact heads it was verified against — so an unreachable source is a NOT_RUN rather than an empty section. """ blocked = missing_steps(spec, outcomes) if blocked: return blocked parsed = parsed_step(spec, outcomes) if isinstance(parsed, Evaluation): return parsed payload, key = parsed evidence: dict[str, Any] = {} for label, path in spec.expect["record"].items(): try: evidence[label] = dotted(payload, path) except (KeyError, ValueError, IndexError): return Evaluation( NOT_RUN, f"path {path} is absent from step {key}", evidence ) return Evaluation(PASS, "state recorded", evidence) @evaluator("routing_evidence") def evaluate_routing_evidence( spec: CheckSpec, outcomes: dict[str, Outcome] ) -> Evaluation: """Summarise a Switchyard routing log tail into provider/effort coverage. Route identifiers are ``///``, so provider, model family, effort tier, and lane all come from the same field. Fallback and failure evidence is a separate ``fallback_reason`` on the record that was retried. Every row must be complete strict JSON; a clipped or malformed line makes the evidence unusable rather than being silently discarded. """ blocked = missing_steps(spec, outcomes) if blocked: return blocked key = spec.expect["step"] outcome = outcomes[key] if not outcome.ok: return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})") records: list[dict[str, Any]] = [] routes: list[list[str]] = [] stamps_by_dimension: dict[tuple[str, str], dt.datetime] = {} now_value = spec.expect.get("now") try: now = _now(now_value) if now_value is not None else None except (TypeError, ValueError) as exc: return Evaluation(NOT_RUN, f"invalid routing run clock: {exc}") for row_number, line in enumerate(outcome.stdout.splitlines(), 1): line = line.strip() if not line: continue try: record = strict_json(line) except ValueError as exc: return Evaluation( NOT_RUN, f"routing row {row_number} is malformed JSON: {exc}" ) if not isinstance(record, dict): return Evaluation(NOT_RUN, f"routing row {row_number} is not an object") try: observed = _timestamp(record.get("ts")) except (TypeError, ValueError) as exc: return Evaluation( NOT_RUN, f"routing row {row_number} has invalid timestamp: {exc}" ) if now is not None and observed > now: return Evaluation(FAIL, f"routing row {row_number} has a future timestamp") model = record.get("model") if not isinstance(model, str) or not model: return Evaluation(NOT_RUN, f"routing row {row_number} has no model") route = model.split("/") if len(route) == 4 and all(_ROUTE_PART_RE.fullmatch(part) for part in route): routes.append(route) for label, value in ( ("provider", route[1]), ("effort", route[3]), ("lane", route[0]), ): key_dimension = (label, value) stamps_by_dimension[key_dimension] = max( observed, stamps_by_dimension.get(key_dimension, observed) ) elif record.get("tier") != "classifier": return Evaluation( NOT_RUN, f"routing row {row_number} has malformed route {model!r}" ) fallback = record.get("fallback_reason") if fallback is not None and ( not isinstance(fallback, str) or not fallback.strip() ): return Evaluation( NOT_RUN, f"routing row {row_number} has malformed fallback_reason" ) record["_observed"] = observed records.append(record) fallbacks = [record for record in records if record.get("fallback_reason")] stamps = sorted(record["_observed"] for record in records) evidence: dict[str, Any] = { "records": len(records), "routed": len(routes), "lanes": sorted({route[0] for route in routes}), "providers": sorted({route[1] for route in routes}), "families": sorted({route[2] for route in routes}), "efforts": sorted({route[3] for route in routes}), "fallback_events": len(fallbacks), "fallback_reasons": sorted( {str(record["fallback_reason"]) for record in fallbacks} ), "latest_ts": stamps[-1].isoformat() if stamps else "", } if not records: return Evaluation( NOT_RUN, "routing log tail contained no parsable records", evidence ) missing = [ f"{label}:{item}" for label, wanted in ( ("provider", spec.expect.get("providers", ())), ("effort", spec.expect.get("efforts", ())), ("lane", spec.expect.get("lanes", ())), ) for item in wanted if item not in evidence[f"{label}s"] ] if spec.expect.get("require_fallback_evidence") and not fallbacks: missing.append("fallback:none-observed") max_age = spec.expect.get("max_age_seconds") if max_age and now: ages: dict[str, int] = {} for label, wanted in ( ("provider", spec.expect.get("providers", ())), ("effort", spec.expect.get("efforts", ())), ("lane", spec.expect.get("lanes", ())), ): for item in wanted: stamp = stamps_by_dimension.get((label, item)) if stamp is None: continue age = int((now - stamp).total_seconds()) ages[f"{label}:{item}"] = age if age > max_age: missing.append( f"freshness:{label}:{item}:{age}s-exceeds-{max_age}s" ) evidence["age_seconds"] = ages if missing: return Evaluation( FAIL, f"routing evidence missing {', '.join(missing)}", evidence ) return Evaluation(PASS, "routing evidence covers the required lanes", evidence) @evaluator("flux_health") def evaluate_flux_health(spec: CheckSpec, outcomes: dict[str, Outcome]) -> Evaluation: """Classify a compact Flux projection into suspensions and unhealthy objects. Each row carries name, generation, observed generation, suspension, and all condition states. A non-suspended object must be current, explicitly ``Ready=True``, not ``Healthy=False/Unknown``, and not reconciling. Suspended objects are judged against the exact expected-suspension list. """ blocked = missing_steps(spec, outcomes) if blocked: return blocked key = spec.expect["step"] outcome = outcomes[key] if not outcome.ok: return Evaluation(NOT_RUN, f"step {key} failed (rc={outcome.returncode})") expected = set(spec.expect.get("expected_suspensions", ())) suspended: list[str] = [] unhealthy: list[str] = [] total = 0 for row_number, line in enumerate(outcome.stdout.splitlines(), 1): if not line.strip(): continue columns = line.split("\t") if len(columns) != 5: return Evaluation( NOT_RUN, f"Flux row {row_number} has {len(columns)} columns" ) name, generation, observed_generation, suspend, conditions = columns name = name.strip() if not _FLUX_NAME_RE.fullmatch(name): return Evaluation( NOT_RUN, f"Flux row {row_number} has malformed object name" ) try: current_generation = int(generation) reconciled_generation = int(observed_generation) except ValueError: return Evaluation( NOT_RUN, f"Flux row {row_number} has malformed generations" ) if current_generation <= 0 or current_generation != reconciled_generation: return Evaluation( FAIL, f"Flux row {row_number} has stale observedGeneration" ) if suspend.strip().lower() not in {"", "false", "true"}: return Evaluation( NOT_RUN, f"Flux row {row_number} has malformed suspension state" ) total += 1 if suspend.strip().lower() == "true": suspended.append(name) continue states: dict[str, str] = {} for pair in filter(None, conditions.strip().strip(",").split(",")): if pair.count("=") != 1: return Evaluation( NOT_RUN, f"Flux row {row_number} has malformed condition" ) condition, state = pair.split("=", 1) if ( not condition or state not in {"True", "False", "Unknown"} or condition in states ): return Evaluation( NOT_RUN, f"Flux row {row_number} has malformed condition state" ) states[condition] = state healthy = states.get("Healthy") if ( states.get("Ready") != "True" or healthy in {"False", "Unknown"} or states.get("Reconciling") == "True" ): unhealthy.append(name) unexpected = sorted(set(suspended) - expected) evidence = { "total": total, "suspended": sorted(suspended), "unexpected_suspensions": unexpected, "unhealthy": sorted(unhealthy), } if not total: return Evaluation(NOT_RUN, f"step {key} returned no Flux objects", evidence) problems = [] if unexpected: problems.append(f"unexpected suspensions: {', '.join(unexpected)}") if unhealthy: problems.append(f"unhealthy: {', '.join(sorted(unhealthy)[:8])}") if problems: return Evaluation(FAIL, "; ".join(problems), evidence) return Evaluation(PASS, f"{total} Flux objects reconciled as expected", evidence)