Hospital-at-Home Risk Monitor Contracts

이 문서는 Hospital-at-Home Risk Monitor 예제에서 언어와 app 사이를 연결하는 공통 계약을 정의합니다. 각 언어별 문서는 구현 책임을 설명하고, 이 문서는 서로 어떤 데이터와 protocol로 맞물리는지 설명합니다.

이 contract는 상용 제품 claim이 아니라 dev-guide 최소 실행 예제를 위한 engineering contract입니다. 실제 제품에서는 병원 EMR, device firmware, model validation, regulatory documentation에 맞춰 별도 검증이 필요합니다.

처음 읽을 때는 모든 JSON 예시를 한 번에 외우려고 하지 않아도 됩니다. 먼저 아래 순서로 contract의 큰 경계만 잡고, 실제 payload shape는 schema와 fixture를 함께 확인합니다.

먼저 볼 것 이유
## 1. Contract 원칙 schema, fixture, protocol adapter, PHI boundary가 어떤 관계인지 잡습니다.
## 2. Signal Packet v1 local demo와 device ingestion의 기본 입력을 이해합니다.
## 4. Preprocessed Signal v1 Rust preprocessing이 Python risk workflow에 넘기는 feature contract를 봅니다.
## 5. Risk Assessment v1## 7. Monitoring Snapshot v1 Python API와 TypeScript dashboard가 공유하는 runtime contract를 봅니다.
## 8. RiskMonitor Runtime Policy v1 Go operator와 CD repo가 바라보는 runtime desired/observed state를 봅니다.
## 11. Validation Responsibility 각 언어와 adapter가 어디서 contract를 검증해야 하는지 확인합니다.

1. Contract 원칙

1-1. 예제 범위를 작게 유지한다

이 contract는 아래 local demo를 연결하는 데 필요한 최소 계약만 정의합니다.

synthetic signal generator
  -> HTTP /signal-packets ingestion in local demo
  -> Python API
      -> app-owned preprocessing adapter
      -> Python core clinical result builder
  -> TypeScript dashboard

HTTP POST /signal-packets는 1차 local demo와 CI contract test의 기본 입력 경로입니다. MQTT는 같은 domain contract와 usecase를 호출하는 후속 inbound adapter로 둡니다.

아래 항목은 1단계 예제 범위가 아닙니다.

  • 실제 PCB hardware driver
  • HIL 테스트
  • production MQTT security 전체 구현
  • 실제 모델 학습 pipeline
  • 병원 EMR/PACS/LIS 연동
  • production-grade Kubernetes operator
  • KubeVirt 기반 device VM
  • Rust preprocessing network service
  • Node API/BFF
  • protobuf/Arrow 기반 binary transport

1-2. Domain language를 먼저 고정한다

언어별 type 이름이 달라도 제품 언어는 같아야 합니다. 이 예제에서 공통으로 사용하는 핵심 개념은 아래와 같습니다.

Concept Meaning
SignalSample 장비 또는 simulator가 특정 시점에 생성한 생체신호 sample
SignalPacket 여러 sample과 device metadata를 묶은 전송 단위
SignalFrame 3600Hz 이상 waveform과 multi-channel sensor를 담는 channelized frame
SignalFrameSet 같은 site/ward/window에서 함께 처리할 수 있는 여러 SignalFrame 묶음
SignalProcessingRun SignalFrameSet을 feature로 변환하는 실행 단위와 그 provenance
PreprocessedSignal Rust preprocessing 이후 Python model이 소비할 feature contract
ClinicalContext risk assessment에 필요한 synthetic clinical metadata
RiskAssessment Python risk usecase가 만든 위험도 결과
ReviewQueueItem TypeScript dashboard가 표시할 의료진 검토 단위
AlarmAuditEvent acknowledge/mute 같은 alarm workflow action 이력
RiskMonitor Go minimal operator가 감시하는 Kubernetes custom resource
RuntimePolicy RiskMonitor spec/status로 표현되는 model/runtime/threshold 정책
FirmwareReleaseManifest .elf/.hex/.bin/.map firmware release artifact를 source revision과 checksum으로 묶는 evidence index

1단계 초반에는 이 문서의 설명과 아래 JSON Schema가 contract source of truth입니다. 문서는 사람이 읽는 의도와 경계를 설명하고, schema는 app/package 사이 payload shape를 machine-readable contract로 고정합니다.

