petter2025 commited on
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