Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ existing content with no warning. Either `docker compose down && rm -rf
pg_data/` before starting on the new schema, or accept the loss of synthetic
dev data in those three fields.

Issue #75 repeats it: migrations `20261016135918697` and `20261016140159775` `DROP COLUMN` `triage_sessions.resultRisk`/`resultDisplay` and `visits.riskLevelBefore`/`riskLevelAfter` with no backfill. On an old volume, old triages drop out of the patient's "Meus Dados" risk history and a pre-migration visit is left out of the ACS `visits.pull` and answered `SyncStatus.rejected` (`visita anterior à cifra do risco (#75)`, only that visit; the rest of the batch uploads) by `visits.sync`/`syncLegacy`/`syncDeferred`, until `docker compose down && rm -rf pg_data/`.

Rotating the MQTT passwords is enough on its own: `infra/docker/mosquitto/init.sh`
rewrites the broker's `passwordfile` on every boot. It used to only create it
when absent, so a changed password silently did nothing and the broker kept
Expand Down
7 changes: 7 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1655,3 +1655,10 @@ Relatório item a item de `spec/PRD_system.md` contra o código, com evidência
Contagem dos 64 IDs do PRD: 24 entregue, 27 parcial, 4 ausente, 9 externo (mais 57 linhas sem ID próprio, para SLOs, STRIDE, criptografia, acessibilidade, telemetria, métricas do §1.3 e módulos dos apps).
Os três P0 do backlog: colunas de risco em texto plano (INV-04), o estado "Atendido" que o paciente nunca vê (RF05) e o SMS real do OTP (RF01).
Backlog priorizado na seção final do relatório; conferência por `python3 scripts/qa/cobertura_prd.py --final`.

## Colunas de risco cifradas (#75) — 2026-10-16

- Entregue: `triage_sessions.resultRisk` e `visits.riskLevelBefore`/`riskLevelAfter` cifrados em AES-256-GCM na borda ORM, com codec de largura fixa (o tamanho do ciphertext não revela a classe); `triage_sessions.resultDisplay` removida (derivada). Migrações `20261016135918697` (triagem) e `20261016140159775` (visitas).
- Decisão: `alerts.riskLevel` permanece legível (só `red` é gravado; chave dos filtros SQL da INV-03). Registrada no PRD (INV-04) e em `spec/lgpd_data_audit.md` §2.3; amarrada pelo teste `INV-04 (#75)` de `test/unit/red_alert_discard_paths_test.dart`.
- Provas: `test/unit/encrypted_risk_level_test.dart`; testes `#75` em `test/integration/health_data_encryption_test.dart` (linha bruta por `row_to_json` sem risco legível, ida e volta, `After` nulo e conclusão, linha antiga fora do `pull` e `rejected` só para ela no `sync`) e em `test/integration/patient_data_overview_endpoint_test.dart`.
- Aberto: migrações sem backfill (volume antigo precisa ser recriado); rotação de chave continua inexistente (`keyVersion` só marcador), como nas demais colunas cifradas.
2 changes: 1 addition & 1 deletion backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ The seed runs in **four** steps. `development.sql` is plain SQL over `psql`, so

Each encrypted column stores a `keyVersion` next to the ciphertext, but **nothing reads it back**: `HealthDataCipher.decrypt` always uses the one key the process was configured with. The column is a marker for a future rewrite routine; until that routine exists, changing `HEALTH_DATA_ENCRYPTION_KEY` makes every previously encrypted row unreadable (GCM authentication failure). Real key rotation is a future production improvement, not an MVP requirement.