contracts/hospital-at-home/
  signal-frame.v1.schema.json
  signal-frame-set.v1.schema.json
  signal-packet.v1.schema.json
  preprocessed-signal.v1.schema.json
  risk-assessment.v1.schema.json
  review-queue-item.v1.schema.json
  monitoring-snapshot.v1.schema.json
  alarm-audit-event.v1.schema.json
  runtime-policy.v1.schema.json
  firmware-release-manifest.v1.schema.json

Schema 파일의 examples, fixtures/signals/ CSV, manifests/risk-monitor.demo.jsonmake contract/test로 검증합니다. 문서의 JSON 예시는 schema example과 일치해야 합니다.

각 언어 테스트는 최소 하나 이상의 fixture 또는 schema를 참조해야 합니다.

Area Contract check
C/Firmware SignalPacket/SignalFrame equivalent fixture와 packet build test
Rust SignalPacket/SignalFrameSet/window fixture -> PreprocessedSignal
Python OpenAPI/JSON schema compatible request/response
TypeScript ReviewQueueItem fixture -> view model mapper
Go RiskMonitor manifest fixture -> Go type/reconcile test
Firmware release FirmwareReleaseManifest schema -> .elf/.hex/.bin/.map release evidence manifest

1-3. Protocol은 adapter다

MQTT, HTTP, file fixture, Kubernetes API는 domain이 아닙니다. Domain과 usecase는 protocol을 몰라야 하고, adapter가 protocol payload를 domain input으로 변환합니다.

MQTT payload / HTTP body / CSV fixture
  -> adapter
  -> domain contract
  -> usecase

1-4. PHI를 포함하지 않는다

dev-guide fixture와 demo message에는 실제 환자 PHI를 포함하지 않습니다. patientId 대신 synthetic subjectId 또는 demoSubjectId를 사용합니다. 실제 제품에서는 PHI boundary, audit, encryption, retention policy를 별도 설계해야 합니다.

2. Signal Packet v1

SignalPacket은 1차 demo와 HTTP smoke test를 위한 row-oriented JSON 입력입니다. 실제 PCB 장비에서 PPG, EMG, motion, impedance 같은 channel이 초당 수천 point 이상으로 들어오는 경우에는 JSON row array를 source of truth로 삼지 않습니다. 이때는 SignalFrame을 사용해 channel별 sampling rate, sample count, encoding, payload reference를 분리하고, raw waveform은 binary segment store나 equivalent raw segment boundary에 둡니다.

정리하면 1차 경계는 아래와 같습니다.

Contract Use
SignalPacket local demo, HTTP contract test, small fixture
SignalFrame high-frequency waveform, multi-channel frame, binary segment reference
SignalFrameSet ward/gateway가 같은 processing window로 묶은 multi-subject/multi-device input
PreprocessedSignal Rust preprocessing output consumed by Python risk workflow

Redis는 최신 상태, short-lived stream, cache에는 사용할 수 있지만 raw waveform source of truth로 두지 않습니다. MQTT를 붙이면 MQTT inbound adapter가 SignalPacket, SignalFrame, 또는 SignalFrameSet domain input으로 변환하고, Rust preprocessing은 이 입력을 feature로 바꿉니다.

2-0. Grouped signal processing

여러 bed/device에서 같은 시간 창의 신호가 들어오는 경우 domain contract에는 Batch라는 이름을 쓰지 않습니다. batch size는 app runtime이 처리량과 지연시간을 조절하기 위한 실행 정책이고, clinical/domain contract가 아닙니다.

이 예제에서는 여러 frame을 함께 처리하는 입력을 SignalFrameSet으로 부릅니다.

SignalFrameSet
  signalSetId
  siteId
  wardId
  windowStartMs
  windowEndMs
  source
  frames[]

SignalFrameSet은 하나의 환자만 의미하지 않습니다. 같은 ward/gateway/window에서 묶을 수 있는 여러 SignalFrame을 담습니다. 단건 처리는 크기 1인 SignalFrameSet으로 표현할 수 있습니다. App runtime은 내부적으로 frame을 묶을 때 maxItems, maxLatencyMs, maxPointsPerSet 같은 SignalGroupingPolicy를 사용할 수 있지만, 이 값은 Rust/Python domain result의 clinical 의미를 바꾸지 않아야 합니다.

