# Logs: how S10 is built

What S10 built (plan CORE §3.12, §3.11; TuteDesk's Activity and Purge pages and LogPurger, 24 October 2026) and how
later slices add to it. The rules it serves: **every log is purged by date, its age a setting** (handbook 9.6), and
**every export is audited** (handbook 7.5). The logs themselves are S0's (`audit`, `auth_event`, append-only by
trigger) and S7's (`email_message`).

## The pieces

| Piece | What it does |
|---|---|
| `AuditWords` | How the audit log reads: `event()` (`audit.event.<event>`, read as one array since an event holds a dot), `subject()` (`audit.subject.<model>`), `channel()`, `actor()` ("The system" for none), `changes()` (each field before and after: a time in the reader's zone, a translation in their language, a list joined, keys and stamps left out), `name()` (a record's name, copied when the row is written) |
| `audit.subject_name` | The record's name as it was, written by `Audited` and `AuditTrail::record()`, so a rename or a removal never rewrites history. Never an ID number (an ID is named by its kind) |
| `Person`, `PersonIdentifier`, `User` | Everyone's records: a change to one belongs to the workspace it was made in (`auditTenantId()`: the current workspace; none from the platform's pages) |
| `AuditList`, `PlatformAuditList` | The audit log: a workspace's own rows (the log isn't a scoped model), the Service's every workspace's and the platform's, with a workspace column and filter |
| `AuthEventList` | The sign-in log, the Service's: failures and locks marked (`ALARMS`) |
| `PurgeList` | Every purge, newest first, each log's count and archive |
| `Export`, `ExportAuditLog` | An export asked for: the row (`export`: kind, filters, status, rows, path, `expires_at`), the queued job (tenant-aware) writing CSV in the asker's language and zone, a formula made text (`CsvExport::safe`), then `export.ready` |
| `ExportController` | The file: for whoever asked, while they may still export that log in its workspace, until it expires; each download audited |
| `TableLog` | One append-only table's part in the purge: count, archive, delete in batches by (id, time), so a partitioned table loses each row from its partition |
| `Cutoff` | A log's day: the platform's, and each workspace's that keeps its own (grouped by day in one query) |
| `LogPurger` | `logs()` (audit, auth_event, email_message), `days($key, $tenant)`, `floor()` (the setting's least), `due()` (each log's cutoff from the settings), `preview()`, `run()` (one `log_purge` row; no archive for a log with nothing due) |
| `logs:purge` | `--due` (the night's: each log by its age, then the expired exports) or `--before=Y-m-d` (one day everywhere, the operator's); `--log=`; a preview without `--force` |
| The schedule | `logs:purge --due --force` daily at 02:30, without overlapping, on one server |
| `PurgeLogs`, `ExpiredExports` | The night's purge run from the Purges page (a platform job); exports past their day: the row, and the file taken away (S11: the vault) |

## The settings

| Key | Levels | Bounds | Shipped |
|---|---|---|---|
| `log.audit_days` | platform, workspace | 365–3,650 days | 730 |
| `log.email_message_days` | platform, workspace | 30–1,095 days | 365 |
| `log.message_days` | platform, workspace | 30–1,095 days | 180 |
| `log.product_email_days` | platform, workspace | 30–1,095 days | 180 (plan NU, NU5: products' e-mail) |
| `log.auth_event_days` | platform (its rows have no workspace) | 90–1,095 days | 365 |
| `log.export_days` | platform | 1–30 days | 7 |

The bounds were `log_retention`'s metadata until S10; the list now only names the logs.

## Pages

| Page | Route | Permission |
|---|---|---|
| Audit log, one change, export | `/audit`, `/audit/{audit}`, `POST /audit/export` | `audit.view`, `audit.export` (Admin standard role) |
| The Service's audit log | `/console/audit…` | `platform_audit.view`, `platform_audit.export` |
| Sign-in log | `/console/sign-in-log` | `auth_event.view` |
| Purges, run now, an archive | `/console/purge`, `POST /console/purge` (step-up), `…/{purge}/archive/{log}` | `purge.view`, `purge.run` |
| An export's file | `/export/{export}` | its own person (PermissionGateTest lists it) |

The e-mail log and the audit log say how long they keep their entries; the ages are set on Settings › Logs.

## Adding a log

1. Its migration: append-only by trigger (`log_append_only()`), a time column, `tenant_id` if a workspace owns rows. Its
   personal columns are the trigger's arguments (`log_append_only('user_id', 'ip_address', …)`): only an erasure may
   empty them (S17, `privacy.md`); a person's name in it is either such a column or sealed (CORE-D17).
2. A `TableLog` in `LogPurger::logs()`; the setting `log.<key>_days` with its bounds (levels: a workspace's if its rows
   have one); its name in `log_retention` and `lang/en/lookup.php`.
3. `LogGuardTest` fails until all three are there. S11 added the file access log (`file_access`, `files.md`).

The night's purge also runs the **sweeps** (`Sweep`): `ExpiredExports`, S11's `DeletedFiles`, S12's
`StaleIdempotencyKeys`, S13's `OldMeasurements` (the operations page's job runs, readiness runs and worker beats) and
S14's `OldNotifications` (notices past `notification.keep_days`) and S15's `OldImports` (an import's rows and file past
`import.keep_days`; one never finished discarded), counted on the purge's row and shown on the Purges page. A purge's archives are kept in the vault (S11), opened by the checked route.

## The guards

| Test | Proves |
|---|---|
| `Guards/LogGuardTest` | every append-only table purged by date or kept for good (`person_merge`, `tenant_status_change`, S17's `consent` and `privacy_request_event`, S18's `setup_event`, NU6's `send_block_line`), each log's age a setting with bounds and named, the nightly schedule; every event the code writes and every audited record labelled; its self-test |
| `Log/PurgeTest` | the done-when: each log by its age, a workspace by its own, archived with a checksum, recorded; the floor by hand and in the settings; the schedule; the Purges page and the password before running one (through the real confirm route) |
| `Log/ExportTest` | the done-when: an export asked for, made, e-mailed, downloaded only by its own person while they may, until it expires, each step audited; the Service's; the list engine's export audited |
| `Log/AuditLogTest` | a workspace's own changes only, before and after, the name as it was; the Service's every workspace's; the sign-in log the Service's |
