> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Data retention

> Weekly organisation purge, per-user erasure, and the retention Lambda in each service

The privacy policy makes two deletion commitments:

* **Organisation data** is kept for the life of the subscription and deleted 90 days after it ends. A subscription has ended when the organisation's status is `disabled`.
* **Personal data** is erased within 30 days of a confirmed erasure request.

Each service that holds organisation or personal data meets these commitments with its own retention Lambda. The retention Lambda is the only hard-delete path in each service; user-facing deletes stay soft. It is never invoked from a request path. `api-global` and the regional Postgres services run it as their `BYPASSRLS` platform database role. The `admin-api` database has no RLS, so its Lambda runs on the service's normal execution role and database connection; `api-billing` and `api-login` hold their data in DynamoDB and give the Lambda its own IAM role scoped to those tables.

## Retention Lambdas by service

| Service | Function name | Organisation purge | Clock | `erase-user` |
| - | - | - | - | - |
| `api-assistant` | `stralos-<env>-au-nz-api-assistant-retention` | 90 days, plus conversation and action windows | `assistant_org_offboarding` | Deletes the user's conversations, drafts, action audit and per-user rate counters |
| `api-estimate` | `stralos-<env>-au-nz-api-estimate-retention` | 90 days | `estimate_org_offboarding` | No personal data; empty counts |
| `api-rates` | `stralos-<env>-au-nz-api-rates-retention` | 90 days | `rates_org_offboarding` | No personal data; empty counts |
| `api-activities` | `stralos-<env>-au-nz-api-activities-retention` | 90 days | `activities_org_offboarding` | No personal data; empty counts |
| `api-scheduler` | `stralos-<env>-au-nz-api-scheduler-retention` | 90 days | `scheduler_org_offboarding` | Anonymises a workforce person linked to the user |
| `api-documents` | `stralos-<env>-au-nz-api-documents-retention` | 90 days, including S3 objects | `documents_org_offboarding` | No personal data; empty counts |
| `api-billing` | `stralos-<env>-au-nz-api-billing-retention` | 90 days (local items only) | `OFFBOARDING#<org>` item in the billing table | No personal data; empty counts |
| `admin-api` | `stralos-<env>-admin-retention` | 104 days | `org_offboarding` | Deletes the user's identity rows and queues the Cognito delete |
| `api-login` | `stralos-<env>-login-retention` | None | None | Deletes the user's session partition |
| `api-global` | `stralos-<env>-au-nz-api-global-retention` | 104 days | `org_offboarding` | Anonymises the user, deletes sessions and OTP challenges, writes the audit row |

Every scheduled Lambda runs weekly on `cron(0 15 ? * SUN *)`: Sunday 15:00 UTC, which is Monday 03:00 or 04:00 in New Zealand depending on daylight saving. Each service sets the schedule through its `retention_schedule_expression` Terraform variable. The window is set by `ORG_OFFBOARDING_PURGE_AFTER_DAYS` (Terraform `org_offboarding_purge_after_days`, default `90`) in the regional services, and by `DIRECTORY_PURGE_AFTER_DAYS` (Terraform `directory_purge_after_days`, default `104`) in `admin-api` and `api-global`.

The retention Lambdas in `api-assistant`, `api-estimate`, `api-rates`, `api-activities`, `api-scheduler`, `api-documents` and `api-billing` run with a reserved concurrency of 1, so an overlapping retry cannot race a purge in progress.

Service-specific detail:

* [Assistant service: retention and offboarding](https://help.stralos.app/internal/assistant-service.md#retention-and-offboarding)
* [Activities service: retention and offboarding](https://help.stralos.app/internal/activities-service.md#retention-and-offboarding)
* [Organisation security: data retention and user erasure](https://help.stralos.app/internal/org-security.md#data-retention-and-user-erasure) (`api-global`)
* [Estimate service](https://help.stralos.app/internal/estimate-service.md), [Rates service](https://help.stralos.app/internal/rates-service.md), [Document AI pipeline](https://help.stralos.app/internal/document-ai-pipeline.md), and [Drive sync](https://help.stralos.app/internal/drive-sync.md)

## Organisation purge

### The offboarding clock

`api-global` records no "disabled since" timestamp, so each service keeps its own clock. On every weekly run, a service checks each organisation that still has rows in its store:

| Status | Action |
| - | - |
| `disabled`, first sighting | Insert a clock row recording the first `disabled` sighting. Purge nothing. |
| `disabled`, window not yet elapsed | Purge nothing. |
| `disabled`, window elapsed | Purge the organisation, then stamp `purged_at` on the clock row. |
| Any other status | Clear the clock row, so a reactivated organisation starts again from zero. Purge nothing. |
| Status lookup failed | Leave the clock unchanged and record a failure. |

Because the clock is the job's own first observation, the effective window is the configured value plus up to seven days. It is never shorter than the configured value.

`api-assistant` differs slightly: it clears its clock only on an explicit `active` status. Any other non-`disabled` status (such as `provisioning` or `failed`) leaves the clock untouched.

The clock rows hold only an organisation ID and timestamps. In `api-global` and the regional Postgres services only the platform role can read or write them. The purge does not delete them: `purged_at` is the durable record that the purge was carried out.

### Organisation status guard

The regional services do not hold organisation status, so they ask `api-global`. Each service's retention Lambda invokes its own `mint-for-<service>-offboarding` alias on the `api-global` mint Lambda (IAM-authenticated, no shared secret) to get a service token for the candidate organisation. It then calls [`GET /api/global/service/organisation-status`](https://help.stralos.app/internal/service-token-auth#service-only-endpoints). The token can make no other call. See [Service token authentication](https://help.stralos.app/internal/service-token-auth.md#caller-notes).

The guard fails closed:

* Only an explicit `disabled` authorises a purge. `active`, `provisioning`, a `404` (reported as `missing`), a mint refusal, a network error, or a malformed response all refuse it.
* The purge asks again immediately before it deletes anything, so an organisation reactivated between the sweep's check and the delete is still refused. `api-documents` re-checks before each destructive step.

`admin-api` is the source of organisation status and `api-global` holds the projected copy, so both read their own `organisations` table rather than calling the endpoint. Their purge transaction locks the organisation row and confirms it is still `disabled` before deleting.

### What a purge deletes

Each purge runs in a single database transaction per organisation. Every statement is scoped to that one organisation explicitly, because the platform role bypasses RLS (the `admin-api` database has no RLS to rely on). Soft-deleted rows are included: data a user deleted is still the organisation's data.

* **Regional services** hard-delete every org-scoped row, children before parents. `api-scheduler` also reads the live catalogue before deleting and refuses if a table carrying an organisation column is missing from its purge list. `api-estimate`, `api-rates`, `api-activities` and `api-documents` enforce the same coverage in their test suites, so a new table cannot silently survive.
* **`api-documents`** first deletes every S3 object version and delete marker under the organisation's prefixes (`uploads/`, `derived/`, `thumbnails/`, `pages/`, `sync-staging/`, `quarantine/`), plus the exact keys and per-document prefixes of documents stored before org-scoped keys. It then deletes the organisation's Google service accounts for drive sync, and deletes rows last, because the object plan and account names are derived from them. If the S3 pass or a service account delete fails (a Google failure also emits the `drive_identity_purge_failure` metric), the purge stops before deleting rows, and the next run retries.
* **`api-billing`** deletes its local DynamoDB items for the organisation, including the Stripe customer mapping. It never calls Stripe. The Stripe customer, subscriptions, invoices and payment records stay at Stripe, because NZ tax law requires them to be kept for seven years.
* **`api-assistant`** deletes the organisation's conversations, messages, drafts, action audit and usage counters.

### Directory services purge last

`admin-api` and `api-global` use a 104-day window: the 90-day policy window plus two weekly runs. Every other service therefore purges first, and a service whose purge failed one week can still run the next.

Neither directory service deletes the `organisations` row. Each anonymises it into a tombstone: the name becomes `Purged organisation <id>`, `status` stays `disabled`, and `purged_at` is set. The other services' guards and the mint can still read a stable `disabled` answer for a purged organisation.

`api-global`'s purge deletes the organisation's projects and their children, companies register rows, settings and catalogue rows, outbox rows, memberships, and every user who belongs only to this organisation. A sole user still referenced by a row in another organisation is anonymised instead of deleted. The tombstone also nulls `slug` and `country_code`. The admin-event projection drops any event about a purged organisation or an erased user, so the reconciler cannot write their data back.

`admin-api`'s purge deletes the organisation's memberships, seed snapshot, signup intents, sole users (queuing each Cognito delete), and the outbox rows, report-ledger rows and audit snapshots of those aggregates. A user who belongs to another organisation keeps their row; if this was their default organisation, the default is cleared. The tombstone slug is `purged-<id>`. The purge emits no events: deleting the `admin_outbox` rows stops the reconciler from re-sending them.

### Failures and alarms

One organisation failing never stops the sweep from attempting the others. After every organisation has been attempted, the Lambda throws if any failed, and the `retention` CloudWatch Errors alarm pages the environment's alerts topic. A privacy commitment cannot lapse silently.

## Per-user erasure

Support runs `scripts/erase-user.mjs` in `stralos-core` once the requester has been verified:

```bash theme={null}
node scripts/erase-user.mjs --env <dev|test|prod> --email <email> --request-id <ticket-id> [--dry-run] [--yes]
```

The script stops at the first step that is not a clean success:

1. Checks that the AWS credentials belong to the `--env` account.
2. Resolves the email with `api-global`'s `{ "kind": "erase-user-lookup", "email" }` event, which returns `{ userId, organisationIds }` and writes nothing.
3. Asks the operator to type the email and organisation IDs back (skipped by `--yes`).
4. Invokes every service's retention Lambda with `{ "kind": "erase-user", "userId", "requestId" }`.
5. Deletes the person's rows from the `stralos-<env>-identity-links` DynamoDB table, using the operator's own credentials.
6. Only if every report is well formed, invokes `api-global` last with the same event plus `reports`.

`api-global` goes last because its erasure overwrites the email the script looks the person up by. If a service fails, `api-global` is not called and no audit row claims an erasure that did not complete. Every service's erasure is idempotent, so recovery from any failure is to rerun the same command. `--dry-run` resolves the user, prints the plan, checks every function exists and counts identity-link rows, but erases nothing.

Every service validates the event the same way: `userId` must be a UUID and `requestId` a non-empty string. Each returns `{ service, userId, requestId, counts }`, and a second run reports zero counts.

### What each service erases

The erasure contract keeps user IDs and business records. Other services resolve display names from `api-global` at read time, so once `api-global` anonymises the user, their records show **Erased user**.

| Service | What is erased |
| - | - |
| `api-global` | `users.name` becomes `Erased user`; `users.email` becomes `erased-<id>@erased.invalid` (the column is `NOT NULL UNIQUE`, and `.invalid` can never receive mail or match a login); `idp_subject` and `last_login_at` are nulled; `status` becomes `disabled`. `project_access_requests.reason` becomes `Erased user`. Email-keyed `global_directory_outbox` rows are deleted. The user's regional DynamoDB sessions and OTP challenges are deleted before the email is overwritten. |
| `admin-api` | The `users` row, memberships, signup intents, project lead departure intents, directory backfill rows, `admin_outbox` and `runtime_report_seen` rows for the user's aggregates, and the `before`/`after` snapshots on matching `audit_log` rows (the audit rows stay). A Cognito `delete` is queued; the events worker's sweeper calls `AdminDeleteUser` once no admin row exists. |
| `api-login` | The user's whole partition in the central sessions table: sessions, family revocation tombstones and step-up rows. This also signs the person out everywhere. |
| `api-assistant` | Conversations (cascading messages and drafts), drafts the user created, action audit rows, and per-user rate counters. Organisation-level counters stay. |
| `api-scheduler` | A `scheduler_people` row linked to the user is anonymised (first and last name become `Erased` and `user`; preferred name, email, phone, employee number and notes are nulled), and notes and reference numbers on that person's licences, endorsements, competencies and unavailability are cleared. Structured scheduling facts stay. |
| `api-estimate`, `api-rates`, `api-activities`, `api-documents`, `api-billing` | Nothing. They hold only opaque user IDs, so they return empty counts. The event exists so the fan-out is uniform. |

`api-billing` does not change the Stripe customer. If the erased user was the organisation's billing contact, their email stays on the Stripe customer and on issued invoices, which are tax records. Support replaces a live organisation's billing email through Stripe.

### Audit record

`api-global` writes the audit row to `user_erasures`:

| Column | Content |
| - | - |
| `request_id` | Primary key. The support request ID. |
| `user_id` | The erased user's UUID. |
| `requested_at` | When the erasure was first recorded. |
| `completed_at` | When `api-global` applied the erasure. |
| `reports` | JSON array of every service's report, including `api-global`'s own. Counts only. |

The table holds no name or email. Rerunning the same `requestId` for the same user appends the new reports. A `requestId` already recorded for a different user is refused with `InvalidErasureRequestError`.

`admin-api` also writes an `audit_log` row with action `user.erased` when at least one of its counts is non-zero.

## Operations

For manual sweeps, checking clock state, and invoking one service's erasure directly, see [Data retention operations](https://help.stralos.app/internal/runbooks.md#data-retention-operations) in the runbooks.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.