SignalFrame의 channel payload는 두 경로를 갖습니다.

Payload Use
payload fixture, small demo, contract smoke
payloadRef high-rate waveform segment, product-like ingestion

payloadRef는 단순 문자열이 아니라 uri, binary encoding, byteLength, checksum, sampleOffset을 갖는 value object입니다. Python app과 Rust adapter는 이 metadata를 사용해 waveform 본문을 다시 읽을 수 있지만, core clinical workflow는 table 이름, object store 이름, Redis key 같은 storage detail을 알지 않습니다.

20 bed 이상을 처리하는 경로에서는 partial failure가 정상 흐름입니다. 한 frame의 quality가 낮거나 payload가 누락되어도 전체 set을 실패시키지 않고 item별 결과를 분리합니다.

SignalProcessingRunResult
  processingRunId
  signalSetId
  status: completed | completed_with_errors | failed
  succeededCount
  failedCount
  skippedCount
  items[]

Idempotency와 retry를 위해 최소한 아래 identity가 contract에 남아야 합니다.

Field Reason
signalSetId grouped processing input identity
frameId per-device/per-subject frame identity
deviceId hardware/source identity
demoSubjectId or subject binding PHI 없는 subject binding
sessionId device/bed/subject binding period
windowStartMs, windowEndMs event-time processing window
sequenceStart device-local ordering hint
payloadFingerprint duplicate, retry, and projection idempotency

2-1. SignalSample

SignalSample은 firmware/simulator가 생성하는 최소 sample입니다.

{
  "offsetMs": 0,
  "sequenceNumber": 128,
  "ppgRaw": 512,
  "ecgRaw": 23,
  "spo2Permille": 980,
  "heartRateBpm": 72,
  "motionMg": 12,
  "contactQuality": 98
}

필드 기준은 아래와 같습니다.

Field Type Required Meaning
offsetMs integer yes packet 기준 상대 시각
sequenceNumber integer yes device-local sequence number
ppgRaw integer no raw or lightly processed PPG sample
ecgRaw integer no raw or lightly processed ECG sample
spo2Permille integer no SpO2를 permille로 표현. 980은 98.0%
heartRateBpm integer no device-local heart rate estimate
motionMg integer no accelerometer 기반 motion magnitude
contactQuality integer no 0-100 contact quality

2-2. SignalPacket

SignalPacket은 HTTP ingestion 또는 후속 MQTT adapter로 전송되는 단위입니다.

{
  "schemaVersion": "signal.packet.v1",
  "siteId": "demo-site",
  "deviceId": "demo-device-001",
  "demoSubjectId": "demo-subject-001",
  "timestampMs": 1718600000000,
  "samplingRateHz": 50,
  "firmware": {
    "version": "0.1.0-dev",
    "hardwareRevision": "demo"
  },
  "quality": {
    "droppedSamples": 0,
    "sensorDisconnected": false,
    "clipped": false,
    "saturated": false,
    "excessiveMotion": false
  },
  "samples": [
    {
      "offsetMs": 0,
      "sequenceNumber": 128,
      "ppgRaw": 512,
      "ecgRaw": 23,
      "spo2Permille": 980,
      "heartRateBpm": 72,
      "motionMg": 12,
      "contactQuality": 98
    }
  ]
}

필수 metadata는 아래와 같습니다.

Field Meaning
schemaVersion payload schema version
siteId demo site 또는 병원 site scope
deviceId device identity
demoSubjectId PHI가 아닌 synthetic subject identity
timestampMs packet 기준 Unix epoch milliseconds
samplingRateHz sample rate
firmware.version firmware artifact version
firmware.hardwareRevision hardware revision 또는 demo
quality hardware/device 관점 quality flag
samples packet에 포함된 sample list

2-3. Ordering과 duplicate 기준

Device ingestion은 network 상태에 따라 중복이나 순서 변경이 발생할 수 있습니다. 1단계 demo에서는 아래 기준만 문서화하고, 구현은 duplicate detection과 missingness 반영까지만 목표로 합니다.

  • (deviceId, sequenceNumber)는 duplicate detection의 기본 key입니다.
  • timestampMs + offsetMs는 분석용 event time입니다.
  • missing sequence는 droppedSamples 또는 Rust preprocessing의 missingness로 반영합니다.
  • 같은 sequence가 중복 수신되면 ingestion adapter는 idempotent하게 처리합니다.

