# Imports: how S15 is built

What S15 built (plan CORE §3.21; TuteDesk's ticket import made generic, TuteCampus's kinds and template, TuteLoop's
status and row outcome, 25 October 2026). The rules: **one engine, kinds registered**; **the check writes nothing**;
**a row goes through the form's own doors** (its rules, its matcher, its action); **undo leaves what someone changed**.

## The steps

`Imports` (in the workspace), one step at a time; the page (`import/Show`) shows the step the import is at and asks
again every 2 seconds while one runs in the background.

1. **The file** (`start()`): a CSV or XLSX through `FileIntake` with the import as its record (S11: the type list,
   the scan, the vault). `advance()`, called by the page, reads it once the scan says clean (`SheetReader`, OpenSpout,
   streaming; at most `import.max_rows`) into `import_row` (the sheet's row number, its cells), and guesses the
   columns (`ImportHeadings::guess`) and how dates are written (`ImportDates::guessOrder`). A file the scan removed,
   or that can't be read, ends the import `refused`, saying why. One unfinished import per person and kind.
2. **The columns** (`columns()`): field → column, each column one field, the required ones present; the date order
   (`import_date_order`, a system list); the kind's own options (`ImportKind::chosen`: people's ID kind and roles map).
   **Remember** writes `import_mapping` (heading as compared → field), which the next file's guess reads first.
3. **The check** (`check()`, the `CheckImport` job): each row through `ImportKind::check()`, in order, with one
   `Repeats` shared between them. It writes the row's values, problems (`[column, value, words]`, in the reader's
   language) and outcome, and the import's counts. **Nothing else**: `ImportTest` fingerprints every other table
   (its rows, in order) before and after.
4. **Importing** (`run()` then `import()`, the `RunImport` job, one try): **as the importer** (`Imports::as()`: their
   account is the actor, their membership the current one, so the gate and the role ceiling are theirs; every audit row
   carries the import's tag `import:<public id>`). Each row the check passed in its own transaction through
   `ImportKind::write()`; a `ValidationException` (the world changed since the check) makes it a row to fix, and the
   rest go on. Then `import.finished` in the audit log, and the `import.finished` event to the importer.
5. **Undo** (`undo()`), until `finished_at + import.undo_hours`: each written row, unless `ImportKind::changed()`
   says why it stays (stored in `import_row.kept`), goes through `ImportKind::undo()`. `undoPreview()` (an optional
   prop) says first how many go and which stay.

**Discard** (`discard()`) ends an unfinished import: its rows and its file go. The night's purge (`OldImports`, a
sweep) clears every import's rows and file after `import.keep_days` and discards one never finished; the line stays.

## The pieces

| Piece | What it does |
|---|---|
| `Import`, `ImportRow`, `ImportMapping` | Workspace-scoped models. `Import::STATES` and `ImportRow::OUTCOMES` are code (CHECK constraints too) |
| `ImportKind` | The contract a kind fills: key, permission, fields, example, index route, its two lists, options, check, write, changed, undo, title, finish |
| `ImportKinds::ALL` | The registry; `ImportKinds::route($kind, $page)` names `import.<kind>.<page>` |
| `ImportField` | A field: key, label key, how it reads (`text`, `date`, `id`, `roles`), required, extra heading words, hint |
| `ImportHeadings` | How a heading is compared, each column's sample and distinct values, the guess |
| `SheetReader`, `ImportDates` | The file into text cells; a day from the ways a file writes one |
| `ImportTemplate` | The kind's XLSX: a Data sheet (its headings, an example) and a Notes sheet |
| `CheckImport`, `RunImport` | The two jobs, each carrying the workspace and language (`CarriesTenant`) |
| `OldImports` | The purge's sweep |
| `ImportList`, `ImportRowList` | Earlier imports, and one import's rows by outcome (the list engine); a kind registers its own subclasses |
| `ImportController` | The pages; reads the kind from the route's default, never a request value |
| `ImportPolicy` | View and undo: the kind's permission in the import's workspace; update and download: also its maker |

## People, the first kind (`PersonImport`)

- **Fields:** first name, other names, family name, e-mail, mobile, date of birth, ID (of the kind chosen; the ID
  kinds' names are extra heading words), roles, *your number for them* (`membership.original_ref`, unique in a
  workspace).
- **The check, in order:** the form's rules (`PersonRules`, shared with `PersonRequest`; a date that can't be read
  is its own problem); each roles value through the map (else a role of that name within reach); a repeat of an
  earlier row's e-mail, mobile, ID or number is `twice`; a number already on someone here is `here`; then as
  `AddPerson` decides, without writing: an e-mail held here by someone else is a row to fix; a sure match
  (`PersonMatcher`) already here is `here`; someone elsewhere on the platform is `new`, like anyone else, so the
  check never tells a workspace who is known elsewhere (plan SP; until then it said `linked`, an oracle); the same
  name and date of birth as someone here is a row to fix (the form asks; a file can't); else `new`.
- **Write:** the rules again, then `AddPerson::add($data, 'new')` (which says how: `new`, `linked`, `restored`, kept as
  the row's `made`; the details go on the membership, plan SP), the
  membership's `import_id` and `original_ref`, then `RoleEditor::assign()` with the row's roles (none: the default
  role stays). A restored place first records the roles it had.
- **Changed since** (`changed()`): the membership gone (`removed`); any audit row on the membership at or after the
  row was written that isn't under the import's tag (`changed`); for someone it made, an account (`account`). (The
  person's own record no longer holds what a workspace typed, so an edit is always on the membership.)
- **Undo:** restored → its old roles back, removed again; linked → the membership goes, and with it this workspace's ID
  for them (the database's key, plan SN D2; before, the ID stayed); new → the membership goes and, with no membership
  anywhere and no account, the person and their IDs too.
- **What follows:** invitations (`InviteImported`, a job: each membership it made with an e-mail, no account and no
  open invitation, through `InvitePerson`, as whoever asked, under the import's tag).

## Adding a kind

1. A class extending `ImportKind`; register it in `ImportKinds::ALL`.
2. Two lists: `<Kind>ImportList extends ImportList` and `<Kind>ImportRowList extends ImportRowList`, in `Lists::ALL`.
3. Its permission in the catalogue (and a standard role, if any); its words in `lang/en/import.php`: `title.<kind>`,
   `before.match.<kind>`, `go.run.<kind>`, `heading.<kind>.<field>` (comma-separated), its field labels and hints.
4. `check()` reads only; `write()` goes through the same action as the kind's form; `changed()` answers from the
   audit log under the import's tag.

`Guards/ImportGuardTest` refuses a kind whose permission isn't in the catalogue, whose routes or lists are missing, or
whose words are.

## The guards

| Guard | Test |
|---|---|
| The check writes nothing but the import's rows | `Imports/ImportTest` (every other table fingerprinted) |
| A row the form refuses, the import refuses | `Imports/ImportTest` (a dataset through both) |
| 1,000 people import as a job, in its workspace, as the importer | `Imports/ImportTest` |
| Undo leaves what someone changed since | `Imports/ImportTest` |
| Every kind whole: permission, routes, lists, words | `Guards/ImportGuardTest` |
| A policy decides by the kind's permission | `Guards/PermissionGateTest` (`$kind->permission()`) |
