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:
- Retell AI — voice-agent audio, transcripts, tool-call arguments/results (structured PHI already redacted app-side before Retell logs anything of ours).
- LLM / transcript provider — Retell’s underlying LLM (OpenAI or equivalent) sees transcript prose during the call for tool-selection.
- Telnyx — SIP media path only. No PHI transits Telnyx as structured data.
- Cliniko — the customer’s own PMS. Data flows from the clinic outward, not from us.
- Resend — outbound email SMTP (billing alerts, admin notifications). No patient email in message bodies.
- DigitalOcean — hosting. Encrypted-in-transit, node-local storage.
Anchors: docs/legal/privacy-policy.md,
docs/legal/terms-and-conditions.md.