3. MQTT Contract

3-1. Topic naming

MQTT는 HTTP와 같은 app workflow를 호출하는 inbound adapter입니다. Topic은 site와 source scope를 드러내고, broker URL, credential, QoS, reconnect는 app runtime config가 소유합니다.

patient-risk/{site_id}/{device_id}/signal-packets/v1
patient-risk/{site_id}/{ward_id}/signal-frame-sets/v1
Topic Payload
patient-risk/{site_id}/{device_id}/signal-packets/v1 SignalPacket
patient-risk/{site_id}/{ward_id}/signal-frame-sets/v1 SignalFrameSet

3-2. QoS 기준

MQTT adapter를 추가할 때는 아래 기준을 둡니다. Local demo에서는 Mosquitto 같은 broker를 사용할 수 있지만, production broker 운영과 보안 hardening은 구현하지 않습니다.

Message QoS Reason
signal packet 1 최소 1회 전달. duplicate는 sequence로 처리
signal frame set 1 최소 1회 전달. duplicate는 signalSetId/payloadFingerprint로 처리
device status 1 상태 변경 누락 방지
transient debug event 0 유실 허용

Retained message는 device status에는 사용할 수 있지만 signal packet에는 사용하지 않습니다.

3-3. Adapter 책임

MQTT subscriber는 아래를 담당합니다.

  • topic에서 siteId, deviceId 추출
  • payload schema validation
  • topic scope와 payload scope 일치 확인
  • duplicate 처리
  • domain SignalPacket, SignalFrame, 또는 SignalFrameSet으로 변환
  • invalid payload audit event 생성

Domain과 usecase는 MQTT topic, QoS, broker URL을 알지 않습니다.

3-4. Security 기준

1단계 예제는 local demo 중심이므로 production security를 구현하지 않습니다. 그래도 경계는 문서화합니다.

  • broker URL과 credential은 app config 또는 secret으로 주입합니다.
  • deviceId만으로 device identity를 신뢰하지 않습니다.
  • MQTT topic의 siteId/deviceId와 payload의 scope를 비교합니다.
  • production에서는 TLS와 device credential rotation이 필요합니다.
  • local demo에서는 plain broker를 허용합니다.

4. Preprocessed Signal v1

4-1. Rust preprocessing output

Rust는 SignalPacket, SignalFrame, 또는 SignalFrameSet을 받아 Python risk model이 소비할 feature를 만듭니다. 단건 API가 필요한 경우에도 domain에서는 크기 1인 SignalFrameSet으로 볼 수 있어야 합니다. Rust/PyO3 경계는 한 frame씩 반복 호출하는 구조보다 set 단위 호출을 우선합니다.

{
  "schemaVersion": "preprocessed.signal.v1",
  "deviceId": "demo-device-001",
  "demoSubjectId": "demo-subject-001",
  "preprocessingVersion": "rust-signal-preprocessor-0.1.0",
  "windowStartMs": 1718600000000,
  "windowEndMs": 1718600030000,
  "sampleCount": 1500,
  "missingRatio": 0.01,
  "artifactScore": 0.12,
  "signalQuality": "good",
  "features": {
    "heartRateMeanBpm": 78.5,
    "heartRateTrendBpmPerMin": 2.1,
    "spo2MinPermille": 945,
    "spo2DropCount": 2,
    "motionBurdenRatio": 0.08,
    "baselineDeviationScore": 0.18
  }
}

Multi-frame processing 결과는 전체 실행 결과와 item 결과를 분리합니다.

