ADR-022: CLARISSA Portal - Software Architecture¶
| Status | Proposed |
|---|---|
| Date | 2026-01-22 |
| Authors | Wolfram Laube, Claude (AI Assistant) |
| Supersedes | - |
| Related | ADR-021 (System & Security Architecture), ADR-020 (Portal Vision) |
Context¶
ADR-021 defines the system and security architecture of the CLARISSA Portal (Cloud Run, OIDC, etc.). This ADR describes the software architecture:
- Hexagonal Architecture: Platform-agnostic core with adapters
- API Design: Endpoints, Contracts, Error Handling
- Data Model: Firestore Collections
- Frontend: Alpine.js SPA
Decision¶
Key Decisions¶
| Decision | Choice | Rationale |
|---|---|---|
| Architecture Pattern | Hexagonal (Ports & Adapters) | Platform portability, testability |
| Core Language | Python 3.11+ | Team-Skills, FastAPI, async |
| API Framework | FastAPI | Async, Pydantic, OpenAPI |
| Frontend | Alpine.js + HTMX | Lightweight, kein Build-Step |
| Styling | Tailwind CSS | Utility-first, schnell |
Hexagonal Architecture¶
Prinzip¶
The business logic is platform-independent in the shared/ directory. Thin adapters translate between platform (Cloud Run, OpenWhisk) and core.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Shared Core (Pure Python) โ
โ Platform-agnostic Business Logic โ
โ โ
โ shared/ โ
โ โโโ billing/ โ
โ โ โโโ pdf_generator.py # Jinja2 + WeasyPrint โ
โ โ โโโ invoice_calculator.py # Line items, totals โ
โ โ โโโ models.py # Pydantic Models โ
โ โ โ
โ โโโ benchmarks/ โ
โ โ โโโ aggregator.py # Stats computation โ
โ โ โโโ anomaly_detector.py # Deviation detection โ
โ โ โ
โ โโโ core/ โ
โ โโโ retry.py # Retry strategy โ
โ โโโ correlation.py # Request correlation โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ OpenWhisk Adapter โ โ Cloud Run Adapter โ
โ โ โ โ
โ def main(args: dict): โ โ @app.post("/worker/...") โ
โ result = core.run(...) โ โ async def handler(req): โ
โ return {"body": result} โ โ return core.run(...) โ
โ โ โ โ
โ JSON in โ JSON out โ โ HTTP Request/Response โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Vorteile¶
- Same Core Code: Business-Logik einmal schreiben, einmal testen
- Platform Portability: Switch without code changes
- Benchmarking: Apples-to-apples comparison Cloud Run vs OpenWhisk
- Testbarkeit: Core ohne HTTP/Container testbar
Project Structure¶
clarissa-portal/
โ
โโโ shared/ # โญ Platform-agnostic Core
โ โโโ __init__.py
โ โโโ core/
โ โ โโโ retry.py # Retry strategy (tenacity)
โ โ โโโ correlation.py # Correlation ID handling
โ โ โโโ exceptions.py # Domain exceptions
โ โ
โ โโโ billing/
โ โ โโโ __init__.py
โ โ โโโ pdf_generator.py # Core: Jinja2 + WeasyPrint
โ โ โโโ invoice_calculator.py # Core: Line items, totals
โ โ โโโ models.py # Pydantic: Invoice, LineItem
โ โ โโโ templates/
โ โ โโโ invoice.html # Jinja2 Template
โ โ
โ โโโ benchmarks/
โ โโโ __init__.py
โ โโโ aggregator.py # Core: Stats computation
โ โโโ anomaly_detector.py # Core: Deviation detection
โ โโโ models.py # Pydantic: BenchmarkResult
โ
โโโ services/ # Cloud Run Services
โ โโโ portal-api/
โ โ โโโ src/
โ โ โ โโโ main.py # FastAPI entry
โ โ โ โโโ config.py # Settings (Pydantic)
โ โ โ โโโ core/
โ โ โ โ โโโ auth.py # OIDC + PKCE
โ โ โ โ โโโ middleware.py # CORS, Logging, Correlation
โ โ โ โ โโโ dependencies.py # FastAPI Dependencies
โ โ โ โโโ api/v1/
โ โ โ โ โโโ auth.py
โ โ โ โ โโโ time.py
โ โ โ โ โโโ billing.py
โ โ โ โ โโโ benchmarks.py
โ โ โ โโโ services/
โ โ โ โโโ worker_client.py # HTTP client to Worker
โ โ โโโ Dockerfile
โ โ โโโ requirements.txt
โ โ
โ โโโ worker/
โ โโโ src/
โ โ โโโ main.py
โ โ โโโ billing/
โ โ โ โโโ generate_pdf.py # Adapter โ shared/billing
โ โ โโโ benchmarks/
โ โ โโโ aggregate.py # Adapter โ shared/benchmarks
โ โโโ Dockerfile
โ โโโ requirements.txt
โ
โโโ adapters/ # OpenWhisk (Local Dev)
โ โโโ openwhisk/
โ โโโ billing/
โ โ โโโ __main__.py # def main(args) โ dict
โ โโโ benchmarks/
โ โโโ __main__.py
โ
โโโ frontend/
โ โโโ portal/ # SPA (Alpine.js)
โ โโโ index.html
โ โโโ time/index.html
โ โโโ billing/index.html
โ โโโ benchmarks/index.html
โ โโโ assets/
โ โโโ js/
โ โ โโโ app.js # Alpine.js Store
โ โ โโโ api.js # Fetch wrapper
โ โ โโโ auth.js # OAuth flow
โ โโโ css/
โ โโโ portal.css
โ
โโโ tests/
โโโ shared/ # โญ Test Core once
โ โโโ test_pdf_generator.py
โ โโโ test_aggregator.py
โโโ services/
โ โโโ test_api.py
โโโ e2e/
โโโ test_full_flow.py
Core Implementation Examples¶
Billing: PDF Generator¶
# shared/billing/pdf_generator.py
from jinja2 import Environment, PackageLoader
from weasyprint import HTML
from .models import Invoice
class InvoicePDFGenerator:
"""
Platform-agnostic PDF generation.
No HTTP, no Firestore - pure business logic.
"""
def __init__(self):
self.env = Environment(
loader=PackageLoader('shared.billing', 'templates')
)
self.template = self.env.get_template('invoice.html')
def generate(self, invoice: Invoice) -> bytes:
"""
Generate PDF bytes from Invoice model.
Args:
invoice: Validated Invoice model
Returns:
PDF as bytes
"""
html_content = self.template.render(
invoice=invoice,
line_items=invoice.line_items,
total=invoice.calculate_total(),
vat=invoice.calculate_vat()
)
pdf_bytes = HTML(string=html_content).write_pdf()
return pdf_bytes
Adapter: Cloud Run Worker¶
# services/worker/src/billing/generate_pdf.py
from fastapi import APIRouter, BackgroundTasks
from shared.billing.pdf_generator import InvoicePDFGenerator
from shared.billing.models import Invoice
router = APIRouter()
@router.post("/worker/billing/generate-pdf")
async def generate_pdf(invoice_id: str, background_tasks: BackgroundTasks):
"""
Cloud Run adapter for PDF generation.
Handles HTTP, Firestore, GCS - delegates logic to shared core.
"""
background_tasks.add_task(generate_pdf_task, invoice_id)
return {"status": "accepted", "invoice_id": invoice_id}
async def generate_pdf_task(invoice_id: str):
db = get_firestore_client()
storage = get_gcs_client()
# Update status
await db.update_invoice(invoice_id, {"status": "processing"})
try:
# Load data
invoice_data = await db.get_invoice(invoice_id)
invoice = Invoice(**invoice_data)
# Core logic (platform-agnostic)
generator = InvoicePDFGenerator()
pdf_bytes = generator.generate(invoice)
# Upload to GCS
pdf_url = await storage.upload(
f"invoices/{invoice_id}.pdf",
pdf_bytes,
content_type="application/pdf"
)
# Update status
await db.update_invoice(invoice_id, {
"status": "ready",
"pdf_url": pdf_url
})
except Exception as e:
await db.update_invoice(invoice_id, {
"status": "failed",
"error": str(e)
})
raise
Adapter: OpenWhisk Action¶
# adapters/openwhisk/billing/__main__.py
from shared.billing.pdf_generator import InvoicePDFGenerator
from shared.billing.models import Invoice
def main(args: dict) -> dict:
"""
OpenWhisk adapter for PDF generation.
Same core logic, different platform wrapper.
"""
try:
invoice_data = args.get("invoice")
invoice = Invoice(**invoice_data)
generator = InvoicePDFGenerator()
pdf_bytes = generator.generate(invoice)
# Base64 encode for JSON response
import base64
pdf_base64 = base64.b64encode(pdf_bytes).decode()
return {
"statusCode": 200,
"body": {
"pdf_base64": pdf_base64,
"filename": f"invoice-{invoice.id}.pdf"
}
}
except Exception as e:
return {
"statusCode": 500,
"body": {"error": str(e)}
}
API Design¶
Endpoints¶
Base: https://clarissa-portal-api-xxxxx.run.app/api/v1
Auth
โโโ GET /auth/login โ GitLab OIDC redirect
โโโ GET /auth/callback โ OAuth callback
โโโ POST /auth/refresh โ Refresh session
โโโ POST /auth/logout โ Clear session
โโโ GET /auth/me โ Current user
Time Entries
โโโ GET /time/entries โ List (paginated)
โโโ POST /time/entries โ Create
โโโ GET /time/entries/{id} โ Get one
โโโ PUT /time/entries/{id} โ Update
โโโ DELETE /time/entries/{id} โ Delete
โโโ GET /time/summary โ Aggregated stats
Billing
โโโ GET /billing/invoices โ List invoices
โโโ POST /billing/invoices โ Create draft
โโโ GET /billing/invoices/{id} โ Get (incl. status)
โโโ PUT /billing/invoices/{id} โ Update draft
โโโ POST /billing/invoices/{id}/generate-pdf โ Trigger async
โ โโโ Returns: 202 Accepted { status: "queued" }
โโโ GET /billing/invoices/{id}/pdf โ Redirect to GCS
Benchmarks
โโโ GET /benchmarks/latest โ Latest per runner
โโโ GET /benchmarks/history โ Time range query
โโโ POST /benchmarks/ingest โ CI posts results
Request/Response Contract¶
# shared/billing/models.py
from pydantic import BaseModel, Field
from datetime import date
from decimal import Decimal
from enum import Enum
class InvoiceStatus(str, Enum):
DRAFT = "draft"
QUEUED = "queued"
PROCESSING = "processing"
READY = "ready"
FAILED = "failed"
class LineItem(BaseModel):
description: str
hours: Decimal = Field(ge=0)
rate: Decimal = Field(ge=0)
@property
def amount(self) -> Decimal:
return self.hours * self.rate
class Invoice(BaseModel):
id: str
client_id: str
contractor_id: str
period_start: date
period_end: date
line_items: list[LineItem]
status: InvoiceStatus = InvoiceStatus.DRAFT
pdf_url: str | None = None
error: str | None = None
def calculate_total(self) -> Decimal:
return sum(item.amount for item in self.line_items)
def calculate_vat(self, rate: Decimal = Decimal("0.20")) -> Decimal:
return self.calculate_total() * rate
Error Handling¶
Status State Machine¶
โโโโโโโโโโโ
โ draft โ
โโโโโโฌโโโโโ
โ POST .../generate-pdf
โผ
โโโโโโโโโโโ
โ queued โ
โโโโโโฌโโโโโ
โ Worker picks up
โผ
โโโโโโโโโโโโโโโโ
โ processing โ
โโโโโโโโฌโโโโโโโโ
โ
โโโโโโโโโโดโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโ โโโโโโโโโโโโ
โ ready โ โ failed โ
โโโโโโโโโโโ โโโโโโฌโโโโโโ
โ retry_count < 3
โผ
โโโโโโโโโโโ
โ queued โ (retry)
โโโโโโโโโโโ
Retry Strategy¶
# shared/core/retry.py
from tenacity import retry, stop_after_attempt, wait_exponential
RETRY_CONFIG = {
"stop": stop_after_attempt(3),
"wait": wait_exponential(multiplier=1, min=4, max=60),
"reraise": True
}
@retry(**RETRY_CONFIG)
async def execute_with_retry(func, *args, **kwargs):
return await func(*args, **kwargs)
API Error Responses¶
# Standard error response format
{
"error": {
"code": "INVOICE_NOT_FOUND",
"message": "Invoice INV-001 not found",
"correlation_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
# HTTP Status Codes
# 400 - Validation error (bad input)
# 401 - Not authenticated
# 403 - Not authorized
# 404 - Resource not found
# 409 - Conflict (e.g., invoice already processing)
# 500 - Internal error (with correlation_id for debugging)
Data Model (Firestore)¶
firestore/
โโโ users/
โ โโโ {user_id}/
โ โโโ gitlab_id: string
โ โโโ email: string
โ โโโ name: string
โ โโโ avatar_url: string
โ โโโ role: "admin" | "user"
โ โโโ refresh_token: string (encrypted)
โ โโโ created_at: timestamp
โ
โโโ time_entries/
โ โโโ {entry_id}/
โ โโโ user_id: string
โ โโโ project: string
โ โโโ task: string
โ โโโ duration_hours: number
โ โโโ date: date
โ โโโ notes: string
โ โโโ created_at: timestamp
โ
โโโ invoices/
โ โโโ {invoice_id}/
โ โโโ client_id: string
โ โโโ contractor_id: string
โ โโโ period_start: date
โ โโโ period_end: date
โ โโโ line_items: array
โ โโโ status: string
โ โโโ pdf_url: string | null
โ โโโ error: string | null
โ โโโ retry_count: number
โ โโโ timestamps...
โ
โโโ benchmarks/
โโโ {benchmark_id}/
โโโ runner_id: string
โโโ executor: "shell" | "docker" | "k8s"
โโโ machine: string
โโโ pipeline_id: string
โโโ job_id: string
โโโ duration_seconds: number
โโโ status: "success" | "failed"
โโโ timestamp: timestamp
Firestore Security Rules¶
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Users can only read/write their own data
match /users/{userId} {
allow read, write: if request.auth.uid == userId;
}
match /time_entries/{entryId} {
allow read, write: if request.auth.uid == resource.data.user_id;
allow create: if request.auth.uid == request.resource.data.user_id;
}
// Invoices: contractors can see their own
match /invoices/{invoiceId} {
allow read: if request.auth.uid == resource.data.contractor_id;
allow write: if request.auth.uid == resource.data.contractor_id
&& resource.data.status == "draft";
}
// Benchmarks: anyone authenticated can read, only CI can write
match /benchmarks/{benchmarkId} {
allow read: if request.auth != null;
allow write: if false; // Only via Admin SDK
}
}
}
Frontend Architecture¶
Tech Stack¶
- Alpine.js: Reactive UI (15KB, no build step)
- HTMX: HTML-over-the-wire (optional, for simple interactions)
- Tailwind CSS: Utility-first styling
- Chart.js: Benchmark visualizations
Structure¶
<!-- frontend/portal/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>CLARISSA Portal</title>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/assets/js/app.js"></script>
</head>
<body x-data="app" class="bg-gray-100">
<!-- Navigation -->
<nav class="bg-white shadow" x-show="$store.auth.isAuthenticated">
<a href="/portal/">Dashboard</a>
<a href="/portal/time/">Time</a>
<a href="/portal/billing/">Billing</a>
<a href="/portal/benchmarks/">Benchmarks</a>
<button @click="$store.auth.logout()">Logout</button>
</nav>
<!-- Login -->
<div x-show="!$store.auth.isAuthenticated" class="flex items-center justify-center h-screen">
<button @click="$store.auth.login()" class="btn-primary">
Login with GitLab
</button>
</div>
<!-- Content -->
<main x-show="$store.auth.isAuthenticated">
<!-- Page content loaded here -->
</main>
</body>
</html>
Alpine.js Store¶
// frontend/portal/assets/js/app.js
const API_BASE = 'https://clarissa-portal-api-xxxxx.run.app/api/v1';
document.addEventListener('alpine:init', () => {
// Auth Store
Alpine.store('auth', {
user: null,
isAuthenticated: false,
async init() {
await this.checkSession();
},
async checkSession() {
try {
const res = await fetch(`${API_BASE}/auth/me`, {
credentials: 'include'
});
if (res.ok) {
this.user = await res.json();
this.isAuthenticated = true;
}
} catch (e) {
this.isAuthenticated = false;
}
},
login() {
window.location.href = `${API_BASE}/auth/login`;
},
async logout() {
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
this.isAuthenticated = false;
window.location.href = '/';
}
});
// API Helper
Alpine.magic('api', () => ({
async get(endpoint) {
const res = await fetch(`${API_BASE}${endpoint}`, {
credentials: 'include'
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return res.json();
},
async post(endpoint, data) {
const res = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return res.json();
}
}));
});
Implementation Roadmap¶
Phase 1: Core (Week 1-2)¶
- [ ]
shared/module structure - [ ] Pydantic models
- [ ] PDF generator (Jinja2 + WeasyPrint)
- [ ] Unit tests for Core
Phase 2: Services (Week 3-4)¶
- [ ] Portal API endpoints
- [ ] Worker Service
- [ ] Firestore integration
- [ ] Error handling + retry
Phase 3: Frontend (Week 5)¶
- [ ] Alpine.js setup
- [ ] Auth flow (Login/Logout)
- [ ] Time tracking UI
- [ ] Billing UI
Phase 4: Polish (Week 6)¶
- [ ] Benchmark dashboard
- [ ] E2E tests
- [ ] Documentation