Security & Data-Handling Decision Log

Internal + partner-review record of the data-handling posture behind this app. Complements the customer-facing docs/legal/privacy-policy.md and docs/legal/terms-and-conditions.md; those state what we do, this states what we decided and why.

Scope: everything that touches patient data (Cliniko PMS integration, Retell AI voice-agent transcripts, Twilio/Resend messaging, on-disk logs, git-tracked fixtures).

Snapshot

Domain Current posture Anchor
At-rest field encryption Removed 2026-08-10 — root-cause fix (no plaintext Cliniko keys stored) supersedes it §1
Log PHI redaction Structured PHI keys redacted at every logging sink §2
Free-text prose in transcripts Array-keyed PHI redacted; free-text prose (names/DOB inside speech) explicitly out of scope §3
Retention purge 30-day default, max(1, days) floor, symlink-safe sweeper §4
Cliniko auth-failure auto-disable Wired end-to-end: 3× 401/403 → client locked + admin alert §5
Git-history PII Fixtures scrubbed in-tree; no git filter-repo — accepted (see §6) §6
Sub-processor disclosure Published at /privacy — names Retell, LLM/transcript provider, Telnyx §7

1. At-rest field encryption — removed

Decision (2026-08-10): FieldEncryption + the FIELD_ENCRYPTION_KEY env var were removed. Onboarding no longer stores customer Cliniko API keys anywhere at rest.

Why: the earlier encryption layer (added 2026-07, commit e6dd85e) protected a store that shouldn’t have existed. Removing the store is the root-cause fix; encrypting a store that could be dropped is defence in depth against an already-avoidable exposure.

How to apply: no FIELD_ENCRYPTION_KEY needs to be provisioned on any node. Any reappearance of FieldEncryption in the codebase is a regression — investigate before merging.

Anchors: 4d266f0 refactor: remove FieldEncryption + FIELD_ENCRYPTION_KEY, 37129a1 chore(admin-secrets): drop FIELD_ENCRYPTION_KEY from admin.env.age.

2. Log PHI redaction

Decision: structured PHI keys (patient_name, dob, phone, email, from_number, to_number, caller_number, etc. — full list in src/Helper/LogSanitizer.php PHI_KEYS) are redacted before any log write across the app.

Sinks covered: - src/Handler/RequestHandler.php — every inbound tool-call body sanitised via LogSanitizer::sanitizeForLogging (:143). - src/Webhook/WebhookHandler.php — every Retell event payload sanitised (:81). - src/Api/BaseApi.php — response bodies pass through redactBodyForLogging (LogSanitizer::sanitizeString(json_encode(redactPhi(...)))) before any debug log line. - src/Alerting/GitHubIssueReporter.php — exception messages + job data redacted before opening a GitHub issue. - src/Messaging/SendSMS.php, src/Messaging/SendEmail.php — recipient phone/email masked via LogSanitizer::maskPhone / maskEmail. - src/Handler/PatientHandler.php — phone-removal path uses maskPhone + maskName.

Tests: LogPhiRedactionTest, LogSanitizerPhiTest, LogSanitizerSecretCoverageTest, SendSMSLoggingRedactionTest, SendEmailLoggingRedactionTest, PatientHandlerRemovePhoneLoggingRedactionTest, CallerContextBuilderMultiplePatientLoggingRedactionTest.

Why: patient identifiers appearing in developer-readable logs would breach the “no plaintext PHI in operational artefacts” line. Structured key-based redaction handles the guaranteed-shape data (API bodies, tool arguments, DTOs).

Anchors: 192b5a7 fix(cliniko-readiness-gaps): security follow-ups — systemic PHI masking.

3. Free-text prose in transcripts — accepted out of scope

Decision: LogSanitizer::redactPhiString() masks phone-shaped digit groups in free text; it does NOT redact spoken names, DOBs, or emails embedded in Retell transcript prose. This limit is documented in code (src/Helper/LogSanitizer.php lines 137–146).

Why: arbitrary name detection in prose requires NER (spaCy or equivalent) or an LLM callback per log line; neither justifies the operational cost on log lines whose primary use is short-window debugging on the node itself. Transcripts are also only logged as strlen($transcript), not raw prose (src/Webhook/WebhookHandler.php :358–363) — the full transcript is stored on Retell’s platform, not ours, so the on-disk exposure is limited to what an operator sees in a debug replay.

