Skip to main content
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

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:

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: 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. The token can make no other call. See Service token authentication. 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:
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. 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: 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 in the runbooks.