# PII inventory (for general awareness, self-hosting, etc)

Where a deployment stores or forwards PII: residency, retention, and what leaves the deployment. Collection is limited to what platform security and abuse prevention need.

**Status:** IP addresses and media-upload PII are documented below. Additional sections (email, phone, identifiers, etc.) will be added over time.

**Terminology:** User-facing docs say **Alias**. Code often still says `identity` (paths, types, names below follow the code). Account is the person. Alias is the pseudonym.

---

## Content moderation (hash-based only)

Automated moderation uses **CSAM hash checks only** — we do **not** send cleartext images or video to third-party ML/label APIs (no Amazon Rekognition or similar).

| Tier | Service | What leaves your deployment |
| ---- | ------- | --------------------------- |
| 1 | NCMEC hash list (DynamoDB) | Nothing — MD5/SHA1 computed locally and matched in-region |
| 2 | [Project Arachnid Shield](https://www.projectarachnid.ca/) | **PDQ perceptual hash only** over HTTPS — not media bytes |

**`conv_scan` scan copies:** Clients upload cleartext thumbnails/frame grids to **your** media S3 bucket so the media-processor Lambda can compute hashes locally. On a clean pass, cleartext S3 is deleted and `conv_scan` Mongo rows are purged (see retention below). On CSAM match, evidence is archived to a separate evidence bucket and metadata (including upload IP) is copied to `platform_reports`.

**Report sources:** `automated_csam_hash`, `automated_hash_check`, `manual_user`.

**Code references:** `infra/aws/lambda/media-processor/src/csam-hash-check.ts`, `infra/aws/lambda/media-db-writer/src/index.ts`.

---

## How client IP is determined

All server-side IP handling starts from `getClientIp()` in `apps/api/src/routes/auth/controller.ts`:

1. **Non-production:** optional `DEV_CLIENT_IP` env override (sanitized; for local geo/compliance testing).
2. `**X-Real-IP`** header (typical if running behind Caddy or similar).
3. `**X-Forwarded-For**` — first hop in the chain.
4. Fallback: `127.0.0.1`.

**Self-hosting note:** In production, set `TRUST_PROXY_HEADERS=true` and configure your reverse proxy to **strip or overwrite** client-supplied `X-Forwarded-For` / `X-Real-IP`. Otherwise geo, compliance, and stored IPs may reflect spoofed values. See `apps/api/README.md` and `docs/deployment/ecs-environment.md`.

IPs used for persistence are sanitized via `sanitizeIpForStorage()` (`apps/api/src/utils/sanitize.ts`) where noted below.

---

## IP address inventory

### Summary


| Form stored                     | Where                                                                                                  | Retention                                           | Reversible?                               |
| ------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------- | ----------------------------------------- |
| **Full IPv4/IPv6**              | MongoDB `sessions` (**account sessions only**), `media_uploads`, `platform_reports`, Redis MFA pending | Session TTL / indefinite* / report lifetime / 5 min | Yes (direct)                              |
| **Masked IP** (UI/API only)     | Session API response, account security UI                                                              | Not persisted                                       | Partial (first two octets or IPv6 prefix) |
| **Keyed hash** (`hashIp`)       | Redis rate limits, auth-related logs                                                                   | Window TTL / log retention                          | No* (without `SESSION_SECRET`)            |
| **Keyed hash** (`hashIpForGeo`) | MongoDB `users`, `audit_logs`, Redis geo cache                                                         | User lifetime / 90 days / ~24h cache                | No* (without `ACCOUNT_HASH_SECRET`)       |
| **Partial prefix**              | Application logs (IPLocate errors)                                                                     | Log retention                                       | Partial only                              |


 `media_uploads` and CSAM `platform_reports` rows are not TTL-expired by default. Hashes are one-way but could be attacked offline if the corresponding secret leaks.

---

### MongoDB (persistent)


| Collection / field                                   | Trigger                                                                          | What's stored                                                                                                                                            | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| ---------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sessions.ipAddress` (**account sessions only**)     | Account login (OTP verify, MFA completion)                                       | Full sanitized IP at login time — **only when `sessions.type` is `account`**. Set by `createAccountSession()`; omitted (field absent) on Alias sessions. | Session management for the signed-in **account**; shown masked in Account → Security. Not used for Alias login or chat WebSocket auth.                                                                                                                                                                                                                                                                                                                             |
| `users.geo.ipHash`                                   | Login, session refresh, compliance checks                                        | `SHA-256(ip + ACCOUNT_HASH_SECRET)`                                                                                                                      | Detect IP/network change; **no raw IP** on user doc. On change we recheck jurisdiction to determine if age verification is needed, if OFAC requires service denial, etc.                                                                                                                                                                                                                                                                                           |
| `users.geo.*`                                        | Same as above                                                                    | Jurisdiction, country, region, VPN/abuse flags                                                                                                           | Age verification, geofencing, compliance, from the IP lookup. Flags mark known-bad networks, not VPN use. |
| `users.compliance.vpnAttestationPending.ipHash`      | VPN/anonymized IP detected                                                       | Keyed hash (`hashIpForGeo`)                                                                                                                              | Tie attestation flow to current network                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `users.compliance.lastVpnAttestation.ipHash`         | VPN attestation completed                                                        | Keyed hash                                                                                                                                               | Skip re-prompt on same network                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `users.ageVerification.requiredReasonIpHash`         | Abusive-IP compliance path                                                       | Keyed hash                                                                                                                                               | Record which network triggered AV requirement                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `media_uploads.uploadIpAddress`                      | Presigned upload URL request (avatars, banners, DM/space media, E2E scan copies) | Full sanitized IP                                                                                                                                        | NCMEC CyberTipline reporting if CSAM detected.                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `audit_logs.ipHash`                                  | OFAC ban, abusive-IP block                                                       | Keyed hash from compliance (`hashIpForGeo`)                                                                                                              | Compliance audit trail                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `audit_logs.ipHash`                                  | Admin actions                                                                    | Literal `'admin'` (placeholder)                                                                                                                          | Admin audit trail — **admin IP not recorded**                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `platform_reports.detectionMetadata.uploadIpAddress` | Automated CSAM hash match (Lambda → Mongo)                                       | Copy of upload IP from `media_uploads`                                                                                                                   | Moderation + CyberTipline bundle building                                                                                                                                                                                                                                                                                                                                                                                                                          |


**Account vs Alias sessions in `sessions`:**

The `sessions` collection holds both **account** sessions (email/phone sign-in) and **Alias** sessions (pseudonym login for chat). They share a schema, but IP handling differs:


| Session type (`sessions.type`) | `ipAddress` stored?              | When created                                       |
| ------------------------------ | -------------------------------- | -------------------------------------------------- |
| `account`                      | Yes — full sanitized IP at login | OTP verify or MFA completion                       |
| `identity` (Alias)             | **No** — field is not written    | `createIdentitySession()` never passes `ipAddress` |


Alias activity is authenticated with Alias session cookies; it does **not** add or update `sessions.ipAddress`. IP captured at account login lives on the account session row only. The chat service reads Alias sessions from Mongo for validation but does not persist IP there either.

**Retention:**

- `sessions` (both types): MongoDB TTL on `expiresAt` (default **7 days**, renewed on activity). Only **account** rows may carry `ipAddress`.
- `audit_logs`: **90-day** TTL (`apps/api/src/db/mongo.ts`).
- `users.geo` / compliance fields: lifetime of user document.
- `media_uploads` (non–`conv_scan`): no automatic expiry; `uploadIpAddress` is **unset** after terminal moderation (`ready` / `rejected` / `failed`). CSAM path copies IP to `platform_reports` first.
- `media_uploads` (`purpose: conv_scan`): immediate purge of rows + S3 prefix after clean `ready`; **7-day TTL** on terminal rows as a backstop (`apps/api/src/db/mongo.ts`).
- `platform_reports`: no automatic expiry (operational deletion only).

**Code references:** `apps/api/src/services/session.service.ts`, `apps/api/src/services/geo/geo.service.ts`, `apps/api/src/services/compliance/compliance-enforcement.service.ts`, `apps/api/src/services/upload.service.ts`, `apps/api/src/services/e2e-upload.service.ts`, `infra/aws/lambda/media-db-writer/src/index.ts`.

---

### Media uploads (`media_uploads`)

| Field | Purposes | PII / linkage risk |
| ----- | -------- | ------------------ |
| `uploadIpAddress` | All upload types at presign time | Full IP; **expunged** on terminal moderation except CSAM snapshot in `platform_reports` |
| `identityId` | avatar, banner, dm_attachment, etc. (not `conv_scan`) | Links upload to Alias |
| `scanHash` | `conv_scan` only | Join to `e2e_media.identityId` enables IP → Alias correlation while row exists |
| `userId` | `ticket_attachment` only | Links upload to account |

**E2EE conversation media:** Encrypted payload lives in `e2e_media` + E2E S3 bucket. `conv_scan` rows are ephemeral moderation copies; they do not hold the ciphertext.

**Mitigations in production:**

- **A:** Unset `uploadIpAddress` after terminal moderation.
- **E:** Delete `conv_scan` rows + S3 prefix immediately on clean `ready`.
- **D:** 7-day TTL on terminal `conv_scan` rows.

**S3 note:** `conv_scan` presigned PUTs may include `identity-id` in S3 object metadata — a separate linkage surface if bucket access logs are exposed.

---

### Redis (transient)


| Key pattern                                            | Identifier in key                       | Contents                            | TTL                                  |
| ------------------------------------------------------ | --------------------------------------- | ----------------------------------- | ------------------------------------ |
| `ratelimit:auth:request:ip:{hashIp}`                   | Hashed IP                               | Request timestamps (sorted set)     | Auth request window (default 15 min) |
| `ratelimit:auth:verify:ip:{hashIp}`                    | Hashed IP                               | Same                                | Auth verify window                   |
| `ratelimit:user:email:ip:{hashIp}`                     | Hashed IP                               | Same                                | Email verification window            |
| `ratelimit:user:phone:ip:{hashIp}`                     | Hashed IP                               | Same                                | Phone verification window            |
| `ratelimit:spaces:announcement-public:{hashIp}`        | Hashed IP                               | Same                                | 10 minutes                           |
| `ratelimit:spaces:guest-view:{hashIp}`                 | Hashed IP                               | Same                                | 10 minutes                           |
| `ratelimit:spaces:slug-preview:{hashIp}`               | Hashed IP                               | Same                                | 10 minutes                           |
| `ratelimit:spaces:invite-preview:{hashIp}`             | Hashed IP                               | Same                                | 10 minutes                           |
| `ratelimit:subscription:catalog-prices:{clientIp}`     | **Raw IP**                              | Same                                | Catalog prices rate limit            |
| `ratelimit:subscription:confirm:{clientIp}:{clientIp}` | **Raw IP** (action name also embeds IP) | Same                                | Checkout confirm rate limit          |
| `mfa:pending:{token}`                                  | —                                       | JSON including **full `ipAddress`** | **5 minutes**                        |
| `geo:ip:{hashIpForGeo}`                                | Keyed hash                              | Jurisdiction JSON (no raw IP)       | `GEO_CACHE_TTL` (default 24h)        |
| `geo:ip_neg:{hashIpForGeo}`                            | Keyed hash                              | Negative cache marker               | 5 minutes                            |


**Note:** Redis session cache (`session:{sessionId}`) does **not** include IP for either account or Alias sessions — see `CachedSessionData` in `apps/api/src/models/session.ts`.

`global:ip` rate-limit action is defined in config but **not currently invoked** by application code.

**Code references:** `apps/api/src/services/rate-limit.service.ts`, `apps/api/src/routes/auth/controller.ts`, `apps/api/src/routes/users/controller.ts`, `apps/api/src/routes/account/subscription/controller.ts`, `apps/api/src/db/redis.ts`.

---

### Third-party disclosure (IP leaves your deployment)


| Service                | When                                    | What is sent                                                     | Operator controls                                                                                                           |
| ---------------------- | --------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| **IPLocate.io**        | Geo lookup (`resolveJurisdiction`)      | **Full IP** in request URL                                       | `GEO_ENABLED`, `IPLOCATE_`* env vars; disable geo to avoid lookups                                                          |
| **NCMEC CyberTipline** | Manual/automated CSAM report submission | **Full upload IP** in XML (`ipCaptureEvent`)                     | `CYBERTIPLINE_`* credentials; only when reporting                                                                           |
| **Stripe**             | Checkout / billing                      | Stripe may record customer IP from browser/session independently | Standard Stripe data processing; not written to Adieuu Mongo from `getClientIp()` except subscription rate-limit keys above |


Age-verification providers (`apps/api/src/services/age-verification/`) do not receive client IP from the API. The vendor's hosted page runs in the browser, so the vendor sees the client IP. That is in the vendor policy shown before the check starts.

**Code references:** `apps/api/src/services/geo/iplocate.client.ts`, `apps/api/src/services/cybertipline.service.ts`, `apps/api/src/services/cybertipline-report-builder.service.ts`, `apps/api/src/routes/moderation/controller.ts`.

---

### Age verification attempts

Age verification is provider-abstracted (`apps/api/src/services/age-verification/provider.ts`); the active provider is a MongoDB platform setting, switchable from the admin panel. Credentials are env-only and never stored in the database. Each attempt records which provider created it so a mid-flight swap cannot poll the wrong vendor.

No provider returns identity data to us. We never receive a name, date of birth, document number, MRZ, or any image. What we persist on `age_verifications`:


| Field                   | What it is                                                                     | Exposure                                                       |
| ----------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| `providerId`            | Which vendor handled the attempt                                               | Server-side only                                               |
| `providerVerificationId`| Vendor's id for the attempt (VerifyMy) or our CSPRNG reference id (Agemin)      | Returned to the attempt's owner only                           |
| `providerSessionToken`  | Opaque vendor session credential used to poll status                            | Server-side only; excluded from the account data export        |
| `status`                | `pending` / `verified` / `failed` / `expired`                                    | Returned to the owner                                          |
| `confidence`            | Coarse vendor confidence label (e.g. `high`), used to decide whether a weak result should escalate to a stronger method | Server-side only |
| `ageThreshold`          | The age the vendor evaluated against (e.g. `18`), echoed back by the vendor      | Server-side only                                               |

`providerSessionToken` is a bearer for the vendor status endpoint. It is omitted from the GDPR export in `apps/api/src/routes/account/data/controller.ts` and from `AgeVerificationDetails`. `confidence` and `ageThreshold` stay server-side.

The authenticated status route acts only on the caller's own attempt id, and returns the same not-found for unknown and unowned ids, before any vendor call. The unauthenticated provider callback is limited to non-terminal, recent attempts, and is rate limited.

**Code references:** `apps/api/src/models/age-verification.ts`, `apps/api/src/services/age-verification/age-verification.service.ts`, `apps/api/src/routes/age-verification/controller.ts`, `apps/api/src/routes/account/data/controller.ts`.

---

### Application logs


| Event                                       | Field                      | Content                    |
| ------------------------------------------- | -------------------------- | -------------------------- |
| OTP request — IP sanitization changed input | `originalLength`, `deltas` | Length only, not raw IP    |
| OTP request — identifier sanitization       | `ipHash`                   | `hashIp()` value           |
| IPLocate non-200                            | `ipPrefix`                 | First two IPv4 octets only |
| IP sanitization before upload storage       | `originalLength`, `deltas` | Length only                |


Structured logs do not routinely include full client IPs. Retention is whatever the deployment keeps (stdout, CloudWatch). Application code does not set it.

**Code references:** `apps/api/src/routes/auth/controller.ts`, `apps/api/src/services/geo/iplocate.client.ts`, `apps/api/src/utils/sanitize.ts`.

---

### Exposed to users and admins (not additional storage)


| Surface                         | Data shown                                 | Source                                                                                            |
| ------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| Account → Overview              | `maskedIp` for **current** request         | Computed per `GET /api/auth/session`; not read from DB                                            |
| Account → Security → Sessions   | Masked `ipAddress` per **account** session | `maskIpAddress()` on `sessions.ipAddress` (account rows only; Alias sessions are not listed here) |
| Admin → User profile → Sessions | Masked IP                                  | Same masking as account Security                                                                  |
| Session API `geo`               | Jurisdiction/country only                  | `users.geo` without `ipHash`                                                                      |


Masking rules: IPv4 `a.b.*.`*; IPv6 first two groups + `:*` (`apps/api/src/models/session.ts`).

---

### Request-time only (not persisted by Adieuu)

These paths call `getClientIp()` for policy decisions but do not add new IP rows beyond the stores above:

- Compliance VPN attestation (`POST /api/compliance/vpn-attestation`) — compares `hashIpForGeo(ip)` to pending state.
- Session polling / `evaluateComplianceOnAccess` on authenticated requests.
- Upload presigned URL routes — IP passed into upload services (stored as `uploadIpAddress` when valid).
- Subscription catalog / checkout confirm — rate limiting only (see Redis table).

---

### Infrastructure outside this repository

Self-hosters should also account for:

- **Reverse proxy / load balancer access logs** (Caddy, nginx, ALB) — typically full client IP per request.
- **CDN / WAF logs** if media or static assets are fronted.
- **MongoDB and Redis backups** — include all persistent fields above.
- **S3 evidence buckets** — CSAM evidence archives; IP may appear in associated `platform_reports` metadata, not necessarily in object bytes.

---

## Email deliverability cache (`email_deliverability`)

Outbound email is gated on a per-address deliverability record so the deployment
stops mailing addresses that bounce or complain. The cache stores **only**
`hashEmail()` (HMAC-SHA256 of the lowercased address, keyed by
`ACCOUNT_HASH_SECRET`) — never the address, and never SES SMTP diagnostic text,
which routinely echoes the recipient.


| Store / field                                  | Trigger                                              | What's stored                                                    |
| ---------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- |
| `email_deliverability.emailHash`               | First send to an address, SES event, account deletion | Keyed hash only (unique index)                                    |
| `email_deliverability.status` / `reasons`      | Same                                                  | `allowed` / `undeliverable`, plus `bounce`, `complaint`, `validation`, `account_deleted` |
| `email_deliverability.suppression`             | SES suppression lookup or bounce/complaint event      | `BOUNCE` / `COMPLAINT`, source, event timestamp                   |
| `email_deliverability.validation`              | SESv2 `GetEmailAddressInsights` on a cache miss       | `HIGH` / `MEDIUM` / `LOW` verdict and check time                  |

The plaintext address leaves the deployment on the SES send, and on the two SESv2 lookups on a cache miss (`GetSuppressedDestination`, `GetEmailAddressInsights`). Bounce and complaint notifications at `POST /api/webhooks/ses` contain the recipient in the SNS payload. It is hashed before any write and is not logged. Account deletion writes the same hash to `deleted_emails` and this collection.

Logs for these paths carry `emailHash` only. SES send failures log the response
status and `<Code>`, never the response body.

**Code references:** `apps/api/src/services/messaging/email/deliverability.gate.ts`, `apps/api/src/services/messaging/email/sesv2.client.ts`, `apps/api/src/repositories/email-deliverability.repository.ts`, `apps/api/src/routes/webhooks/ses.controller.ts`.

---

## Hashing secrets (self-hosting)


| Function         | Secret env            | Used for                                                                       |
| ---------------- | --------------------- | ------------------------------------------------------------------------------ |
| `hashIp()`       | `SESSION_SECRET`      | Auth rate limits, auth log `ipHash`                                            |
| `hashIpForGeo()` | `ACCOUNT_HASH_SECRET` | `users.geo`, compliance VPN state, compliance audit logs, Redis geo cache keys |
| `hashEmail()`    | `ACCOUNT_HASH_SECRET` | `deleted_emails`, `email_deliverability`, deliverability log fields            |


Rotate these only with a planned migration — existing hashes will not match new IPs after rotation.

---

## Configuration checklist (IP minimization)


| Goal                             | Action                                                                                                    |
| -------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Accurate IP behind proxy         | `TRUST_PROXY_HEADERS=true`, trustworthy proxy config                                                      |
| Reduce third-party IP sharing    | Disable geo (`GEO_ENABLED` / platform settings) or omit IPLocate API key                                  |
| Avoid dev IP override in prod    | Do not set `DEV_CLIENT_IP` in production                                                                  |
| Limit CyberTipline exposure      | Reports only when legally required; use test endpoint in non-prod                                         |
| Understand session IP visibility | Account users see masked login IPs for **account** sessions in Security UI; Alias sessions never store IP |


---

## Space announcements (`space_announcements`)

Announcements posted on a Space Home. No IP is stored.

| Field | Contents | Retention |
| ----- | -------- | --------- |
| `authorId`, `signerId`, `editedBy` | Alias identity ids of the original poster, the signer of the current revision, and the last editor | Until deleted |
| `content` / `attachments` | Plaintext body and cleartext image URLs (plaintext Spaces only) | Until deleted |
| `ciphertext`, `signature`, `e2eMediaIds` | Community Cipher ciphertext and E2E media references (E2EE Spaces; the server cannot read them) | Until deleted |
| `audience` | Targeted channel and role ids | Until deleted |
| Tombstone (`deletedAt`) | Ids and timestamps only; body, media, and audience are cleared on delete | 30 days (TTL index) |

- Deleting an announcement also deletes media it no longer shares with another live announcement or channel message (best effort). Deleting the Space deletes every announcement and its media.
- Unrestricted announcements on public and non-E2EE unlisted Spaces are readable by signed-out visitors, including the author's display name, username, and avatar at stranger-level privacy. Posters are warned about this in the composer.
- Signed-out reads carry no identity ids: `authorId`, `signerId`, and `editedBy` are replaced with opaque refs (a keyed hash of the Space id and identity id). A ref is stable within one Space, so pages line up, but it cannot be reversed or matched across Spaces. Signed-in non-members still get identity ids, as they can already list a public Space's members.
- Audit log rows (`announcement_post`, `announcement_edit`, `announcement_delete`) record the acting identity and the announcement id, never the content.

**Code references:** `apps/api/src/models/space-announcement.ts`, `apps/api/src/services/space/announcements.ts`, `apps/api/src/services/space/announcement-media.ts`.

### Announcement toast dismissals (`space_announcement_dismissals`)

One row per member per dismissed announcement toast, so a dismissal follows the member across devices. No IP or content is stored.

| Field | Contents | Retention |
| ----- | -------- | --------- |
| `spaceId`, `identityId`, `announcementId` | Which alias identity dismissed which announcement | Until `expiresAt` |
| `dismissedAt`, `expiresAt` | When it was dismissed; the announcement's creation time plus the 7-day toast window | TTL index on `expiresAt` |

- Leaving, being kicked or banned, or temporary access expiring deletes the member's rows for that Space (best effort; the TTL removes any stragglers). Deleting the Space deletes all of its rows.

**Code references:** `apps/api/src/models/space-announcement-dismissal.ts`, `apps/api/src/services/space/announcement-toasts.ts`, `apps/api/src/services/space/announcement-dismissals.ts`.

## Signed-out Space browsing

Public and non-E2EE unlisted Spaces can be browsed without signing in (`GET /api/spaces/slug/:slug/guest`). No IP is stored beyond the hashed-IP rate-limit key above, and no identity is involved.

- The response holds the Space name, slug, member count, theme, and the channel and category names open to the Everyone role. Owner and creator ids, role ids, Cipher challenges, and Cipher-locked channels are left out. No messages, members, or voice participants are included.
- The avatar, description, and banner are included only when the Everyone role holds View avatar, View bio, and View banner. The same three flags gate those fields for non-members everywhere else (slug preview, directory, invite previews, lurked Spaces) and for members through the union of their roles, including the `space_updated` realtime event and encrypted logo/banner downloads.
- Directory search matches a Space's description only while the Everyone role holds View bio (mirrored onto the Space as the server-only `everyoneViewsBio` flag), so a hidden description cannot be recovered by probing search terms.
- Limitation: hiding a field stops serving it, but a cleartext logo or banner URL a viewer already saw stays fetchable from the CDN until the image is replaced.

**Code references:** `apps/api/src/services/space/guest-view.ts`, `apps/api/src/services/space/profile-visibility.ts`, `packages/shared/src/api/space-profile-visibility.ts`.

---

## Planned sections

The following PII categories will be documented in future updates:

- **Email addresses**
- **Phone numbers**
- **Government identifiers** (age-verification identifiers are covered above)
- **User-generated content and media**
- **Billing and payment metadata**

---

*Last updated from codebase audit: June 2026. When adding features that touch PII, update this file in the same PR.*