{
  "schemaVersion": "signal.processing-run-result.v1",
  "processingRunId": "processing-run-demo-001",
  "signalSetId": "signal-set-demo-001",
  "status": "completed_with_errors",
  "succeededCount": 18,
  "failedCount": 1,
  "skippedCount": 1,
  "items": [
    {
      "frameId": "frame-demo-001",
      "deviceId": "demo-device-001",
      "demoSubjectId": "demo-subject-001",
      "status": "succeeded",
      "preprocessedSignal": {
        "schemaVersion": "preprocessed.signal.v1",
        "deviceId": "demo-device-001",
        "demoSubjectId": "demo-subject-001",
        "preprocessingVersion": "rust-signal-preprocessor-0.1.0",
        "windowStartMs": 1718600000000,
        "windowEndMs": 1718600030000,
        "sampleCount": 1500,
        "missingRatio": 0.01,
        "artifactScore": 0.12,
        "signalQuality": "good",
        "features": {
          "heartRateMeanBpm": 78.5,
          "heartRateTrendBpmPerMin": 2.1,
          "spo2MinPermille": 945,
          "spo2DropCount": 2,
          "motionBurdenRatio": 0.08,
          "baselineDeviationScore": 0.18
        }
      }
    }
  ]
}

4-2. Rust와 firmware quality 차이

Firmware quality는 hardware integrity를 표현합니다. Rust quality는 분석 가능한 signal인지 표현합니다. 둘 다 필요하고 서로 대체하지 않습니다.

Layer Example
Firmware quality clipped, saturated, sensor disconnected, dropped samples
Rust quality artifact score, missing ratio, baseline deviation, physiological plausibility

5. Risk Assessment v1

5-1. Python risk output

Python risk usecase는 preprocessing 결과, clinical context, model metadata, risk policy를 조합해 RiskAssessment를 반환합니다.

{
  "schemaVersion": "risk.assessment.v1",
  "assessmentId": "assessment-demo-001",
  "demoSubjectId": "demo-subject-001",
  "riskLevel": "high",
  "riskScore": 0.82,
  "riskHorizon": "24h",
  "reviewRequired": true,
  "recommendedAction": "clinician-review",
  "evidence": [
    {
      "code": "spo2-drop",
      "label": "Repeated SpO2 drop",
      "severity": "high"
    },
    {
      "code": "tachycardia-trend",
      "label": "Rising heart rate trend",
      "severity": "moderate"
    }
  ],
  "dataQuality": {
    "signalQuality": "good",
    "missingRatio": 0.01,
    "artifactScore": 0.12
  },
  "auditContext": {
    "modelVersion": "demo-risk-model-0.1.0",
    "policyVersion": "demo-policy-0.1.0",
    "preprocessingVersion": "0.1.0-dev",
    "requestId": "req-demo-001"
  }
}

5-2. Risk level 기준

초기 risk level은 demo policy입니다. 실제 임상 claim이 아닙니다.

Level Meaning
low routine monitoring
moderate trend review may be useful
high clinician review required
urgent urgent review workflow example

6. Review Queue v1

6-1. TypeScript view input

TypeScript web app은 RiskAssessment를 view model로 변환해 review queue를 표시합니다.

{
  "reviewItemId": "assessment-demo-001",
  "demoSubjectId": "demo-subject-001",
  "priority": "high",
  "riskLabel": "High",
  "riskScoreText": "0.82",
  "primaryEvidence": [
    "Repeated SpO2 drop",
    "Rising heart rate trend"
  ],
  "dataQualityLabel": "Good signal quality",
  "modelVersion": "demo-risk-model-0.1.0",
  "lastUpdatedMs": 1718600031000,
  "stale": false
}

Frontend는 risk rule을 재계산하지 않습니다. API response를 읽기 쉬운 view model로 변환하고, loading/error/empty/stale state를 표현합니다.

7. Monitoring Snapshot v1

7-1. TypeScript runtime input

TypeScript web app은 review queue만 표시하는 화면에서 끝나지 않습니다. Python API는 UI가 waveform, numeric, risk, alarm을 한 번에 구성할 수 있는 MonitoringSnapshot을 제공해야 합니다. 이 snapshot은 frontend가 risk rule이나 alarm severity를 재계산하지 않도록 만든 runtime contract입니다.

