# Files: how S11 is built

What S11 built (plan CORE §3.13; TuteDesk's ticket files, any record, 24 October 2026) and how later slices use it. The
rules it serves: **no disk access outside the vault**, and **a file leaves the vault only through the checked route**,
for whoever its record's policy allows, in its workspace (handbook 9.1–9.3).


A workspace's logo (S18) is a file whose record is the workspace (`TenantPolicy::download`: anyone acting in it, and the
Service; the one policy method with no permission, named in `PermissionGateTest`), shown in the chip once its scan says clean.

## The pieces

| Piece | What it does |
|---|---|
| `file` / `File` | One file: `record_type`/`record_id` (any record), `tenant_id` (none: the platform's), `name` (as given, cleaned), `type` (an upload's `upload_type`), `content_type`, `size`, `state` (`scanning`, `stored`, `removed`, a CHECK), `removed_reason`, `sha256`, and, never sent to a page (`$hidden`), `path`, `wrapped_key`, `key_id`, `holding`. Soft-deleted. Not a scoped model: the checked route asks the workspace |
| `FileVault` | AES-256-GCM in pieces of `vault.chunk_bytes` (1 MB), each with its nonce and tag, bound to the workspace, the file's public id, its place and whether it is the last; a key per file, wrapped by `FILE_VAULT_KEY` with the same binding. `store`, `stream($from, $to)` (only the pieces asked, to the output), `read` (the whole file as a string, never through the output: S15 found the old buffered `stream` flushed a page's headers early), `rewrap`, `delete`. A piece moved, cut or changed opens nothing |
| `FileStore` | `make($record, $tenantId, $plainPath, $name, $type)` for files the app makes (unscanned), `delete` (soft, audited), `forget` (bytes and row, the purge's), `guarded` (a record holds files only with a policy that has `download()`) |
| `FileIntake` | `accept($record, $upload, $by)`: the extension against `upload_type`'s `extensions`, the bytes (`finfo`) against its `mimes`, its `max_mb`; held (`Holding`, 0600), audited `file.uploaded`, `ScanFile` queued. `process`: clean → a JPEG's location stripped (`PhotoLocation`), into the vault; infected → removed (`file.removed`); the scanner silent → waits, removed after `file.give_up_hours` |
| `Scanner` | `ClamAvScanner` (clamd's INSTREAM: the bytes sent, never a path) or `FakeScanner` (`FILE_VAULT_SCANNER=fake`, the EICAR string "infected"), bound in `AppServiceProvider` |
| `ScanFile` | The scan, queued in the file's workspace; retried 1, 5, 15, then every 30 minutes |
| `FileController` / `GET /file/{file}` | The checked route: stored, in the current workspace (or the platform's), its record's `download` policy; else 404 |
| `FileResponse` | Streams decrypted, with Range (206, 416); `Cache-Control: private, no-store`, `nosniff`; inline only for pictures and PDFs (`File::opensInline`), never an SVG; `?download=1`; an opening from the start written to `file_access`. Kept from the session (plan SEC, SEC-6): every file carries `Content-Security-Policy: sandbox` with nothing allowed but itself and `Cross-Origin-Resource-Policy: same-site`; a PDF opens in place only on the files host (Chrome won't draw a sandboxed one), and downloads on the app's hosts |
| The files host (`HOST_FILES`; SEC-7) | When set, `FileController` logs the opening and redirects to `/f/{file}` on that host, signed (`signed:relative`) for 5 minutes and that file alone (`SignedFileController`), outside the web group, so no session is read there. `EnforceHost` keeps that host to `f/*` and every other host off it |
| `file_access` / `FileAccess` | Who opened which file when: the file's public id and name copied (no foreign key), the actor's name, the address. Append-only; a log the purge empties (`log.file_access_days`) |
| `DeletedFiles` | A sweep of the night's purge: files taken away more than `file.deleted_days` ago, bytes and row |
| `ExportPolicy`, `LogPurgePolicy` | The two records holding files today: an export's own person while they may export, in its workspace, until it expires; whoever may run a purge |
| `vault:key`, `vault:rewrap` | A new master key (printed); every file's key re-wrapped with the current one (`FILE_VAULT_PREVIOUS_KEYS` while it runs) |

## What keeps files

| Record | File | Workspace | Who opens it |
|---|---|---|---|
| `Export` (S10) | the CSV, `text/csv` | its own, or none for the Service's | its own person, while they may export, until `log.export_days` (`/export/{export}` and the checked route) |
| `LogPurge` (S10) | each log's archive, `application/gzip` | none | whoever may run a purge (`purge.run`) |
| `BillingWithholding` (plan TB, TB4) | a withholding certificate a school sends, a PDF or an image (`FileIntake`, scanned) | its own | whoever sees the organisation's bills (`billing.view`), and the Service (`BillingWithholdingPolicy`) |

S15's import is the first upload. A new record: a policy with `download(User, Record)`, then `FileStore::make` or
`FileIntake::accept`.

## Settings

| Key | Levels | Bounds | Shipped |
|---|---|---|---|
| `file.deleted_days` | platform | 1–365 days | 30 |
| `file.give_up_hours` | platform | 1–72 hours | 24 |
| `log.file_access_days` | platform, workspace | 30–730 days | 180 |

## The server (config/vault.php)

| Env | What it is |
|---|---|
| `FILE_VAULT_KEY` | "base64:" and 32 bytes (`vault:key`); without it nothing is kept or opened |
| `FILE_VAULT_PREVIOUS_KEYS` | old keys, comma-separated, while `vault:rewrap` runs |
| `FILE_VAULT_ROOT`, `FILE_VAULT_HOLDING` | the vault disk (`storage/app/vault`) and the holding area (`storage/app/holding`) |
| `FILE_VAULT_SCANNER` | `clamav` (production), `fake` (tests) |
| `CLAMAV_SOCKET`, or `CLAMAV_HOST` and `CLAMAV_PORT`, `CLAMAV_TIMEOUT` | where clamd answers |
| `FILE_VAULT_CHUNK_BYTES` | the piece size (1 MB; 1 KB in tests) |

Back the vault up with the database. Every test runs on a fake vault and a holding folder of its own (`tests/TestCase`).

## The guards

| Test | Proves |
|---|---|
| `Guards/VaultGuardTest` | the done-when: no `Storage::`, `->disk(` or disk set-up outside `app/Support/Files`; no page sent a vault path or a key; its self-test |
| `Tenancy/TenantSweepTest` | the done-when: the sweep's downloads: another workspace's owner, the file's own person acting in another workspace, the Service without a lens all find nothing |
| `Files/VaultTest` | pieces, ranges, tampering (swapped, cut, changed, passed off as another file), the key changed and re-wrapped, no key, a record without a policy, nothing hidden leaks into `toArray` |
| `Files/IntakeTest` | a clean upload into the vault; the type, the bytes and the size refused in words; infected; the scanner silent then given up on; a photo's location removed and nothing else |
| `Files/FileRouteTest` | private, unsniffed, logged; ranges and 416; nobody else, nothing removed; pictures inline and sandboxed, PDFs downloaded on the app's hosts, an SVG never; the files host's signed hand-off, refused when tampered with, expired, for another file or on the wrong host |
| `Guards/LogGuardTest` | `file_access` is purged by date with its age setting |
