0026 — PII boundary lives at the Temporal activity entry, not in workflow inputs
- Status: Accepted
- Date: 2026-05-15
- Deciders: Fede
Context
Phase 4 of the renewal-architecture migration (see ADR-0025) puts a Temporal Cloud workflow alongside the existing RenewalSaga in shadow mode, and is on track to become the authoritative engine for renewals (and later maintenance / leasing / other long-lived state machines).
Temporal's workflow history is a durable, append-only, replayable record of every input, signal, and activity result. The Cloud's storage commitment is "encrypted in transit and at rest" but the workflow-history view in the Cloud Web UI shows payload contents in plaintext to anyone with namespace read access. That visibility is fundamentally useful — it's why the engine is observable — but it means anything we put into a workflow input, signal payload, or activity return value lives there forever in operator-readable form.
The renewal workflow's natural inputs include tenant phone, tenant email, tenant full name, lease numbers, and (down the road) compensation-adjacent fields — exactly the data we'd never paste into a Slack channel or support-tooling log.
We need a contract that ensures PII never lands in workflow history, no matter who writes the next activity.
Decision
Workflow inputs and signal payloads carry IDs only. The four-tuple
(renewalId, leaseId, tenantId, propertyId) plus event-shape fields
(from, to, observedAt, channel, status, kind, etc.) — nothing
else.
Activities resolve IDs to PII internally, use it only inside the activity
body, and never return PII to the workflow. The activity's return value
is bounded: 'dispatched' | 'dispatched_failed' | 'ok' | 'failed' | void
or an outcome enum. No phone, no email, no name, no address ever leaves the
activity scope toward the workflow caller.
Logs are sanitized at the activity boundary. Use the existing
maskPhone() and never console.log(tenant) raw. Activity logs flow to
CloudWatch + Sentry, both of which we have audit-controlled access to.
Re-stating the rule for code review:
- Anything imported from
@/lib/temporal/workflows/*(a workflow or its types) must have onlystring | number | boolean | enumfields, where every string is either an ID (UUID, AppFolio occupancy id, etc.) or bounded enum value. Noemail,phone,phoneE164,firstName,lastName,addressLine1,name,notes, free-text. The Phase 4 shadow-bridge enforces this for outgoing signals. - Inside
@/lib/temporal/activities/*, you can callgetTenantById(),getLease(), etc. — full PII is allowed in local variables and on in-process logs, IF those logs go throughmaskPhone()first. - Activity return values follow the same contract as workflow inputs: only IDs and enums. If you need to return a structured result to the workflow (e.g., "tenant said yes via voice channel"), use a discriminated enum, not a payload object.
- The dedup
eventIdon signal envelopes is plain text. Sender-stable constructions like Twilio MessageSid + status, AppFolio webhook event id, or workflow-generated UUIDs are all fine. Do NOT include the tenant's phone number or email in the eventId.
Consequences
What this commits us to
- Every activity is responsible for its own data lookup. There's no free PII riding on activity input parameters.
- Activity bodies own the integration with
@/lib/data(DynamoDB lookups). They cannot stay pure / DI-clean the way workflows can; that asymmetry is by design. - Workflows replay deterministically without needing PII access — replay tests against saved histories don't need a DynamoDB read.
- The Cloud Web UI is freely shareable for ops triage without leaking customer data. Screenshots in incident channels are safe.
- A regression test on every new activity asserts that no PII field-name
appears in the activity's return type (and no PII appears in
_workflow-only_shapes).
What becomes harder
- An activity that needs three different bits of PII makes three DDB reads (or one batched read of the whole tenant). We accept the extra read cost for the data-isolation benefit.
- Workflow-level snapshot queries (
getSnapshot) return only the workflow's own state (phase, pms, intent kind, channels attempted, outcome). The UI must still join the snapshot with the live DDB tenant/lease records — but it does that already. - Future "rich audit" features (e.g., a Cloud-side dashboard showing
per-tenant timelines with names) require a parallel PII-aware store —
this ADR explicitly bans putting that data in workflow history. The
existing
EntityActivityEventtable is the right home.
Sentry, langfuse, downstream logs
The same rules apply to anything that ships off-box:
logInfo/Warn/Error/Criticalcalls inside activities must sanitize theirextrapayloads. ExistingsanitizeForLog()+maskPhone()helpers are the canonical way.tracedClaudeCall()and langfuse spans inside activities follow the same rule.- Sentry breadcrumbs auto-attach
requestId/tenantId/senderIdfrom AsyncLocalStorage — these are IDs, fine to ship.
Alternatives considered
A: Encrypt PII at the workflow input boundary. Temporal supports custom data converters; we could wrap inputs in an envelope encrypted with KMS, unwrapped in the worker. Rejected because:
- Cloud Web UI shows ciphertext, which is useless for ops triage. The whole point of moving to Temporal is operator observability; encrypting it back out defeats the goal.
- KMS dependency on every workflow start adds latency and a per-call cost.
- A misconfigured worker (wrong key, expired secret) breaks every workflow, not just a single render path.
B: Put PII in workflow inputs but flag them with a "secret" type. Temporal SDK has a "Payload Codec" interface; we could mark fields as secret-payload and have a codec strip them in the UI. Rejected because:
- Same UX downside as A.
- Adds repo-wide complexity to every activity author for one field.
- The simpler rule ("IDs only at the boundary") is easier to enforce in code review.
C: Trust Cloud's RBAC + encryption-at-rest. Just send PII; rely on "only logged-in PropFlow engineers can see it." Rejected because:
- Cloud is a third-party service. Their breach is our breach. Limiting what we send is the only durable mitigation.
- The 7-year audit retention plan (still TBD per ADR-0025) means PII would outlive the customer's relationship with us.
- Compliance (SOC 2 Type II, NACHA, customer DPAs) is easier to argue when the PII boundary is documented + machine-enforced rather than policy-only.
D: Use Temporal's Search Attributes for PII. Search attributes are indexed for filtering and don't appear by default in history. Rejected because the data is still in Cloud, the filter UX is intentional, and the attributes are subject to a separate-but-similar access policy — same attack surface, different door.
Enforcement
src/lib/temporal/shadow-bridge.tsbuildWorkflowInput()strictly emits the four-tuple. A regression test asserts no PII-field-name leaks (seesrc/__tests__/temporal-shadow-bridge.test.ts— "does not leak PII into workflow input").- Phase 4 activity bodies (PR2) will use
maskPhone()on every log line that mentions a phone number. - A new ESLint rule (Phase 5, optional) could lint workflow-touching files for known PII field names — discussed but not adopted now to avoid false-positive churn.
Related
- ADR-0025 — Temporal Cloud for renewal workflows (architecture-level)
docs/renewal-architecture/HANDOFF.md— operational migration plansrc/lib/platform/security/sanitize.ts— masking helpers