# Input Fields: how S5 is built

What S5 built (plan CORE §3.6; TuteDesk's Input Fields and TuteLoop's translatable labels, 24 October 2026) and how
later slices add to it. The house rule: no option list in code. Anything that fills a picker, or a `Rule::in`, reads
Input Fields, and the validator reads the same active list as the picker.

## The pieces

| Piece | What it does |
|---|---|
| `lookup_category` | One list: `code` (the route key), `name` and `description` per language, `kind` (`system`, `reference`, `open`), `module`, `retired_at`. Platform data: no `tenant_id` (CORE-D5) |
| `lookup_value` | One option: `value` (the code records store, never changed), `label` and `description` per language (spatie/laravel-translatable, jsonb, English always), `sort_order`, `is_active`, `is_locked`, `metadata` (jsonb) |
| `LookupCategory::options/rule/label/meta` | How code reads a list: `options($code)` value ⇒ label, active only, in order; `rule($code)` a `Rule::in` over the same values; `label($code, $value)` the words, a retired value's too; `meta($code, $value, $key)` |
| `Lookups` | Behind those: each list read once per request (scoped), the current language else English, `{brand:…}` filled. A list the code reads but the database lacks throws: `lookups:check` finds it first |
| `Catalogue` | Every list the code reads: its kind, module, `used_by` (the columns that store it, so a value in use is never deleted), `fields` (the metadata the page may change) and the values the code relies on, each with its starting metadata and whether it is `locked` |
| `lang/en/lookup.php` | The starting words: `<code>.name`, `<code>.description`, `<code>.values.<value>`. Read as an array, because codes contain dots (`tenant.status_changed`) |
| `CatalogueSync` / `lookups:sync` | Writes the catalogue: lists and values it lacks, English words it lacks, metadata keys it lacks, system values locked and on. Never overwrites a label, order, switch or metadata the page changed. Not audited (it is the deploy, not a person). `--dry-run` says what it would do |
| `lookups:check` | Fails when a list or value the code relies on is missing, has the wrong kind, a system value is off or unlocked, or a value has no English label. Run after `lookups:sync` on every deploy |
| The data migration | `2026_10_24_000900_apply_lookup_catalogue` runs the sync, so a fresh database (and the test database) has every list |
| `InputFieldEditor` | The only way the page changes a list, by its kind: `add` (open lists only; the code made from the English label), `update` (words per language, the catalogue's `fields`), `setActive` (never on a system list, never off for a locked value), `moveTo` (a place, the rest renumbered in steps of ten), `delete` (open, unlocked, and no record holds it), `usage` (counted from `used_by`) |

## The starting lists

| Code | Kind | Module | What reads it |
|---|---|---|---|
| `identifier_kind` | open | people | The person forms' ID kind; `Identifier::fits` reads each kind's `pattern` |
| `language` | reference | languages | `tenant.locale`, `user.locale`: one value per `lang/<code>` folder, added off by `lookups:sync`, switched on only when complete, English locked on (S9, `languages.md`). Labels and templates are written in every folder's language, on or not |
| `currency` | reference | regional | `tenant.currency`; `symbol`, `decimals` in metadata |
| `upload_type` | open | files | The file vault's uploads (`FileIntake`, S11): `extensions` (the names it takes), `mimes` (what its bytes may read as), `max_mb` (changed on the page) |
| `mail_event` | system | mail | The mail engine's triggers (S7) |
| `auth_event` | system | audit | The sign-in log's event names (they were `lang/en/auth_event.php`) |
| `notification_category` | system | notifications | Each event's kind, the rows of What reaches you (S14); `icon` in metadata |
| `import_date_order` | system | imports | How an import reads a day: `dmy`, `mdy`, `ymd` (`ImportDates`, S15) |
| `workspace_colour` | open | workspace | A workspace's chip colour, `hex` in metadata (the brand layer's `brand.workspace_colours` seeds them; S18) |
| `country` | open | workspace | Where a workspace is; `time_zone`, `currency`, `locale` in metadata fill its region (S18) |
| `privacy_channel` | open | privacy | How a personal-data request came: in the app, e-mail, letter, in person (S17) |
| `consent_purpose` | open | privacy | What a person may agree to; a value's description is the wording, `version` in metadata raised when it changes (S17) |
| `log_retention` | system | logs | The logs' names (the purge, the Purges page). Their ages and bounds moved to the settings `log.<key>_days` in S10 ("a figure the code reads is a setting") |
| `organisation_kind` | open | organisation | `tenant.kind`: what kind of organisation a record is; a product serves some kinds (CL4) |
| `region`, `district` | open | organisation | Where an organisation is: Ghana's regions, and districts under a region in metadata (CL4) |
| `refund_reason` | open | subscription | `billing_refund.reason`: why the Service gives money back (TB-17, TB7) |
| `voucher_void_reason` | open | vouchers | `voucher.voided_reason`: why the Service voids a code or a batch (plan TE, TE-11) |
| `link_off_reason` | open | links | `link.revoked_reason`: why the Service turns a short link off (plan TL, TL-5) |
| `block_lift_reason` | open | notify | `send_block_line.reason` on a lift: why the Service lets products write to a blocked address again (plan NU, NU6) |
| `credit_reason` | open | notify | `sms_credit_line.reason`: why the Service adds or takes SMS credit by hand (plan NU, NU1) |
| `certificate_reject_reason` | open | subscription | `billing_withholding.reject_reason`: why the Service rejects a withholding certificate (TB-23, TB10) |
| `payout_channel` | open | subscription | `billing_refund.payout_channel`: how a refund Paystack couldn't send was sent by hand (TB-21, TB7) |

