petter2025's picture
Upload folder using huggingface_hub
da70460
Raw
History Blame Contribute Delete
5.35 kB
"""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,
)