Telemetry Cloud Contract for BionicScout
Last updated: 2026-03-27 13:40 ET Owner: BionicLoop app team (contract) + BionicScout backend team (handler implementation)
Purpose
This is the canonical backend handoff contract for implementing /v1/telemetry handlers in BionicScout.
Use this document as the single source for:
- envelope validation
- event schema validation by event_type + schema_version
- idempotent persistence
- routing to analytics/timeline storage and CloudWatch log streams
For broader roadmap context, see:
- Docs/Planning/ExecutionPlan.md (Workstream J)
- Docs/Planning/TelemetryCloudIntegrationPlan.md
Phase 1 Contract Lock Baseline (Frozen)
The following is locked for schema_version = 1.0.0 as of 2026-03-09:
- Envelope identity/correlation fields in this document are mandatory for app-side emitters.
- Event-family payload minimums in this document are the canonical contract for backend validators.
- loop.step.executed must carry execution semantics via step_executed_at and applied clinical config fields (applied_target_mgdl, applied_meal_upfront_percent, applied_tmax_minutes) when available.
- Critical UI telemetry naming is locked to snake_case screen_id, element_id, and reason codes.
- New event types or payload field removals require explicit contract version increment and migration notes.
Scope and Auth
- Endpoint:
POST /v1/telemetry - Auth: JWT Bearer token
- Required scope:
bionicscout.dev.api/telemetry.ingest - Reject unauthorized/forbidden requests before payload parsing.
Canonical Envelope (Required)
All events must include these fields:
{
"event_type": "loop.step.executed",
"schema_version": "1.0.0",
"subject_id": "SUBJECT-001",
"auth_user_sub": "cognito-user-sub",
"created_at": "2026-02-21T21:10:00.000Z",
"event_id": "uuid",
"session_id": "uuid",
"app_version": "1.0",
"build_number": "1234",
"app_env": "dev",
"payload": {}
}
Required envelope rules:
- event_type: non-empty string
- schema_version: currently 1.0.0
- subject_id: non-empty string (UNSET allowed in development)
- auth_user_sub: Cognito sub string from authenticated identity; UNSET allowed only for unauthenticated continuity mode
- created_at: ISO-8601 UTC timestamp
- event_id: UUID string (idempotency key component)
- session_id: UUID string
- app_env: dev|staging|prod
- payload: JSON object
Identity trust rule:
- Backend must treat JWT-authenticated sub as source of truth.
- If client payload auth_user_sub is present and does not match JWT sub, reject or quarantine by policy.
- Persist server-derived auth_user_sub for query/indexing regardless of client payload value.
Idempotency and Persistence Contract
Primary idempotency key:
- (subject_id, event_id)
Handler behavior:
1. If key is new: persist event, return 202 accepted.
2. If key already exists: do not create duplicate; return 202 accepted with existing ingest metadata.
Persistence expectation:
- Store raw envelope for forensic replay.
- Store normalized event row for querying/timelines.
- Include ingest metadata: ingest_id, received_at, ingest_version, validation status.
J5 Backend Implementation Packet (Schema + Idempotency)
This section is the implementation target for Workstream J5 in BionicScout.
Required validation pipeline order
- JWT auth + scope gate (
bionicscout.dev.api/telemetry.ingest) - Envelope field/type validation
- Event schema validation by
(event_type, schema_version) - Idempotency evaluation on
(subject_id, event_id) - Persistence transaction:
- raw envelope write
- normalized timeline projection write
- optional fanout (
app.log.batch) - Response generation (
202 accepted,dedupedsemantics)
Canonical error body (backend)
{
"error": {
"code": "invalid_payload_schema",
"message": "payload failed schema validation",
"details": {
"field": "payload.units_requested",
"expected": "number",
"actual": "string"
}
}
}
Error code policy
invalid_envelope(400)unsupported_event_type(400)unsupported_schema_version(400)invalid_payload_schema(400)auth_sub_mismatch(400or403per security policy; must be deterministic)idempotency_conflict(409) for same(subject_id,event_id)with non-identical payload hashpersistence_failed(500)
Idempotency behavior contract
- First-seen key
(subject_id,event_id): - persist event
- return
202,deduped=false - Exact replay (same key, same payload hash):
- do not duplicate
- return
202,deduped=true, preserve originalingest_id - Key collision with different payload (same key, different hash):
- do not overwrite prior record
- return
409witherror.code = idempotency_conflict
app.log.batch fanout contract
- Fanout only after envelope+schema validation succeeds.
- Preserve correlation metadata in CloudWatch records:
subject_id,auth_user_sub,event_id,session_id,app_env,created_at- Enforce metadata allowlist for
entries[].metadatakeys. - Unknown metadata keys are dropped (not fatal) and counted in observability metrics.
Allowlisted integration-test metadata keys for app.log.batch.entries[].metadata:
test_run_idtest_session_modetest_upload_leveltest_session_started_at_utctest_session_expires_at_utctest_event_at_utctest_scenario_labeltester_nametest_stop_reason
J5 contract test matrix (backend must pass)
| ID | Scenario | Expected |
|---|---|---|
| CT-J5-001 | Valid envelope + valid payload | 202, deduped=false, persisted raw + normalized rows |
| CT-J5-002 | Replay exact same envelope | 202, deduped=true, same ingest_id, no duplicate rows |
| CT-J5-003 | Replay same (subject_id,event_id) with changed payload |
409, error.code=idempotency_conflict, prior record unchanged |
| CT-J5-004 | Missing required envelope field | 400, error.code=invalid_envelope |
| CT-J5-005 | Unknown event_type |
400, error.code=unsupported_event_type |
| CT-J5-006 | Unsupported schema_version |
400, error.code=unsupported_schema_version |
| CT-J5-007 | Payload type mismatch vs schema | 400, error.code=invalid_payload_schema |
| CT-J5-008 | Missing telemetry scope | 403 |
| CT-J5-009 | JWT sub != payload auth_user_sub |
deterministic reject/quarantine behavior per policy |
| CT-J5-010 | Simulated storage exception | 500, error.code=persistence_failed |
| CT-J5-011 | Valid app.log.batch with allowlisted metadata |
202, CloudWatch fanout written with correlation metadata |
| CT-J5-012 | app.log.batch with disallowed metadata keys |
202, keys stripped, validation metric incremented |
Event Families and Required Payload Fields
App Source Mapping Matrix (Emitter -> Event Family)
| App component | Event types emitted |
|---|---|
BionicLoop/App/BionicLoopApp.swift |
app.lifecycle.*, alert.notification.tapped |
BionicLoop/App/ContentView.swift |
auth.session.* |
BionicLoop/Runtime/LoopRuntimeEngine.swift |
loop.session.*, loop.step.*, loop.command.*, alert.notification.scheduled, alert.notification.cleared |
BionicLoop/Features/CGM/G7ViewModel.swift |
cgm.reading.*, cgm.connection.changed, cgm.state.changed |
BionicLoop/Integrations/Pump/PumpStatusObserver.swift |
pump.connection.changed, pump.status.refreshed, pump.command.result |
BionicLoop/Integrations/Pump/AppPumpManagerDelegate.swift |
pump.pod.lifecycle |
BionicLoop/App/AuthSessionNetworking.swift (CloudLogUploadLogger, DeviceClockSyncMonitor) |
app.log.batch, lifecycle clock-check context fields |
1) App/Auth lifecycle
app.lifecycle.launchedapp.lifecycle.foregroundedapp.lifecycle.backgroundedauth.session.authenticatedauth.session.signed_outauth.session.restore_failed
Payload minimum:
- source or reason when available
- device_timezone_id (IANA timezone ID)
- device_utc_offset_seconds
- clock_check_result (ok|skewed|unavailable)
- clock_check_skew_seconds (when available)
- clock_check_rtt_ms (when available)
- clock_check_at_utc (when available)
- semantic guard: on NSSystemTimeZoneDidChange and UIApplication.significantTimeChangeNotification, app emits app.lifecycle.foregrounded with reason = timezone_or_time_changed and refreshed clock-check fields.
2) Loop/runtime/algorithm
loop.session.armedloop.session.resetloop.step.executedloop.step.skippedloop.command.requestedloop.command.appliedloop.command.blockedalgorithm.session.snapshotalgorithm.step.snapshot
loop.step.* payload minimum:
- expected_step, executed_step, step_executed_at, wake_cause, skip_reason, recommendation_applied
- optional snapshots: algorithm_input_snapshot, algorithm_output_snapshot
- implemented skip reasons now include mealSlotConflict for meal-submit slot conflicts where another step advanced before coordinator acceptance; cloud reconstruction should treat this as explicit blocked meal intent rather than a generic cadence-only skip.
loop.command.* payload minimum:
- step, command_type, units_requested, units_delivered, apply_result, reason
- implemented app payload also carries command_outcome (applied, blocked, uncertain) so cloud reconstruction can distinguish ambiguous delivery from ordinary blocked command outcomes.
- semantic guard: loop.command.blocked is emitted only when a loop recommendation/command existed for the step but could not be applied; cadence-only skips (stepNotDue, missingFreshGlucose without command) are not command-block events.
algorithm.session.snapshot payload minimum:
- subject_id, session_id, event_id, session_started_at_utc, timezone_id, app_version, build_number
- algorithm_name, algorithm_build_id?, pump_id?
- subject_weight_kg, real_t0_seconds, step_interval_seconds
- set_point_nominal_mgdl, set_point_mgdl, tmax_minutes, meal_upfront_percent
- constants block for algorithm/session constants
algorithm.step.snapshot payload minimum:
- subject_id, session_id, event_id, executed_step, step_executed_at, timezone_id, app_version, build_number
- bp full matrix row object
- bp_log typed object for currently implemented families:
- a
- i
- g
- tildeI
- tildeG
- adaptation (AD + G24h0/G24hWt)
- pa
- p
- b
- c
- d
- s
- contract note: algorithm_build_id and pump_id remain nullable/open app-side fields and must not be inferred/fabricated downstream.
3) CGM
cgm.reading.processedcgm.reading.maskedcgm.connection.changedcgm.state.changed
Payload minimum:
- reading_timestamp
- reliable
- has_sensor
- value_mgdl (when reliable)
- trend (when available)
- mask_reason (when masked)
- source_state
- semantic guard: cgm.state.changed must reflect post-refresh/current callback state (not stale pre-refresh cached state).
- semantic guard: cgm.connection.changed status_text must be derived from refreshed/current lifecycle state (no stale pre-refresh status labels).
4) Pump
pump.connection.changedpump.status.refreshedpump.command.resultpump.pod.lifecycle
Payload minimum:
- delivery_state
- reservoir_level_u or reservoir_units
- units_requested, units_delivered (command/result)
- pod_active
- error_code (when applicable)
- schema guard: pump.command.result uses pump/result payload fields only (units_requested, units_delivered, delivery_state, result metadata) and is not reused for loop-command-block payload shape.
- emission guard: pump.command.result is emitted only on command-result state/progress deltas (request_step, units_requested, units_delivered, or delivery_state change), not on repeated unchanged refresh snapshots.
5) Alerts
alert.issuedalert.retractedalert.acknowledgedalert.notification.scheduledalert.notification.clearedalert.notification.tapped
Payload minimum:
- alert_code, severity, source, dedupe_key
- requires_acknowledge, ack_state
- message (issued paths)
- title, recommended_action (issued/updated/cleared parity payload)
- current app-level actionable alerts include ALERT-AUTH-LOGIN-REQUIRED, ALERT-SUBJECT-ID-CONFLICT, and ALERT-APP-CLOCK-SKEW.
- lifecycle edge rule: alert.notification.cleared is emitted only when an active alert is actually removed; repeated retract calls on absent dedupe keys are intentionally no-op for notification-cleared telemetry
- lifecycle side-effect rule: OS notification clear is attempted on every retract call (idempotent clear), even when no active in-memory alert remains, to handle async schedule/retract races.
6) Critical UI interactions (schema locked; implementation in progress)
ui.critical.tapui.critical.submitui.critical.cancelui.critical.blockedui.critical.state_viewed
Payload minimum:
- screen_id, element_id, action, result
- reason for blocked/failed
- flow_id for multi-step flows
- contract note: Home manual BG tap uses canonical element_id = home.bg_button
- contract note: Home manual BG submit is runtime-authoritative only
- contract note: meal ui.critical.submit is a correlated lifecycle keyed by flow_id: submitted on user confirm, followed by runtime-emitted accepted / success / blocked / uncertain / resolved transitions; resolved may arrive after relaunch reconciliation or session reset using the persisted meal flow ID + target step
- contract note: participant target-change approval flow uses stable screen_id = settings and element_id values under settings.target_change.*; payload details include current_target_mgdl, requested_target_mgdl, target_range_profile, approver_name, and approval_time_utc when submitted
Locked reason-code baseline for currently instrumented critical flows:
- meal availability/actions: loop_off, missing_profile, no_pump, awaiting_first_step, too_early, too_late, pump_delivering, missing_fresh_glucose, signal_loss, request_in_progress, unavailable
- manual BG/actions: loop_off, awaiting_first_step, missing_config_or_pump, rejected_value
- clinical settings save flow: locked, invalid_profile, no_changes, subject_id_conflict, auth_required, claim_retryable
- participant target-change approval flow: target_outside_allowed_range, no_change, missing_approver_name, missing_approval_time
- modal/system action: app_background
7) Telemetry transport health
telemetry.outbox.enqueuedtelemetry.flush.startedtelemetry.flush.succeededtelemetry.flush.failedtelemetry.event.dropped
Payload minimum:
- queue_depth, retry_count, error_class/error_code, dropped_count, reason
- flush guardrail: if a flush request arrives while a flush is active, reporter records a deferred follow-up flush request and executes it immediately after the active pass to avoid stranded queue entries.
8) Structured app logs
app.log.batch
Payload minimum:
- threshold
- source
- entries[] where each entry has:
- timestamp, level, subsystem, category, messageTemplate, metadata
Current app sources for app.log.batch:
- source = bionicloop.app (structured app logger)
- source = bionicloop.integration_test_session (integration test session markers)
- source = bionicloop.algo2015.bp_log (Algo2015 native diagnostics tailer)
- source = bionicloop.algo2015.bp_matrix (Algo2015 BP matrix diagnostics tailer)
Current Algo2015 metadata keys in entries[].metadata:
- bp_log_file
- line_prefix
- step_hint (when parseable from STEP=, *OUT: L_BP=, or prefix-record tokens)
- optional upload-cap summary keys:
- dropped_line_count
- retained_line_count
- upload_cap
Current integration-test metadata keys in entries[].metadata:
- test_run_id
- test_session_mode
- test_upload_level
- test_session_started_at_utc
- test_session_expires_at_utc
- test_event_at_utc
- optional:
- test_scenario_label
- tester_name
- test_stop_reason
Home Screen Reconstruction Contract (Backend Read Model)
This section defines the minimum telemetry requirements for a backend service/app to reconstruct BionicLoop Home in near-real time.
Required event families (must ingest + query)
- CGM stream
cgm.reading.processedcgm.reading.maskedcgm.connection.changed-
cgm.state.changed -
Loop runtime stream
loop.session.armedloop.session.resetloop.step.executed-
loop.step.skipped -
Command and delivery stream
loop.command.requestedloop.command.appliedloop.command.blocked-
pump.command.result -
Pump status/lifecycle stream
pump.status.refreshedpump.connection.changed-
pump.pod.lifecycle -
Alert lifecycle stream
alert.issuedalert.retractedalert.acknowledged
Home UI element -> telemetry dependencies
- Top CGM card (value mask behavior + stale handling)
- Primary source:
cgm.reading.processed(value_mgdl,reading_timestamp) - Mask source:
cgm.reading.masked(mask_reason,latest_timestamp) -
Status context:
cgm.state.changed,cgm.connection.changed -
Loop status tile (Ready/Active/Aging/No CGM/No Pod/Stale)
loop.session.armed/loop.session.resetloop.step.executed/loop.step.skippedwith:expected_step,executed_step,skip_reason,used_unavailable_pump_status-
pump.status.refreshed(delivery_state,has_active_pod,has_established_session) -
Pod card (reservoir text/fill/tone)
pump.status.refreshed(reservoir_level_u,delivery_state,has_active_pod)pump.pod.lifecyclefor replacement/deactivation transitions-
pump.connection.changedfor disconnected state transitions -
Combined chart (CGM line + insulin bars)
- CGM line:
cgm.reading.processedsequence byreading_timestamp - Insulin bars (delivered and meal-highlight eligibility):
loop.step.executedwith step/output contextpump.command.resultandloop.command.appliedfor delivered/requested reconciliation-
keep zero-dose steps (for timeline parity)
-
Alert carousel/center
alert.issued,alert.retracted,alert.acknowledged- use
dedupe_keyas primary UI identity key
Minimum read-model projections required
home_latest_state_by_subject- one row/document per
(subject_id, auth_user_sub, app_env)with: - latest CGM display state
- latest pump status
- loop session status + latest cadence signals
-
active alerts
-
home_cgm_timeseries -
append-only (or idempotent-upsert by
(subject_id, reading_timestamp)) for chart windows (4/8/12/24h) -
home_insulin_step_timeseries - step-level timeline keyed by
(subject_id, executed_step or event_id): - requested/delivered units
- wake cause
- step timestamp
-
meal-associated marker when available from loop snapshots
-
home_alert_state - active alerts keyed by
dedupe_key - cleared history with
cleared_at
Field-level requirements for Home parity
- Identity/correlation
-
envelope fields mandatory:
subject_id,auth_user_sub,event_id,session_id,created_at,app_env -
Time semantics
-
all timeline rendering and freshness logic must use UTC-normalized timestamps from envelope/event payload
-
Idempotency
- dedupe event ingest by
(subject_id, event_id) - dedupe alert state by
dedupe_key
Known parity gaps (current app emitters)
-
App-side
ui.critical.*canonical control coverage is complete (J3); backend timeline projections should now ingest and preserve all canonicalelement_idpaths. -
Loop step payload includes
step_executed_at; backend should prefer it over envelopecreated_atfor execution-time semantics.
Acceptance criteria (Home reconstruction)
Backend implementation is considered complete when:
1. For a given subject_id, backend Home read model matches app Home state (CGM card, loop tile, pod tile, charts, active alerts) under normal operation and reconnect/error transitions.
2. Masked CGM states (--) are reproduced when cgm.reading.masked indicates unreliable/stale inputs.
3. Loop status transitions are reproducible from cadence events and skip reasons.
4. Insulin chart reflects reconciled delivered doses and zero-dose steps.
5. Alert lifecycle (issue/retract/acknowledge) remains consistent across app relaunch and backend replay.
Handler Routing Requirements (BionicScout)
Implement router by event_type with strict validation:
- Envelope validator
- validate required envelope fields and value formats
-
reject invalid with
400+ structured error body -
Event schema validator
- select schema by
(event_type, schema_version) -
reject unknown
event_typeor unsupportedschema_versionwith400 -
Idempotent write
-
upsert on
(subject_id, event_id) -
Storage fanout
- raw ingest table/store
- normalized timeline table/store
-
optional stream routing for analytics
-
app.log.batchfanout - send entries to CloudWatch log group partitioned by
app_env - preserve correlation metadata (
subject_id,session_id,event_id)
Response Contract
Success:
- HTTP 202 Accepted
- body includes:
- status: "accepted"
- ingest_id
- deduped: true|false
Client errors:
- HTTP 400 for validation/schema failures
- HTTP 401/403 for auth/scope failures
Server errors:
- HTTP 5xx; caller retries via app outbox policy
Observability for Backend Handlers
Emit backend metrics:
- ingest request count by event_type
- validation failure count by rule
- dedupe hit rate
- per-event latency
- persistence failures
Emit alarms: - sustained 5xx - DLQ growth - schema failure spikes
Redaction and Data Minimization
- Do not persist credentials/tokens in payload.
- Treat free-text fields as potentially sensitive; enforce allowlist for
app.log.batch.metadatakeys. - Keep raw envelopes access-controlled by role.
Acceptance Test Set for BionicScout (must pass)
- Accept valid event envelope and return
202. - Reject missing required envelope fields with
400. - Reject unsupported
schema_versionwith400. - Enforce scope gate (
bionicscout.dev.api/telemetry.ingest). - Idempotent replay of same
(subject_id, event_id)does not duplicate persisted records. - Validate each family sample payload and route to expected sink.
app.log.batchfanout sends each entry to CloudWatch with required correlation metadata.
Current App Implementation Notes (for backend sequencing)
Implemented app-side:
- required envelope fields + correlation fields
- persistent outbox with retry/backoff and permanent-failure classification
- runtime/CGM/pump/alert event emitters
- log-level policy + app.log.batch emission path
Remaining app-side expansion:
- optional additional Algo2015 normalization beyond current step_hint extraction
Backend should still implement full handler contract now so these new event families can land without API redesign.