{
  "schemaVersion": "monitoring.snapshot.v1",
  "demoSubjectId": "demo-subject-001",
  "timestampMs": 1718600031000,
  "waveforms": [
    {
      "code": "ppg",
      "label": "PPG",
      "sampleRateHz": 50,
      "unit": "raw",
      "windowStartMs": 1718600000000,
      "samples": [512, 534, 548, 531]
    }
  ],
  "numerics": [
    {
      "code": "heart_rate",
      "label": "Heart rate",
      "value": 92,
      "unit": "bpm",
      "status": "watch"
    },
    {
      "code": "spo2",
      "label": "SpO2",
      "value": 94.5,
      "unit": "%",
      "status": "warning"
    }
  ],
  "risk": {
    "assessmentId": "assessment-demo-001",
    "riskLevel": "high",
    "riskScore": 0.82,
    "riskHorizon": "24h"
  },
  "alarm": {
    "alarmId": "alarm-demo-001",
    "severity": "warning",
    "code": "spo2_drop_risk",
    "message": "SpO2 trend requires clinician review",
    "audible": false,
    "acknowledged": false,
    "muted": false
  },
  "dataQuality": {
    "signalQuality": "good",
    "missingRatio": 0.01,
    "artifactScore": 0.12
  },
  "auditContext": {
    "modelVersion": "demo-risk-model-0.1.0",
    "policyVersion": "demo-policy-0.1.0",
    "preprocessingVersion": "0.1.0-dev",
    "requestId": "req-demo-001"
  }
}

7-2. Waveform과 numeric 기준

Waveform은 chart library의 prop shape가 아니라 product contract입니다. Backend는 어떤 생체신호인지, sample rate가 무엇인지, 어떤 window를 보고 있는지 전달하고, frontend는 이를 viewport와 rendering state로 변환합니다.

Numeric measurement도 단순 number가 아닙니다. status는 정상/관찰/경고/위급 상태를 표현하며, frontend는 이 값을 기준으로 시각적 강조를 바꿉니다. Frontend가 threshold를 다시 계산하지 않습니다.

Field Responsibility
waveforms[].code waveform identity
waveforms[].sampleRateHz rendering and time-axis scale
waveforms[].samples small demo window data
numerics[].code measurement identity
numerics[].value display value
numerics[].status visual state chosen by backend/domain policy

7-3. Alarm 기준

Alarm은 risk level과 별도 contract로 둡니다. Risk가 높아도 알람이 항상 울리는 것은 아니며, 알람은 acknowledge, mute, snooze 같은 workflow state를 갖습니다.

Field Meaning
severity normal, watch, warning, critical
audible frontend가 sound request를 표시해야 하는지
acknowledged 사용자가 확인했는지
muted 현재 sound가 mute 상태인지
code alarm policy identifier

Browser sound 재생 가능 여부는 frontend runtime state입니다. Backend는 audible intent를 결정하고, frontend는 autoplay policy와 사용자 설정에 따라 실제 sound state를 관리합니다.

Alarm action은 snapshot을 다시 계산하지 않고 현재 alarm state를 전이합니다. POST /alarms/{alarm_id}/ackacknowledged=true, audible=false로 갱신합니다. POST /alarms/{alarm_id}/mutemuted=true, audible=false로 갱신하며 기존 acknowledged state를 보존합니다. 두 endpoint 모두 갱신된 MonitoringSnapshot을 반환합니다. GET /alarms/{alarm_id}/audit-events는 acknowledge/mute action이 만든 append-only audit event를 읽는 endpoint이며, 이전 alarm state와 현재 alarm state를 함께 반환합니다. 동일 action이 반복되어 alarm state가 바뀌지 않은 경우에도 사용자 시도는 audit event로 남기되, outcome=no-op으로 표시해 실제 상태 전이와 구분합니다.

8. RiskMonitor Runtime Policy v1

8-1. Minimal CRD contract

Go 예제는 RiskMonitor custom resource를 감시하는 minimal Kubernetes operator입니다. Production-grade operator가 아니라, spec/status와 reconcile loop를 보여주는 예제입니다. Machine-readable contract는 contracts/hospital-at-home/risk-monitor.v1.schema.jsoncontracts/hospital-at-home/risk-monitor-status.v1.schema.json에 둡니다.

최소 CRD shape는 아래와 같습니다.

apiVersion: samd.tirosh.ai/v1alpha1
kind: RiskMonitor
metadata:
  name: hospital-at-home-risk-monitor
  generation: 7
  annotations:
    samd.tirosh.ai/runtime-workload-name: patient-risk-python-api
spec:
  siteId: demo-site
  desiredModelVersion: demo-hospital-at-home-risk-24h
  desiredImageTag: 0.1.0-dev.42
  desiredPolicyVersion: demo-policy-0.1.0
  desiredPreprocessingVersion: rust-signal-preprocessor-0.1.0
  rolloutChannel: dev
  highRiskThreshold: 0.85