Mitigations: on-node log retention is 30 days (see §4); logs never leave the node; the operator population is one person (the vendor). If Cliniko partner review requires stricter prose-level redaction, the fallback path is a regex pass in redactPhiString covering email + AU-DOB patterns.

4. Retention

Decision: all PII stores purged on a 30-day window (RETENTION_DAYS, override via env). Both RetentionPurgeService (DB stores) and LogRetentionSweeper (on-disk logs) run daily via cron installed by dropletAdmin/cloud-init-setup.sh.

Guards: - RetentionPurgeService::getRetentionDays() returns max(1, (int) $env) — a mis-set RETENTION_DAYS=0 cannot wipe live rows. - LogRetentionSweeper::deleteDirectory() treats symlinks with is_link()unlink() on the link itself (removes the pointer, never recurses into the target).

Stores covered by RetentionPurgeService: - RescheduleDatabase::cleanup() — reschedule sessions - VerificationDatabase::purgeDuplicateAlerts(), purgeVerificationLog() — verification records - SpendTracker::purgeCallCosts() — cost ledger - OnboardingDatabase::purgeVerificationCodes(), purgeSubmissions() — onboarding submissions + reCAPTCHA verification codes

Anchors: 192b5a7, RetentionPurgeService.php:33, LogRetentionSweeper.php:39,51.

5. Cliniko authentication-failure auto-disable

Decision: three consecutive Cliniko 401/403 responses from the same client key auto-lock the client and email the operator. This prevents a rotated / revoked API key from silently hammering Cliniko or Retell until a human notices.

Chain: 1. BaseApi::handleResponse / handleErrorResponse calls recordClinikoAuthFailureBestEffort (BaseApi.php :675, :698) on every response gated to Cliniko URLs only. 2. ClinikoAuthFailureTracker::recordResponse (increments Redis counter on 401/403, resets on any 2xx/other). 3. On the third consecutive failure, RedisClinikoAuthFailureStore::disableClient posts to the admin server via CentralApiClient::reportClinikoAuthFailure. 4. Admin route POST /api/client/{name}/cliniko-auth-failure (index.php:739) → CentralApiHandler::reportClinikoAuthFailure flips clients.locked = 1 (idempotent) and sends the ATTENTION: Cliniko auth failure — {name} locked email to ADMIN_ALERT_EMAIL.

Tests: ClinikoAuthFailureTrackerTest, BaseApiClinikoAuthFailureTrackingTest, RedisClinikoAuthFailureStoreTest, CentralApiClientReportClinikoAuthFailureTest, CentralApiHandlerReportClinikoAuthFailureTest.

Anchors: 842bea0 feat(cliniko-auth-failure): wire the dormant tracker end-to-end.

6. Git-history PII — accepted, no filter-repo rewrite

Decision (2026-08-10): a prior PHPUnit fixture leak (real patient names + Cliniko appointment IDs, discovered by security-quorum 2026-07-19, scrubbed in commit-tree 2026-07-30) is left in git history. A git filter-repo history rewrite is NOT applied.

Why: - The fixtures held no reusable credential — Cliniko API keys, session tokens, or PIN signing keys were never in the affected files. The residual leak is pure PII (names + resource IDs). - The repository is private, has never been contractor-readable or publicly shared. Access is limited to the sole developer + platform infra. - The rewrite cost — invalidating every clone, breaking outstanding worktrees, forcing a mandatory re-clone of every deploy node — is disproportionate to the residual risk (private history of a private repo). - The pattern is documented in fleet memory (episode 3c486d1c) so future fixtures are enumerated against a real-names / real-IDs filter by security-quorum on every plan.

How to apply: if this repo is ever made contractor-readable, public, or transferred outside the sole developer’s control, execute the rewrite BEFORE the access change. See ~/.claude/projects/-var-www-html/memory/project_field_encryption_and_history_pii.md for the operational recipe.

Anchors: episode 3c486d1c (security-quorum discovery), episode 7f7d5628 (filter-repo procedure), episode 3f23de5d (urgency-triage decision framework).

7. Sub-processor disclosure

Decision: docs/legal/privacy-policy.md served at /privacy names every sub-processor and states what data each receives:

Anchors: docs/legal/privacy-policy.md, docs/legal/terms-and-conditions.md.