fix: CR endpoint — greenlet bug + UUID format + missing column

Three bugs in the complete handler:

1. SQLAlchemy greenlet: ORM model attribute access triggers sync
   lazy-loads in async context. Fix: raw SQL via text() for all
   DB operations in the CR handler.

2. UUID format: SQLite stores UUIDs without hyphens (via SQLModel).
   Raw SQL comparisons must strip hyphens: str(uuid).replace("-","")

3. Missing received_at: NOT NULL constraint on completion_receipts.
   Raw INSERT was missing the column. Added received_at=now().

Full AC/CR cycle now verified:
  AC → 200, principal DID resolved from Keycloak token
  CR → 200, receipt ID + Chronicle CID returned
  Session → 200, chain of custody queryable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tyler J King 2026-04-01 18:51:55 -04:00
parent 4c58a4414b
commit 5ac577af19

View file

@ -3,9 +3,8 @@ import uuid
from datetime import datetime, UTC from datetime import datetime, UTC
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel import select from sqlalchemy import text
from gsap_broker.db import get_session from gsap_broker.db import get_session
from gsap_broker.db_models import AuthorizationContextDB, CompletionReceiptDB
from gsap_broker.models import CompleteRequest, CompleteResponse from gsap_broker.models import CompleteRequest, CompleteResponse
from gsap_broker import chronicle from gsap_broker import chronicle
@ -13,27 +12,66 @@ router = APIRouter()
@router.post("/complete/", response_model=CompleteResponse, summary="Receive CR (GSAP §5.4)") @router.post("/complete/", response_model=CompleteResponse, summary="Receive CR (GSAP §5.4)")
async def complete(request: CompleteRequest, db: AsyncSession = Depends(get_session)): async def complete(request: CompleteRequest, db: AsyncSession = Depends(get_session)):
result = await db.exec(select(AuthorizationContextDB).where( # Pure raw SQL to avoid all SQLModel/SQLAlchemy greenlet issues
AuthorizationContextDB.context_id == request.context_id, result = await db.execute(
AuthorizationContextDB.status == "authorized")) text("SELECT context_id, status FROM authorization_contexts WHERE context_id = :ctx_id AND status = 'authorized'"),
ac_db = result.first() {"ctx_id": str(request.context_id).replace("-", "")},
if not ac_db: raise HTTPException(status_code=404, detail="AC not found or already consumed.") )
ac_row = result.first()
if not ac_row:
raise HTTPException(status_code=404, detail="AC not found or already consumed.")
sig_verified = bool((request.signature or {}).get("value")) sig_verified = bool((request.signature or {}).get("value"))
cr_db = CompletionReceiptDB( cr_id = uuid.uuid4()
id=uuid.uuid4(), context_id=request.context_id, outcome=request.outcome.value, now = datetime.now(UTC)
completed_at=request.completed_at, failure_reason=request.failure_reason or "",
chronicle_session_id=request.chronicle_session_id or "", # Insert CR
chronicle_events=request.chronicle_evidence.events, await db.execute(
merkle_root=request.chronicle_evidence.merkle_root or "", text("""INSERT INTO completion_receipts
behavioral_attestation_status=request.behavioral_attestation.status.value, (id, context_id, outcome, completed_at, received_at, failure_reason,
ffc_did=request.ffc.get("did", ""), ffc_signature=(request.signature or {}).get("value", ""), chronicle_session_id, chronicle_events, merkle_root,
signature_verified=sig_verified) behavioral_attestation_status, ffc_did, ffc_signature,
db.add(cr_db) signature_verified, chronicle_event_cid)
ac_db.status = "consumed"; ac_db.consumed_at = datetime.now(UTC) VALUES (:id, :ctx_id, :outcome, :completed_at, :received_at, :failure_reason,
:session_id, :events, :merkle_root,
:ba_status, :ffc_did, :ffc_sig,
:sig_verified, '')"""),
{
"id": str(cr_id).replace("-", ""), "ctx_id": str(request.context_id).replace("-", ""),
"outcome": request.outcome.value,
"completed_at": str(request.completed_at),
"received_at": str(now),
"failure_reason": request.failure_reason or "",
"session_id": request.chronicle_session_id or "",
"events": str(request.chronicle_evidence.events),
"merkle_root": request.chronicle_evidence.merkle_root or "",
"ba_status": request.behavioral_attestation.status.value,
"ffc_did": request.ffc.get("did", ""),
"ffc_sig": (request.signature or {}).get("value", ""),
"sig_verified": sig_verified,
},
)
# Update AC status
await db.execute(
text("UPDATE authorization_contexts SET status = 'consumed', consumed_at = :now WHERE context_id = :ctx_id"),
{"now": str(now), "ctx_id": str(request.context_id).replace("-", "")},
)
# Chronicle event
cid = await chronicle.emit("GSAP_CR_RECEIVED", {
"event_code": "0x2706",
"context_id": str(request.context_id).replace("-", ""),
"outcome": request.outcome.value,
})
# Update CR with chronicle CID
if cid:
await db.execute(
text("UPDATE completion_receipts SET chronicle_event_cid = :cid WHERE id = :id"),
{"cid": cid, "id": str(cr_id).replace("-", "")},
)
cid = await chronicle.emit("GSAP_CR_RECEIVED", {"event_code": "0x2706", "context_id": str(request.context_id), "outcome": request.outcome.value})
cr_db.chronicle_event_cid = cid
await db.commit() await db.commit()
return CompleteResponse(receipt_id=cr_db.id, signature_verified=sig_verified, chronicle_event_cid=cid or None) return CompleteResponse(receipt_id=cr_id, signature_verified=sig_verified, chronicle_event_cid=cid or None)