# Settings: how S6 is built

What S6 built (plan CORE §3.7; TuteDesk's settings catalogue with trace, 24 October 2026, made one table for
Atrium's four levels) and how later slices add to it. The house rules it serves: an either/or becomes a setting at
the level it varies; a figure the code reads lives in one catalogue, never in `config()` scattered through the code.

## The pieces

| Piece | What it does |
|---|---|
| `config/setting.php` | The standard of every entry (the value shipped), env-backed where a server may want another (`SIGN_IN_PER_MINUTE`). Read only by `Entry::standard()` |
| `Entry` | One setting: `key` (`<group>.<name>`), `Kind`, the `levels` it may be set at below the platform, hard `min`/`max`, `unit` (counted words), `options` (an Option's answers, from the code) or `list` (a Choice's Input Fields list), `most` (Numbers), `exceptable`. `cast`, `words` ("5 tries a minute"), `span`, `answers` |
| `Kind` | `int`, `numbers` (several whole numbers, in order), `option` (an answer the code acts on), `choice` (an Input Fields value), `bool`, `text` |
| `Level` | `platform`, `tenant`, `unit` (a part of a workspace: the hook), `person` |
| `Catalogue` | Every entry Atrium reads, by group (`GROUPS`, with icons). Bound in the container, so a test stands in its own. `grouped($level)` is what a level's page lists |
| `setting_value` | One row per level and key, holding only what someone changed: `level` (`platform` · `tenant` · `exception` · `person`), `tenant_id`, `user_id`, `key`, `value` jsonb, `range` jsonb (a platform row's range for the levels below), `until` and `note` (an exception), `set_by`, `set_by_name` (a person's name as it was; null for the Service). A check holds each level to its keys; partial unique indexes keep one row per level and key |
| `Settings` | The one reader. `value`, `trace` (the current workspace and person), `resolve` (exactly the ones named), `range`, `rangeOf`, `rangeWords`, `latestEnd`, `who`. The platform's rows and each workspace's are cached until a write forgets them; a person's are read once a request; a failed read gives the standards |
| `Setting` | The one line per read: `Setting::int('lens.reason_min')`, `::bool`, `::string`, `::numbers`, `::value`, each with an optional workspace and person |
| `SettingRules` | The one validator for every page that writes a setting: `prepare` ("30, 60, 120" as numbers), `for($entry, $range)`, `messages` |
| `SettingsEditor` | Every write, each audited in words: `set` (at a level it allows; the platform's equal to the standard keeps no value), `follow`, `range`, `grant`, `change`, `end`, `sweep`, `row` |
| `UnitSettings` / `NoUnits` | The part-of-a-workspace level: a product with branches or campuses binds its own; Atrium's workspaces have none |
| `Limiters` | Every named limiter and the setting holding its figure (`sign-in` ⇒ `security.sign_in_attempts`) |
| `SettingPage` | What the pages draw: `entry` (a 404 for an entry the level may not set), `groups`, `heading`, `trace`, `form`, `rangeForm`, `above` |
| `settings:exceptions` | Daily at 00:10: removes the exceptions whose day has passed, each into the audit log |

## How a value is found

From the top down, each level replacing the one above where the entry allows it and someone set it: the standard, the
platform, the workspace, its part, the person. Every level below the platform is held within the platform's range
(a number at the nearest bound; an answer no longer offered gives way to the level above); the row keeps what was
chosen, so widening the range brings it back. An exception, while its `until` is in the future, stands above them all;
it stays within the hard bounds.

`trace` returns every level with its value, who set it and when, and its state: `win`, `over`, `blank` (may be set,
isn't), `skip` (not set at this level), plus `held` when held.

## The entries

| Key | Levels | Kind | Standard | Read by |
|---|---|---|---|---|
| `security.sign_in_attempts` | platform | number, 1–30 tries a minute | 5 | the `sign-in` limiter (`Limiters`) |
| `security.password_change_attempts` | platform | number, 1–30 tries a minute | 5 | the `password-change` limiter |
| `security.email_change_attempts` | platform | number, 1–30 tries a minute | 5 | the `email-change` limiter |
| `security.email_change_hours` | platform | number, 1–72 hours | 24 | `RequestEmailChange` |
| `sign_up.open` | platform | on or off | **off** | `SignUpController`, the sign-in page's link |
| `sign_up.attempts` | platform | number, 1–60 tries a minute | 5 | the `sign-up`, `sign-up-code` and `sign-up-resend` limiters |
| `sign_up.code_minutes`, `.tries`, `.resend_seconds`, `.resends` | platform | numbers | 15, 5, 60, 3 | `SignUps`, `SignUp` |
| `sign_up.form_seconds` | platform | number, 0–60 seconds | 3 | `SignUpController::looksAutomated` |
| `sign_up.per_network_per_hour`, `.per_email_per_hour` | platform | numbers | 5, 5 | `SignUps::guard` |
| `sign_up.pending_hours` | platform | number, 1–168 hours | 24 | `SignUp::prunable` |
| `lens.lengths` | platform | numbers, each 5–480 minutes, at most 5 | 30, 60, 120 | the lens form and its check (`LensController`) |
| `lens.reason_min` | platform | number, 1–100 characters | 10 | `OpenLens` |
| `workspace.reason_min` | platform | number, 1–100 characters | 10 | `ChangeTenantStatus` |
| `list.per_page` | platform, workspace, person | answer of `ListView::PAGE_SIZES` | 25 | `ListView` (a list's first page) |
| `list.export_rows` | platform; exceptable | number, 100–10,000 rows | 1,000 | `ListController::export` |
| `exception.max_days` | platform | number, 1–366 days | 90 | `Settings::latestEnd` |
| `log.audit_days` | platform, workspace | number, 365–3,650 days | 730 | `LogPurger::due` (S10) |
| `log.email_message_days` | platform, workspace | number, 30–1,095 days | 365 | `LogPurger::due` (S10) |
| `vouchers.batch_keep_days` | platform, workspace | number, 1–30 days | 7 | `Batches`: how long a printed batch's codes stay after its first download (plan TE, TE-5) |
| `vouchers.misses_per_hour` | platform | number, 10–100,000 misses an hour | 500 | `Vouchers`: codes not found from one product server an hour, every source together, before it waits (plan TE, TE-8) |
| `links.default_days` | platform, workspace | number, 1–1,825 days | 365 | `Links::make`: a short link's life unless its product says (plan TL, TL-4) |
| `links.keep_days` | platform | number, 30–3,650 days | 180 | `OldLinks`: how long past its end a link remembers where it went (TL-10) |
| `notify.codes_per_hour` | platform | number, 10–100,000 texts | 600 | `Nuntius::codeValues`: company-paid codes from one server in an hour (plan NU, NU7) |
| `log.product_email_days` | platform, workspace | number, 30–1,095 days | 180 | `LogPurger::due`: products' e-mail (plan NU, NU5) |
| `log.auth_event_days` | platform | number, 90–1,095 days | 365 | `LogPurger::due` (S10) |
| `log.export_days` | platform | number, 1–30 days | 7 | `ExportAuditLog` (an export's `expires_at`) (S10) |
| `log.file_access_days` | platform, workspace | number, 30–730 days | 180 | `LogPurger::due` (S11) |
| `file.deleted_days` | platform | number, 1–365 days | 30 | `DeletedFiles` (S11) |
| `file.give_up_hours` | platform | number, 1–72 hours | 24 | `FileIntake` (S11) |
| `api.per_minute` | platform | number, 10–6,000 calls a minute | 120 | the `api` limiter (S12) |
| `api.token_days` | platform | number, 1–365 days | 90 | `MintToken` (S12) |
| `notification.keep_days` | platform | number, 30–365 days | 90 | `OldNotifications` (S14) |
| `notification.poll_seconds` | platform | number, 10–300 seconds | 30 | the bell (S14) |
| `notification.quiet_hours` | person | on or off | off | `Preferences::heldUntil` (S14) |
| `notification.quiet_from`, `quiet_to` | person | number, 0–23 (hour) | 20, 7 | `Preferences::heldUntil` (S14) |
| `notification.digest_hour` | person | number, 0–23 (hour) | 7 | `Digests` (S14) |
| `import.max_rows` | platform | number, 100–20,000 rows | 5,000 | `SheetReader` (S15) |
| `import.undo_hours` | workspace | number, 1–168 hours | 24 | `Imports::undoableUntil` (S15, CORE-D14) |
| `import.keep_days` | platform | number, 7–365 days | 30 | `OldImports` (S15) |
| `search.per_minute` | platform | number, 30–1,200 searches a minute | 120 | the `search` limiter (S16) |
| `privacy.due_days` | workspace | number, 1–30 days | 30 | a personal-data request's due date (S17; the law allows 30 at most) |
| `privacy.link_days` | platform | number, 1–30 days | 7 | how long a copy's link works (S17) |
| `privacy.code_attempts` | platform | number, 1–20 tries a minute | 5 | the `privacy-code` limiter (S17) |

They replaced `config/tenancy.php`, `auth.sign_in.per_minute`, `service.lens_minutes`, `service.lens_reason_min` and
`ListDefinition::EXPORT_NOW`.

## Adding a setting

In the commit that reads it: the entry in `Catalogue::define()`, its standard in `config/setting.php`, its words in
`lang/en/setting.php` (`entry.<key>.label` and `.help`, a `unit` if new, its group's), and the read through `Setting`.
A security control stays platform only. A limiter adds its name and setting to `Limiters::ALL` and its
`RateLimiter::for` to `register()`. The guard checks the rest.

## Pages

Three levels share `setting/Index`, `setting/Show` (the trace) and `setting/Edit`:

| Level | Routes | Permission |
|---|---|---|
| Platform (the Service) | `/console/setting…`, plus `/range` | `platform_setting.view`, `platform_setting.manage` |
| Workspace | `/setting…` | `setting.view`, `setting.manage` (the Admin standard role has both) |
| Person | `/me/setting…` (the menu under the name) | none: their own (listed in the permission gate); closed inside a lens |

A setting is named in the address by its key (`/setting/list.per_page`). Exceptions: `setting/Exceptions` (on the list
engine, `SettingExceptionList`, every workspace on purpose) and `setting/Exception` (give or change), with
`platform_setting.exception`. An exception on a setting no workspace sees is audited as the platform's own line.

## The guards

| Test | Proves |
|---|---|
| `Guards/SettingGuardTest` | each entry sound (bounds in order, standard of its kind and within them, an answer it may take, its words); `config/setting.php` and the catalogue hold the same keys; no `config('setting.…')` outside `Entry`, none of the replaced keys; security controls the platform's only; every limiter's figure a catalogued number; its self-test |
| `Guards/NamedLimiterTest` | no plain `throttle:N,M`; every `throttle:` names a limiter that exists |
| `Settings/SettingsEngineTest` | each level in turn; never set at a level not allowed (and such a row ignored); held within the range and kept; an exception above all until its day, then swept; the standard keeps no row; follow; audit lines; the standards when the table can't be read |
| `Settings/SettingPagesTest` | the done-when: each value with where it comes from; a workspace refused every platform-only control, whatever is sent; the workspace's, the person's and the platform's pages; the limiter counting the new figure; exceptions given, changed, ended |
| `subscription.term_months` | platform | number, 1–120 months | 12 | `GrantEntitlement`, `entitlements:roll`: a term's length; Free and complimentary renew themselves (TB-8) |
| `subscription.grace_days` | platform | number, 0–60 days | 7 | `SetBillingTerm`: the days an organisation charged from today keeps everything while it chooses a plan (TB-16); an unpaid renewal's grace with TB3 (TB-11) |
| `subscription.reminder_days` | platform | numbers, 1–60 days, up to 4 | 14, 7, 1 | `Renewals`: the days before a period paid by hand ends that its organisation is reminded (TB-9, TB3) |
| `subscription.renewal_tries` | platform | number, 1–5 tries | 2 | `Renewals`: how many times a saved card is charged for a renewal (TB3) |
| `subscription.retry_days` | platform | number, 1–7 days | 2 | `Renewals`: the days between a declined try and the next (TB3) |
| `subscription.certificate_days` | platform | number, 7–120 days | 30 | `Invoices`, `billing:certificates`: how long a school has to send the certificate for what it withheld (TB-15, TB4) |
| `subscription.sends_per_minute` | platform | number, 1–600 a minute | 10 | The `invoice-send` and `invoice-shared` limiters: papers sent, and shared PDFs opened (TB4, TB6) |
| `subscription.notice_days` | platform | number, 30–365 days | 60 | `Launches`: the least notice before a product's billing switches on (TB-13, TB5) |
| `subscription.refund_days` | platform | number, 0–90 days | 14 | `Refunds::options`: ending a period within this many days of paying suggests giving all of it back; later, the unused days (TB-19, TB7) |
| `notify.sender_fallback` | platform, workspace | on or off | on | A client's text may go out under the company's name when its own isn't registered (NS-6); outside clients never |
| `notify.short_links` | platform, workspace | on or off | off | Texts' links through the short-link host (NS-11); nothing changes until Tutelink exists |
| `notify.reports_per_minute` | platform | number, 60–6,000 | 600 | Delivery reports a minute from one address (limiter `notify-report`) |
| `notify.charge_texts` | platform; the Service’s exception for one client (NU7: never the workspace’s own) | on or off | off | `Credit`: a client's texts paid for out of its plans' allowance, then its SMS credit; refused with neither (NU1). Off until the Architect sets the price |
| `notify.credit_low` | platform | number, 0–100,000 texts | 200 | `Credit::tell`: below this, owners, billing people and products are told once (NU-4) |
