"""Routes for on-chain attestation rationale. `RiskAttestationRegistry` (arf-onchain) anchors only a `rationale_hash` -- never the reasoning itself, to keep operational detail about a customer's infrastructure off a public chain. These endpoints are the off-chain half: a reference risk evaluator (`arf_enterprise.onchain.evaluator`) persists the plaintext here immediately after signing an attestation, keyed by the same hash it put on-chain, and an auditor who reads a `DecisionAnchored` or `AttestationIssued` event can fetch the reasoning behind it here. Internal-key gated like `routes_governance.py`: this is a service-to-service surface for the evaluator process and for auditor tooling, not a tenant-scoped customer endpoint -- see `OnchainRationaleDB`'s docstring for why there is no tenant_id to enforce here. """ import logging from fastapi import APIRouter, Depends, HTTPException, Response from pydantic import BaseModel, field_validator from sqlalchemy.orm import Session from app.api.deps import get_db, verify_internal_key from app.database.models_onchain import OnchainRationaleDB logger = logging.getLogger(__name__) router = APIRouter(dependencies=[Depends(verify_internal_key)]) def _validate_hex_hash(value: str) -> str: text = value.strip() if not text.startswith("0x") or len(text) != 66: raise ValueError("rationale_hash must be a 0x-prefixed 32-byte hex string") try: int(text, 16) except ValueError: raise ValueError("rationale_hash is not valid hex") from None return text.lower() class RationaleRequest(BaseModel): rationale_hash: str rationale: str agent_address: str | None = None evaluator_address: str | None = None @field_validator("rationale_hash") @classmethod def _validate_hash(cls, value: str) -> str: return _validate_hex_hash(value) @field_validator("rationale") @classmethod def _validate_rationale(cls, value: str) -> str: if not value.strip(): raise ValueError("rationale must not be empty") return value class RationaleResponse(BaseModel): rationale_hash: str rationale: str agent_address: str | None evaluator_address: str | None @router.post("/onchain/rationale", status_code=201) async def persist_rationale( req: RationaleRequest, response: Response, db: Session = Depends(get_db), ): """Store the plaintext behind an anchored `rationale_hash`. Idempotent on `rationale_hash`: signing the same decision twice (a retry after a network error, for instance) posts the same hash and text, so the second call is a no-op rather than a uniqueness-constraint error. A *different* text arriving for a hash already on record is refused -- that would mean either hash collision or a caller bug, and silently overwriting an anchored record's preimage is the one thing this table must never do. """ existing = ( db.query(OnchainRationaleDB) .filter(OnchainRationaleDB.rationale_hash == req.rationale_hash) .one_or_none() ) if existing is not None: if existing.rationale != req.rationale: raise HTTPException( status_code=409, detail=( "rationale_hash already recorded with different text; " "an anchored hash's preimage cannot be overwritten" ), ) # The route decorator's status_code=201 is FastAPI's default for # every plain-dict return from this handler, including this one -- # it must be overridden explicitly here or a replayed post reports # itself as newly Created. response.status_code = 200 return {"status": "already_recorded", "rationale_hash": req.rationale_hash} row = OnchainRationaleDB( rationale_hash=req.rationale_hash, rationale=req.rationale, agent_address=req.agent_address, evaluator_address=req.evaluator_address, ) db.add(row) db.commit() logger.info("persisted rationale for hash %s", req.rationale_hash) return {"status": "recorded", "rationale_hash": req.rationale_hash} @router.get("/onchain/rationale/{rationale_hash}", response_model=RationaleResponse) async def get_rationale( rationale_hash: str, db: Session = Depends(get_db), ): """Fetch the plaintext behind an anchored `rationale_hash`. What an auditor calls after reading a `DecisionAnchored` event off-chain -- the hash from the event is the only key this endpoint accepts, by design: there is no listing or search here, only lookup by the exact value that was signed and anchored. """ try: normalized = _validate_hex_hash(rationale_hash) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc row = ( db.query(OnchainRationaleDB) .filter(OnchainRationaleDB.rationale_hash == normalized) .one_or_none() ) if row is None: raise HTTPException( status_code=404, detail="no rationale recorded for this hash" ) return RationaleResponse( rationale_hash=row.rationale_hash, rationale=row.rationale, agent_address=row.agent_address, evaluator_address=row.evaluator_address, )