runtimeState:
  observedModelVersion: demo-hospital-at-home-risk-24h
  observedImageTag: 0.1.0-dev.41
  observedPolicyVersion: demo-policy-0.1.0
  observedPreprocessingVersion: rust-signal-preprocessor-0.0.9
  healthy: true

runtimeState는 실제 CRD spec에 들어갈 production desired state가 아니라, 이 sample app이 Kubernetes API server 없이 reconcile을 재현하기 위해 주입받는 observed state입니다. 실제 controller-runtime adapter에서는 runtimeState를 CRD에 쓰지 않고 runtime 조회 adapter가 observed state를 만듭니다. 현재 sample의 KubernetesDeploymentRuntimeStateProvidermetadata.annotations["samd.tirosh.ai/runtime-workload-name"]가 가리키는 Deployment를 읽어 annotation, first container image tag, Available condition을 RiskMonitorRuntimeState로 변환합니다. Controller는 이렇게 계산된 상태를 status.observedGeneration, status.driftDetected, status.conditions로 status subresource에 기록합니다.

Operator reconcile은 아래까지만 담당합니다.

  • RiskMonitor.spec validation
  • synthetic/current runtime state와 desired state 비교
  • ReconcileDecision 계산
  • status.observedGeneration, status.driftDetected, status.conditions 갱신

Operator가 담당하지 않는 것은 아래와 같습니다.

  • 실제 model server rollout
  • Argo CD/Application 조작
  • admission/conversion webhook
  • multi-cluster reconcile
  • production HA controller
{
  "schemaVersion": "runtime.policy.v1",
  "riskMonitorName": "hospital-at-home-demo",
  "siteId": "demo-site",
  "desired": {
    "modelVersion": "demo-risk-model-0.1.0",
    "preprocessingVersion": "0.1.0-dev",
    "policyVersion": "demo-policy-0.1.0",
    "rolloutChannel": "dev"
  },
  "current": {
    "modelVersion": "demo-risk-model-0.0.9",
    "preprocessingVersion": "0.1.0-dev",
    "policyVersion": "demo-policy-0.0.9",
    "healthy": true
  }
}

RiskMonitorStatus는 drift와 필요한 action을 표현합니다. conditions는 Kubernetes status summary로 사용하고, drifts는 field-level desired/observed/action detail로 둡니다.

{
  "phase": "Drifted",
  "driftDetected": true,
  "requeueRequired": true,
  "recommendedAction": "rollout-runtime",
  "drifts": [
    {
      "field": "desiredImageTag",
      "desired": "0.1.0-dev.42",
      "observed": "0.1.0-dev.41",
      "recommendedAction": "rollout-runtime"
    },
    {
      "field": "desiredPreprocessingVersion",
      "desired": "rust-signal-preprocessor-0.1.0",
      "observed": "rust-signal-preprocessor-0.0.9",
      "recommendedAction": "sync-runtime-policy"
    }
  ],
  "conditions": [
    {
      "type": "Ready",
      "status": "False",
      "reason": "RuntimeDrift"
    }
  ]
}

CD repo는 이 contract를 기준으로 environment별 desired runtime policy를 RiskMonitor manifest로 표현합니다. 1단계 source repo 예제는 CRD type, controller, sample manifest를 제공하고, CD repo는 이후 이 manifest를 app-of-apps에 포함할 수 있습니다.

  • desired modelVersion
  • desired preprocessingVersion
  • desired policyVersion
  • rollout channel
  • site id

Go package는 이 desired state와 current runtime state를 비교해 RiskMonitorStatus를 반환합니다.

manifests/risk-monitor.demo.json은 desired/current input fixture이고, manifests/risk-monitor-status.demo.json은 이 input에 대한 expected output fixture입니다. scripts/validate-contracts.py와 Go app test가 두 파일을 함께 검증합니다.

9. Fixture Contract

9-1. 위치

Fixture는 실제 PHI가 없는 synthetic 또는 public-derived sample만 포함합니다.

fixtures/signals/
  README.md
  manifest.json
  normal-resting.csv
  motion-artifact.csv
  sensor-dropout.csv
  low-spo2-trend.csv

9-2. CSV column