## Adding a list

Add it to `Catalogue::all()` in the commit that reads it, with its words in `lang/en/lookup.php`, and read it only
through `LookupCategory::options/rule/label/meta`. Deploy runs `lookups:sync` then `lookups:check`. A list nothing
reads any more goes in `Catalogue::RETIRED` with why: the sync retires it and keeps its rows for old records.

## IDs on people

One ID per workspace on Add and Edit (`PersonRequest`: the kind by `LookupCategory::rule('identifier_kind')`, the
number by the kind's pattern). `Identifier::normalise` keeps letters and digits in capitals, so a number typed two
ways is one number; `PersonIdentifier` normalises on the way in, and the matcher finds a person on the platform by it.
`UpdatePerson` changes, adds or takes away the workspace's own ID (`source_tenant_id`) and refuses another person's
number. A person's page shows `Identifier::mask` (first three, last three).

## Pages

`platform/InputFields` (on the list engine: `LookupList`), `platform/InputFieldList` (a list's values:
`LookupValueList::for($category)`), `platform/InputFieldValue` (a value, or a new one). Permissions `lookup.view` and
`lookup.manage`, both platform level: the Service's, outside a lens. A platform list has no saved views (they belong to
a workspace).

## The guards

| Test | Proves |
|---|---|
| `Guards/InputFieldGuardTest` | every list the code reads is in the catalogue; each stored where it says; English words for every value; `lookups:check` clean, every system value locked and on; a retired value still renders and is refused for new records; its self-test |
| `Guards/NoHardcodedListsTest` | no literal list in a validator, a `<SelectItem>`, an `<option>`, or a script's `{ value: '…' }` (choices that are code are listed with why) |
| `InputFields/InputFieldPagesTest` | the Service's alone; each kind's rows draw only what it allows, and the editor refuses the rest; adding, retiring, deleting while unused, words, metadata and place; the sync never overwrites the page |
| `InputFields/PersonIdentifierTest` | the kinds offered and refused; the pattern; one way of writing; matched across the platform; changed and taken away by its workspace; another's number refused; masked |
