Spaces:
Build error
Build error
Commit ·
690acd1
1
Parent(s): af423c3
Upload folder using huggingface_hub
Browse files
alembic/versions/e4a7c1f9b3d2_create_onchain_rationales_table.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""create onchain_rationales table
|
| 2 |
+
|
| 3 |
+
Stores the plaintext preimage of an anchored `RiskAttestation.rationale_hash`
|
| 4 |
+
(arf-onchain's RiskAttestationRegistry / enterprise's
|
| 5 |
+
arf_enterprise.onchain.attestation). The chain only ever holds the hash --
|
| 6 |
+
see models_onchain.py's module docstring for why the text has to live here,
|
| 7 |
+
keyed by that same hash, or the anchored hash is unverifiable.
|
| 8 |
+
|
| 9 |
+
Revision ID: e4a7c1f9b3d2
|
| 10 |
+
Revises: a1f3c9d2e6b7
|
| 11 |
+
Create Date: 2026-09-07 00:00:00.000000
|
| 12 |
+
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from typing import Sequence, Union
|
| 16 |
+
|
| 17 |
+
from alembic import op
|
| 18 |
+
import sqlalchemy as sa
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# revision identifiers, used by Alembic.
|
| 22 |
+
revision: str = "e4a7c1f9b3d2"
|
| 23 |
+
down_revision: Union[str, Sequence[str], None] = "a1f3c9d2e6b7"
|
| 24 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 25 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def upgrade() -> None:
|
| 29 |
+
"""Upgrade schema."""
|
| 30 |
+
op.create_table(
|
| 31 |
+
"onchain_rationales",
|
| 32 |
+
sa.Column("id", sa.String(length=64), nullable=False),
|
| 33 |
+
sa.Column("rationale_hash", sa.String(length=66), nullable=False),
|
| 34 |
+
sa.Column("rationale", sa.Text(), nullable=False),
|
| 35 |
+
sa.Column("agent_address", sa.String(length=42), nullable=True),
|
| 36 |
+
sa.Column("evaluator_address", sa.String(length=42), nullable=True),
|
| 37 |
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
| 38 |
+
sa.PrimaryKeyConstraint("id"),
|
| 39 |
+
)
|
| 40 |
+
op.create_index(
|
| 41 |
+
op.f("ix_onchain_rationales_rationale_hash"),
|
| 42 |
+
"onchain_rationales",
|
| 43 |
+
["rationale_hash"],
|
| 44 |
+
unique=True,
|
| 45 |
+
)
|
| 46 |
+
op.create_index(
|
| 47 |
+
op.f("ix_onchain_rationales_created_at"),
|
| 48 |
+
"onchain_rationales",
|
| 49 |
+
["created_at"],
|
| 50 |
+
unique=False,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def downgrade() -> None:
|
| 55 |
+
"""Downgrade schema."""
|
| 56 |
+
op.drop_index(
|
| 57 |
+
op.f("ix_onchain_rationales_created_at"), table_name="onchain_rationales"
|
| 58 |
+
)
|
| 59 |
+
op.drop_index(
|
| 60 |
+
op.f("ix_onchain_rationales_rationale_hash"), table_name="onchain_rationales"
|
| 61 |
+
)
|
| 62 |
+
op.drop_table("onchain_rationales")
|
app/api/routes_onchain.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Routes for on-chain attestation rationale.
|
| 2 |
+
|
| 3 |
+
`RiskAttestationRegistry` (arf-onchain) anchors only a `rationale_hash` --
|
| 4 |
+
never the reasoning itself, to keep operational detail about a customer's
|
| 5 |
+
infrastructure off a public chain. These endpoints are the off-chain half:
|
| 6 |
+
a reference risk evaluator (`arf_enterprise.onchain.evaluator`) persists the
|
| 7 |
+
plaintext here immediately after signing an attestation, keyed by the same
|
| 8 |
+
hash it put on-chain, and an auditor who reads a `DecisionAnchored` or
|
| 9 |
+
`AttestationIssued` event can fetch the reasoning behind it here.
|
| 10 |
+
|
| 11 |
+
Internal-key gated like `routes_governance.py`: this is a service-to-service
|
| 12 |
+
surface for the evaluator process and for auditor tooling, not a
|
| 13 |
+
tenant-scoped customer endpoint -- see `OnchainRationaleDB`'s docstring for
|
| 14 |
+
why there is no tenant_id to enforce here.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
|
| 19 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 20 |
+
from pydantic import BaseModel, field_validator
|
| 21 |
+
from sqlalchemy.orm import Session
|
| 22 |
+
|
| 23 |
+
from app.api.deps import get_db, verify_internal_key
|
| 24 |
+
from app.database.models_onchain import OnchainRationaleDB
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _validate_hex_hash(value: str) -> str:
|
| 32 |
+
text = value.strip()
|
| 33 |
+
if not text.startswith("0x") or len(text) != 66:
|
| 34 |
+
raise ValueError("rationale_hash must be a 0x-prefixed 32-byte hex string")
|
| 35 |
+
try:
|
| 36 |
+
int(text, 16)
|
| 37 |
+
except ValueError:
|
| 38 |
+
raise ValueError("rationale_hash is not valid hex") from None
|
| 39 |
+
return text.lower()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class RationaleRequest(BaseModel):
|
| 43 |
+
rationale_hash: str
|
| 44 |
+
rationale: str
|
| 45 |
+
agent_address: str | None = None
|
| 46 |
+
evaluator_address: str | None = None
|
| 47 |
+
|
| 48 |
+
@field_validator("rationale_hash")
|
| 49 |
+
@classmethod
|
| 50 |
+
def _validate_hash(cls, value: str) -> str:
|
| 51 |
+
return _validate_hex_hash(value)
|
| 52 |
+
|
| 53 |
+
@field_validator("rationale")
|
| 54 |
+
@classmethod
|
| 55 |
+
def _validate_rationale(cls, value: str) -> str:
|
| 56 |
+
if not value.strip():
|
| 57 |
+
raise ValueError("rationale must not be empty")
|
| 58 |
+
return value
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class RationaleResponse(BaseModel):
|
| 62 |
+
rationale_hash: str
|
| 63 |
+
rationale: str
|
| 64 |
+
agent_address: str | None
|
| 65 |
+
evaluator_address: str | None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.post("/onchain/rationale", status_code=201)
|
| 69 |
+
async def persist_rationale(
|
| 70 |
+
req: RationaleRequest,
|
| 71 |
+
db: Session = Depends(get_db),
|
| 72 |
+
):
|
| 73 |
+
"""Store the plaintext behind an anchored `rationale_hash`.
|
| 74 |
+
|
| 75 |
+
Idempotent on `rationale_hash`: signing the same decision twice (a
|
| 76 |
+
retry after a network error, for instance) posts the same hash and
|
| 77 |
+
text, so the second call is a no-op rather than a uniqueness-constraint
|
| 78 |
+
error. A *different* text arriving for a hash already on record is
|
| 79 |
+
refused -- that would mean either hash collision or a caller bug, and
|
| 80 |
+
silently overwriting an anchored record's preimage is the one thing
|
| 81 |
+
this table must never do.
|
| 82 |
+
"""
|
| 83 |
+
existing = (
|
| 84 |
+
db.query(OnchainRationaleDB)
|
| 85 |
+
.filter(OnchainRationaleDB.rationale_hash == req.rationale_hash)
|
| 86 |
+
.one_or_none()
|
| 87 |
+
)
|
| 88 |
+
if existing is not None:
|
| 89 |
+
if existing.rationale != req.rationale:
|
| 90 |
+
raise HTTPException(
|
| 91 |
+
status_code=409,
|
| 92 |
+
detail=(
|
| 93 |
+
"rationale_hash already recorded with different text; "
|
| 94 |
+
"an anchored hash's preimage cannot be overwritten"
|
| 95 |
+
),
|
| 96 |
+
)
|
| 97 |
+
return {"status": "already_recorded", "rationale_hash": req.rationale_hash}
|
| 98 |
+
|
| 99 |
+
row = OnchainRationaleDB(
|
| 100 |
+
rationale_hash=req.rationale_hash,
|
| 101 |
+
rationale=req.rationale,
|
| 102 |
+
agent_address=req.agent_address,
|
| 103 |
+
evaluator_address=req.evaluator_address,
|
| 104 |
+
)
|
| 105 |
+
db.add(row)
|
| 106 |
+
db.commit()
|
| 107 |
+
logger.info("persisted rationale for hash %s", req.rationale_hash)
|
| 108 |
+
return {"status": "recorded", "rationale_hash": req.rationale_hash}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.get("/onchain/rationale/{rationale_hash}", response_model=RationaleResponse)
|
| 112 |
+
async def get_rationale(
|
| 113 |
+
rationale_hash: str,
|
| 114 |
+
db: Session = Depends(get_db),
|
| 115 |
+
):
|
| 116 |
+
"""Fetch the plaintext behind an anchored `rationale_hash`.
|
| 117 |
+
|
| 118 |
+
What an auditor calls after reading a `DecisionAnchored` event off-chain
|
| 119 |
+
-- the hash from the event is the only key this endpoint accepts, by
|
| 120 |
+
design: there is no listing or search here, only lookup by the exact
|
| 121 |
+
value that was signed and anchored.
|
| 122 |
+
"""
|
| 123 |
+
try:
|
| 124 |
+
normalized = _validate_hex_hash(rationale_hash)
|
| 125 |
+
except ValueError as exc:
|
| 126 |
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 127 |
+
|
| 128 |
+
row = (
|
| 129 |
+
db.query(OnchainRationaleDB)
|
| 130 |
+
.filter(OnchainRationaleDB.rationale_hash == normalized)
|
| 131 |
+
.one_or_none()
|
| 132 |
+
)
|
| 133 |
+
if row is None:
|
| 134 |
+
raise HTTPException(
|
| 135 |
+
status_code=404, detail="no rationale recorded for this hash"
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
return RationaleResponse(
|
| 139 |
+
rationale_hash=row.rationale_hash,
|
| 140 |
+
rationale=row.rationale,
|
| 141 |
+
agent_address=row.agent_address,
|
| 142 |
+
evaluator_address=row.evaluator_address,
|
| 143 |
+
)
|
app/database/models_onchain.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database models for on-chain governance attestations.
|
| 2 |
+
|
| 3 |
+
``RiskAttestation.rationale_hash`` (see arf-onchain's ``AttestationLib.sol``
|
| 4 |
+
and enterprise's ``arf_enterprise.onchain.attestation``) is a ``keccak256``
|
| 5 |
+
digest anchored on Monad -- the chain deliberately never stores the rationale
|
| 6 |
+
text itself, only its hash, to keep operational detail about a customer's
|
| 7 |
+
infrastructure off a public ledger. That means the text has to live
|
| 8 |
+
somewhere off-chain, keyed by the same hash, or the anchored hash proves
|
| 9 |
+
nothing: nobody could ever produce the preimage to check it against.
|
| 10 |
+
|
| 11 |
+
This table is that store. It is intentionally separate from
|
| 12 |
+
``DecisionAuditLogDB`` (``models_intents.py``) rather than an extension of
|
| 13 |
+
it: that table is written for every governance decision, on-chain or not,
|
| 14 |
+
and already has its own signature column for a different purpose (Ed25519
|
| 15 |
+
intent-signing, not the secp256k1 EIP-712 signature the guard verifies).
|
| 16 |
+
Conflating the two would mean a column that is only sometimes meaningful
|
| 17 |
+
depending on whether the decision was ever attested on-chain.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import uuid
|
| 21 |
+
import datetime
|
| 22 |
+
|
| 23 |
+
from sqlalchemy import Column, String, DateTime, Text
|
| 24 |
+
|
| 25 |
+
from .base import Base
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class OnchainRationaleDB(Base):
|
| 29 |
+
"""The plaintext preimage of an anchored ``rationale_hash``.
|
| 30 |
+
|
| 31 |
+
Keyed by the hash itself (unique, indexed) rather than by an
|
| 32 |
+
auto-incrementing id: a lookup always starts from a hash read off-chain
|
| 33 |
+
(from `DecisionAnchored` or `AttestationIssued`), never from a row id
|
| 34 |
+
nothing on-chain knows about.
|
| 35 |
+
|
| 36 |
+
No ``tenant_id`` / foreign key to ``tenants``: an on-chain agent is
|
| 37 |
+
identified by its wallet address, not by this service's tenant concept,
|
| 38 |
+
and the two are not yet bridged. `evaluator_address` and `agent_address`
|
| 39 |
+
are recorded instead so a row can still be attributed and audited
|
| 40 |
+
without assuming a tenant relationship that may not exist.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
__tablename__ = "onchain_rationales"
|
| 44 |
+
|
| 45 |
+
id = Column(String(64), primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 46 |
+
rationale_hash = Column(
|
| 47 |
+
String(66), nullable=False, unique=True, index=True
|
| 48 |
+
) # "0x" + 64 hex chars
|
| 49 |
+
rationale = Column(Text, nullable=False)
|
| 50 |
+
agent_address = Column(String(42), nullable=True)
|
| 51 |
+
evaluator_address = Column(String(42), nullable=True)
|
| 52 |
+
created_at = Column(
|
| 53 |
+
DateTime, default=datetime.datetime.utcnow, nullable=False, index=True
|
| 54 |
+
)
|
app/main.py
CHANGED
|
@@ -91,6 +91,7 @@ from app.api import (
|
|
| 91 |
routes_history,
|
| 92 |
routes_incidents,
|
| 93 |
routes_intents,
|
|
|
|
| 94 |
routes_risk,
|
| 95 |
routes_memory,
|
| 96 |
routes_admin,
|
|
@@ -491,6 +492,9 @@ def create_app() -> FastAPI:
|
|
| 491 |
app.include_router(
|
| 492 |
routes_governance.router, prefix="/api/v1", tags=["governance"]
|
| 493 |
)
|
|
|
|
|
|
|
|
|
|
| 494 |
app.include_router(
|
| 495 |
routes_memory.router, prefix="/v1/memory", tags=["memory"]
|
| 496 |
)
|
|
|
|
| 91 |
routes_history,
|
| 92 |
routes_incidents,
|
| 93 |
routes_intents,
|
| 94 |
+
routes_onchain,
|
| 95 |
routes_risk,
|
| 96 |
routes_memory,
|
| 97 |
routes_admin,
|
|
|
|
| 492 |
app.include_router(
|
| 493 |
routes_governance.router, prefix="/api/v1", tags=["governance"]
|
| 494 |
)
|
| 495 |
+
app.include_router(
|
| 496 |
+
routes_onchain.router, prefix="/api/v1", tags=["onchain"]
|
| 497 |
+
)
|
| 498 |
app.include_router(
|
| 499 |
routes_memory.router, prefix="/v1/memory", tags=["memory"]
|
| 500 |
)
|
tests/test_routes_onchain.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for POST/GET /api/v1/onchain/rationale.
|
| 2 |
+
|
| 3 |
+
`verify_internal_key` and `get_db` are already overridden in conftest.py the
|
| 4 |
+
same way every other route test relies on -- this exercises the endpoint's
|
| 5 |
+
own logic (idempotent-on-hash write, conflict on a differing text, 404 on an
|
| 6 |
+
unknown hash, hash format validation), not authentication or the database
|
| 7 |
+
layer itself.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
VALID_HASH = "0x" + "ab" * 32
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _post(
|
| 14 |
+
client,
|
| 15 |
+
rationale_hash=VALID_HASH,
|
| 16 |
+
rationale="the volume's final backup was skipped",
|
| 17 |
+
**extra,
|
| 18 |
+
):
|
| 19 |
+
payload = {"rationale_hash": rationale_hash, "rationale": rationale, **extra}
|
| 20 |
+
return client.post("/api/v1/onchain/rationale", json=payload)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class TestPersistRationale:
|
| 24 |
+
def test_a_new_hash_is_recorded(self, client):
|
| 25 |
+
resp = _post(client)
|
| 26 |
+
assert resp.status_code == 201, resp.text
|
| 27 |
+
assert resp.json() == {"status": "recorded", "rationale_hash": VALID_HASH}
|
| 28 |
+
|
| 29 |
+
def test_posting_the_same_hash_and_text_again_is_a_no_op(self, client):
|
| 30 |
+
first = _post(client)
|
| 31 |
+
assert first.status_code == 201
|
| 32 |
+
second = _post(client)
|
| 33 |
+
assert second.status_code == 200
|
| 34 |
+
assert second.json()["status"] == "already_recorded"
|
| 35 |
+
|
| 36 |
+
def test_the_same_hash_with_different_text_is_refused(self, client):
|
| 37 |
+
first = _post(client)
|
| 38 |
+
assert first.status_code == 201
|
| 39 |
+
second = _post(client, rationale="a different reason entirely")
|
| 40 |
+
assert second.status_code == 409
|
| 41 |
+
|
| 42 |
+
def test_a_malformed_hash_is_rejected(self, client):
|
| 43 |
+
resp = _post(client, rationale_hash="not-a-hash")
|
| 44 |
+
assert resp.status_code == 422
|
| 45 |
+
|
| 46 |
+
def test_a_short_hash_is_rejected(self, client):
|
| 47 |
+
resp = _post(client, rationale_hash="0x" + "ab" * 16)
|
| 48 |
+
assert resp.status_code == 422
|
| 49 |
+
|
| 50 |
+
def test_empty_rationale_is_rejected(self, client):
|
| 51 |
+
resp = _post(client, rationale=" ")
|
| 52 |
+
assert resp.status_code == 422
|
| 53 |
+
|
| 54 |
+
def test_agent_and_evaluator_addresses_are_stored(self, client):
|
| 55 |
+
resp = _post(
|
| 56 |
+
client,
|
| 57 |
+
rationale_hash="0x" + "cd" * 32,
|
| 58 |
+
agent_address="0x00000000000000000000000000000000000000A1",
|
| 59 |
+
evaluator_address="0x00000000000000000000000000000000000000B2",
|
| 60 |
+
)
|
| 61 |
+
assert resp.status_code == 201
|
| 62 |
+
fetched = client.get(f"/api/v1/onchain/rationale/{'0x' + 'cd' * 32}")
|
| 63 |
+
assert fetched.status_code == 200
|
| 64 |
+
body = fetched.json()
|
| 65 |
+
assert body["agent_address"] == "0x00000000000000000000000000000000000000A1"
|
| 66 |
+
assert body["evaluator_address"] == "0x00000000000000000000000000000000000000B2"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class TestGetRationale:
|
| 70 |
+
def test_fetching_a_recorded_hash_returns_its_text(self, client):
|
| 71 |
+
_post(client)
|
| 72 |
+
resp = client.get(f"/api/v1/onchain/rationale/{VALID_HASH}")
|
| 73 |
+
assert resp.status_code == 200
|
| 74 |
+
assert resp.json()["rationale"] == "the volume's final backup was skipped"
|
| 75 |
+
|
| 76 |
+
def test_fetching_an_unknown_hash_is_404(self, client):
|
| 77 |
+
resp = client.get(f"/api/v1/onchain/rationale/{'0x' + 'ef' * 32}")
|
| 78 |
+
assert resp.status_code == 404
|
| 79 |
+
|
| 80 |
+
def test_fetching_a_malformed_hash_is_422_not_404(self, client):
|
| 81 |
+
resp = client.get("/api/v1/onchain/rationale/not-a-hash")
|
| 82 |
+
assert resp.status_code == 422
|