0026 — PII boundary lives at the Temporal activity entry, not in workflow inputs

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:

  1. Anything imported from @/lib/temporal/workflows/* (a workflow or its types) must have only string | number | boolean | enum fields, where every string is either an ID (UUID, AppFolio occupancy id, etc.) or bounded enum value. No email, phone, phoneE164, firstName, lastName, addressLine1, name, notes, free-text. The Phase 4 shadow-bridge enforces this for outgoing signals.
  2. Inside @/lib/temporal/activities/*, you can call getTenantById(), getLease(), etc. — full PII is allowed in local variables and on in-process logs, IF those logs go through maskPhone() first.
  3. 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.
  4. The dedup eventId on 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

What becomes harder

Sentry, langfuse, downstream logs

The same rules apply to anything that ships off-box:

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:

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:

C: Trust Cloud's RBAC + encryption-at-rest. Just send PII; rely on "only logged-in PropFlow engineers can see it." Rejected because:

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