Golden Age Hub: Phase 1 Technical Specification & Architecture v2.0¶
1. Introduction and Core Principles¶
1.1. Mission¶
The Golden Age Hub is an enterprise-grade healthcare platform designed to provide a unified, compliant, and secure solution for elderly care. This document specifies the architecture and implementation of the Phase 1 backend, focusing on a cryptographically verifiable Proof-of-Care Ledger and a robust data ingestion pipeline.
1.2. Guiding Principles¶
Every component of this system is built upon four core principles:
- Reduce Caregiver Stress: Automate compliance and filter out noise to reduce administrative burden.
- Reduce Patient Risk: Ensure alerts are timely, accurate, and actionable.
- Provide Trustworthy Proof of Care: Create an immutable, auditable log of all care events.
- Respect Privacy and Dignity: Employ a Zero-Data AI and privacy-by-design approach.
1.3. Document Scope¶
This specification covers:
- Backend architecture and data flows
- Security and cryptographic systems
- Data storage and compliance mechanisms
- API contracts and integration points
- Operational procedures and disaster recovery
Out of Scope for Phase 1:
- Mobile application UI/UX implementation
- Device firmware implementation details
- Machine learning model training pipelines
- Multi-tenancy architecture (single organization MVP)
2. System Architecture¶
2.1. High-Level Data Flow¶
The system is designed around a decoupled, asynchronous pipeline that ensures resilience, scalability, and data integrity from the moment an event is generated to its final, auditable storage.
graph TD
A[Device/App Generates Signed Event] --> B{API Gateway: Ingestion Endpoint};
B --> C[Fast Validation & Security Checks];
C --> D[Redis Primary Queue];
D --> E[Pipeline Worker Pool];
E --> F[Normalization & Processing];
F --> G[PostgreSQL Primary + Read Replica];
F --> H[S3 Archive - Multi-Region];
E --> I[Dead Letter Queue on Failure];
G --> J[Real-Time Dashboards via Read Replica];
H --> K[Compliance & Audit Exports];
D --> L[Redis Sentinel - HA Monitoring];
2.2. Technology Stack¶
- Backend Framework: FastAPI (Python 3.11+)
- Database (Normalized Store): PostgreSQL 15+ with TimescaleDB 2.11+ extension
- Message Queue: Redis 7+ with Sentinel for high availability
- Archive Storage: S3-compatible (American Cloud A2) with:
- U.S.-only data residency (us-east-1 primary, us-west-2 replica)
- Object Lock (WORM) enabled
- Versioning enabled for audit trail
- Observability: OpenTelemetry + Prometheus + Grafana
- Secret Management: American Cloud Secrets Manager or HashiCorp Vault
- Containerization: Docker & Docker Compose for development
- Orchestration: American Cloud GKE or Kubernetes for production
- CI/CD: GitHub Actions for automated testing, security scanning, and deployment
2.3. High Availability & Disaster Recovery¶
2.3.1. Component Availability¶
| Component | Configuration | RTO | RPO |
|---|---|---|---|
| PostgreSQL | Primary + Read Replica (Multi-AZ) | 60 seconds | 0 seconds |
| Redis | Primary + Replica + Sentinel (3 nodes) | 30 seconds | 0 seconds |
| API Servers | 2+ instances behind load balancer | 0 seconds (N+1) | N/A |
| Workers | 4+ instances, auto-scaling | 0 seconds (stateless) | 0 seconds |
| S3 Archive | Cross-region replication | 15 minutes | 0 seconds |
2.3.2. Disaster Recovery Strategy¶
Backup Procedures:
- PostgreSQL: Continuous WAL archiving to S3 (Point-in-Time Recovery enabled)
- Redis: AOF (Append-Only File) persistence with automatic rewrites
- S3: Cross-region replication (us-east-1 → us-west-2) with 15-minute lag
- Configuration: Version-controlled infrastructure-as-code (Terraform)
Recovery Procedures:
- Database Failure: Promote read replica to primary (automated failover via American Cloud Managed Postgres)
- Redis Failure: Sentinel automatically promotes replica to primary
- Region Failure: Failover to us-west-2 with DNS update (manual approval required)
- Data Corruption: Restore from S3 WAL archives using automated scripts
Testing Schedule:
- Monthly: Restore database to staging environment
- Quarterly: Full disaster recovery drill with regional failover
- Annually: Tabletop exercise with all stakeholders
2.3.3. Capacity Planning¶
Phase 1 MVP Expected Load:
- Scale: ~1,000,000 LA seniors * 1% signup rate = 10,000 patients
- Average Load: 1 event/patient/hour = 10,000 events/hour = 166 events/min = 2.8 events/sec
- Peak Load: 10× average (morning medication rounds) = 166 events/min = 28 events/sec
- Burst Load: Device sync after offline period = 1,000 events/sec for 10 seconds
Infrastructure Sizing:
- API Servers: 2× t3.medium (2 vCPU, 4 GB RAM each)
- Workers: 4× t3.small (2 vCPU, 2 GB RAM each)
- PostgreSQL: db.t3.large (2 vCPU, 8 GB RAM, 100 GB SSD)
- Redis: cache.t3.medium (2 vCPU, 4 GB RAM)
- S3: 50 TB initial allocation (500 KB avg/event × 240k events/day x 365 days)
Scaling Triggers:
- API: CPU > 70% for 5 minutes → add instance
- Workers: Queue depth > 1000 for 5 minutes → add instance
- Database: Connection pool utilization > 80% → scale up instance class
3. Security Architecture¶
3.1. Device Authentication & Key Management¶
3.1.1. Public Key Infrastructure (PKI)¶
Each device is provisioned with an Ed25519 key pair during manufacturing:
- Private Key Storage:
- Stored in device secure enclave (TEE) or secure element chip
- Never transmitted or exposed via API
-
Factory reset destroys key permanently
-
Public Key Registration:
- During device activation, public key is registered to backend
- Stored in
device_keystable with validity period - Associated with device_id (format:
GA-XXXXXX)
Database Schema:
CREATE TABLE device_keys (
device_id VARCHAR(20) PRIMARY KEY,
public_key BYTEA NOT NULL,
key_version INTEGER NOT NULL DEFAULT 1,
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_until TIMESTAMPTZ NOT NULL, -- 90 days from valid_from
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(255) NOT NULL, -- Provisioning system user
CONSTRAINT valid_period_check CHECK (valid_until > valid_from)
);
CREATE INDEX idx_device_keys_validity ON device_keys(device_id, valid_from, valid_until);
3.1.2. Key Rotation Strategy¶
Rotation Schedule:
- Keys expire after 90 days
- Rotation warning sent to device at 75 days
- Grace period: 15 days (old and new keys both valid)
Rotation Process:
- Device generates new key pair
- Device sends signed registration request with both old signature (authentication) and new public key
- Backend validates old signature, stores new key with overlapping validity
- Device begins using new key for subsequent events
- After grace period, old key is archived (not deleted, for historical verification)
Implementation:
from datetime import datetime, timedelta
from functools import lru_cache
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
class KeyManager:
@lru_cache(maxsize=10000)
def get_active_key(self, device_id: str) -> Ed25519PublicKey:
"""Cached lookup of active public key for device"""
now = datetime.utcnow()
key_record = db.query(DeviceKey).filter(
DeviceKey.device_id == device_id,
DeviceKey.valid_from <= now,
DeviceKey.valid_until >= now
).first()
if not key_record:
raise InvalidDeviceKeyError(f"No valid key for {device_id}")
return Ed25519PublicKey.from_public_bytes(key_record.public_key)
async def rotate_key(self, device_id: str, new_public_key: bytes,
old_signature: bytes, payload: bytes) -> bool:
"""Verify old signature, register new key with overlapping validity"""
# Verify device owns the current key
current_key = self.get_active_key(device_id)
try:
current_key.verify(old_signature, payload)
except Exception:
raise UnauthorizedKeyRotation()
# Store new key with overlapping validity
new_key = DeviceKey(
device_id=device_id,
public_key=new_public_key,
key_version=current_key_record.key_version + 1,
valid_from=datetime.utcnow() + timedelta(days=7), # 7 day transition
valid_until=datetime.utcnow() + timedelta(days=97) # 90 + 7
)
db.add(new_key)
db.commit()
# Invalidate cache
self.get_active_key.cache_clear()
return True
3.2. Signature Verification Pipeline¶
3.2.1. Canonicalization (RFC 8785 - JCS)¶
To ensure byte-for-byte identical signing input, all event payloads are canonicalized:
Rules:
- Sort all object keys alphabetically
- Remove all insignificant whitespace
- Use minimal JSON encoding (no optional spaces)
- Exclude the
signaturefield itself from canonicalization
Implementation:
import jcs # RFC 8785 implementation
def canonicalize_event(event_payload: dict) -> bytes:
"""
Convert event to canonical JSON form for signature verification.
The signature field is excluded from canonicalization.
"""
# Create a copy without the signature field
canonical_payload = {k: v for k, v in event_payload.items() if k != 'signature'}
# JCS canonicalization (RFC 8785)
canonical_bytes = jcs.canonicalize(canonical_payload)
return canonical_bytes
3.2.2. Asynchronous Signature Verification¶
To prevent CPU-bound cryptographic operations from blocking the API, verification is offloaded to a thread pool:
import asyncio
from concurrent.futures import ThreadPoolExecutor
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
# Dedicated executor for CPU-intensive crypto operations
crypto_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="crypto-worker")
async def verify_signature_async(event: EventPayload, key_manager: KeyManager) -> bool:
"""
Asynchronously verify Ed25519 signature without blocking event loop.
Returns True if valid, raises exception otherwise.
"""
loop = asyncio.get_event_loop()
# Offload to thread pool
result = await loop.run_in_executor(
crypto_executor,
_verify_signature_blocking,
event,
key_manager
)
return result
def _verify_signature_blocking(event: EventPayload, key_manager: KeyManager) -> bool:
"""Blocking signature verification (runs in thread pool)"""
try:
# Get device's public key
public_key = key_manager.get_active_key(event.device_id)
# Canonicalize payload (excluding signature)
canonical_bytes = canonicalize_event(event.dict())
# Decode signature from hex string
signature_bytes = bytes.fromhex(event.signature)
# Verify Ed25519 signature
public_key.verify(signature_bytes, canonical_bytes)
return True
except InvalidSignature:
raise SignatureVerificationError("Invalid signature")
except Exception as e:
raise SignatureVerificationError(f"Verification failed: {str(e)}")
Performance Characteristics:
- Single verification: ~0.5ms on modern CPU
- With thread pool: 2000 verifications/sec on 4-core system
- Cache hit rate (public keys): ~99% in production (10,000 device LRU cache)
3.3. Replay Protection & Sequence Validation¶
3.3.1. Sequence ID Mechanism¶
Each device maintains a strictly monotonically increasing counter (sequence_id) stored in persistent storage:
Rules:
sequence_idstarts at 0 for new devices- Increments by 1 for each event generated
- Must survive device reboots (stored in flash memory)
- Cannot decrement or skip values
Server-Side Validation:
class ReplayProtection:
def __init__(self):
# In-memory cache of last seen sequence per device
self.last_sequences = {} # {device_id: sequence_id}
async def validate_sequence(self, device_id: str, sequence_id: int) -> bool:
"""
Validate that sequence_id is strictly greater than last seen.
Prevents replay attacks and detects out-of-order delivery.
"""
# Get last known sequence from cache or database
last_seq = self.last_sequences.get(device_id)
if last_seq is None:
# First event from this device, query database
last_event = await db.query(Event).filter(
Event.device_id == device_id
).order_by(Event.sequence_id.desc()).first()
last_seq = last_event.sequence_id if last_event else -1
self.last_sequences[device_id] = last_seq
# Sequence must be strictly increasing
if sequence_id <= last_seq:
raise ReplayAttackDetected(
f"Device {device_id} sent sequence {sequence_id}, "
f"but last seen was {last_seq}"
)
# Update cache
self.last_sequences[device_id] = sequence_id
return True
async def handle_sequence_gap(self, device_id: str, expected: int, received: int):
"""
Log sequence gaps (potential offline period or packet loss).
Gaps are allowed but logged for monitoring.
"""
gap_size = received - expected
await db.execute(
"""
INSERT INTO sequence_gaps (device_id, expected_seq, received_seq, gap_size, detected_at)
VALUES (:device, :expected, :received, :gap, NOW())
""",
device=device_id, expected=expected, received=received, gap=gap_size
)
# Alert if gap is suspiciously large (possible attack or device replacement)
if gap_size > 10000:
await alert_security_team(device_id, gap_size)
3.3.2. Edge Cases¶
Device Replacement:
When a device is physically replaced but keeps the same device_id:
- New device registers new public key via key rotation endpoint
sequence_idresets to 0 with new key_version- Server tracks sequence per (device_id, key_version) tuple
Clock Skew: If device timestamp is >5 minutes from server time, log warning but accept event:
def validate_timestamp(event_timestamp: datetime) -> bool:
server_time = datetime.utcnow()
delta = abs((event_timestamp - server_time).total_seconds())
if delta > 300: # 5 minutes
logger.warning(
f"Clock skew detected: {delta}s",
extra={"device_id": event.device_id, "event_id": event.event_id}
)
# Still accept event (clocks drift, NTP may be unavailable)
return True
4. Data Ingestion Pipeline¶
4.1. The Data Contract: Schema v1.0 & OpenAPI v1¶
To enable parallel development across firmware, mobile, and backend teams, the data and API contracts for the MVP are now frozen.
- Packet Schema (v1.0): The official structure for all event data payloads. It is formally defined in
schemas/event_payload_v1.schema.json. - API Specification (v1): The official API contract, defined in OpenAPI v3 specification (
openapi/v1.yaml).
Endpoint: POST /v1/events/ingest
4.1.1. Event Payload Schema v1.0¶
File: schemas/event_payload_v1.schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EventPayload",
"description": "Schema for a single, signed event payload from a device or application (v1.0.0).",
"version": "1.0.0",
"type": "object",
"properties": {
"event_id": {
"description": "Unique identifier for the event (UUIDv4).",
"type": "string",
"format": "uuid"
},
"patient_id": {
"description": "The pseudonymous identifier for the patient.",
"type": "string",
"format": "uuid"
},
"device_id": {
"description": "Hardware serial number of the reporting device.",
"type": "string",
"pattern": "^GA-[A-Z0-9]{6}$"
},
"sequence_id": {
"description": "A strictly increasing integer counter from the device for replay protection.",
"type": "integer",
"minimum": 0
},
"timestamp": {
"description": "ISO 8601 timestamp in UTC when the event was generated.",
"type": "string",
"format": "date-time"
},
"event_type": {
"description": "The type of event detected.",
"type": "string",
"enum": ["fall", "wandering", "manual_log", "vitals", "medication_reminder"]
},
"confidence": {
"description": "The model's confidence score for the event (0.0 to 1.0). Nullable for manual events.",
"type": ["number", "null"],
"minimum": 0,
"maximum": 1
},
"location": {
"description": "Geographic context of the event, if available.",
"type": "object",
"properties": {
"lat": { "type": "number", "minimum": -90, "maximum": 90 },
"lng": { "type": "number", "minimum": -180, "maximum": 180 },
"accuracy_m": { "type": "number", "minimum": 0 }
},
"required": ["lat", "lng"]
},
"sensor_data": {
"description": "A flexible object for raw or derived sensor readings relevant to the event.",
"type": "object"
},
"signature": {
"description": "The 64-byte Ed25519 signature of the canonicalized (RFC 8785) payload, hex-encoded, excluding the signature field itself.",
"type": "string",
"pattern": "^[0-9a-f]{128}$"
}
},
"required": [
"event_id",
"patient_id",
"device_id",
"sequence_id",
"timestamp",
"event_type",
"signature"
],
"additionalProperties": false
}
4.1.2. Schema Evolution Strategy¶
Versioning Principles:
- Major version (v1 → v2): Breaking changes (field removal, type changes)
- Minor version (v1.0 → v1.1): Backward-compatible additions (new optional fields)
- Patch version (v1.0.0 → v1.0.1): Documentation or validation fixes only
Backward Compatibility Promise:
- v1.x schemas are backward compatible with v1.0
- Server supports v1.x schemas for minimum 12 months after v2.0 release
- Devices send
_schema_versionin payload for explicit version declaration
Migration Path Example (v1.0 → v1.1):
// v1.1 adds optional "battery_level" field
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EventPayload",
"version": "1.1.0",
"type": "object",
"properties": {
// ... all v1.0 fields ...
"battery_level": {
"description": "Device battery percentage (0-100). Added in v1.1.",
"type": ["number", "null"],
"minimum": 0,
"maximum": 100
}
},
"required": [ /* same as v1.0 */ ],
"additionalProperties": false
}
Server-Side Multi-Version Support:
from pydantic import BaseModel, Field
class EventPayloadV1_0(BaseModel):
event_id: UUID
patient_id: UUID
device_id: str = Field(pattern=r"^GA-[A-Z0-9]{6}$")
# ... v1.0 fields ...
class EventPayloadV1_1(EventPayloadV1_0): # Inherits v1.0
battery_level: Optional[float] = Field(None, ge=0, le=100)
def validate_event(payload: dict) -> BaseModel:
schema_version = payload.get('_schema_version', '1.0.0')
if schema_version.startswith('1.0'):
return EventPayloadV1_0(**payload)
elif schema_version.startswith('1.1'):
return EventPayloadV1_1(**payload)
else:
raise UnsupportedSchemaVersion(
f"Schema version {schema_version} not supported. "
f"Supported versions: 1.0.x, 1.1.x"
)
4.2. Ingress Protection & Rate Limiting¶
4.2.1. Security Validation Pipeline¶
Before an event is accepted, it must pass a series of security checks performed by the API gateway:
graph TD
A[Event Arrives at /v1/events/ingest] --> B{1. Rate Limit Check};
B -- OK --> C{2. Schema Validation};
C -- OK --> D{3. Signature Verification};
D -- OK --> E{4. Replay Protection};
E -- OK --> F[Accept & Queue Event];
B -- 429 Too Many Requests --> G[Reject & Log];
C -- 422 Invalid Schema --> H[Reject & Log];
D -- 401 Invalid Signature --> I[Reject & Alert Security];
E -- 409 Replay Detected --> J[Reject & Alert Security];
Implementation:
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
import time
app = FastAPI()
@app.post("/v1/events/ingest")
async def ingest_event(request: Request, event: dict):
"""
Main ingestion endpoint with comprehensive security checks.
"""
try:
# Step 1: Rate Limiting (per-device token bucket)
device_id = event.get('device_id')
if not await rate_limiter.check_and_consume(device_id):
raise HTTPException(429, "Rate limit exceeded")
# Step 2: Schema Validation
validated_event = validate_event(event)
# Step 3: Signature Verification (async, offloaded to thread pool)
if not await verify_signature_async(validated_event, key_manager):
await alert_security_team("invalid_signature", device_id)
raise HTTPException(401, "Invalid signature")
# Step 4: Replay Protection
if not await replay_protection.validate_sequence(
validated_event.device_id,
validated_event.sequence_id
):
await alert_security_team("replay_detected", device_id)
raise HTTPException(409, "Replay attack detected")
# Step 5: Queue for async processing
await redis_queue.enqueue(validated_event.json())
return JSONResponse(
status_code=202,
content={
"status": "accepted",
"event_id": str(validated_event.event_id),
"message": "Event queued for processing"
}
)
except HTTPException:
raise # Re-raise HTTP errors as-is
except Exception as e:
logger.error(f"Unexpected ingestion error: {e}", exc_info=True)
raise HTTPException(500, "Internal server error")
4.2.2. Token Bucket Rate Limiting¶
To prevent denial-of-service attacks while allowing legitimate burst traffic (e.g., device sync after offline period):
Algorithm:
- Each device has a "bucket" with capacity for
burst_sizetokens - Tokens refill at
ratetokens per second - Each event consumes 1 token
- If bucket is empty, request is rejected with 429
Configuration:
class TokenBucketRateLimiter:
def __init__(self, rate: float = 10.0, burst_size: int = 100):
"""
Args:
rate: Average tokens per second (e.g., 10 = 36,000 events/hour)
burst_size: Maximum burst capacity (e.g., 100 = sync after ~10s offline)
"""
self.rate = rate
self.burst_size = burst_size
self.redis = redis.StrictRedis()
async def check_and_consume(self, device_id: str) -> bool:
"""
Check if device has tokens available, consume 1 if so.
Uses Redis for distributed rate limiting across API servers.
"""
key = f"ratelimit:{device_id}"
now = time.time()
# Lua script for atomic token bucket operation
script = """
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'tokens', 'last_update')
local tokens = tonumber(bucket[1]) or burst
local last_update = tonumber(bucket[2]) or now
-- Refill tokens based on elapsed time
local elapsed = now - last_update
tokens = math.min(burst, tokens + (elapsed * rate))
-- Try to consume 1 token
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_update', now)
redis.call('EXPIRE', key, 3600) -- Expire after 1 hour of inactivity
return 1
else
return 0
end
"""
result = await self.redis.eval(
script,
1,
key,
self.rate,
self.burst_size,
now
)
return result == 1
Rate Limit Configuration for Phase 1:
- Average rate: 10 events/second per device (36,000/hour)
- Burst capacity: 100 events (allows ~10 seconds of buffered data)
- Rationale: Normal operation is 1 event/hour; 10/sec allows for hourly syncs from devices with local buffering
4.3. Queue Architecture & Failure Handling¶
4.3.1. Redis High Availability Configuration¶
Docker Compose Development Setup:
version: '3.8'
services:
redis-primary:
image: redis:7-alpine
command: >
redis-server
--appendonly yes
--maxmemory 2gb
--maxmemory-policy noeviction
--save 60 1000
volumes:
- redis-primary-data:/data
ports:
- "6379:6379"
redis-replica:
image: redis:7-alpine
command: redis-server --replicaof redis-primary 6379
volumes:
- redis-replica-data:/data
depends_on:
- redis-primary
redis-sentinel-1:
image: redis:7-alpine
command: redis-sentinel /etc/redis-sentinel.conf
volumes:
- ./config/sentinel.conf:/etc/redis-sentinel.conf
depends_on:
- redis-primary
- redis-replica
volumes:
redis-primary-data:
redis-replica-data:
Sentinel Configuration (config/sentinel.conf):
sentinel monitor mymaster redis-primary 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 10000
Production (American Cloud Memorystore):
- Redis Cluster Mode Disabled (for Sentinel compatibility)
- Multi-AZ with automatic failover
- 3 nodes: 1 primary, 2 replicas
- Automatic backups to S3
4.3.2. Pipeline Worker Pool¶
Worker Implementation:
import asyncio
from typing import Optional
class PipelineWorker:
def __init__(self, worker_id: int, redis_url: str, concurrency: int = 10):
self.worker_id = worker_id
self.redis = aioredis.from_url(redis_url)
self.concurrency = concurrency
self.running = False
async def start(self):
"""Start worker with concurrent event processing"""
self.running = True
logger.info(f"Worker {self.worker_id} started with concurrency={self.concurrency}")
# Create pool of concurrent processors
tasks = [
asyncio.create_task(self._process_loop())
for _ in range(self.concurrency)
]
await asyncio.gather(*tasks)
async def _process_loop(self):
"""Main processing loop for a single concurrent processor"""
while self.running:
try:
# Blocking pop with timeout (BRPOP for FIFO queue)
result = await self.redis.brpop('event_queue', timeout=5)
if result:
queue_name, event_json = result
await self.process_event(event_json)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Worker {self.worker_id} error: {e}", exc_info=True)
await asyncio.sleep(1) # Back off on errors
async def process_event(self, event_json: str):
"""
Process a single event: normalize to PostgreSQL + archive to S3.
Implements retry logic with exponential backoff.
"""
retry_count = 0
max_retries = 3
while retry_count <= max_retries:
try:
# Parse event
event_data = json.loads(event_json)
event = EventPayload(**event_data)
# Dual-write: PostgreSQL (normalized) + S3 (raw archive)
await asyncio.gather(
self.store_normalized(event),
self.archive_to_s3(event)
)
# Success metrics
events_processed.inc()
return
except Exception as e:
retry_count += 1
if retry_count > max_retries:
# Move to Dead Letter Queue
await self.send_to_dlq(event_json, str(e))
events_failed.inc()
logger.error(
f"Event {event_data.get('event_id')} failed after {max_retries} retries",
extra={"error": str(e), "event": event_json}
)
return
# Exponential backoff: 1s, 2s, 4s
backoff = 2 ** (retry_count - 1)
logger.warning(
f"Event processing failed, retry {retry_count}/{max_retries} in {backoff}s",
extra={"error": str(e)}
)
await asyncio.sleep(backoff)
async def store_normalized(self, event: EventPayload):
"""Store event in PostgreSQL/TimescaleDB normalized format"""
async with db.transaction():
await db.execute(
"""
INSERT INTO events (
event_id, patient_id, device_id, sequence_id,
event_timestamp, event_type, confidence,
location_lat, location_lng, location_accuracy_m,
sensor_data, raw_payload
) VALUES (
:event_id, :patient_id, :device_id, :sequence_id,
:timestamp, :event_type, :confidence,
:lat, :lng, :accuracy,
:sensor_data, :raw_payload
)
""",
event_id=event.event_id,
patient_id=event.patient_id,
device_id=event.device_id,
sequence_id=event.sequence_id,
timestamp=event.timestamp,
event_type=event.event_type,
confidence=event.confidence,
lat=event.location.lat if event.location else None,
lng=event.location.lng if event.location else None,
accuracy=event.location.accuracy_m if event.location else None,
sensor_data=json.dumps(event.sensor_data) if event.sensor_data else {},
raw_payload=event.json()
)
async def archive_to_s3(self, event: EventPayload):
"""Archive original signed payload to S3 for compliance"""
# Partition by date for efficient querying
date_partition = event.timestamp.strftime("%Y/%m/%d")
key = f"events/{date_partition}/{event.event_id}.json"
await s3_client.put_object(
Bucket=ARCHIVE_BUCKET,
Key=key,
Body=event.json(),
ContentType='application/json',
Metadata={
'patient_id': str(event.patient_id),
'device_id': event.device_id,
'event_type': event.event_type,
'ingested_at': datetime.utcnow().isoformat()
},
ServerSideEncryption='AES256',
ObjectLockMode='COMPLIANCE', # WORM
ObjectLockRetainUntilDate=datetime.utcnow() + timedelta(days=2555) # 7 years
)
async def send_to_dlq(self, event_json: str, error: str):
"""Send failed event to Dead Letter Queue for manual intervention"""
dlq_entry = {
'event': json.loads(event_json),
'error': error,
'failed_at': datetime.utcnow().isoformat(),
'worker_id': self.worker_id
}
await self.redis.lpush('dead_letter_queue', json.dumps(dlq_entry))
# Alert operations team for DLQ entries
await alert_operations(
severity="warning",
title="Event Processing Failed",
description=f"Event moved to DLQ: {error}",
event_id=dlq_entry['event'].get('event_id')
)
async def stop(self):
"""Graceful shutdown"""
self.running = False
logger.info(f"Worker {self.worker_id} stopping...")
4.3.3. Dead Letter Queue (DLQ) Management¶
DLQ Monitoring Dashboard:
@app.get("/v1/admin/dlq")
async def get_dlq_stats(current_user: User = Depends(require_admin)):
"""Get Dead Letter Queue statistics and failed events"""
dlq_size = await redis.llen('dead_letter_queue')
# Get last 100 failed events
failed_events = await redis.lrange('dead_letter_queue', 0, 99)
# Parse and summarize
failures_by_type = {}
for entry_json in failed_events:
entry = json.loads(entry_json)
error_type = entry['error'].split(':')[0] # First part of error message
failures_by_type[error_type] = failures_by_type.get(error_type, 0) + 1
return {
'dlq_size': dlq_size,
'failures_by_type': failures_by_type,
'sample_failures': [json.loads(e) for e in failed_events[:10]]
}
@app.post("/v1/admin/dlq/{event_id}/retry")
async def retry_dlq_event(event_id: str, current_user: User = Depends(require_admin)):
"""Manually retry a failed event from DLQ"""
# Find event in DLQ
dlq_events = await redis.lrange('dead_letter_queue', 0, -1)
for entry_json in dlq_events:
entry = json.loads(entry_json)
if entry['event']['event_id'] == event_id:
# Re-queue for processing
await redis.lpush('event_queue', json.dumps(entry['event']))
# Remove from DLQ
await redis.lrem('dead_letter_queue', 1, entry_json)
return {"status": "requeued", "event_id": event_id}
raise HTTPException(404, "Event not found in DLQ")
4.3.4. Backpressure Mechanism¶
To prevent queue overflow when workers cannot keep up with ingestion rate:
class BackpressureManager:
def __init__(self, redis: aioredis.Redis, max_queue_depth: int = 10000):
self.redis = redis
self.max_queue_depth = max_queue_depth
async def check_capacity(self) -> bool:
"""Check if system can accept more events"""
queue_depth = await self.redis.llen('event_queue')
if queue_depth >= self.max_queue_depth:
# Queue is full, reject new events
queue_full_rejections.inc()
return False
# Update metrics
queue_depth_gauge.set(queue_depth)
return True
# In ingestion endpoint:
@app.post("/v1/events/ingest")
async def ingest_event(event: dict):
# ... (previous validation steps) ...
# Check backpressure before queueing
if not await backpressure.check_capacity():
raise HTTPException(
status_code=503,
detail="System at capacity, please retry later",
headers={"Retry-After": "60"} # Suggest retry in 60 seconds
)
await redis_queue.enqueue(validated_event.json())
return JSONResponse(status_code=202, content={"status": "accepted"})
5. Storage Layer¶
5.1. Normalized Store (TimescaleDB)¶
5.1.1. Complete Schema Definition¶
-- Enable TimescaleDB extension
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- Main events table (will become hypertable)
CREATE TABLE events (
event_id UUID PRIMARY KEY,
patient_id UUID NOT NULL,
device_id VARCHAR(20) NOT NULL,
sequence_id BIGINT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_type VARCHAR(50) NOT NULL,
confidence REAL CHECK (confidence IS NULL OR (confidence >= 0 AND confidence <= 1)),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Denormalized location for query performance
location_lat REAL CHECK (location_lat IS NULL OR (location_lat >= -90 AND location_lat <= 90)),
location_lng REAL CHECK (location_lng IS NULL OR (location_lng >= -180 AND location_lng <= 180)),
location_accuracy_m REAL CHECK (location_accuracy_m IS NULL OR location_accuracy_m >= 0),
-- Flexible sensor data storage
sensor_data JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Full original payload for reference
raw_payload JSONB NOT NULL,
-- Ensure sequence monotonicity per device
CONSTRAINT unique_device_sequence UNIQUE(device_id, sequence_id)
);
-- Convert to TimescaleDB hypertable (time-series optimization)
SELECT create_hypertable(
'events',
'event_timestamp',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
-- Critical indexes for query performance
CREATE INDEX idx_events_patient_time ON events (patient_id, event_timestamp DESC);
CREATE INDEX idx_events_device_time ON events (device_id, event_timestamp DESC);
CREATE INDEX idx_events_type_time ON events (event_type, event_timestamp DESC);
CREATE INDEX idx_events_device ON events (device_id);
CREATE INDEX idx_events_sensor_data ON events USING GIN (sensor_data);
-- Compression policy: Compress chunks older than 7 days
SELECT add_compression_policy('events', INTERVAL '7 days');
-- Retention policy: Delete data older than 7 years (HIPAA requirement)
SELECT add_retention_policy('events', INTERVAL '7 years');
-- Continuous aggregate for hourly event counts (materialized view)
CREATE MATERIALIZED VIEW events_hourly
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', event_timestamp) AS hour,
patient_id,
event_type,
COUNT(*) as event_count,
AVG(confidence) as avg_confidence
FROM events
GROUP BY hour, patient_id, event_type;
-- Refresh policy for continuous aggregate
SELECT add_continuous_aggregate_policy('events_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour');
-- Device keys table (for signature verification)
CREATE TABLE device_keys (
device_id VARCHAR(20) NOT NULL,
public_key BYTEA NOT NULL,
key_version INTEGER NOT NULL DEFAULT 1,
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_until TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(255) NOT NULL,
PRIMARY KEY (device_id, key_version),
CONSTRAINT valid_period_check CHECK (valid_until > valid_from)
);
CREATE INDEX idx_device_keys_validity ON device_keys (device_id, valid_from, valid_until);
-- Sequence gaps table (for monitoring out-of-order events)
CREATE TABLE sequence_gaps (
id SERIAL PRIMARY KEY,
device_id VARCHAR(20) NOT NULL,
expected_seq BIGINT NOT NULL,
received_seq BIGINT NOT NULL,
gap_size INTEGER NOT NULL,
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sequence_gaps_device ON sequence_gaps (device_id, detected_at DESC);
-- Audit log table (HIPAA compliance)
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
user_id UUID NOT NULL,
action VARCHAR(100) NOT NULL,
patient_id UUID,
resource_type VARCHAR(50),
resource_id VARCHAR(255),
ip_address INET NOT NULL,
user_agent TEXT,
success BOOLEAN NOT NULL,
error_message TEXT,
metadata JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
CREATE INDEX idx_audit_log_user ON audit_log (user_id, timestamp DESC);
CREATE INDEX idx_audit_log_patient ON audit_log (patient_id, timestamp DESC);
-- Event acknowledgments (for caregiver response tracking)
CREATE TABLE event_acknowledgments (
event_id UUID PRIMARY KEY REFERENCES events(event_id),
acknowledged_by UUID NOT NULL, -- User ID
acknowledged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
action_taken VARCHAR(100) NOT NULL, -- e.g., 'responded', 'dismissed_false_alarm'
notes TEXT
);
-- Merkle tree blocks (for Proof-of-Care Ledger)
CREATE TABLE merkle_blocks (
block_id SERIAL PRIMARY KEY,
block_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
start_event_time TIMESTAMPTZ NOT NULL,
end_event_time TIMESTAMPTZ NOT NULL,
event_count INTEGER NOT NULL,
merkle_root BYTEA NOT NULL,
previous_block_hash BYTEA,
metadata JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX idx_merkle_blocks_timestamp ON merkle_blocks (block_timestamp DESC);
-- Merkle proofs (for individual event verification)
CREATE TABLE merkle_proofs (
event_id UUID PRIMARY KEY REFERENCES events(event_id),
block_id INTEGER NOT NULL REFERENCES merkle_blocks(block_id),
leaf_index INTEGER NOT NULL,
proof_hashes BYTEA[] NOT NULL, -- Array of sibling hashes
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_merkle_proofs_block ON merkle_proofs (block_id);
5.1.2. Query Patterns & Performance¶
Common Query Examples:
-- Get recent events for a patient (uses idx_events_patient_time)
SELECT event_id, event_timestamp, event_type, confidence
FROM events
WHERE patient_id = 'uuid-here'
AND event_timestamp >= NOW() - INTERVAL '24 hours'
ORDER BY event_timestamp DESC
LIMIT 50;
-- Get unacknowledged high-confidence fall events (for alerts)
SELECT e.event_id, e.patient_id, e.event_timestamp, e.confidence
FROM events e
LEFT JOIN event_acknowledgments a ON e.event_id = a.event_id
WHERE e.event_type = 'fall'
AND e.confidence >= 0.8
AND a.event_id IS NULL
AND e.event_timestamp >= NOW() - INTERVAL '1 hour'
ORDER BY e.event_timestamp DESC;
-- Get hourly event counts for dashboard (uses continuous aggregate)
SELECT hour, event_type, SUM(event_count) as total_events
FROM events_hourly
WHERE hour >= NOW() - INTERVAL '7 days'
GROUP BY hour, event_type
ORDER BY hour DESC;
-- Audit trail for specific patient access
SELECT timestamp, user_id, action, ip_address
FROM audit_log
WHERE patient_id = 'uuid-here'
AND timestamp >= NOW() - INTERVAL '90 days'
ORDER BY timestamp DESC;
Performance Targets:
- Patient event feed (last 24h): <100ms p99
- Alert queries (unacknowledged): <50ms p99
- Dashboard aggregates: <200ms p99
- Audit queries: <500ms p99
5.1.3. Database Migration Strategy¶
Using Alembic for version-controlled schema migrations:
# migrations/versions/001_initial_schema.py
"""Initial schema for events and device keys"""
from alembic import op
import sqlalchemy as sa
def upgrade():
op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb")
op.create_table(
'events',
sa.Column('event_id', sa.UUID(), primary_key=True),
# ... (full schema from above)
)
op.execute(
"SELECT create_hypertable('events', 'event_timestamp', "
"chunk_time_interval => INTERVAL '1 day')"
)
def downgrade():
op.drop_table('events')
# migrations/versions/002_add_battery_level.py
"""Add optional battery_level for schema v1.1"""
def upgrade():
op.add_column('events',
sa.Column('battery_level', sa.Float(), nullable=True,
comment='Added in schema v1.1')
)
def downgrade():
op.drop_column('events', 'battery_level')
5.2. Raw Archive (S3 WORM)¶
5.2.1. S3 Bucket Configuration¶
Terraform Configuration:
resource "aws_s3_bucket" "event_archive" {
bucket = "goldenage-event-archive-${var.environment}"
tags = {
Name = "Golden Age Event Archive"
Environment = var.environment
Compliance = "HIPAA"
}
}
# Enable versioning for audit trail
resource "aws_s3_bucket_versioning" "event_archive" {
bucket = aws_s3_bucket.event_archive.id
versioning_configuration {
status = "Enabled"
}
}
# Object Lock for WORM (Write-Once-Read-Many)
resource "aws_s3_bucket_object_lock_configuration" "event_archive" {
bucket = aws_s3_bucket.event_archive.id
object_lock_enabled = "Enabled"
rule {
default_retention {
mode = "COMPLIANCE" # Cannot be overridden, even by root
days = 2555 # 7 years (HIPAA requirement)
}
}
}
# Server-side encryption (AES-256)
resource "aws_s3_bucket_server_side_encryption_configuration" "event_archive" {
bucket = aws_s3_bucket.event_archive.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# Block all public access
resource "aws_s3_bucket_public_access_block" "event_archive" {
bucket = aws_s3_bucket.event_archive.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Cross-region replication for disaster recovery
resource "aws_s3_bucket_replication_configuration" "event_archive" {
bucket = aws_s3_bucket.event_archive.id
role = aws_iam_role.replication.arn
rule {
id = "replicate-to-west"
status = "Enabled"
destination {
bucket = aws_s3_bucket.event_archive_replica.arn
storage_class = "GLACIER_IR" # Cheaper for DR replica
}
}
}
# Lifecycle policy for cost optimization
resource "aws_s3_bucket_lifecycle_configuration" "event_archive" {
bucket = aws_s3_bucket.event_archive.id
rule {
id = "transition-to-glacier"
status = "Enabled"
transition {
days = 90 # Move to Glacier after 90 days
storage_class = "GLACIER"
}
transition {
days = 365 # Move to Deep Archive after 1 year
storage_class = "DEEP_ARCHIVE"
}
}
}
# Bucket policy for least-privilege access
resource "aws_s3_bucket_policy" "event_archive" {
bucket = aws_s3_bucket.event_archive.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyUnencryptedObjectUploads"
Effect = "Deny"
Principal = "*"
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.event_archive.arn}/*"
Condition = {
StringNotEquals = {
"s3:x-amz-server-side-encryption" = "AES256"
}
}
},
{
Sid = "AllowWorkerWrites"
Effect = "Allow"
Principal = {
AWS = aws_iam_role.pipeline_worker.arn
}
Action = ["s3:PutObject", "s3:PutObjectRetention"]
Resource = "${aws_s3_bucket.event_archive.arn}/*"
},
{
Sid = "AllowAuditReads"
Effect = "Allow"
Principal = {
AWS = aws_iam_role.audit_exporter.arn
}
Action = ["s3:GetObject", "s3:ListBucket"]
Resource = [
aws_s3_bucket.event_archive.arn,
"${aws_s3_bucket.event_archive.arn}/*"
]
}
]
})
}
5.2.2. Archive Structure¶
S3 Key Pattern:
events/
2025/
10/
21/
{event_id}.json
{event_id}.json
2025/
10/
22/
...
merkle_blocks/
2025/
10/
block_001234.json
audit_exports/
2025-10-21_facility-123_export.zip
2025-10-21_facility-123_export.zip.sha256
Benefits of Date Partitioning:
- Efficient S3 listing for date-range queries
- Automatic Glacier transition by prefix
- Simplified compliance exports (e.g., "all events for October 2025")
5.3. Schema Evolution Strategy¶
5.3.1. Forward Compatibility Design¶
Principle: New code must read old data; old code rejects new data gracefully.
from typing import Optional
from pydantic import BaseModel, Field, validator
class EventPayloadV1_0(BaseModel):
"""Schema v1.0 - Initial frozen contract"""
event_id: UUID
patient_id: UUID
device_id: str
sequence_id: int
timestamp: datetime
event_type: str
confidence: Optional[float]
location: Optional[Location]
sensor_data: Optional[dict]
signature: str
class EventPayloadV1_1(EventPayloadV1_0):
"""Schema v1.1 - Adds optional battery monitoring"""
battery_level: Optional[float] = Field(
None,
ge=0,
le=100,
description="Device battery percentage. Added in v1.1."
)
@validator('battery_level')
def validate_battery(cls, v):
if v is not None and (v < 0 or v > 100):
raise ValueError("battery_level must be between 0 and 100")
return v
class EventPayloadV1_2(EventPayloadV1_1):
"""Schema v1.2 - Adds optional network quality metrics"""
network_rssi: Optional[int] = Field(
None,
ge=-120,
le=0,
description="Network signal strength in dBm. Added in v1.2."
)
# Schema registry for multi-version support
SCHEMA_REGISTRY = {
'1.0': EventPayloadV1_0,
'1.0.0': EventPayloadV1_0,
'1.1': EventPayloadV1_1,
'1.1.0': EventPayloadV1_1,
'1.2': EventPayloadV1_2,
'1.2.0': EventPayloadV1_2,
}
def validate_event(payload: dict) -> BaseModel:
"""Validate event against appropriate schema version"""
schema_version = payload.get('_schema_version', '1.0.0')
# Normalize version string (1.1 -> 1.1.0)
if schema_version.count('.') == 1:
schema_version += '.0'
schema_class = SCHEMA_REGISTRY.get(schema_version)
if not schema_class:
raise UnsupportedSchemaVersion(
f"Schema version {schema_version} not supported. "
f"Supported versions: {', '.join(SCHEMA_REGISTRY.keys())}"
)
try:
return schema_class(**payload)
except ValidationError as e:
raise SchemaValidationError(f"Invalid payload for schema {schema_version}: {e}")
5.3.2. Breaking Changes Policy¶
When v2.0 is Required (Breaking Changes):
- Removing required fields
- Changing field types (e.g., string → integer)
- Renaming fields
- Changing field semantics (e.g., timestamp from device time → server time)
Deprecation Timeline:
- T+0 months: v2.0 released, v1.x still supported
- T+6 months: Deprecation warning in API responses (
Sunsetheader) - T+9 months: v1.x marked as deprecated in documentation
- T+12 months: v1.x support removed
Migration Support:
# API response headers during deprecation period
@app.post("/v1/events/ingest")
async def ingest_event(event: dict):
response = await process_event(event)
# Add deprecation warning if using old schema
schema_version = event.get('_schema_version', '1.0.0')
if schema_version.startswith('1.'):
response.headers['Warning'] = (
'299 - "API v1 is deprecated and will be removed on 2026-12-31. '
'Please upgrade to v2. See https://docs.goldenage.com/migration"'
)
response.headers['Sunset'] = 'Wed, 31 Dec 2026 23:59:59 GMT'
return response
6. Proof-of-Care Ledger¶
6.1. Merkle Root Computation¶
The ledger provides mathematically verifiable proof that events occurred and were recorded in a specific order, creating an immutable audit trail.
6.1.1. Merkle Tree Algorithm¶
Implementation:
import hashlib
from typing import List, Tuple
from dataclasses import dataclass
@dataclass
class MerkleBlock:
block_id: int
start_time: datetime
end_time: datetime
event_ids: List[UUID]
merkle_root: bytes
previous_block_hash: bytes
class MerkleTreeBuilder:
def __init__(self):
self.leaf_prefix = b'\x00' # Prefix for leaf nodes
self.internal_prefix = b'\x01' # Prefix for internal nodes
def hash_leaf(self, event_json: str) -> bytes:
"""Hash a single event (leaf node)"""
canonical = canonicalize_event(json.loads(event_json))
return hashlib.sha256(self.leaf_prefix + canonical).digest()
def hash_internal(self, left: bytes, right: bytes) -> bytes:
"""Hash two child nodes (internal node)"""
return hashlib.sha256(self.internal_prefix + left + right).digest()
def build_tree(self, event_jsons: List[str]) -> Tuple[bytes, List[List[bytes]]]:
"""
Build Merkle tree from list of events.
Returns: (root_hash, tree_levels) where tree_levels[0] are leaves
"""
if not event_jsons:
# Empty tree has a predefined null hash
return hashlib.sha256(b'EMPTY_TREE').digest(), []
# Level 0: Leaf hashes
current_level = [self.hash_leaf(event) for event in event_jsons]
tree_levels = [current_level.copy()]
# Build tree bottom-up
while len(current_level) > 1:
next_level = []
# Pair up nodes and hash them
for i in range(0, len(current_level), 2):
left = current_level[i]
# If odd number of nodes, duplicate the last one
right = current_level[i + 1] if i + 1 < len(current_level) else left
parent_hash = self.hash_internal(left, right)
next_level.append(parent_hash)
tree_levels.append(next_level)
current_level = next_level
# Root is the single remaining hash
root_hash = current_level[0]
return root_hash, tree_levels
def generate_proof(self, leaf_index: int, tree_levels: List[List[bytes]]) -> List[bytes]:
"""
Generate Merkle inclusion proof for a specific leaf.
Returns list of sibling hashes needed to recalculate root.
"""
proof = []
index = leaf_index
# For each level (except root), collect sibling hash
for level in tree_levels[:-1]: # Exclude root level
# Determine sibling index
if index % 2 == 0:
# We're a left child, sibling is to the right
sibling_index = index + 1 if index + 1 < len(level) else index
else:
# We're a right child, sibling is to the left
sibling_index = index - 1
proof.append(level[sibling_index])
index = index // 2 # Move to parent level
return proof
async def create_block(self, start_time: datetime, end_time: datetime) -> MerkleBlock:
"""
Create a new Merkle block for events in the time range.
Chains to previous block via previous_block_hash.
"""
# Fetch events in time range, ordered by timestamp
events = await db.query(Event).filter(
Event.event_timestamp >= start_time,
Event.event_timestamp < end_time
).order_by(Event.event_timestamp).all()
if not events:
logger.info(f"No events in range {start_time} to {end_time}, skipping block")
return None
# Extract canonical JSON payloads
event_jsons = [event.raw_payload for event in events]
event_ids = [event.event_id for event in events]
# Build Merkle tree
merkle_root, tree_levels = self.build_tree(event_jsons)
# Get previous block hash for chaining
previous_block = await db.query(MerkleBlock).order_by(
MerkleBlock.block_id.desc()
).first()
previous_block_hash = previous_block.merkle_root if previous_block else b'\x00' * 32
# Store block in database
block = MerkleBlock(
start_time=start_time,
end_time=end_time,
event_count=len(events),
merkle_root=merkle_root,
previous_block_hash=previous_block_hash
)
db.add(block)
await db.commit()
# Store individual proofs for each event
for i, event_id in enumerate(event_ids):
proof = self.generate_proof(i, tree_levels)
merkle_proof = MerkleProof(
event_id=event_id,
block_id=block.block_id,
leaf_index=i,
proof_hashes=proof
)
db.add(merkle_proof)
await db.commit()
logger.info(
f"Created Merkle block {block.block_id} with {len(events)} events. "
f"Root: {merkle_root.hex()[:16]}..."
)
return block
6.1.2. Automated Block Creation¶
Scheduled Job (runs hourly):
from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()
@scheduler.scheduled_job('cron', hour='*', minute=5) # Run at :05 past each hour
async def create_hourly_merkle_block():
"""Create Merkle block for previous hour's events"""
try:
now = datetime.utcnow()
# Block covers previous hour (e.g., 14:00:00 to 14:59:59)
end_time = now.replace(minute=0, second=0, microsecond=0)
start_time = end_time - timedelta(hours=1)
builder = MerkleTreeBuilder()
block = await builder.create_block(start_time, end_time)
if block:
# Metrics
merkle_blocks_created.inc()
merkle_block_event_count.observe(block.event_count)
# Notify audit team
await notify_audit_team(
f"Merkle block {block.block_id} created with {block.event_count} events"
)
except Exception as e:
logger.error(f"Failed to create Merkle block: {e}", exc_info=True)
await alert_operations(
severity="error",
title="Merkle Block Creation Failed",
description=str(e)
)
# Start scheduler
scheduler.start()
6.2. Verifiable Export Bundle¶
6.2.1. Export Bundle Generation¶
API Endpoint:
from io import BytesIO
import zipfile
import csv
@app.post("/v1/audit/export")
async def create_audit_export(
start_date: date,
end_date: date,
patient_id: Optional[UUID] = None,
current_user: User = Depends(require_auditor_role)
):
"""
Generate a verifiable audit export bundle.
Returns a self-contained ZIP file with events, proofs, and verification script.
"""
# Audit log the export request
await AuditLog.create(
user_id=current_user.id,
action="EXPORT_REQUEST",
patient_id=patient_id,
ip_address=request.client.host,
success=True,
metadata={
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat()
}
)
# Query events
query = db.query(Event).filter(
Event.event_timestamp >= start_date,
Event.event_timestamp < end_date + timedelta(days=1)
)
if patient_id:
query = query.filter(Event.patient_id == patient_id)
events = await query.order_by(Event.event_timestamp).all()
# Get corresponding Merkle blocks and proofs
block_ids = set()
event_proofs = {}
for event in events:
proof = await db.query(MerkleProof).filter(
MerkleProof.event_id == event.event_id
).first()
if proof:
block_ids.add(proof.block_id)
event_proofs[str(event.event_id)] = proof
blocks = await db.query(MerkleBlock).filter(
MerkleBlock.block_id.in_(block_ids)
).all()
# Create ZIP file in memory
zip_buffer = BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# 1. events.csv - Human-readable event list
csv_buffer = BytesIO()
csv_writer = csv.writer(csv_buffer)
csv_writer.writerow([
'event_id', 'timestamp', 'patient_id', 'device_id',
'event_type', 'confidence', 'sequence_id'
])
for event in events:
csv_writer.writerow([
str(event.event_id),
event.event_timestamp.isoformat(),
str(event.patient_id),
event.device_id,
event.event_type,
event.confidence,
event.sequence_id
])
zip_file.writestr('events.csv', csv_buffer.getvalue())
# 2. manifest.json - Merkle roots and metadata
manifest = {
"export_date": datetime.utcnow().isoformat(),
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"patient_id": str(patient_id) if patient_id else None,
"event_count": len(events),
"exported_by": current_user.email,
"merkle_blocks": [
{
"block_id": block.block_id,
"start_time": block.start_event_time.isoformat(),
"end_time": block.end_event_time.isoformat(),
"event_count": block.event_count,
"merkle_root": block.merkle_root.hex(),
"previous_block_hash": block.previous_block_hash.hex()
}
for block in blocks
]
}
zip_file.writestr('manifest.json', json.dumps(manifest, indent=2))
# 3. proofs.json - Merkle inclusion proofs
proofs_data = {}
for event in events:
proof = event_proofs.get(str(event.event_id))
if proof:
proofs_data[str(event.event_id)] = {
"block_id": proof.block_id,
"leaf_index": proof.leaf_index,
"proof_hashes": [h.hex() for h in proof.proof_hashes],
"event_payload": json.loads(event.raw_payload)
}
zip_file.writestr('proofs.json', json.dumps(proofs_data, indent=2))
# 4. events_raw.json - Full signed payloads
events_raw = [json.loads(event.raw_payload) for event in events]
zip_file.writestr('events_raw.json', json.dumps(events_raw, indent=2))
# 5. verify.py - Self-contained verification script
verification_script = """#!/usr/bin/env python3
'''
Golden Age Hub - Audit Export Verification Script
This script independently verifies the authenticity of events in an audit export
by recalculating Merkle roots from the provided inclusion proofs.
Usage:
python verify.py
Requirements:
Python 3.7+, no external dependencies
'''
import json
import hashlib
from typing import List, Dict, Any
def canonicalize_event(event: Dict[str, Any]) -> bytes:
'''Canonicalize event payload (RFC 8785 - JCS)'''
# Remove signature field
event_copy = {k: v for k, v in event.items() if k != 'signature'}
# Sort keys recursively and create canonical JSON
def sort_dict(obj):
if isinstance(obj, dict):
return {k: sort_dict(obj[k]) for k in sorted(obj.keys())}
elif isinstance(obj, list):
return [sort_dict(item) for item in obj]
return obj
canonical = sort_dict(event_copy)
# Minimal JSON encoding
canonical_json = json.dumps(canonical, separators=(',', ':'), ensure_ascii=False)
return canonical_json.encode('utf-8')
def hash_leaf(event_json: bytes) -> bytes:
'''Hash event as Merkle tree leaf'''
return hashlib.sha256(b'\\x00' + event_json).digest()
def hash_internal(left: bytes, right: bytes) -> bytes:
'''Hash two nodes as Merkle tree internal node'''
return hashlib.sha256(b'\\x01' + left + right).digest()
def verify_proof(event: Dict[str, Any], proof_hashes: List[str],
expected_root: str, leaf_index: int) -> bool:
'''
Verify Merkle inclusion proof for an event.
Args:
event: The event payload
proof_hashes: List of sibling hashes (hex strings)
expected_root: Expected Merkle root (hex string)
leaf_index: Position of event in the block
Returns:
True if proof is valid
'''
# Calculate leaf hash
canonical = canonicalize_event(event)
current_hash = hash_leaf(canonical)
# Traverse tree using proof hashes
index = leaf_index
for sibling_hex in proof_hashes:
sibling = bytes.fromhex(sibling_hex)
# Determine if we're left or right child
if index % 2 == 0:
# We're left child
current_hash = hash_internal(current_hash, sibling)
else:
# We're right child
current_hash = hash_internal(sibling, current_hash)
index = index // 2
# Compare with expected root
return current_hash.hex() == expected_root
def main():
print("Golden Age Hub - Audit Export Verifier")
print("=" * 60)
# Load data files
with open('manifest.json') as f:
manifest = json.load(f)
with open('proofs.json') as f:
proofs = json.load(f)
print(f"\\nExport Information:")
print(f" Export Date: {manifest['export_date']}")
print(f" Date Range: {manifest['start_date']} to {manifest['end_date']}")
print(f" Total Events: {manifest['event_count']}")
print(f" Merkle Blocks: {len(manifest['merkle_blocks'])}")
# Verify each event
verified_count = 0
failed_count = 0
print(f"\\nVerifying event authenticity...")
for event_id, proof_data in proofs.items():
event = proof_data['event_payload']
block_id = proof_data['block_id']
leaf_index = proof_data['leaf_index']
proof_hashes = proof_data['proof_hashes']
# Find corresponding block
block = next(
(b for b in manifest['merkle_blocks'] if b['block_id'] == block_id),
None
)
if not block:
print(f" ✗ Event {event_id}: Block {block_id} not found")
failed_count += 1
continue
# Verify proof
if verify_proof(event, proof_hashes, block['merkle_root'], leaf_index):
verified_count += 1
else:
print(f" ✗ Event {event_id}: Proof verification FAILED")
failed_count += 1
# Summary
print(f"\\n" + "=" * 60)
print(f"Verification Complete:")
print(f" ✓ Verified: {verified_count}")
print(f" ✗ Failed: {failed_count}")
if failed_count == 0:
print(f"\\n✓ All events verified successfully!")
print(f" This export is cryptographically authentic.")
return 0
else:
print(f"\\n✗ Verification FAILED!")
print(f" {failed_count} events could not be verified.")
print(f" This export may have been tampered with.")
return 1
if __name__ == '__main__':
exit(main())
"""
zip_file.writestr('verify.py', verification_script)
# 6. README.txt - Instructions
readme = f"""Golden Age Hub - Audit Export Bundle
Export Date: {datetime.utcnow().isoformat()}
Date Range: {start_date} to {end_date}
Patient ID: {patient_id or 'All Patients'}
Event Count: {len(events)}
Exported By: {current_user.email}
Files in this bundle:
- events.csv: Human-readable list of events
- events_raw.json: Complete signed event payloads
- manifest.json: Merkle block information and roots
- proofs.json: Merkle inclusion proofs for each event
- verify.py: Verification script
- README.txt: This file
To verify the authenticity of this export:
python verify.py
The verification script will:
1. Recalculate the Merkle root for each event using its inclusion proof
2. Compare the calculated root with the root in manifest.json
3. Confirm that no events have been tampered with or omitted
A successful verification provides mathematical proof that:
- These events were recorded by Golden Age Hub systems
- The events occurred in the order shown
- No events have been modified, added, or removed since export
For questions, contact: compliance@goldenage.com
"""
zip_file.writestr('README.txt', readme)
# Prepare response
zip_buffer.seek(0)
filename = f"goldenage_audit_{start_date}_{end_date}"
if patient_id:
filename += f"_patient_{patient_id}"
filename += ".zip"
return Response(
content=zip_buffer.getvalue(),
media_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"X-Event-Count": str(len(events)),
"X-Merkle-Blocks": str(len(blocks))
}
)
6.3. Compliance Deletion & Anonymization¶
6.3.1. GDPR/CCPA Right to Deletion¶
The system must support "right to be forgotten" requests while maintaining ledger integrity. Solution: Anonymization instead of deletion.
@app.post("/v1/compliance/anonymize-patient")
async def anonymize_patient_data(
patient_id: UUID,
legal_request_id: str,
current_user: User = Depends(require_compliance_officer)
):
"""
Anonymize patient data in compliance with GDPR/CCPA right to deletion.
This does NOT delete events (which would break Merkle tree integrity),
but replaces all PHI with anonymized values.
"""
# Verify legal request exists
legal_request = await db.query(ComplianceRequest).filter(
ComplianceRequest.request_id == legal_request_id,
ComplianceRequest.patient_id == patient_id,
ComplianceRequest.request_type == 'RIGHT_TO_DELETION'
).first()
if not legal_request:
raise HTTPException(404, "Legal request not found")
# Audit log
await AuditLog.create(
user_id=current_user.id,
action="ANONYMIZE_PATIENT",
patient_id=patient_id,
ip_address=request.client.host,
success=True,
metadata={"legal_request_id": legal_request_id}
)
try:
# 1. Anonymize in normalized store (PostgreSQL)
anonymized_patient_id = uuid4() # New pseudonymous ID
await db.execute(
"""
UPDATE events
SET
patient_id = :new_id,
location_lat = NULL,
location_lng = NULL,
location_accuracy_m = NULL,
sensor_data = '{}'::jsonb,
raw_payload = jsonb_set(
jsonb_set(raw_payload, '{patient_id}', :new_id_json),
'{location}', 'null'
)
WHERE patient_id = :old_id
""",
new_id=anonymized_patient_id,
new_id_json=json.dumps(str(anonymized_patient_id)),
old_id=patient_id
)
# 2. Anonymize S3 archive (preserve structure, redact PHI)
# List all events for this patient
events = await db.query(Event).filter(
Event.patient_id == anonymized_patient_id # Now using new ID
).all()
for event in events:
# Fetch original from S3
date_partition = event.event_timestamp.strftime("%Y/%m/%d")
key = f"events/{date_partition}/{event.event_id}.json"
try:
original = await s3_client.get_object(Bucket=ARCHIVE_BUCKET, Key=key)
original_data = json.loads(original['Body'].read())
# Redact PHI
redacted_data = original_data.copy()
redacted_data['patient_id'] = "REDACTED_BY_LEGAL_REQUEST"
redacted_data['location'] = None
redacted_data['sensor_data'] = {}
redacted_data['signature'] = "REDACTED_BY_LEGAL_REQUEST"
# Overwrite in S3 (versioning preserves original if needed for legal holds)
await s3_client.put_object(
Bucket=ARCHIVE_BUCKET,
Key=key,
Body=json.dumps(redacted_data),
Metadata={
'redacted': 'true',
'legal_request_id': legal_request_id,
'redacted_at': datetime.utcnow().isoformat()
}
)
except Exception as e:
logger.error(f"Failed to redact S3 object {key}: {e}")
# Continue with other events
# 3. Update compliance request status
legal_request.status = 'COMPLETED'
legal_request.completed_at = datetime.utcnow()
await db.commit()
# 4. Notify requester
await notify_compliance_team(
f"Patient anonymization completed: {patient_id} → {anonymized_patient_id}",
legal_request_id=legal_request_id
)
return {
"status": "completed",
"patient_id": str(patient_id),
"anonymized_patient_id": str(anonymized_patient_id),
"events_anonymized": len(events),
"legal_request_id": legal_request_id
}
except Exception as e:
logger.error(f"Anonymization failed: {e}", exc_info=True)
await AuditLog.create(
user_id=current_user.id,
action="ANONYMIZE_PATIENT_FAILED",
patient_id=patient_id,
ip_address=request.client.host,
success=False,
error_message=str(e)
)
raise HTTPException(500, "Anonymization failed")
Important Notes:
- Original data is preserved in S3 versions (for legal holds/litigation)
- Merkle tree structure remains intact (event still exists, just anonymized)
- Signature becomes invalid after redaction (expected and acceptable)
- Audit trail of anonymization is permanent and cannot be deleted
7. Compliance & Privacy¶
7.1. HIPAA Requirements¶
7.1.1. Administrative Safeguards¶
Access Control:
from enum import Enum
class UserRole(str, Enum):
CAREGIVER = "caregiver"
NURSE = "nurse"
DOCTOR = "doctor"
FACILITY_ADMIN = "facility_admin"
AUDITOR = "auditor"
COMPLIANCE_OFFICER = "compliance_officer"
SYSTEM_ADMIN = "system_admin"
class Permission(str, Enum):
VIEW_PATIENT_EVENTS = "view_patient_events"
VIEW_ALL_EVENTS = "view_all_events"
ACKNOWLEDGE_ALERTS = "acknowledge_alerts"
EXPORT_AUDIT_DATA = "export_audit_data"
ANONYMIZE_PATIENT = "anonymize_patient"
MANAGE_USERS = "manage_users"
VIEW_AUDIT_LOGS = "view_audit_logs"
# Role-based permissions matrix
ROLE_PERMISSIONS = {
UserRole.CAREGIVER: [
Permission.VIEW_PATIENT_EVENTS,
Permission.ACKNOWLEDGE_ALERTS,
],
UserRole.NURSE: [
Permission.VIEW_PATIENT_EVENTS,
Permission.ACKNOWLEDGE_ALERTS,
],
UserRole.DOCTOR: [
Permission.VIEW_PATIENT_EVENTS,
Permission.ACKNOWLEDGE_ALERTS,
],
UserRole.FACILITY_ADMIN: [
Permission.VIEW_ALL_EVENTS,
Permission.ACKNOWLEDGE_ALERTS,
Permission.MANAGE_USERS,
],
UserRole.AUDITOR: [
Permission.VIEW_ALL_EVENTS,
Permission.EXPORT_AUDIT_DATA,
Permission.VIEW_AUDIT_LOGS,
],
UserRole.COMPLIANCE_OFFICER: [
Permission.VIEW_ALL_EVENTS,
Permission.EXPORT_AUDIT_DATA,
Permission.ANONYMIZE_PATIENT,
Permission.VIEW_AUDIT_LOGS,
],
UserRole.SYSTEM_ADMIN: [
# All permissions
*list(Permission)
],
}
def require_permission(permission: Permission):
"""Dependency for enforcing permission-based access control"""
async def permission_checker(current_user: User = Depends(get_current_user)):
user_permissions = ROLE_PERMISSIONS.get(current_user.role, [])
if permission not in user_permissions:
await AuditLog.create(
user_id=current_user.id,
action="ACCESS_DENIED",
success=False,
metadata={"required_permission": permission}
)
raise HTTPException(403, f"Missing required permission: {permission}")
return current_user
return permission_checker
# Usage:
@app.get("/v1/events")
async def get_events(
patient_id: UUID,
current_user: User = Depends(require_permission(Permission.VIEW_PATIENT_EVENTS))
):
# User has been verified to have permission
...
Audit Logging (Every Access):
from functools import wraps
def audit_access(action: str):
"""Decorator to automatically log all PHI access"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
# Extract request context
request = kwargs.get('request')
current_user = kwargs.get('current_user')
patient_id = kwargs.get('patient_id')
start_time = time.time()
error = None
try:
result = await func(*args, **kwargs)
success = True
return result
except Exception as e:
success = False
error = str(e)
raise
finally:
# Log every access attempt
duration_ms = (time.time() - start_time) * 1000
await AuditLog.create(
timestamp=datetime.utcnow(),
user_id=current_user.id if current_user else None,
action=action,
patient_id=patient_id,
ip_address=request.client.host if request else None,
user_agent=request.headers.get('user-agent') if request else None,
success=success,
error_message=error,
metadata={
"duration_ms": duration_ms,
"endpoint": request.url.path if request else None
}
)
return wrapper
return decorator
# Usage:
@app.get("/v1/patients/{patient_id}/events")
@audit_access("VIEW_PATIENT_EVENTS")
async def get_patient_events(
patient_id: UUID,
request: Request,
current_user: User = Depends(get_current_user)
):
...
7.1.2. Physical Safeguards¶
Encryption at Rest:
- PostgreSQL: Transparent Data Encryption (TDE) or LUKS encrypted volumes
- Redis: Encrypted volumes (American Cloud persistent disk encryption)
- S3: Server-side encryption (SSE-S3 or SSE-KMS)
Encryption in Transit:
- All API endpoints: TLS 1.3 only
- Database connections: SSL/TLS enforced
- Redis connections: TLS enabled
# Database connection with enforced SSL
SQLALCHEMY_DATABASE_URL = (
f"postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
f"?sslmode=require&sslcert=/path/to/client-cert.pem"
)
# Redis connection with TLS
redis_client = aioredis.from_url(
f"rediss://{REDIS_HOST}:{REDIS_PORT}", # Note: rediss:// for TLS
ssl_cert_reqs="required",
ssl_ca_certs="/path/to/ca-cert.pem"
)
7.1.3. Technical Safeguards¶
Automatic Logoff:
# JWT tokens expire after 8 hours
ACCESS_TOKEN_EXPIRE_MINUTES = 480
# Session inactivity timeout (30 minutes)
@app.middleware("http")
async def session_timeout_middleware(request: Request, call_next):
if request.url.path.startswith("/v1/"):
token = request.headers.get("Authorization", "").replace("Bearer ", "")
if token:
# Check last activity
last_activity = await redis.get(f"session:{token}:last_activity")
if last_activity:
last_activity_time = datetime.fromisoformat(last_activity.decode())
if datetime.utcnow() - last_activity_time > timedelta(minutes=30):
return JSONResponse(
status_code=401,
content={"detail": "Session expired due to inactivity"}
)
# Update last activity
await redis.setex(
f"session:{token}:last_activity",
3600, # 1 hour TTL
datetime.utcnow().isoformat()
)
response = await call_next(request)
return response
Encryption of PHI in Database:
from cryptography.fernet import Fernet
import base64
class EncryptedField:
"""Encrypt sensitive fields using Fernet (AES-128)"""
def __init__(self, kms_key_id: str):
# In production, fetch key from KMS or HashiCorp Vault
self.key = self._get_encryption_key(kms_key_id)
self.cipher = Fernet(self.key)
def _get_encryption_key(self, key_id: str) -> bytes:
"""Fetch encryption key from KMS"""
# Simplified - in production, use KMS or Vault
kms_client = boto3.client('kms')
response = kms_client.decrypt(
CiphertextBlob=base64.b64decode(KEY_CIPHERTEXT),
KeyId=key_id
)
return base64.urlsafe_b64encode(response['Plaintext'][:32])
def encrypt(self, plaintext: str) -> str:
if plaintext is None:
return None
return self.cipher.encrypt(plaintext.encode()).decode()
def decrypt(self, ciphertext: str) -> str:
if ciphertext is None:
return None
return self.cipher.decrypt(ciphertext.encode()).decode()
# Usage in models:
encrypted_field = EncryptedField(kms_key_id="arn:americancloud:kms:...")
class PatientRecord(BaseModel):
patient_id: UUID
_patient_name_encrypted: str # Stored encrypted
@property
def patient_name(self) -> str:
return encrypted_field.decrypt(self._patient_name_encrypted)
@patient_name.setter
def patient_name(self, value: str):
self._patient_name_encrypted = encrypted_field.encrypt(value)
7.2. GDPR/CCPA Compliance¶
7.2.1. Data Subject Rights¶
Right to Access:
```python @app.get("/v1/patients/{patient_id}/data-export") async def export