timestamp_ms,sequence_number,ppg_raw,ecg_raw,spo2_permille,heart_rate_bpm,motion_mg,contact_quality
1718600000000,1,512,23,980,72,12,98
1718600000020,2,534,41,980,72,13,98

9-3. Manifest

{
  "schemaVersion": "signal.fixture.manifest.v1",
  "samplingRateHz": 50,
  "scenarios": [
    {
      "id": "normal-resting",
      "file": "normal-resting.csv",
      "expectedQuality": "good",
      "expectedRiskHint": "low"
    },
    {
      "id": "motion-artifact",
      "file": "motion-artifact.csv",
      "expectedQuality": "poor",
      "expectedRiskHint": "review"
    }
  ]
}

10. API Boundary

10-1. Local HTTP ingestion

HTTP가 1차 local demo의 device ingestion 기본값입니다. MQTT adapter를 추가하더라도 아래 endpoint와 같은 ingestion usecase를 호출해야 합니다.

POST /signal-packets
POST /risk-assessments   # test/debug endpoint
GET  /monitoring-snapshot
GET  /review-queue
GET  /review-queue/{assessment_id}
POST /alarms/{alarm_id}/ack
POST /alarms/{alarm_id}/mute
GET  /alarms/{alarm_id}/audit-events
GET  /healthz
GET  /readyz

POST /signal-packets는 signal ingestion usecase를 호출합니다. 이후 MQTT adapter를 추가하더라도 HTTP와 MQTT가 서로 다른 business flow를 만들면 안 됩니다.

POST /risk-assessments는 preprocessed fixture나 직접 입력으로 risk rule을 검증하는 test/debug endpoint입니다. Web dashboard의 기본 데이터 경로는 GET /monitoring-snapshot이며, GET /review-queue는 clinician review backlog와 audit follow-up을 위한 보조 조회 경로입니다.

10-2. Health and readiness

GET /healthz는 process liveness만 표현합니다. Storage dependency가 실패해도 process가 살아 있으면 204를 반환합니다.

GET /readyz는 현재 선택된 storage backend가 요청을 처리할 준비가 되었는지 표현합니다. memory backend는 memory check 하나를 반환하고, postgres-redis backend는 PostgreSQL과 Redis check를 순서대로 반환합니다.

Readiness 실패 응답은 어느 check까지 통과했고 어느 dependency에서 실패했는지 드러내야 합니다.

{
  "status": "not-ready",
  "serviceName": "patient-risk-python-api",
  "storageBackend": "postgres-redis",
  "failedCheck": "redis",
  "failureType": "TimeoutError",
  "checks": [
    { "name": "postgres", "status": "ready", "durationMs": 3 },
    { "name": "redis", "status": "failed", "failureType": "TimeoutError", "durationMs": 1001 }
  ]
}

durationMs는 dependency별 latency를 비교하기 위한 adapter-level signal입니다. 구체적인 exception message는 response body에 포함하지 않습니다. Credential, host, network detail이 섞일 수 있으므로 adapter는 structured log에만 남기고, external contract에는 check name과 failure type만 노출합니다.

10-3. Error shape

API error는 machine-readable code와 request id를 포함합니다.

{
  "error": {
    "code": "invalid-signal-packet",
    "message": "Signal packet schema validation failed",
    "requestId": "req-demo-001"
  }
}

11. Validation Responsibility

11-1. 계층별 검증 책임

Contract 검증은 한 곳에 몰아넣지 않고, 각 boundary에서 필요한 만큼 수행합니다.

Layer Validation responsibility
C/Firmware sample range, sequence number, device quality flag, packet build
MQTT/HTTP ingestion adapter payload schema, topic/payload scope, duplicate key
Rust preprocessing window size, missingness, feature input validity
Python risk usecase clinical context, model/policy metadata, risk output shape
TypeScript dashboard API response shape, view model mapping, empty/error/stale state
Go reconcile core desired/current runtime policy shape and drift decision

11-2. Audit context

예제 수준에서도 아래 context는 결과와 log에 남깁니다.

  • requestId
  • assessmentId
  • deviceId
  • demoSubjectId
  • modelVersion
  • policyVersion
  • preprocessingVersion
  • firmware.version

Raw signal 전체와 PHI는 log에 남기지 않습니다.