The three clinical columns are encrypted at the ORM boundary only: `OrmPatientDirectoryStore`, `OrmTriageSessionStore` and `OrmVisitStore` take a `HealthDataCipher` and translate to/from the plaintext types (`PatientDirectoryEntry`, `TriageSessionRecord`, `VisitRecord`) that `application/` works with. Keep it that way — a service that decides whether to encrypt is the failure mode this design exists to prevent.
The clinical columns are encrypted at the ORM boundary only (since #75 also the risk classifications `triage_sessions.resultRiskEncrypted` and `visits.riskLevelBefore/AfterEncrypted`, through the fixed-width codec `infrastructure/crypto/encrypted_risk_level.dart`; `alerts.riskLevel` stays plaintext by a recorded decision — see `spec/lgpd_data_audit.md` §2.3): `OrmPatientDirectoryStore`, `OrmTriageSessionStore` and `OrmVisitStore` take a `HealthDataCipher` and translate to/from the plaintext types (`PatientDirectoryEntry`, `TriageSessionRecord`, `VisitRecord`) that `application/` works with. Keep it that way — a service that decides whether to encrypt is the failure mode this design exists to prevent.


### Backend (`backend/`) — Serverpod workspace
Expand Down
52 changes: 27 additions & 25 deletions backend/sinalacs_client/lib/src/protocol/triage_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,31 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes

import 'package:serverpod_client/serverpod_client.dart' as _i1;
import 'enums/risk_level.dart' as _i2;

/// Sessão de triagem estruturada. resultRisk é produzido pelo TriageEngine
/// Sessão de triagem estruturada. O risco é produzido pelo TriageEngine
/// de forma determinística e não é editável (INV-02).
abstract class TriageSession implements _i1.SerializableModel {
TriageSession._({
this.id,
required this.patientId,
String? answersEncrypted,
int? answersKeyVersion,
required this.resultRisk,
required this.resultDisplay,
String? resultRiskEncrypted,
int? resultRiskKeyVersion,
required this.createdAt,
required this.deviceId,
}) : answersEncrypted = answersEncrypted ?? '',
answersKeyVersion = answersKeyVersion ?? 1;
answersKeyVersion = answersKeyVersion ?? 1,
resultRiskEncrypted = resultRiskEncrypted ?? '',
resultRiskKeyVersion = resultRiskKeyVersion ?? 1;

factory TriageSession({
_i1.UuidValue? id,
required _i1.UuidValue patientId,
String? answersEncrypted,
int? answersKeyVersion,
required _i2.RiskLevel resultRisk,
required String resultDisplay,
String? resultRiskEncrypted,
int? resultRiskKeyVersion,
required DateTime createdAt,
required String deviceId,
}) = _TriageSessionImpl;
Expand All @@ -50,10 +51,8 @@ abstract class TriageSession implements _i1.SerializableModel {
),
answersEncrypted: jsonSerialization['answersEncrypted'] as String?,
answersKeyVersion: jsonSerialization['answersKeyVersion'] as int?,
resultRisk: _i2.RiskLevel.fromJson(
(jsonSerialization['resultRisk'] as String),
),
resultDisplay: jsonSerialization['resultDisplay'] as String,
resultRiskEncrypted: jsonSerialization['resultRiskEncrypted'] as String?,
resultRiskKeyVersion: jsonSerialization['resultRiskKeyVersion'] as int?,
createdAt: _i1.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
Expand All @@ -73,9 +72,12 @@ abstract class TriageSession implements _i1.SerializableModel {

int answersKeyVersion;

_i2.RiskLevel resultRisk;
/// Classificação do TriageEngine, cifrada (INV-04, #75): nome do RiskLevel
/// com largura fixa, ver `encrypted_risk_level.dart`. O rótulo de tela
/// (`resultDisplay`) deixou de ser persistido: era derivado do risco.
String resultRiskEncrypted;

String resultDisplay;
int resultRiskKeyVersion;

DateTime createdAt;

Expand All @@ -89,8 +91,8 @@ abstract class TriageSession implements _i1.SerializableModel {
_i1.UuidValue? patientId,
String? answersEncrypted,
int? answersKeyVersion,
_i2.RiskLevel? resultRisk,
String? resultDisplay,
String? resultRiskEncrypted,
int? resultRiskKeyVersion,
DateTime? createdAt,
String? deviceId,
});
Expand All @@ -102,8 +104,8 @@ abstract class TriageSession implements _i1.SerializableModel {
'patientId': patientId.toJson(),
'answersEncrypted': answersEncrypted,
'answersKeyVersion': answersKeyVersion,
'resultRisk': resultRisk.toJson(),
'resultDisplay': resultDisplay,
'resultRiskEncrypted': resultRiskEncrypted,
'resultRiskKeyVersion': resultRiskKeyVersion,
'createdAt': createdAt.toJson(),
'deviceId': deviceId,
};
Expand All @@ -123,17 +125,17 @@ class _TriageSessionImpl extends TriageSession {
required _i1.UuidValue patientId,
String? answersEncrypted,
int? answersKeyVersion,
required _i2.RiskLevel resultRisk,
required String resultDisplay,
String? resultRiskEncrypted,
int? resultRiskKeyVersion,
required DateTime createdAt,
required String deviceId,
}) : super._(
id: id,
patientId: patientId,
answersEncrypted: answersEncrypted,
answersKeyVersion: answersKeyVersion,
resultRisk: resultRisk,
resultDisplay: resultDisplay,
resultRiskEncrypted: resultRiskEncrypted,
resultRiskKeyVersion: resultRiskKeyVersion,
createdAt: createdAt,
deviceId: deviceId,
);
Expand All @@ -147,8 +149,8 @@ class _TriageSessionImpl extends TriageSession {
_i1.UuidValue? patientId,
String? answersEncrypted,
int? answersKeyVersion,
_i2.RiskLevel? resultRisk,
String? resultDisplay,
String? resultRiskEncrypted,
int? resultRiskKeyVersion,
DateTime? createdAt,
String? deviceId,
}) {
Expand All @@ -157,8 +159,8 @@ class _TriageSessionImpl extends TriageSession {
patientId: patientId ?? this.patientId,
answersEncrypted: answersEncrypted ?? this.answersEncrypted,
answersKeyVersion: answersKeyVersion ?? this.answersKeyVersion,
resultRisk: resultRisk ?? this.resultRisk,
resultDisplay: resultDisplay ?? this.resultDisplay,
resultRiskEncrypted: resultRiskEncrypted ?? this.resultRiskEncrypted,
resultRiskKeyVersion: resultRiskKeyVersion ?? this.resultRiskKeyVersion,
createdAt: createdAt ?? this.createdAt,
deviceId: deviceId ?? this.deviceId,
);
Expand Down
100 changes: 64 additions & 36 deletions backend/sinalacs_client/lib/src/protocol/visit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@
import 'package:serverpod_client/serverpod_client.dart' as _i1;
import 'enums/visit_authorship.dart' as _i2;
import 'enums/arrival_method.dart' as _i3;
import 'enums/risk_level.dart' as _i4;
import 'enums/sync_status.dart' as _i5;
import 'enums/sync_status.dart' as _i4;

/// Visita domiciliar. Registrada offline e sincronizada depois.
abstract class Visit implements _i1.SerializableModel {
Expand All @@ -29,8 +28,10 @@ abstract class Visit implements _i1.SerializableModel {
this.startedAt,
this.completedAt,
required this.status,
required this.riskLevelBefore,
this.riskLevelAfter,
String? riskLevelBeforeEncrypted,
int? riskLevelBeforeKeyVersion,
String? riskLevelAfterEncrypted,
int? riskLevelAfterKeyVersion,
String? notesEncrypted,
int? notesKeyVersion,
required this.syncStatus,
Expand All @@ -39,6 +40,10 @@ abstract class Visit implements _i1.SerializableModel {
this.syncAt,
required this.version,
}) : authorship = authorship ?? _i2.VisitAuthorship.acs,
riskLevelBeforeEncrypted = riskLevelBeforeEncrypted ?? '',
riskLevelBeforeKeyVersion = riskLevelBeforeKeyVersion ?? 1,
riskLevelAfterEncrypted = riskLevelAfterEncrypted ?? '',
riskLevelAfterKeyVersion = riskLevelAfterKeyVersion ?? 1,
notesEncrypted = notesEncrypted ?? '',
notesKeyVersion = notesKeyVersion ?? 1,
arrivalMethod = arrivalMethod ?? _i3.ArrivalMethod.manual;
Expand All @@ -53,11 +58,13 @@ abstract class Visit implements _i1.SerializableModel {
DateTime? startedAt,
DateTime? completedAt,
required String status,
required _i4.RiskLevel riskLevelBefore,
_i4.RiskLevel? riskLevelAfter,
String? riskLevelBeforeEncrypted,
int? riskLevelBeforeKeyVersion,
String? riskLevelAfterEncrypted,
int? riskLevelAfterKeyVersion,
String? notesEncrypted,
int? notesKeyVersion,
required _i5.SyncStatus syncStatus,
required _i4.SyncStatus syncStatus,
_i3.ArrivalMethod? arrivalMethod,
required _i1.UuidValue localId,
DateTime? syncAt,
Expand Down Expand Up @@ -93,17 +100,17 @@ abstract class Visit implements _i1.SerializableModel {
jsonSerialization['completedAt'],
),
status: jsonSerialization['status'] as String,
riskLevelBefore: _i4.RiskLevel.fromJson(
(jsonSerialization['riskLevelBefore'] as String),
),
riskLevelAfter: jsonSerialization['riskLevelAfter'] == null
? null
: _i4.RiskLevel.fromJson(
(jsonSerialization['riskLevelAfter'] as String),
),
riskLevelBeforeEncrypted:
jsonSerialization['riskLevelBeforeEncrypted'] as String?,
riskLevelBeforeKeyVersion:
jsonSerialization['riskLevelBeforeKeyVersion'] as int?,
riskLevelAfterEncrypted:
jsonSerialization['riskLevelAfterEncrypted'] as String?,
riskLevelAfterKeyVersion:
jsonSerialization['riskLevelAfterKeyVersion'] as int?,
notesEncrypted: jsonSerialization['notesEncrypted'] as String?,
notesKeyVersion: jsonSerialization['notesKeyVersion'] as int?,
syncStatus: _i5.SyncStatus.fromJson(
syncStatus: _i4.SyncStatus.fromJson(
(jsonSerialization['syncStatus'] as String),
),
arrivalMethod: jsonSerialization['arrivalMethod'] == null
Expand Down Expand Up @@ -147,16 +154,23 @@ abstract class Visit implements _i1.SerializableModel {

String status;

_i4.RiskLevel riskLevelBefore;
/// Risco antes e depois da visita, cifrados (INV-04, #75): nome do
/// RiskLevel com largura fixa, ver `encrypted_risk_level.dart`. O "depois"
/// ausente (visita não concluída) é cifrado também, com o mesmo tamanho.
String riskLevelBeforeEncrypted;

int riskLevelBeforeKeyVersion;

String riskLevelAfterEncrypted;

_i4.RiskLevel? riskLevelAfter;
int riskLevelAfterKeyVersion;

/// JSON de Map<String, String>, cifrado. Ver patient.spy.yaml para o padrão.
String notesEncrypted;

int notesKeyVersion;

_i5.SyncStatus syncStatus;
_i4.SyncStatus syncStatus;

/// Como o check-in desta visita foi registrado (RF12, decisão §4). Default
/// `manual` até a integração nativa de geofencing existir — hoje nenhum
Expand All @@ -183,11 +197,13 @@ abstract class Visit implements _i1.SerializableModel {
DateTime? startedAt,
DateTime? completedAt,
String? status,
_i4.RiskLevel? riskLevelBefore,
_i4.RiskLevel? riskLevelAfter,
String? riskLevelBeforeEncrypted,
int? riskLevelBeforeKeyVersion,
String? riskLevelAfterEncrypted,
int? riskLevelAfterKeyVersion,
String? notesEncrypted,
int? notesKeyVersion,
_i5.SyncStatus? syncStatus,
_i4.SyncStatus? syncStatus,
_i3.ArrivalMethod? arrivalMethod,
_i1.UuidValue? localId,
DateTime? syncAt,
Expand All @@ -206,8 +222,10 @@ abstract class Visit implements _i1.SerializableModel {
if (startedAt != null) 'startedAt': startedAt?.toJson(),
if (completedAt != null) 'completedAt': completedAt?.toJson(),
'status': status,
'riskLevelBefore': riskLevelBefore.toJson(),
if (riskLevelAfter != null) 'riskLevelAfter': riskLevelAfter?.toJson(),
'riskLevelBeforeEncrypted': riskLevelBeforeEncrypted,
'riskLevelBeforeKeyVersion': riskLevelBeforeKeyVersion,
'riskLevelAfterEncrypted': riskLevelAfterEncrypted,
'riskLevelAfterKeyVersion': riskLevelAfterKeyVersion,
'notesEncrypted': notesEncrypted,
'notesKeyVersion': notesKeyVersion,
'syncStatus': syncStatus.toJson(),
Expand Down Expand Up @@ -237,11 +255,13 @@ class _VisitImpl extends Visit {
DateTime? startedAt,
DateTime? completedAt,
required String status,
required _i4.RiskLevel riskLevelBefore,
_i4.RiskLevel? riskLevelAfter,
String? riskLevelBeforeEncrypted,
int? riskLevelBeforeKeyVersion,
String? riskLevelAfterEncrypted,
int? riskLevelAfterKeyVersion,
String? notesEncrypted,
int? notesKeyVersion,
required _i5.SyncStatus syncStatus,
required _i4.SyncStatus syncStatus,
_i3.ArrivalMethod? arrivalMethod,
required _i1.UuidValue localId,
DateTime? syncAt,
Expand All @@ -256,8 +276,10 @@ class _VisitImpl extends Visit {
startedAt: startedAt,
completedAt: completedAt,
status: status,
riskLevelBefore: riskLevelBefore,
riskLevelAfter: riskLevelAfter,
riskLevelBeforeEncrypted: riskLevelBeforeEncrypted,
riskLevelBeforeKeyVersion: riskLevelBeforeKeyVersion,
riskLevelAfterEncrypted: riskLevelAfterEncrypted,
riskLevelAfterKeyVersion: riskLevelAfterKeyVersion,
notesEncrypted: notesEncrypted,
notesKeyVersion: notesKeyVersion,
syncStatus: syncStatus,
Expand All @@ -281,11 +303,13 @@ class _VisitImpl extends Visit {
Object? startedAt = _Undefined,
Object? completedAt = _Undefined,
String? status,
_i4.RiskLevel? riskLevelBefore,
Object? riskLevelAfter = _Undefined,
String? riskLevelBeforeEncrypted,
int? riskLevelBeforeKeyVersion,
String? riskLevelAfterEncrypted,
int? riskLevelAfterKeyVersion,
String? notesEncrypted,
int? notesKeyVersion,
_i5.SyncStatus? syncStatus,
_i4.SyncStatus? syncStatus,
_i3.ArrivalMethod? arrivalMethod,
_i1.UuidValue? localId,
Object? syncAt = _Undefined,
Expand All @@ -303,10 +327,14 @@ class _VisitImpl extends Visit {
startedAt: startedAt is DateTime? ? startedAt : this.startedAt,
completedAt: completedAt is DateTime? ? completedAt : this.completedAt,
status: status ?? this.status,
riskLevelBefore: riskLevelBefore ?? this.riskLevelBefore,
riskLevelAfter: riskLevelAfter is _i4.RiskLevel?
? riskLevelAfter
: this.riskLevelAfter,
riskLevelBeforeEncrypted:
riskLevelBeforeEncrypted ?? this.riskLevelBeforeEncrypted,
riskLevelBeforeKeyVersion:
riskLevelBeforeKeyVersion ?? this.riskLevelBeforeKeyVersion,
riskLevelAfterEncrypted:
riskLevelAfterEncrypted ?? this.riskLevelAfterEncrypted,
riskLevelAfterKeyVersion:
riskLevelAfterKeyVersion ?? this.riskLevelAfterKeyVersion,
notesEncrypted: notesEncrypted ?? this.notesEncrypted,
notesKeyVersion: notesKeyVersion ?? this.notesKeyVersion,
syncStatus: syncStatus ?? this.syncStatus,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,13 @@ class TriageSessionRecord {
required this.patientId,
required this.answers,
required this.resultRisk,
required this.resultDisplay,
required this.createdAt,
required this.deviceId,
});

final UuidValue patientId;
final List<TriageAnswer> answers;
final RiskLevel resultRisk;
final String resultDisplay;
final DateTime createdAt;
final String deviceId;
}
Expand Down Expand Up @@ -154,7 +152,6 @@ class TriageSessionService {
),
],
resultRisk: risk,
resultDisplay: _displayFor(risk),
createdAt: _clock(),
deviceId: user.deviceId,
));
Expand All @@ -179,10 +176,4 @@ class TriageSessionService {

return risk;
}

static String _displayFor(RiskLevel risk) => switch (risk) {
RiskLevel.red => 'Vermelho',
RiskLevel.yellow => 'Amarelo',
RiskLevel.green => 'Verde',
};
}
Loading
Loading