# Changelog Notable changes, newest first. Dates are the merge date. Until 1.0 the format is prose rather than a strict category list: what changed and why it matters to somebody running a server. ## Unreleased **Fix: installing Apache frees port 80 and the site web server is a recorded choice (#517).** The panel's plain-HTTP ACME block is now its own nginx file (`installer/nginx/maran-port80.conf`, installed as `conf.d/maran-port80.conf`), so nginx's panel vhost binds the panel port alone; the Debian family's packaged default site is taken out of the enabled set. `InstallApp(apache)` parks that block (validated, nginx reloaded) before Apache starts, puts it back if Apache does not start, and removing Apache restores it after the package is gone. Which web server serves sites is the agent's marker `/etc/maran/site-web-server` (`nginx` or `apache`): `ListApps` reports a web server only per the marker (Apache also needs its binary), so a fresh host, where nginx is present as the panel's front, lists none and the first-run setup happens; `InstallApp(nginx)` writes it, `UninstallApp` clears it (the nginx package is never removed), and the account-home owner, php-fpm pool user and the startup web-tree pass read it instead of a binary. The installer also comments out the distribution's own default port-80 server in `/etc/nginx/nginx.conf` (RHEL family; a no-op where the stock file has none), reversibly, and uninstall restores it. Re-run the installer to get the split file. **Change: the panel has an Apps module: the app store backend and the first-run gate (#517, backend).** `GET /api/v1/apps` (searched, filtered by kind and state, sorted and paged on the server), `GET /api/v1/apps/installable` (what the host's package sources offer, never a static list), `POST /api/v1/apps/{id}/install` and `/uninstall` (202 with a recorded task whose progress and log the task list shows) and `POST /api/v1/apps/{id}/service/{action}` are administrator-only; `GET /api/v1/apps/stack` is readable by every signed-in role. An app is named by the closed catalogue (`nginx`, `apache`, `php-<7.4 to 8.4>`, `nodejs-<18, 20, 22, 24>`) and nothing else reaches the agent. Only one site web server can be installed or on its way at a time, and removing an app a site uses is refused with `409 AppsInUse` and the sites listed under `sites`. The panel's ledger of installed apps (`apps.InstalledApps`) is settled against the host at start-up, the host winning, and is what the stack gate reads: while no web server is installed the Sites module refuses site creation and every site change with `AppsStackNotReady`. The agent contract is revision 27, and the demonstration installs its web server and PHP versions through the same command. **Change: the agent has an app store: it lists, installs, removes and controls web servers and runtimes (#517, agent and proto).** `proto/agent/v1/apps.proto` adds `AppsService` (`ListApps`, `ListInstallableApps`, `InstallApp`, `UninstallApp`, `ControlAppService`), `sites.proto` gains the `SiteWebServer` choice and the Node.js backend, and three error codes (`APP_NOT_INSTALLED`, `APP_IN_USE`, `WEB_SERVER_ALREADY_INSTALLED`) join `common.proto`; the contract revision is 27. An app id is parsed against a closed catalogue (`nginx`, `apache`, `php-`, `nodejs-`) and the package comes from the distribution adapter, never from the caller. Installing and removing run through the installer's `maran-app-install@` and `maran-app-remove@` root units, with the unit start and the reading of a failed run shared with PHP in `maran_ops::packages`. What an app is on the host is probed, never remembered. Removal is refused while a site or an account's pool uses the app, nginx (the panel's own front) is never removed or stopped, and installing Apache enables its modules, writes the panel's port-80 challenge virtual host and the include of the agent's vhost directory. PHP pools and account homes follow the installed site web server's identity. **Change: the panel gets an app store, a first-run stack wizard and a Node.js site form (#517, SPA).** `/apps` (Settings, administrators) lists the installed apps one server page at a time, with server-side search, a kind and state filter (a modal on a desktop, a bottom sheet on a phone) and start, stop, restart and uninstall in each row; below it, what the host's package sources can still install, one button each. Installs and removals are the recorded tasks the panel returns, followed until they end (a reload picks a task in flight up again from its row), and a removal refused because sites still use the app shows the panel's sentence and the list of those sites. A new `stackSetupGuard`, registered after the auth guard, holds an administrator on the `/setup/stack` wizard (web server required, then PHP and Node.js versions or none, a review, and installs one at a time with live progress) from every URL until the panel reports a web server installed, and holds everyone else on a "panel is being set up" screen; a stack status that cannot be read lets the visit through, because the server refuses site work regardless. The menu offers the PHP pages only while a PHP version is installed, the site form offers the backends and runtimes the panel's choices list, names the host's web server, and a Node.js site asks for a version, a port (1024 to 65535) and a start command. The PHP versions page no longer installs anything: it points to the app store. Locales en, ru and hy; Playwright specs with guarded stubs for the wizard, the guard redirects, the app store actions, the in-use refusal, the site form's runtime filtering and the 390 px layout, axe scans of every new screen in both themes, and Node-run specs for the stack store, the guard and the apps store. **Change: the agent renders sites for Apache and runs Node applications (#517, agent sites).** A site names its web server (`maran_distro::SiteWebServer`, mapped from the `web_server` field of `sites.proto`), which decides the vhost directory (`/etc/maran/nginx/sites` or `/etc/maran/apache/sites`), the dialect, the validator (`nginx -t` or `apachectl -t`) and the service that is reloaded; the distro adapter answers Apache's control script, unit, user and group per family. New `templates/src/apache/` mirrors the nginx shapes (static, PHP through `proxy_fcgi`, proxy, redirect, TLS block, suspended stub) plus the `maran-acme.conf` host for the panel's own challenge. A Node.js site (`SiteKind::Node`: `NodeMajor`, `Port`, `NodeStartCommand`, whose program is a closed `node`/`npm` choice and whose arguments exclude `$`, `%`, quotes and control characters) is proxied to its loopback port and runs under a per-site `maran-node-.service` unit written before its vhost and removed after it. A request limit on an Apache host is refused (`RequestRateOnApache`) because Apache has no such module; a name served twice is refused by a scan of the agent's own Apache directory because Apache's `-t` does not warn. **Fix: the panel names the installed web server on every site call and tells the app store which sites a runtime serves (#517, integration).** Deleting a vhost, reloading the web server, and every re-render of a vhost (enable, disable, PHP version change, options, account suspension and resumption, certificate install, removal and renewal, the start-up PHP pass and the creation's compensating delete) now carry the installed site web server (`DeleteSiteRequest.web_server`, `ReloadWebServerRequest.web_server`, `SiteSpec.web_server`), which the wire would otherwise read as nginx and so write nginx files on an Apache host; the Sites module implements `ISiteRuntimeUsage` so an uninstall of the web server, a PHP version or a Node.js major that a site still uses is refused with the sites listed; the generated `SiteWebServer` and `SiteBackendType.Node` names replace the guessed ones; the task list names `AppInstall` and `AppUninstall` in en, ru and hy, and the app store draws an `unknown` service status as neutral. **Change: sites follow the installed stack, and a site can be a Node application (#517, Sites backend).** `CreateSite` and every site-mutating command (change PHP version, enable, disable, delete, replace options, change log retention) refuse with a conflict `AppsStackNotReady` while no site web server is installed, and a creation is rendered for the web server that is installed. A PHP version or Node major must be installed as an app, otherwise `AppsNotInstalled`, which also answers the PHP versions, extensions and settings endpoints while no PHP app exists. A new `node` backend takes `nodeVersion`, `appPort` (1024 to 65535, not shared with any other site on the server) and one printable-line `startCommand`, stored on the site (migration `SiteNodeBackend`, expand only, defaulted columns) and carried on every vhost re-render, the certificate hand-over and an account move. `GET /api/v1/sites/choices` offers only the backends whose runtime is installed, and lists the installed PHP versions, Node majors and the web server. `POST /api/v1/sites/php-versions/install` and `GET /api/v1/sites/php-versions/installable` are removed in favour of the Apps module. **Change: the installer's package units are app units, and a fresh host has no site runtime (#517, installer and polygon).** `maran-php-install@.service` is replaced by `maran-app-install@.service`, and a sibling `maran-app-remove@.service` (apt-get / dnf / zypper `remove`, never purge, autoremove or force) takes an app's package away, both with the same sandbox and a 4.4 ceiling; the uninstaller removes both, the unattended OS update hold list gains `apache2*`, `httpd*` and `nodejs*`, and the ubuntu24, alma9 and leap16 polygon images no longer preinstall php-fpm (the systemctl stand-in runs the real package manager for the two unit kinds). New polygon assertions cover the fresh-host state, a real Apache install and removal with inverse controls, and the uninstaller's removal of both units; `docs/superpowers/notes/2026-11-10-app-install-uninstall-threat-note.md` carries the threat note (second review OUTSTANDING, #331) and `docs/php/` says PHP is an app. **Fix: two polygon real-host cases that could not observe what they claimed.** `removing_one_of_two_ssh_rules_leaves_the_port_open_to_the_other_source_only` listed the runtime-ban table although allowing and denying a port never load it (the installer seeds it and the first ban ensures it), so it now loads the table the way a ban does before reading it back; and `restore_recovery_on_a_real_host`, whose #487 case makes a real SFTP bind mount, moved from the unprivileged polygon step to the privileged one (`.github/workflows/agent.yml`, `docker/README.md`), because a container without `CAP_SYS_ADMIN` answers `JailFailed` there. **Fix: the real-host import-refusal case no longer reuses a published id.** The case imported the package of a backup still published under its id, which the agent rightly answers as a converged retry before it looks the system user up; it now frees the id first so the refusal for an existing account is what is observed, and a unit test pins that a published package still converges once its account exists. **Fix: the SPA gate's teardown, card and census failures.** The network guard no longer waits on the raw header event or on the body of a request still in flight when the findings are read (26 specs timed out tearing the guard down); the audit journal's subject and action values carry no `whitespace-nowrap` of their own, so a card at 390px breaks an identifier instead of scrolling; the route census reader ignores prose that names `path:` in backticks, which had swallowed the marketplace and migration sweep rows; the editor Tab spec goes to the document start with Control+Home; the account-import stub counts the upload's body bytes from the request body. **Fix: the audit journal keeps an identifier whole, and a section drawn as one row is found by its page's words.** The journal's subject column wraps a sentence the backend spelled out (a chain verdict, a retention change) at word boundaries but keeps a stored identifier such as an account name on one line, mono, as it did before the readable subjects (#509). A sidebar section left with a single page is drawn as that one row, and the jump-to palette now finds it by that page's search words (typing "crontab" finds the row that opens Scheduled tasks). The stubbed lane's specs were brought in line with what the panel does: `.check()` on the kit's visually hidden checkbox and radio inputs is forced, a dialog is awaited closed before its opener is queried again, the breadcrumb is read at a width where it fits, the sidebar is 240px, and a new 390px sweep (`e2e/migration/no-horizontal-scroll.spec.ts`) opens every server-migration screen in the three languages so the route census passes. **Fix: the remote-server screens read the enum casing the panel sends, and the stubbed lane's bodies conform to the contract.** The panel writes every enum camelCase (`ready`, `hostKeyChanged`, `genericLinux`, `php`), but the SPA's migration types, state badge, filter sheet, connection-test result and locale keys were written PascalCase, so a real server in the `ready` state matched no branch; they now use the wire spelling in all three locales. The stubs were corrected to the real wire shape (DNS record and sub-user ids are UUIDs, calendar days stay in the month, list rows carry no detail-only fields, the cron history rows carry no output, and an entry that never ran answers `204`), and the network guard reports a body as unparseable only when the text was read and does not parse, not when the browser had already dropped it on navigation. **Fix: gate failures in the Dns, Backups, Licensing and Marketplace tests.** A zone at its record limit no longer refuses a customer record because certificate validation records sit in it (they never count against the limit, as documented); the backup options list names the `migration` kind and the failure names cover the agent's `AgentPackageNotAvailable`, in en, ru and hy; and five tests were corrected to what the code rightly does (a variable valid in both A and TXT records, a lease must belong to the installed licence, a two-key file needs a line break between blocks, the sweeper's test container resolves the zone editor, a base64 key is searched as text because JSON escapes its `+`). **Fix: the OpenAPI document lists the minimal endpoints (`maran openapi`).** The `openapi` verb returned before the panel's endpoints were mapped, so the document held the controller actions and none of the minimal ones (`/api/v1/modules`, the dashboard routes, the search). The verb now maps the same endpoint list a served start does (`MapPanelEndpoints`), the module catalogue and the dashboard declare their answer, and the frame file route is excluded from the API document. The SPA contract guard no longer judges a framed packaged module's own routes (`X-Maran-Frame`) against the panel baseline. **Fix: the SPA and the panel agree on four request contracts (`maran api`).** The migration-job control sent `Cancel`, `Pause`, `CutOver`... where the panel serialises `cancel`, `pause`, `cutOver`; the SPA now sends the wire spelling. The DNSSEC calls and the marketplace calls built their paths in helper functions the contract check cannot follow, so it saw `POST ''`; each path is now written out where it is called. `MarketplaceController` carried no `[Route]` of its own (it relied on the base controller's prefix), so the check never read it and `PUT /api/v1/marketplace/settings` looked unserved; it now states `api/v1/marketplace` like every other controller. **Feature: whole-server migration, phase 2 (#197).** From a registered server's discovered accounts an administrator creates a migration job, maps each account to a plan, an owner and a primary domain, and reads a dry run that changes nothing and lists every conflict (a taken account or domain name, a database that cannot keep its name, a plan limit, two accounts of the job claiming one domain; names are never renamed). The engine then creates each account and copies its home with `rsync` over the pinned SSH key, as the account's own user, in slices that a pause, a changed bandwidth limit or a cancel can interrupt at a boundary, and loads each database through the restricted one-database loader (a dump with a view, routine, event or definer is refused, never loaded with more privilege), up to 1 to 8 accounts at once (default 2); the source keeps serving. Cut-over is the administrator's decision per account: the final pass deletes what the source deleted, loads the databases again and fetches each site from this panel's own web server, and a comparison report states what was established. A failure before the first sync deletes the account the job created and never blocks the others; a failure at cut-over keeps the account for a retry. A DNS helper shows where the job's domains resolve (A and AAAA only), and the summary downloads as CSV or JSON. A source that is a Maran panel is moved by the package path of #127 with one pull grant per account. Mail and cron entries are counted and warned about, not carried; nothing is ever written to a generic source. Agent contract revision 26 (`PullAccountFiles`, `StreamRemoteDatabase`), `rsync` joins the installer's package list, and `docker/polygon/images/source-linux` plus `docker/polygon/asserts/server-migration.sh` rehearse ten accounts three at a time. Second review of this privileged surface: OUTSTANDING (#331); threat note `docs/superpowers/notes/2026-11-07-server-migration-threat-note.md` ("Amendment: phase 2 as built"). **Feature: the panel renews its licence lease itself (#8, D4).** Nothing wrote `lease.json`, so every online licence lapsed at once and a marketplace download failed as `LeaseRequired`. The Licensing module now asks the licence service for a lease about once a day (a first attempt a few minutes after start, only when the stored lease is missing or 20 hours old), through the named outbound pipeline to `https://api-marketplace-maran.innovayse.com` (`Licensing__LeaseServiceBaseUrl`), sending the licence id and nothing else. The answer is stored, atomically, only when it parses, verifies under the pinned lease key, names this licence and server, was issued within the last day and ends later than the lease already stored; any other answer changes nothing and the old lease keeps covering paid modules until it ends. Nothing is sent for an offline licence or with no licence installed. Each stored lease and each failure is written to the audit journal; three failures in a row raise one operator alert (mail, webhook, bell) and one when it recovers. `rules/security.md` item 10 names it as the fifth documented exception (owner decision 46, option a); the contract is in `docs/marketplace/api-v1.md` and `openapi-v1.yaml`, the operator guide in `docs/operations/licensing.md`. Second review of this privileged surface: OUTSTANDING (#331); threat note `docs/superpowers/notes/2026-11-07-licensing-lease-threat-note.md` (fourth amendment). **Feature: pulling a package from another Maran server, expiry of exports, certificates and extra database users in an account move, and the lists the screens need (#127, backend).** An administrator on the sending server issues a single-use grant for an export; on the receiving server they type the sending panel's address (`https://host`, nothing after it), the grant and the key fingerprint, check a preview of the package's signed head (up to three reads), and pull: the receiving panel makes one HTTPS GET with the grant in the `Authorization` header and nothing else, and stages the package exactly as an upload is staged. The grant opens one export, one read of the artifact (taken at the first byte, under a row lock, so two simultaneous requests cannot both succeed) and expires in at most an hour; a wrong, expired, used, revoked or address-refused grant is one bodyless 401 that says nothing about which, wrong ones are counted per address and lock the address out, and a refused destination (loopback, link-local, multicast, this server's own addresses, judged on the address actually connected to, never on the URL; private ranges are allowed on purpose) is never connected to. Exports are removed seven days after they were made, artifact and record, by an hourly pass that journals each removal under the panel's own name. The account move now carries the account's certificates as metadata (the dry run says which to issue again and which to upload again; no certificate or key travels) and its additional database users with their grants (new passwords are shown once). The download streams with a `Content-Length`; exports, imports and pull grants have paged, searchable lists; an import's failure has a localized name; and an import is a task in Tasks. nginx gets locations for the package upload (256 GiB plus 4 MiB, no request buffering), the pull and the downloads. Second review of this privileged surface: OUTSTANDING (#331); threat note `docs/superpowers/notes/2026-11-07-account-move-threat-note.md` ("The pull transport as built"). **Feature: the backend half of moving an account between Maran servers (#127), and the migration module is renamed `ServerMigration`.** An administrator can export an account (a backup is made, every module hands over named fields only, never a password or key) and download its package, a file signed by the server's own key; on the receiving server the administrator uploads it with the key fingerprint they compared on the sending server, reads a dry run that changes nothing and lists every conflict (a taken account name, a hosted domain, a plan limit, a missing PHP version), then starts the import with two typed confirmations. A background job creates the account, its databases, sites, SFTP and FTPS logins and cron entries (imported switched off) through each module's own commands, restores the home and the databases (the dumps load through the agent's restricted one-database loader), and deletes the account again if any step fails, refusing to delete any account that existed before the import began. New passwords for databases and logins are shown once and then forgotten; the owner's invitation is made but not mailed. The job acts as the administrator who started it, re-checked at every step (a new job-caller primitive), so a demoted administrator halts it. Single-use pull grants can be issued and revoked, **but no endpoint accepts one yet**, so moving a package is by download and upload only. The module that holds the remote-server registry is now `ServerMigration` (schema `servermigration`) because its old name shadowed EF Core's `Migration` class. The remote-server actions and the new ones are refused in the public demo. Second review of this privileged surface: OUTSTANDING (#331); threat note `docs/superpowers/notes/2026-11-07-account-move-threat-note.md`. **Not included:** the pull endpoint, the screens, the installer's upload limit and expiry of old exports. **Feature: lists for the account move, and the import's failure in words (#127, SPA follow-up).** Three new screens under "Moving accounts": the exports made, the imports staged or run, and the pull grants issued. Each is one server page (page, size, search and sort in the address; imports and grants take a state filter in a sheet), so the panel searches, sorts and counts and the browser never narrows a list. Grants are listed without their token and a live one can be revoked from its row after a confirmation; an export's screen no longer keeps a list of its own and links to the grants page already searched by its account, which replaces the earlier "issued from this screen" list. A failed import shows the panel's own sentence with the code beneath it (the SPA keeps no table of codes), and a running import links to its background task, opened already narrowed to the import kind and the account. English, Russian and Armenian; Playwright specs with contract-shaped stubs and axe scans of the three lists in both themes. The OpenAPI baseline still has to be regenerated for the migration endpoints. **Feature: the screens for moving an account between Maran servers (#127, SPA part).** A new "Moving accounts" section of the sidebar (administrators only, refused while viewing as a login) holds the remote servers, an export screen and an import screen. Exporting picks an account and shows the package with the server's key fingerprint to retype on the receiving side, the download, a plain notice that the package is not encrypted, and the single-use pull grants (the token is shown once in a dialog that Escape and the backdrop cannot dismiss, and is dropped from memory when it closes; grants can be revoked after a confirmation). Importing uploads a package with the sending server's fingerprint, shows the package, runs the dry run against a chosen plan and reseller with every module's findings (conflicts first, the cron-entries-imported-disabled notice apart), keeps the start off until the account name and the fingerprint are retyped and no conflict remains, follows the job by its stage, and hands over the generated credentials once. The module is now keyed `servermigration` in the SPA's landing-route table, which fixes the module-landing coverage check. English, Russian and Armenian; Playwright specs on the guarded fixture and axe scans in both themes. **Fix: the marketplace remembers a running install across a restart, lists installed modules by name, and presents the licence lease (#186, #187).** A retried install, update, remove or rollback is answered with the running task's id even after the panel restarted, because the module's operation claim row now names the operation, version and task instead of process memory (new nullable columns, expand-only migration). The Installed list shows the catalogue's name for a module, kept with the ledger when the package was staged, instead of its id. The lease credential has a wire shape, `Authorization: Bearer base64url(payload).base64url(signature)`, the lease the licence service issued handed back unchanged, defined in `docs/marketplace/api-v1.md` and `openapi-v1.yaml`; the Licensing module's `LeaseRequestAuthorizer` attaches it only while the licence verifies and its lease is good, adds nothing else to the request, and an offline licence presents none, so a download is no longer refused as `LeaseRequired` for want of any authorizer. The component that fetches and writes `lease.json` is still not in this repository. Second review of the lease credential: OUTSTANDING (#331); threat note `docs/superpowers/notes/2026-11-07-marketplace-loader-threat-note.md`. **Fix: the OpenAPI document describes the wire (#519).** The generator now lists the minimal endpoints (module catalogue, dashboard, attention count, first-run, search) beside the controller actions, writes a nullable reference member as `nullable` with a one-element `allOf`, describes each `[JsonPolymorphic]` family (resource ceiling and count, invitation status, quota backing, cron run end, dashboard) as a `oneOf` of its cases with the discriminator on every case, describes a dashboard section as its loaded and unavailable shapes, and a `SensitiveString` as the plain string it is written as. The committed baseline is regenerated by `maran openapi --accept` in its own commit; the SPA contract check refuses an unknown member again. **Docs: the operator guide and the module-author pointers for the marketplace (#189).** `docs/operations/marketplace.md` says when the panel contacts the marketplace (only on an administrator's action, only after the switch is turned on, never from a timer or at start-up), exactly what it sends, the host `api-marketplace-maran.innovayse.com` and how to point the panel at another with `Marketplace__BaseUrl`, how a download is checked, what "restart required" means, update, rollback and remove (remove keeps the module's data), every failure message, and what is not promised: a module is trusted code in the panel process, not a sandbox, and the root agent is never extended. It states plainly that this repository has no screen or command that installs a package file, and points module authors to `docs/marketplace/ipm-format.md` and the `maran ipm` verbs. The public website pages are the owner's. **Security: the marketplace's threat note, written for the service client, the download path and the re-entered password (#188).** `docs/superpowers/notes/2026-11-07-marketplace-loader-threat-note.md` gains the twenty attacks of the issue and nine more against the implemented slice, four keys with four purposes, the download and grant path as coded, the reauthentication window, the supply chain, residual risks R8 to R12, the four locks that keep an unapproved call from leaving the server, the installer's key and directory step, and what the author could not verify. Second review: OUTSTANDING (#331); the mutation table is a stub marked NOT RUN. `rules/security.md` items 9, 10 and 13 and section 13 of the design spec carry the owner-approved amendment (decision list items 46 and 47, option a, 2026-11-09): the marketplace is the fourth documented outbound destination, and the loader is closed C#, not closed Rust. **Feature: the Marketplace screens of the panel (#187, SPA part).** Settings gains a Marketplace page, administrators only. Its first card is the switch (off by default): it shows the host, the panel's own list of what is sent, and asks for the password to turn the marketplace on or off; with the switch off the page makes no request but the status read. With it on, three tabs: Available (server-side search, load more, licence state, a cabinet link only when the panel marks the address as a trusted https link, the panel's reason when a module cannot be installed, designed states for an unverified catalogue and an unreachable marketplace), Installed (lifecycle, failure text, "data kept" after a removal, rollback and remove, and a confirmed "Restart the panel" that waits for the module on the local panel) and Updates (nothing is checked until "Check for updates" is pressed). A module's own page shows its versions, plain-text release notes and the manifest summary. Install and update show the panel's copy of the manifest (publisher, tier, schema, dependencies, agent capabilities, permissions) and need an acknowledgement and the password; progress is followed through the task and survives a reload. **Feature: the marketplace's panel API and its installer pieces (#187).** The installer creates `/var/lib/maran/marketplace` (owned by the service account, mode 0700, through a helper that never follows a symbolic link planted in the service account's own tree) and installs `installer/keys/marketplace-metadata.pub` to `/usr/local/maran/keys/` when the release bundle carries it; when it does not, it prints "marketplace key not bundled: the marketplace will show catalogues as unverified" and continues. `installer/panel.env.example` documents `Marketplace__BaseUrl`, `Marketplace__MetadataKeyPath` and `Marketplace__PackageDirectory`. The polygon's directory census lists the new directory (17 paths). Enabling the marketplace, installing, updating, removing and rolling back ask for the administrator's current password again. **Feature: the marketplace service client and its contract (#186).** `Maran.Modules.Marketplace` lists modules, shows a module's details, checks installed modules for updates and downloads a package through a single-use grant, but only after an administrator switches it on (off by default). Every answer must carry a P-384 signature from a pinned metadata key and be less than 24 hours old; a redirect or download address on another host is refused; the package is stored under a size and SHA-256 check before the closed loader sees it. `docs/marketplace/api-v1.md` and `docs/marketplace/openapi-v1.yaml` are the contract the cloud service implements. Nothing is installed without the closed loader and the licence lease, neither of which is in this repository. **Feature: the SUSE family, openSUSE Leap 16.x, is admitted on a branch (#59).** UNVERIFIED until the first `leap16` polygon run: nothing about SUSE was run by us. Leap 16.0 and 16.1 (and SLES 16.0, claimed through Leap) get an agent adapter, an installer arm, a polygon image and a nightly polygon leg; PR smoke is unchanged. The family ships one PHP minor from the distribution's own repositories, offers no FTPS and no mail, and `firewalld` is stopped and masked once SSH is confirmed in the agent's nftables table. The one wire change is additive, `DISTRO_FAMILY_SUSE = 3`, with the contract at revision 25. Fedora and Alpine are declined and refused by preflight and by the agent. **Feature: register a remote server and discover what it hosts, the first phase of server migration (#197).** An administrator can register a Linux server as a migration source: the agent generates an ed25519 key for it (the private half is kept encrypted by the panel and never shown), the panel gives the `authorized_keys` line to paste on the source, with a stated limit that the key cannot be restricted to read-only, the agent scans the source's host keys, and the administrator confirms the fingerprint as a step of its own before it is pinned (a changed key is refused, never accepted). A connection test reports who it logged in as, the free space of `/home`, the system release, the database and web server and whether mail is present, and a read-only discovery lists the source's accounts with their sites, databases, cron entries, mailboxes and disk use in a paged, searchable list; mail is counted and warned about, not migrated. The agent gains its first outbound client (`ssh`, `ssh-keyscan` and `ssh-keygen`, a closed set of read-only commands, the host name resolved once and the resolved address checked), the installer adds the OpenSSH client package on both families, and `PROTO_VERSION` is 24. Administrators only; the second review of the threat note is outstanding (#331). **Not included yet:** mapping and dry run, the parallel file and database transfer with pause, resume and cancel, the verification report, the DNS helper, cut-over and the Maran-to-Maran adapter, all of which need the #127 backend that is not in the tree. **Test: a real-stack browser lane, a stub contract check and a network guard (#519).** `maran e2e-real` (also `maran test e2e-real`) drives a real Chromium against a panel and agent that are really running, with no stub and by clicking only: journeys for the administrator, the reseller and the customer (every sidebar page, account create/suspend/reactivate/delete, view-as, the first sign-in of an owner given an initial password), every page in Russian and Armenian, and state that must not travel between screens. A journey fails on a 4xx/5xx it did not announce, a console error, a response that disagrees with `docs/api/openapi-baseline.json`, English chrome or a raw key on a translated page. The stubbed suite gets the same guard as an opt-in `fixtures/guarded-test.ts` (an unstubbed request, an unannounced refusal, a console error or a drifted stub body fails the spec), and `e2e/contract/stub-contract.spec.ts` checks the shared fixtures against the baseline. The lane runs nightly against the panel named by the repository variable `MARAN_E2E_REAL_URL`. **Feature: a paid module's licence lapse and recovery are written to the audit journal, the search follows the licence, and the gate is proved on the wire (#8).** The Licensing module's `LicenceCoverageWatcher` (a hosted service, every five minutes, outside any request) reads the same cover the gate reads and writes `LicenceModuleLapsed` when a module's licence or lease ran out and `LicenceModuleRecovered` when it is covered again, as the panel itself (`maran-licensing`) with no account on the entry, once per change; the subject is `module=;from=;to=`, never the licence id, text or machine id. `SearchModuleGate` no longer asks a tier-only fallback that locked every paid module: it asks the licence window, once, for the paid modules the panel composed, so a covered paid module is searchable and a lapsed or uncovered one is not, while an included module consults no licence and a panel with no paid module never asks (`ModuleStateResolver.Of(LicenceTier)`, which encoded the fallback, is gone). A Host integration test boots a paid probe controller behind `[RequiresLicensedModule]` and installs a signed test licence through the real endpoint: the probe is refused with 403 `ModuleLicenceRequired`, served under the licence, refused again after it expires, and `/health`, the licence status and sign-in are served in every state. Agent: property tests (`proptest` `=1.11.0`, a dev-dependency of `maran-agent-core` only) hold `AccountName`, `Domain`, `RelativePath`, `CronCommand`, `EnvVarValue` and `SshPublicKey` to their grammars over generated input, from a fixed seed. Not done: the production lease client (#186). **Feature: the licence window, the request-time gate and the lease (#8).** One rule now answers whether the installed licence covers a module (`ILicenceEntitlements`: covered, not covered or lapsed, with an end date), and the module catalogue (new `lapsed` state and `validUntil`), the new `[RequiresLicensedModule]` gate (403 `ModuleLicenceRequired` for everyone, an administrator included) and a paid module's own work all ask it, so the menu and the boundary cannot disagree. A licence that is not an offline licence (new optional signed `offline` member) needs a signed 14-day lease (`/var/lib/maran/lease.json`, checked against a lease key that is separate from the licence key); a lease that is late keeps the cover for its whole life, a missing or ended one lapses the paid modules. Every module that ships is still included, so nothing changes for an existing server; nothing in the panel fetches or writes a lease yet. The Licensing module joined the module registry, `maran status` prints a `Licence:` line, and `docs/licensing/` holds the conformance vectors (test keys only) that any other implementation of the window must match. The second review of this privileged surface is OUTSTANDING (#331); see `docs/superpowers/notes/2026-11-07-licensing-lease-threat-note.md`. **Feature: a packaged module's screen appears in the menu without rebuilding the SPA, in an isolated frame (#185, part of #181).** `GET /api/v1/modules` now also carries `version`, `lifecycle`, `isPackaged`, `spa` and `failureDisplay` for each module; the shell adds `/m//…` routes and a "Modules" menu group from the catalogue and shows the screen in `UiSandboxFrame` (`sandbox="allow-scripts"`, opaque origin, no network of its own), served by the panel from `/module-frames/{id}/{version}/` with a generated document and a pinned classic entry script. The frame talks to the shell only over a versioned message bridge (nonce handshake, then a `MessageChannel`; unknown, malformed, oversized or too frequent messages are dropped and counted, three violations or ten silent seconds show a retryable unavailable panel), and the shell relays its API calls with `X-Maran-Frame: `: the backend refuses (403 `ModuleFrameOutOfScope`) any endpoint that module does not own. `rules/vue.md`, `rules/architecture.md` and `rules/security.md` (new item 14) state the decision. nginx serves `/module-frames/` with its own header set while `/api/` keeps `X-Frame-Options DENY`; the package validator refuses `.html` under `spa/` and a non-`.js` entry; `frontend/packages/module-bridge` is the author's client. The open panel ships `NullModuleSpaAssets`: a real package needs the separately distributed loader. The second review of this privileged surface is OUTSTANDING (#331). **Sdk: the seams the marketplace compiles against (#186), and a password re-entry window (#187).** `IVerifiedModulePackageSource` and `ExpectedModulePackage` let the closed loader check a downloaded package against what the signed catalogue announced (`ModulePackageNotAsAnnounced`), `ILeaseRequestAuthorizer` attaches the licence lease without handing it over (default `NoLeaseRequestAuthorizer`: anonymous, browse only), and `IReauthentication` is answered by Identity (`PasswordReauthentication`): a wrong password counts against the same lockout as a wrong sign-in and every failure is the one `ReauthenticationFailed` refusal. `SharedKernel/Utilities/Signatures/EcdsaP384Signature` is now the only P-384/SHA-384 verification (the package signer and verifier delegate to it). New audit actions `MarketplaceClientAuditActions` and `IdentityAuditActions.ReauthenticationFailed`, and the task kinds `ModuleInstall`, `ModuleUpdate`, `ModuleRemove` and `ModuleRollback`. **Feature: an administrator-only Licence screen shows the licence status and installs a pasted licence (#8).** Settings > Licence (`/settings/licence`) renders the backend's own state, advice, tier, covered modules, expiry and lease line, and installs a pasted licence with the backend's refusal shown as sent; the module catalogue gains the `lapsed` state, which the interface treats like `locked`. **CI: a nightly installer polygon on Ubuntu 24.04 and AlmaLinux 9, and a weekly one on the other eleven images (#8).** `.github/workflows/nightly.yml` builds an offline bundle signed with a throwaway key and runs `maran installer --mode offline --bundle` on a hosted runner, passing only on the printed `INSTALL-POLYGON-OK` line; it also runs on demand. The Playwright golden path is not part of it (it needs a real backend), and `rules/testing.md` now says what runs where. No timeout has a measured basis yet. **Security: a reseller can no longer change an account's owner email (#482).** `PUT /api/v1/accounts/{id}/owner-email` is administrator-only (a reseller or its API key is answered 403): the address decides where the customer's next invitation and password reset go, so a reseller that could name it could take over a customer who never agreed. No confirmation-by-the-old-address flow exists, so the administrator is the only caller; the SPA no longer offers the action to a reseller. Threat note: `docs/superpowers/notes/2026-10-30-account-owner-email-threat-note.md`. **Security: a reseller can no longer create an account with a login shell or a password it chose (#488).** `CreateAccountCommandHandler` refuses `ShellAccess` and a first password (`loginMode` `initialPassword`) from a reseller or a reseller's API key with 403 (`AccountShellAccessAdministratorOnly`, `AccountInitialPasswordAdministratorOnly`) before anything is provisioned; a reseller's accounts have no login shell and the owner chooses the password through the invitation. An administrator is unchanged, and the create form offers a reseller the invitation alone. Threat note: `docs/superpowers/notes/2026-11-07-new-account-form-threat-note.md`. **Fix: Armenian says "reseller" with one word (#388).** Every `hy` string, SPA and resx alike, now uses "ռեսելլեր", the term the reseller screens and menus already used; the thirty-odd strings that said "վերավաճառող" (the Accounts and Identity tables, the view-as banner, the security-policy switches and the demo notice) were rewritten. No key changed. **Fix: Armenian sidebar labels match the pages they open (#356).** `app.nav.auditJournal` is "Աուդիտի մատյան", `app.nav.securityPolicy` is "Մուտքի կանոններ" and `app.nav.phpVersions` is "PHP տարբերակներ", the same words as each page's heading; all three still fit one line of the 216px sidebar. **Fix: Russian strings use one word per concept (#357).** Two-step verification is "двухэтапная проверка" everywhere (the resx tables said "двухфакторная аутентификация"), a hosting account is "аккаунт" (not "учётная запись"), a signed-in session is "сеанс", sub-users are "субпользователи", e-mail is written "email", the licence tier is "уровень лицензии", and the view-as texts read "от имени". No key changed. **Refactor: `UiChart.vue` is split by concern (#352).** The plot (`UiChartPlot`), the value labels (`UiChartYAxis`), the time labels (`UiChartXAxis`, with the label-collision measuring in `composables/useChartTimeAxis.ts`) and the sr-only readings table (`UiChartReadings`) are their own components; the view box and the round-step range moved to `utils/chartView.ts` and `utils/niceChartRange.ts`. `UiChart`'s props, markup, class names and behaviour are unchanged, and every file is under 400 lines. **Refactor: `UiDropdown.vue` is split by concern (#353).** Keyboard navigation (`useDropdownItems`), panel placement and following the trigger on scroll (`useDropdownPlacement`), dismissal by an outside press (`useDropdownDismissal`) and the trigger's classes (`utils/dropdownTriggerClasses.ts`) left the component, which keeps its props, emits and slots (including `chevron` and `aria-label`) and every file is under 400 lines. **Refactor: `AgentBackupClient.cs` is split by operation (#354).** The create and restore streams (`BackupCreateStream`, `BackupRestoreStream`), the wire mapping of destinations and scopes (`BackupDestinationMapper`), the listing mapping (`BackupListingMapper`) and the payload-free stream events (`BackupStreamEvents`) are internal types beside it; `IAgentBackupClient`, the error codes, the logger category and the log operation names are unchanged, and every file is under 400 lines. **Fix: English UI spells "email" and "Username" the same way everywhere (#109).** The invitation screens said "e-mail" and the SMTP settings said "User name"; they now match the rest of the English interface. The database "User name" label is left for its own change. **Tests: `InvitationTokenHasher` has unit tests like its sibling hashers (#111).** Five tests pin the base64url shape, uniqueness, determinism and the SHA-256 digest of a known token. **Refactor: the agent's "not found" code is one constant (#348).** `Maran.Agent.Client.Errors.AgentErrorCodes.NotFound` replaces the private `"AgentNotFound"` copy that thirteen module handlers and translators each kept, and the literals in the simulated agent and the Files translator; a test reads it back from the client's error table. **Docs: the agent unit's cgroup comment matches the code (#349).** `maran-agent.service` no longer says no systemd-run exists in the agent; it says cron run-now starts it through the adapter, into the account's slice. **Feature: the SFTP key and folder-picker search fields refuse a term the server would refuse, and say so (#350).** A term over 100 characters shows an error under the field (`aria-invalid`, `aria-describedby`) and is not sent. `e2e/ui-kit/field-error-state.spec.ts` covers the error state of `UiSearchInput` and `UiDateInput`. **Feature: automatic security-patch updates, on by default and switchable (#383).** `maran-update-security.timer` starts `maran-update-security.service` once a day (midnight plus up to four hours of random delay), which runs `maran update --security-only --scheduled` as root: the same lock, version check, pre-update dump, health gate and code-only rollback as an operator's update, applied only when the signed manifest marks the release `security` (`maran release build --security`) and its major version is the installed one; anything else is reported and left alone. A first online install enables the timer and says so; an offline install does not; `maran update-schedule enable|disable|status` switches it and the choice survives a reboot, an installer re-run and an update. A failed run rolls the code back, is remembered so that release is not retried, and reaches the panel's audit log and the operator's alert mail. The check sends nothing but the HTTPS request for the signed manifest. Servers moved to this release by `maran update` get the units only when the installer is run on them again. `rules/security.md` item 10 and the design spec now name the timer as a documented exception; the second review of this privileged change is outstanding and the owner's written approval of the exception is not recorded. **Security: local sendmail and PHP mail() no longer bypass the mail checks (#500).** Postfix's `authorized_submit_users` defaulted to everybody, so any customer script could drop mail into the maildrop queue and have `pickup` send it to any recipient with any sender, past the login, the sender check and the send limit. It now admits only root, the Postfix owner and the system users listed in `maran_local_submit_users` (in Postfix's own directory, seeded empty), so local submission is closed until an account is listed. Binding a listed account's local mail to its own sender and send limit is still open and recorded in the mail threat note. **Refactor: `scripts/lib/release-bundle.sh` is split by concern (#351).** The entry point keeps the header, the sourcing and the dispatch; the shared definitions, component builders, integrity manifest, `cmd_build`, sign and verify, and the selftest (in three files, one function per outcome group) moved to `release-bundle-support.sh`, `release-build-components.sh`, `release-integrity.sh`, `release-cmd-build.sh`, `release-sign-verify.sh`, `release-selftest.sh`, `release-selftest-completeness.sh` and `release-selftest-fields.sh`. Every file is under 400 lines and no line of logic changed. **Refactor: `scripts/lib/mutate.sh` is split by concern (#355).** The entry point keeps the header and sources, in run order, `mutate-arguments.sh`, `mutate-guards.sh`, `mutate-restore.sh`, `mutate-apply.sh`, `mutate-lanes.sh` and `mutate-verdict.sh`. Every file is under 400 lines; the `maran mutate` behaviour and output are unchanged. **Docs: the pull request template groups its gate checklist by area (#112).** It now names `maran proto`, `maran api`, `maran migrate guard`, `maran lock selftest`, `maran handshake` and `maran licenses --check` beside the ones it had, says which change each applies to, and links CONTRIBUTING.md for the full list. **Tests: axe scans the cron run history (#359).** `e2e/accessibility/axe-scan-cron-run-history.spec.ts` reaches the screen by clicks from the scheduled-tasks list and scans it in both themes with one run of each outcome in the table. **Docs: `llms.txt` at the repository root summarises Maran for AI assistants (#362).** It links the README, the documentation site, the architecture section, CONTRIBUTING.md and the good-first-issue label. **Feature: the reseller role (#179, #389).** A reseller is a tenant between the administrator and the customers: it owns its own plans and accounts and sees nothing else. An administrator manages resellers under Resellers (`/api/v1/resellers`: list, create, rename, set the envelope of accounts and disk the reseller may hand out, suspend, reactivate, delete) and invites the reseller to set a password. A reseller signs in to its own overview (`GET /api/v1/reseller/me`, usage against its envelope) and creates plans and accounts inside the envelope; an account made by a reseller is invisible to every other reseller, and account, reseller and domain names cannot collide across owners. The menu is the administrator's tree filtered by role, so a reseller never sees an administrator screen, route or menu item, and the server refuses by role on every request whatever the SPA shows. An administrator can **View as** a reseller (and as a customer) from the reseller's page: a read-only session of fifteen minutes under a banner naming the operator, journalled under the administrator. Later reseller work is listed under its own issues: branding, API keys, account transfer, the audit view, rename and invitations (#367-#374), and the reactivation rule (#493). **Feature: the header avatar opens the account menu (#462), and the sidebar is a little wider.** Clicking the avatar in the header opens the same account menu as the sidebar's user block (profile, sessions, two-step verification, sign out); the sidebar widens from 13.5 to 15 rem so longer translated labels fit. **Feature: the Panel updates screen can check for updates (#516).** An administrator-only **Check for updates** button asks the server to fetch the signed release manifest and record what it names, so the screen no longer depends on an operator running `maran update --check` or waiting for the timer. The panel creates a fixed marker (`/run/maran-update-request/check-requested`, no body, nothing the caller chooses) in the request directory the update button already uses, and a root-owned path unit (`maran-update-check.path`) starts `maran-update-check.service`, which runs the fixed `maran update --check` and removes the marker when the check ends, so the marker is also what the screen shows as "Checking for updates"; the check installs nothing. `POST /api/v1/panel-update/check` answers 202 with no body and `GET /api/v1/panel-update` gains `checkInProgress`; the screen follows the check and says whether it recorded a result (the last-checked time moved) or not. Rate-limited by its own bucket, `update-check`, 10 per hour per administrator (`RateLimiting__UpdateCheckMaxRequests` and `RateLimiting__UpdateCheckWindowSeconds`); audited as `PanelUpdateCheckRequested`; refused while an update or another check is in flight. The installer installs and enables the new pair with the update request's units (step 69), the uninstaller removes it, and the polygon assertion `update-request-units.sh` holds the new service and path unit as code (30 and 8 planted breaks, 78 in all with the update request's, and accepting controls). **The second security review of this privileged path is OUTSTANDING and the branch must not merge to `main`** until a human reviewer has read the threat note, `docs/superpowers/notes/2026-11-09-panel-update-check-threat-note.md`. Operator documentation: `docs/operations/panel-update.md`. **Feature: DNS zone templates and DNSSEC (#452).** Administrators manage zone templates (closed variables `{apex}`, `{ipv4}`, `{ipv6}`, `{ns1}`, `{ns2}`, `{hostmaster}`; built-in "web" and "web and mail") that seed a new zone, the default one seeds zones created from a site, and records a template wrote stay editable by the zone's owner. A zone's owner or an administrator can sign a zone with a panel-generated ECDSA P-256 key (one key, no automatic rollover), read the DS record to publish at the registrar, and withdraw signing: the withdrawal is finished only 48 hours after it began, enforced by the server. DNSSEC is hidden on a host whose name server cannot sign. Agent contract revision 22. The second review of the key handling is outstanding (#331); nothing here has been built or run on a real host yet. **Fix: the audit journal and the dashboard's latest activity show readable, localized subjects (#509).** A chain verdict, a retention change or run, an alert-threshold change and a log-level change were stored as machine lines (`intact rowsChecked=164`, `disk=90;cpu=off;memory=off -> ...`, `Warning -> Information`) and printed as they were, in English, in every language, and a sign-out named the person by a bare id. The journal's rows are unchanged (they are hash-chained and an export reads them), and the entry now carries `subjectName`, spelled by the backend in the request's language beside the stored `subject`; a subject the backend does not recognise is shown as stored. A new sign-out records the login's name as its subject and the login's id for erasure. Rows written before this change keep the id they stored. **Fix: the reseller's usage bars are captioned and its invitation language reads as a name (#511).** The Usage card named its three bars only in their `aria-label`, leaving sighted readers three unlabelled percentages; each bar now sits under a caption of its name and "used of allowed". The detail printed the raw language code (`en`); `ResellerDetailDto` now carries `languageName`, the language written in itself, from the same table the language picker uses. **Fix: the new-site form and a cron entry's run history are placed in the navigation, and an invalid alias names itself (#499).** Both screens are reached from a list and are not entries of the navigation tree, so their eyebrow and breadcrumb read "Panel"; their routes now name the destination they sit under (`meta.navigationParent`), giving "Hosting / Sites and domains / New site" and "Hosting / Files and access / Scheduled tasks / Run history", and the section that holds them stays lit and open. An invalid line in the aliases box was answered with "Domain must be a valid hostname."; it now reads `Alias "a b" must be a valid hostname.` and names the first line that fails. **Fix: an administrator is told mail is off and can turn it on, instead of being told to ask the server administrator (#505).** The mail screens' create forms answered `MailNotEnabled` ("Ask the server administrator to enable it") to the administrator, and no screen could turn mail on. On Mail domains, Mailboxes and Forwarders and aliases an administrator now reads that mail is off with a host name field and a Turn mail on button in place of the form that would only be refused; the request carries the starting values the panel states in the mail server status (`startingValues`, from `MailDefaults`), so the SPA holds no figure of its own. Customers and resellers never read the status and keep the form and the panel's own sentence. A status that cannot be read leaves the form offered. **Fix: viewing as a customer no longer requests what the session is refused, and says it is read-only (#513, #515).** The landing screen kept the administrator's dashboard answer across the start of viewing, so the customer's session believed it was the server's and opened the live stream and the first-run checklist (both 403); the shell now forgets that answer, and the stream, with the rest of the identity's reads. My account no longer asks for membership offers while viewing, and Signed-in devices, Sub-users and a reseller's API keys are neither offered in the menu nor opened (a bookmark or the account menu lands on "Page not available") while an operator views as a login. The "You are viewing as" banner now says the session is read-only, and while viewing every form is drawn disabled (its fields and its submit button, in a dialog's footer too) and every destructive button is disabled, instead of the refusal arriving only after submit. The panel still refuses every write of a view-as session on its own; a write behind another kind of button is refused after the click as before. **Fix: a customer created with an initial password is asked for the new password at the first sign-in (#514).** The sign-in succeeded and opened the whole customer shell, whose every request answered 403 beside a banner telling the person to choose a new password and no place to do it. The SPA now reads `requiresPasswordChange` from the sign-in and the refresh and holds the person on a dedicated "Choose a new password" screen (current password, new password, confirmation) from every address, without the shell and without the requests that would answer 403. A successful change renews the session, which drops the restriction, and enters the panel; Sign out is the only other way off the screen. **Fix: a too-short initial password is refused with the rule it breaks (#512).** The server answered a weak initial password with the sentence for "no sign-in method chosen". The password policy now names the rule it broke (`InitialPasswordVerdict`: too short, too long, equal to the account name) and the new-account refusal carries its own translated sentence (`AccountInitialPasswordTooShort`, `AccountInitialPasswordTooLong`, `AccountInitialPasswordSameAsName`); `AccountInitialPasswordInvalid` now means only that the sign-in mode and the password do not belong together. The minimum length is the operator's setting and the form does not know it, so the check stays on the server. **Fix: turning on password protection for a site answered 500 on a real host (#498).** The agent's unit forbids a setgid bit (`RestrictSUIDSGID=true`), and the credential directory was made `2750`, so the kernel refused its `chmod` with EPERM on every host. The directory is now `root:` `0750` and the credential file `0644`, which only the directory's group can reach; a directory an earlier agent left with another mode is put right on the next write. **Fix: the account page's Resources card follows a plan change (#494).** After Change plan (or a hand-over to another owner, which also sets a plan) the details card showed the new plan while the Resources card kept the old plan's limits until the page was reopened. The page now reads the summary again once the panel accepts either edit. **Fix: Panel updates no longer says "up to date" before any check, and the running version keeps its pre-release part (#506).** With no check on record the Available version row reads "Not checked yet", and a line under the disabled Update now button says why it is off (nothing checked, or nothing newer found). The release build never passed its version to the backend publish, so the assembly reported the SDK default `1.0.0` for a `1.0.0-beta.5.1` release while the previous version (the release directory's name) kept its suffix; `maran release build` now stamps the version (`-p:Version`). A Check for updates button on the page is not part of this change: it needs its own root request path and threat note, and a check still runs with `maran update --check` or the scheduled run. **Fix: Save environment on the Scheduled tasks page confirms the write (#501).** The variables were written but nothing said so, including when the list was emptied. A success message now follows the panel's acceptance, as on the other forms; a refusal still shows the panel's sentence and no success message. **Fix: a number field holding text is faulted beside the field, and a refused JSON body names the field (#504).** The security policy form and the SMTP port sent whatever was typed, and the server answered `The value given for “command” cannot be read.`, naming the action's internal parameter. Both screens now check that the text is a whole number, show a translated message under the field (`aria-invalid` set) and send nothing; the bounds stay the server's. The server's fallback for an unreadable value (`InvalidModelStateRefusal`) takes the field from the JSON path the formatter reports (`lockoutMinutes`) instead of the parameter name, so every other form that posts a number names the right field too. **Fix: error and success banners from an earlier visit no longer survive navigation and a language switch (#508).** The audit journal's retention card, integrity card and export, the security policy screen and the SMTP settings kept the last outcome of a save or verification in their store, so it was shown again on the next visit, in the language of the visit that produced it. Each screen now clears that outcome when it opens. **Fix: the sidebar user card no longer carries a full-width Sign out button (#510).** The button under the card is gone; the chevron at the right of the name row is replaced by an icon-only Sign out button with the translated name and tooltip, and it ends the session the same way. The account menu opens from the name and avatar as before, and the app bar's avatar menu is unchanged. **Fix: the jump-to palette finds a page by the words an operator uses for it (#507).** Typing `firewall`, `cron`, `smtp`, `ssl` or `password` returned nothing because only the menu labels (`Rules`, `Scheduled tasks`, `Outgoing mail`) were searched. Pages now carry search words per language (`app.nav.searchTerms`) that are matched beside the label and never drawn. **Wildcard certificates through the panel's own name server (#161).** A site's SSL tab offers "Also cover every name under this domain (wildcard)" when the account's plan includes DNS zones, the name server is on and the account owns a zone that contains the domain; otherwise it shows why in the panel's language. The order names the domain and `*.domain`, publishes a TXT record per name at `_acme-challenge` through a new narrow window of the DNS module (`IDnsChallengeRecords`: it writes one digest-shaped TXT in a zone of the named account and nothing else), asks the authority to look, and removes the records on success, failure and cancellation; they do not count against the zone's record limit, at most two sit under one name, and a record left by a crash is swept within the hour. Renewal repeats the same proof. There is no delegation pre-check and no new outbound call: the authority is the judge, and a DNS, CAA or authorization problem is reported with a hint to check delegation and `issuewild`. New additive column `Certificates."IsWildcard"` and a new `GET /api/v1/certificates/issuance-options`. **Rollback caveat:** a release that predates the column renews a wildcard certificate as a single-name certificate, so names that relied on the wildcard lose TLS at the next renewal. The second review of the new threat note `docs/superpowers/notes/2026-11-09-acme-dns01-wildcard-threat-note.md` is **outstanding**. Operator guide: `docs/operations/certificates.md`. **The file editor is a code editor (#503).** The edit page replaces its plain textarea with CodeMirror 6 (MIT): syntax colour chosen from the file name (PHP, JavaScript and TypeScript, CSS and SCSS, HTML, Vue, JSON, YAML, XML, SQL, shell, ini, `.htaccess` and nginx configuration, Markdown, Python), line numbers, the current-line highlight, bracket matching, auto-indent, find and replace, go to line (buttons as well as shortcuts, for a phone), Ctrl/Cmd+S to save, and the panel's light and dark themes. The Tab key still moves focus on; a switch makes it indent, and Escape then Tab always leaves. Every earlier behaviour is kept: size limits, refusal of binary files, the unsaved-changes guard, no input until the file has loaded, en/ru/hy text, 390 px width. The editor and each grammar are separate lazy chunks fetched only on this page, and the plain textarea is the fallback if the chunk cannot be fetched. New third-party dependencies (MIT) recorded in THIRD-PARTY-NOTICES.md. **Fix: the controls on a list or file-manager toolbar did not line up (#502).** The Up button, the search field with its stacked label, the Show hidden files switch, the Select all checkbox and the refresh button each had their own height and sat on the bottom of the row. A new `UiToolbar` lays them on one line with a shared centre line, every control takes the kit's one height scale, and a search field's label is hidden visually (still its accessible name) inside it. Every list page's search row and the file manager use it; a Playwright spec measures equal heights and centre lines at 1440 and 390 px. **Security: a second FTPS or SFTP login for an account changed the account's home from `0750` to `0755` (#483).** The jail's `home` directory is the mount point of the account's bind mount, and the setup that runs for every new login applied the jail's mode to it by path, which goes through a live mount onto the real home, so another tenant could read the account's files. The mount point's mode is now applied only while the account's home is not mounted over it (recognised by inode); the jail and its base directory are still corrected on every creation. Covered by new real-host cases for both protocols. **Security: an FTPS login's password also worked on sshd, and jailed logins were not denied every forwarding path (#484).** sshd authenticates any local user with a valid password and a `nologin` shell only stops commands, so an FTPS credential could open a tunnel into the server's private network. The sshd block the installer manages now denies the `maran-ftps` group outright and switches off TCP, unix-socket, agent and X11 forwarding, tunnel devices, ttys and `~/.ssh/rc` for both file-transfer groups. The installer step rewrites the block on every run, so a host picks it up when the step runs again. **Fix: after a whole-account restore the SFTP and FTPS jails kept showing the old home, and later uploads were lost (#487).** A bind mount follows the inode, and the restore swaps the home directory for a new one. The restore, and the recovery of an interrupted restore at agent start, now mount the account's jails onto the restored home before the replaced tree is removed, and the mount unit carries `LazyUnmount=yes` so a connected session cannot block it. If a jail cannot be refreshed the restore reports it and keeps the replaced tree. A transfer session that was already open at that moment keeps seeing the old tree until it reconnects. **Fixed: `maran structure` could report a violation for a file that exists (#497).** Check 16 and the other checks that looked a name up with `printf ... | grep -q` under `pipefail` failed intermittently: `grep -q` exits at its first match, the still-writing producer dies of SIGPIPE, and the pipeline reads as a miss. Every such lookup in `scripts/lib/check-structure.sh` and in the other scripts under `scripts/lib/` and `installer/lib/` now reads a here-string or a command substitution, and the gate plants a match at the top of a 200000-line set (plus an absent-name control) to prove it. **Mail queue page and navigation badge (#320).** Administrators get a Mail queue page: the queued messages, paged and searched on the server, with a confirmed removal of one message and a confirmed retry of the deferred ones, and the Mail queue menu item shows how many messages are waiting (`GET /api/v1/mail-queue/count`, one entry read from the agent, administrator-only). A queue that cannot be read draws no badge rather than a zero. The item was offered to resellers although its endpoints refuse them; it is now the administrator's alone, with the route. `GET /api/v1/mail-queue` now takes `page`, `pageSize`, `search` and `sort` and answers `{ messages, queueTotal, truncated }`, and each message carries its localized queue and deferral names. **Security: reset and invitation tokens reached the panel's access log through the Referer header (#465).** The vhost now sends `Referrer-Policy: no-referrer`, the access log writes a literal `-` for the Referer, and the SPA moves the token of a `/reset-password` or `/accept-invitation` link into memory before the router starts, so it is never in the address, the history or a request header. Reloading such a page no longer carries the token: open the mailed link again. **Security: a deleted site's or account's visitor logs were readable by the next holder of the same account name and domain (#477).** Deleting a site now removes its access and error logs and their rotated generations, and deleting an account removes its whole log directory, both through the agent's descriptor-anchored primitives; a failure is reported and the retry converges. Logs left by deletions before this release are not swept. **Security: an omitted SMTP security setting meant cleartext (#491).** `SmtpSecurity` has no value 0 any more: `startTls` and `implicitTls` keep their numbers, `none` is now 3 and has to be chosen, a request that omits the field is refused, and an unrecognised stored value is read as STARTTLS. A migration moves rows that held the cleartext mode to the new number. **Security: a reseller could reactivate an account an administrator suspended (#493).** The account now records who suspended it; a reseller can lift only a suspension a reseller placed and is refused otherwise with a translated message and a failed-reactivation audit entry. Suspensions made before this release have no recorded origin and are treated as the administrator's. **Fix: the PHP versions screen offered versions the host's package sources do not carry (#466).** On Ubuntu 24.04, whose repositories hold only php8.3, the screen offered 8.2; installing it ended in `E: Unable to locate package`, an HTTP 500 and a generic "contact support" alert. The installable list is now read from the host's own package manager (`apt-cache policy` / `dnf list --available`, read-only and cache-only, one run for all supported packages) instead of a list written into the agent, so a host that carries only 8.3 lists only 8.3 (plus any version already installed). A package no source carries is now its own typed refusal end to end: the new `ERROR_CODE_PACKAGE_NOT_AVAILABLE` (proto revision 23) becomes `AgentPackageNotAvailable`, an HTTP 409 with a sentence in English, Russian and Armenian that the screen shows as written. **Security: a customer could take the panel's advertised address as a site and get a certificate for it (#468).** Only the name `hostname -f` gave was reserved, while the address the operator tells customers (`Panel__PublicUrl`) could be any other name. One reserved-name directory now holds both the vhost's name and the host of `Panel__PublicUrl`, each with its `www.` form, and every door asks it: site create and its aliases, certificate issue and install, and DNS zone create. The agent refuses the same names at its own boundary for sites, aliases, certificates and zones, learning them from the new `MARAN_AGENT_RESERVED_HOST_NAMES` in `agent.env` (written by the installer on every run, passed to the agent as `--reserved-host-names=`; an `agent.env` from an older installer simply reserves nothing extra until the installer runs again). Existing sites with such a name are not removed. **Fix: `maran migrate guard` accepted any `// contract-phase:` marker and ignored `DropIndex` (#495).** The marker must now name the migration or commit of its expand phase (`expand: `, which a published release tag must contain, or `never-shipped: `, which none may), so a contraction can no longer ship in the same release as its expand phase. Dropping or renaming an index, key, constraint, schema or sequence is refused like a dropped column, and an `AlterColumn` is accepted only when it proves a widening. `maran migrate selftest` plants each break beside an accepting control and runs before the guard in CI. **Security: a customer could fill the shared backup volume with manual and pre-restore backups (#478, #479).** Manual and pre-restore backups now count against a per-account allowance of 10 together, a manual backup past it is refused (`BackupAllowanceReached`), and only the newest 3 pre-restore copies of an account are kept, the rest pruned after each restore and by every scheduled retention pass. **Security: the agent accepted a PHP size of zero, PHP's spelling of "unlimited" (#480).** `memory_limit`, `upload_max_filesize` and `post_max_size` now have a floor of one byte at the agent boundary, so `0`, `0K`, `0M` and `0G` are refused. **Fix: creating an account whose owner e-mail or name another login already held answered success and left the account with no login (#485).** The creation is now refused before anything is provisioned (`AccountOwnerEmailTaken`, `AccountLoginNameTaken`), and the login handler treats only the owner-membership index as a repeated delivery. **Fix: a package entry name ending in a line feed passed the name rule (#489).** The segment pattern now ends at the true end of the input. **Security: the published setup token was accepted outside Development, and tokens mailed to an old address or before a password change stayed live (#461, #467, #475).** The panel now refuses to start outside Development and Testing with `Setup__Token=dev-setup-token` or any token under 32 characters or 10 distinct characters (an empty token stays allowed; the installer's 48-character token is unaffected). Changing an account owner's or reseller's login e-mail, and changing a password, now retires every unspent invitation and password-reset token of that login in the same save and records `CredentialTokensRetired`. **Security: the licence verifier trusted a placeholder key whose private half was in the repository (#470, #472).** Anyone could sign a licence that verified. The verifier now holds no key in source: the release embeds the licence public key (`installer/keys/licence-verification.pub`, not yet generated) and a build without one refuses every licence with a clear "no licence key" outcome; tests inject their own key, and an architecture test fails if the production key equals the public half of any private key committed under `backend/`. The Licensing module also declares its agent capability and is now covered by the agent capability guard at startup and in CI, although it sits outside the module registry. **Security: removing a sub-user could wipe the source address of other people's failed sign-ins (#464).** Erasing a login matched journal rows by the text of their subject, and a sub-user's name is chosen by the account owner, so naming one like a sprayed username and removing it blanked the address and user agent of those sign-in attempts. Rows are now matched by an identity recorded on them (actor, impersonator, or the new subject login); rows that only carry a typed name are never changed by another person's erasure. **Security: the release integrity list is checked per component, and the SFTP jail check requires the key directory (#473, #474).** The signed `integrity-manifest.json` could be built with no frontend or agent files, or for one architecture only, because its minimum was a total the API alone cleared; `maran release build` now refuses a bundle in which `api`, `agent` or `frontend` is missing, is staged for different architectures than the others, or is below its own minimum, and refuses a file name that would break the signed JSON. The SFTP jail drift check now also requires the block's `AuthorizedKeysFile` line (the panel's key directory) and reads each directive as sshd does, first value wins, so a missing or overridden key directory is reported as drift instead of intact; its notes no longer claim a later `Match` block can undo the jail (an earlier one can, and is still not detected). **Security: stopping the agent killed the programs of the operation it was draining (#476).** The unit now uses `KillMode=mixed`, so a stop signals the agent alone and `useradd`, `setquota`, `tar` and the rest keep running until the drain ends, instead of dying with it and leaving a half-applied account or config. **Fix: re-running the installer deleted the only copy of a killed restore's previous databases (#481).** Step 40 now keeps the backup scratch when it is already a real directory owned by root (a planted link or a foreign-owned directory is still replaced); emptying it is left to the agent's start-up pass, which keeps and logs the rollback dumps. **Security: a burst of wrong passwords counted as one failure, so the account lockout was defeated by parallel guesses (#469).** Failed sign-ins and failed password changes are now counted under a row lock on the login, so every failure counts and the lock engages at exactly the configured number. **Security: the two-factor sign-in endpoint sat outside the account lockout (#463).** It now refuses a locked login, counts wrong passwords and wrong codes against the same lockout as sign-in, and answers a correct password on a login without two-factor with the same 401 as a wrong one instead of a 409. **Security: refresh and logout had no rate limit (#471).** Both now share their own per-address limit (`RateLimiting__SessionRefreshMaxRequests`, `RateLimiting__SessionRefreshWindowSeconds`, 60 per minute by default) and answer 429 when it is spent. **Security: asking for a password-reset mail and completing a reset shared one rate-limit bucket (#492).** Completing a reset now has its own limit (`RateLimiting__PasswordResetCompletionMaxRequests`, `RateLimiting__PasswordResetCompletionWindowSeconds`). **Security: turning DNS on or off never opened or closed port 53, although every text said it did (#486).** The name server listens on every address and the panel never touched the firewall, so the port was reachable at once on a host with no firewall in force and stayed shut under the panel firewall while the screen claimed it was open. The texts (English, Russian and Armenian), the module manifest and the docs now say the port is a separate step, and the DNS server card shows whether the panel firewall lets TCP and UDP 53 through, with one-click Open port 53 and Close port 53 actions. **Security: the firewall no longer lets an administrator cut themselves off (#459).** Removing a rule is now judged on the rules that would be left: with two rules on an SSH port, deleting the one that admits your address is refused (the unconditional SSH accept returns only when the last rule goes). Banning the address you are connected from, or an address inside a whitelisted range, is refused too; the bans table still runs ahead of every accept. Each refusal has a code and text in English, Russian and Armenian. **Security: a reseller could keep viewing an account it had just lost (#460).** Moving an account ended the former reseller's view-as sessions before the move was saved, so a session opened in that gap survived for its 15 minutes. Every request of a reseller's view-as session now checks that the reseller still owns the account, so the session stops working the moment the move is saved, and the sessions are ended after the save. **Log rotation no longer makes nginx log `[emerg] open() ... Permission denied` (#490).** Every nightly rotation signalled nginx with USR1, which makes each worker reopen every log by path as the web user; the workers cannot enter `/var/log/maran`, so each logged the error and kept writing customer and panel access lines into the file that had just been rotated away. The panel's two policies and the distribution's own nginx policy now reload nginx instead, so only the root master opens the logs; the directory modes are unchanged. **Security: importing a backup could reach another customer's database (#457).** The temporary database user that loads an imported backup was granted its database by a name in which `_` is a wildcard, so for an account such as `acct_my` it also held another account's `acctxmy_db`. The grant is now written escaped, through the one helper every other grant already used, and the import's pre-scan also refuses any statement that names a different database. **Security: port 25 no longer relays for local processes (#458).** The mail server trusted loopback, so any customer script on the host could send to any recipient with any sender, skipping the login, the sender check and the send limit. Port 25 now accepts mail only for domains served here; relaying needs a login on port 587 or 465. The panel's own mail must use `127.0.0.1:587` with STARTTLS and a mailbox's credentials. **Reseller features: branding, API keys with administrator oversight, account transfer, an audit view, rename, invitations and the plan picker (#367, #368, #435, #369, #370, #373, #374, #429, #437).** A reseller sets a name, logo and accent colour that its customers' panel shows (Settings, Branding); the server checks the colour is readable under white text and the logo is a real PNG, JPEG or WebP image (#367). A reseller creates read-only API keys of its own (`plans.read`, `accounts.read`) on Settings, API keys, and an administrator lists and revokes one reseller's keys from the reseller's page, journalled under the administrator (#368, #435). An administrator can move an account to another reseller, or to the administrator, with a plan of the new owner (`PUT /api/v1/accounts/{id}/owner`); the new owner's limits and disk pool are checked, and the former reseller's view-as sessions on the account end (#369). A reseller reads its own part of the audit journal, entries about its accounts and its own logins, under Activity; other accounts answer 404 (#370). An administrator can rename a reseller and change its contact address and language (`PUT /api/v1/resellers/{id}`); the reseller's login follows (#373). An administrator can resend a reseller's invitation by e-mail or create a one-time invitation link from the reseller's page, and a reseller whose login was never created has it rebuilt from its record (#374, #429). The create-account form lists the chosen reseller's plans when an administrator picks a reseller as owner, and clears the chosen plan when the owner changes (#437). Database users: customers can create additional MySQL logins (local only), choose per database from a closed list of 18 privileges with read-only, read-write and full-access presets, reset their passwords and drop them. A database's dedicated user is dropped with its database and cannot be dropped alone. Existing databases get their dedicated user recorded at start-up (#156). Revoking a user's wildcard grant now sends two statements MariaDB accepts instead of one it rejects (#450). PHP: an administrator lists and switches PHP extensions per installed version, OPcache is configured through a Maran drop-in reconciled at start, and end-of-life PHP versions are marked with a lifecycle badge and a dashboard attention item for sites still on them (#157). File manager: browse, edit, upload, download, copy, move, rename, permissions, archives (create and extract) and a trash with restore, all run as the account's own user (#166, #167, #168, #169, #170, #171). Rules and spec: the operator-run `maran update` / `maran update --check` fetch of a signed release is a documented exception to security item 10 (timer-driven checks are not covered). Release artifacts are stated as signed with ECDSA P-384 / SHA-384 and licences with Ed25519. The update order is stated as API restart, agent restart, then the health gate (#384). Fixed: the System status heading no longer says "All systems are operational." above a "Needs attention" notice; the sentence now reflects every check (#393). Fixed: the dev agent container can now check the clock — a timedatectl stand-in in the polygon images replaces the one that needed systemd, so the status page no longer shows "The clock could not be checked" (#392). Test: the Armenian service names on the status page are now asserted (#394). **The database restore after an update that added schema now works (#386).** maran rollback --restore-database used to refuse with "already exists" when the update had created a schema or table. It now drops every schema except public and the system ones in the same transaction that replays the dump, so the database returns exactly to the dump's state, and a failure changes nothing. maran panel-restore uses the same code. `maran dev` now stops on SIGINT as well as SIGTERM when started in the background (#395); `maran signals` proves it. Added: maran issue-panel-certificate orders, installs and renews the panel's own certificate from the server console, with the operator recorded in the audit journal (#426). Update manifest: signed `released` date and `notes_url`; `maran update --check` prints both, validated, and `maran release build` gains `--notes-url` (#385). Added: an administrator can resend a reseller's invitation by e-mail or create a one-time invitation link from the reseller's page (#374). Added: an administrator can rename a reseller and change its contact address and language (`PUT /api/v1/resellers/{id}`); the reseller's login follows the new name and address (#373). Accounts: an administrator can move an account to another reseller, or to the administrator, with a plan of the new owner (PUT /api/v1/accounts/{id}/owner); the new owner's limits and disk pool are checked, and the former reseller's view-as sessions on the account end (#369). Resellers can read their own part of the audit journal (entries about their accounts and their own logins) at Activity; other accounts answer 404 (#370). Fixed: the `StartupPhpSiteReconciler` doc comment no longer claims the agent keeps on-disk PHP settings when it converges a pool; it rewrites them from the overrides given (#431). Fixed: customer PHP and reverse-proxy vhosts now set `client_max_body_size 512m` (the PHP override ceiling), so nginx's 1 MiB default no longer cuts uploads and form posts (#430). Tests: an architecture test records the exact public surface of `Maran.Sdk` and a per-module ceiling on the Sdk types each module uses, so growth of the shared project is a reviewed edit (#105). Tests: a node-only spec recomputes every design-token contrast ratio from `main.css` in both themes and fails when a pairing drops under its WCAG line (issue #82). Resellers can set a name, logo and accent colour that their customers' panel shows (Settings, Branding); the server checks the colour is readable under white text and the logo is a real PNG, JPEG or WebP image (#367). Dropping a database now revokes every grant on it first, so a database recreated under the same name no longer inherits a previous user's access (#432). ci: the SPA Playwright suite runs in 3 parallel shards (maran test spa --shard i/3) and a verdict job proves the partition and scores the merged rows against the per-spec-file baseline (#289). Added: architecture tests pin each module's internal layering (Domain depends on no Persistence, Commands, Queries, Controllers or EF Core; Controllers do not reach Persistence), with planted violations and accepting controls (#104). Added: a reseller can issue API keys that read its own plans and accounts (`plans.read`, `accounts.read`), managed at `api/v1/reseller/api-keys`; the keys are read-only and stop working while the reseller is suspended. Installer polygon: new account-restore stage (issue #6) backs an account up through the API, damages it, restores it and requires every axis identical, and kills the root agent mid-restore in three windows; `--no-account-restore` skips it. The second review of its threat note is OUTSTANDING. Added `npm run screens` (every screen at 1440/768/390 px, dark and light) and a 768 px no-horizontal-scroll sweep (#82). Tests: 390 px sweep for the settings, customer-team, cron-history, updates and reseller screens, a route-coverage census that fails when a routed screen has no sweep row, and axe scans of detail, form, settings, drawer and dialog screens in both themes (#82). Monitoring: the disk, processor and memory alert thresholds are now editable (50–99, administrators only), with new CPU and RAM alerts that are off by default (#142). php-fpm pools now set `upload_max_filesize` and `post_max_size` to 32M by default, and an override set with `post_max_size` below `upload_max_filesize` is refused (#434). Added `maran help --json` and `installer/cli/commands.json`, a machine-readable catalogue of the operator's verbs and installer flags, with a CI gate (`maran operator-commands`) that checks it against the dispatcher, the flag parsers and `docs/operations` (#155). Firewall: adding a single TCP allow rule for an SSH port whose source excludes the caller's address is refused with 409 FirewallRuleLocksOutCaller when no rule exists for that port; no override (#159). Tests: a checked customer self-service matrix and a census that fails when a customer-reachable endpoint is missing from it, or names a missing isolation test class (#147). Added: global search in the jump-to palette. `POST /api/v1/search` finds accounts and sites, grouped, with the text kept in the request body; a group that cannot answer is shown as unavailable and the rest still work (#198). Administrators get a notification feed: failed background tasks are listed with a per-administrator dismiss, kept for 90 days or the newest 500 (#164). Removed the unused `Controllers/Requests/` types; endpoints bind their commands directly, and `maran structure` refuses the folder (#436). Added tenant-isolation integration tests for the global search endpoint (customer, reseller, API key and literal-term cases) (#198). Fixed: an administrator can now invite a reseller whose login was never created; the login is rebuilt from the reseller's record, and a suspended reseller still gets none (#429). Database users: the agent can create and drop extra database logins and set a login's privileges on one database from a closed set of 18 (no `GRANT OPTION`, no global privileges). Local logins only; second review outstanding (#331). Contract revision 21. `maran panel-backup` records the release that took the backup, and `maran panel-restore` refuses, before stopping the panel, an archive taken by a newer release than the installed one (#129). Alert settings page (#142): several alert recipients and editable disk/CPU/RAM thresholds; the alert address moved out of the outgoing mail form. Restoring an account now first takes a backup of it as it stands (kind PreRestore) and refuses the restore if that backup cannot be made; the restore dialog says so (#433). Operator alerts now go through one dispatcher that also publishes a webhook event and a notification-feed entry; alert recipients become a list (#142, #164). Added: first-run checklist endpoint GET /api/v1/dashboard/first-run (five derived steps; the seeded plans and local backup destination do not count) and the alert-delivery state in the dashboard attention block and its navigation count (#164, #142). Accounts: an administrator can hand an account to a reseller, or back to the administrator, from the account page, choosing a plan of the new owner and confirming; the panel's refusals are shown as sent (#369). Fixed: the create-account form now lists the chosen reseller's plans when an administrator picks a reseller as owner, and clears the chosen plan when the owner changes (#437). Alerts can be mailed to up to 10 recipients instead of one (`api/v1/notifications/alert-recipients`); the dashboard reads whether alert mail is being delivered (`api/v1/notifications/alert-delivery`); `alertRecipient` is removed from the SMTP settings request, and the first recipient is still mirrored into the old column for one release. Reseller API keys: resellers manage their keys on Settings > API keys, and an administrator lists and revokes one reseller's keys from the reseller's page, journalled under the administrator (#435). Added a notification bell for administrators in the app bar (a popover with the feed, per-administrator dismiss and dismiss-all, a count badge) and a first-run checklist card on the dashboard (#164). Customers can set their own backup schedule (daily or weekly, keeping 1–7 copies) on a new My backup schedule page, `GET/PUT /api/v1/me/backup-schedule` (#147). A caller with no account can no longer read the host-wide backup schedule; the Backups tenant filter now matches no row for a null tenant (#438). CI: the backend pull-request lane runs six integration shards plus a unit shard from one `SHARDS` setting, packed by per-class weight when `scripts/test-shard-weights.txt` exists; the AlmaLinux 9 polygon image build uses a weekly-scoped layer cache with the fingerprint label unchanged (#289). New account form: choose between an invitation and an initial password the owner must replace at first sign-in (with a generator), and set per-account process, inode and open-files limits; a reseller may only lower them (#194). Agent: per-site log retention prune (`PruneSiteLogs`, 1 to 14 rotations, shorten only) and an incremental nginx combined-log traffic reader (`SummariseSiteTraffic`), both bounded and audited; the tail and the summary now share one hardened log opener (#152). Added the in-memory simulated implementations of the database, SFTP, FTPS, files, cron, firewall and backup agent clients (#95). Added a machine-readable billing contract (`docs/api/billing-contract/lifecycle.json`) that an integration test replays against the reseller API on every change, plus authorization, IDOR and audit tests for the key-driven account writes and reads (#7). Per-site options (#151): redirects, canonical host, basic auth (bcrypt hash only), IP allow/deny list, upload size and static cache headers, rendered by the agent into the site's vhost and carried on every re-render; the certificate challenge location stays open. Per-site options (canonical host, redirects, password protection, IP rules, upload size, static cache lifetime) on the site's new Options tab, kept on every vhost re-render including certificate install and removal (#151). Sites: a customer can keep 1 to 14 days of a site's rotated logs, and the site's Traffic tab shows requests, bandwidth, status codes and top paths counted hourly from the access log (#152). Customers set their PHP memory limit, upload size, execution time, input variables and timezone for their whole account within the limits their plan sells; plans carry those limits, and a plan change rewrites the account's pools (#138). Added: public demo composition root (Maran.Host.Demo), simulated agent, demo guard, deployment, bundle and workflow (#95). (#439) deleting a site whose vhost is gone now removes the row; (#440) brute-force swallows only unique race; (#441) redelivered code-integrity report no longer counted twice. Accounts: plans store PHP bounds in four new columns (expand-only migration); an account transfer now re-renders pools when the new plan bounds PHP differently; the plan-change announcement no longer logs a failure when the Sites module is absent (#138). Creating an account with an initial password now judges the password by the panel's password policy before anything is provisioned; a weak one is refused with `AccountInitialPasswordInvalid` and leaves no account without a login (#194). Real-host browse suites for the file manager: traversal, symlink, hard link, other-owner, FIFO, size ceilings and concurrency caps (#166). Agent: the file manager can read a file (up to 512 MiB, hardlinks and links refused) and search entries by name (200k entries scanned, 500 results) inside an account's home (#166). Added: file manager browser (listing with server paging, search, sort, path bar, hidden-file toggle, row selection) at /files (#170). File manager: dialogs for create, rename, move, copy, permissions, compress and extract; a trash page with restore and empty; batch actions with a per-item result notice (#170). File manager: the agent files client now carries create, move, copy, permissions, trash, archive and extract operations (#169). Added the module contract (kind, minimum panel version, schema, versioned dependencies, permissions, menu and routes) with a manifest validator and semantic version parsers (#182). Architecture tests now enforce the module contract (manifest, schema ownership, resources, references, SPA routes), with planted violations and accepting controls; contract written in docs/modules/module-contract.md (#182). Agent: the run-by-hand, browse, archive and bulk-file concurrency bounds are one shared `BoundedSlots` type (#166, #167). Backend agent client: streamed file upload (`WriteFileStreamAsync`) over the client-streaming `WriteFile` rpc, with byte cap, quota and limit refusals mapped (#169). Panel recovery proof: the polygon stage now observes the licence file, a restore onto a database holding an extra schema, and the release guard against a real archive (#129). Sites: per-site request limit (1 to 1000 requests per second per visitor address, burst 0 to 1000, answered 429), rendered as an nginx `limit_req` zone; carried in the site options so every re-render keeps it (#159). Database users: customers can create additional MySQL logins (local only), choose per database from a closed list of 18 privileges with read-only, read-write and full-access presets, reset their passwords and drop them. A database's dedicated user is dropped with its database and cannot be dropped alone. Existing databases get their dedicated user recorded at start-up. Agent client: calls are routed per server through an agent target and a per-server channel pool; mutual-TLS channel factory with certificate pinning (issue #180). Agent clients are routed by target: every call reaches the server the current scope selects, and a refused server is answered as a typed failure (#180). Backups: optional encryption at rest for the local destination (XChaCha20-Poly1305, panel-held key, per-destination switch `PUT /api/v1/backup-destinations/{id}/encryption`); existing backups stay readable. Second security review outstanding (#331). Keep the panel recovery archive: it is the only place the key can be recovered from. Fleet (#180, #319): the `Maran.Modules.Fleet` module core, a registry of servers with the local server and enrolled nodes, an approval state machine and the `fleet` schema. Not composed or exposed yet. Agent: fleet mode groundwork (#180): `--fleet-listen` and `--fleet-dir` options, and `maran-agent --generate-node-identity` creates a node's ECDSA P-256 keypair and self-signed certificate and prints its SHA-256 fingerprint. Mail: agent distro facts (`MailFacts`) and the panel's `IAgentMailClient` for the mail rpcs (#177). Backups: an administrator can switch encryption of new backups on or off per destination from the Backup destinations screen, with a warning that the key stays in the panel (#160). Panel: an administrator can see the version state and start `maran update` from Settings, Panel updates; the request carries no value and runs the signed-release update as root through a root-owned path unit, with progress in the task list (#382). The second security review is outstanding. Added: `MailService` agent contract (`mail.proto`) and the validated mail types in agent-core (issue #177, phase 1). Agent: a restricted loader for dumps someone else wrote. A pre-scan refuses client commands, unlisted statements, definers, stored programs and other databases. The dump is then loaded as a temporary user that holds one database only (#127). feat: restore one database or one site from a local backup (agent scope, restore-choices endpoint, restore dialog picker) (#160) Mail: SPA pages for mail domains (with DNS records to publish and DKIM rotation), mailboxes (quota, send limit, password) and aliases, forwarders and catch-all, in en/ru/hy (#177). Marketplace seam (#184): the panel composes packaged modules at start behind the same agent-capability and manifest checks as shipped ones, records loader steps and refusals in the audit journal, and restarts to activate a staged module. Added: maran ipm package|sign|verify|selftest and the Maran.Host --cli module-manifest and module-verify verbs build, sign and check .ipm module packages (#183); samples/hello-module is the reference packageable module; format in docs/marketplace/ipm-format.md. Added the Maran.Packaging library: the .ipm module package format (signed manifest, SHA-384 pins), a safe archive reader and ECDSA P-384 signature checking with a caller-supplied key (#183). DNS: the agent renders, checks and serves zones (PutZone, DeleteZone, GetZone, ListZones), with a start-up pass that re-adds zone files the daemon lost (#178, held). DNS server (BIND 9, authoritative only), installed and switched off; `EnableDns`/`DisableDns`/`GetDnsStatus` agent rpcs and the zone rpcs declared for the zone phase (issue #178, phase S1). Second review of the DNS surface is outstanding. Agent: `ExportBackup` / `ImportBackup` (new `TransferService`) move a backup artifact between servers under an ECDSA P-384 signed manifest and an operator-confirmed key fingerprint (#127). Agent: optional TCP+mutual-TLS fleet listener (off by default), TLS 1.3 only, pinned panel certificate (#180). Added: a live dashboard stream (`GET /api/v1/dashboard/stream`) for administrators: host readings every 2 s from one shared feed that polls the agent only while a dashboard is open, a 1-hour back-fill, sections pushed on change, and a 15-minute stream lifetime with silent renewal (#199). Dashboard: live processor, memory, disk and network charts fed by one stream (reconnect, pause when hidden, renew), clickable totals and services, quick-actions bar with ?action=create on the owning screens, per-person customise sheet (#199, slice S1). Mail host lifecycle (issue #177, phase 2): `EnableMail`, `DisableMail`, mail status and TLS reload in the agent, Postfix, Dovecot 2.3 and 2.4 and Rspamd templates, and installer step 89-mail that installs the mail daemons masked and switched off. Removed an unused second `UserAgentText` helper (`Utilities/Http/`); the code-point-counting one in `Utilities/Network/` remains the only one (#446). Internal: the design-time encryption stand-in used by EF migration tooling is now one type in `Maran.SharedKernel/Security` instead of four copies (#444). Internal: the design-time current user, copied into eleven modules, is now one type in Maran.SharedKernel (Security/DesignTimeCurrentUser) beside DesignTimeEncryptionService; nine module copies removed (#447). Restoring an imported backup now loads its database dumps through the restricted one-database loader, and a dump the scan refuses fails the restore before anything is dropped (#127). Dashboard layout: each person's widgets and shortcuts, their order and what they hid, are saved on the panel (`/api/v1/me/dashboard-layout`) (#199). DNS: zones list, record editor, DNSSEC status, nameserver settings and zone templates in the panel (#178). A sealed backup that cannot be opened with its destination's key now says so on restore and verify instead of a generic rejected-archive message (#445). The live streams now write a null member as `null`, exactly as the plain API does (#199). Added: the agent serves MailService (#177): the host lifecycle rpcs (GetMailStatus, EnableMail, DisableMail, ReloadMailTls) report versions, listeners, password scheme, queue and Sieve availability; the data-plane rpcs answer NOT_IMPLEMENTED until their operations land. Added the DNS module backend: zones and records API, name-server settings, `MaxDnsZones` plan allowance (#178). Polygon: the mail uninstall is asserted to stop only the key-value unit the installer recorded, and to leave an operator's own redis or valkey unit running (#177). Added: a new site gets its DNS zone automatically when the name server is on and the plan allows it; at start-up the panel republishes zones whose served serial differs from the database and removes served zones no account owns (#178). Mail: hosted mail module with mail domains and DKIM, mailboxes, aliases, forwarders and catch-all, autoresponders and an administrator server/queue API; plans carry mail limits (issue #177) Agent: temporary database users (`maran_imp_*`) left behind by an interrupted backup import are dropped at start-up (#127). rust.md rows owed. PanelPinFile spawn_blocking done. AgentMailClient 367 lines; CreateUnixSocket DisposeHttpClient. Agent: a client-streamed upload (WriteFile, ImportBackup) that goes silent for 30 s is dropped with STREAM_DROPPED and releases the account lock; a retried ImportBackup now re-hashes the published artifact instead of trusting its sidecar (#127, #167). Reseller provisioning API (#7): reseller API keys carry `accounts.write` and `accounts.delete`, can create, read, usage-report and terminate their own accounts under mandatory idempotency keys, rate-limited per key at 60 requests a minute. Rust dependency refresh for the advisory fixes: rustls 0.23.46, rustls-webpki 0.103.16, tokio 1.54.1, quinn-proto 0.11.19 (#443); `askama` built without default features so three unused crates leave the lock (#448). Final contract revision: the agent protocol is revision 21 (`PROTO_VERSION`), and the panel's accepted window moved with it. **Bug batch: thirteen small fixes (#386, #392, #394, #395, #430, #431, #432, #433, #434, #436, #438, #439, #440).** Security: dropping a database now revokes every grant on it first, so a database recreated under the same name no longer inherits a previous user's access (#432); a caller with no account can no longer read the host-wide backup schedule, because the Backups tenant filter now matches no row for a null tenant (#438); restoring an account first takes a backup of it as it stands (kind `PreRestore`) and refuses the restore if that backup cannot be made, and the restore dialog says so (#433). Fixed: `maran rollback --restore-database` used to refuse with "already exists" after an update that added a schema or table; it now drops every schema except `public` and the system ones in the same transaction that replays the dump, so the database returns exactly to the dump's state, and `maran panel-restore` uses the same code (#386); customer PHP and reverse-proxy vhosts now set `client_max_body_size 512m`, the PHP override ceiling, so nginx's 1 MiB default no longer cuts uploads and form posts (#430); php-fpm pools now set `upload_max_filesize` and `post_max_size` to 32M by default, and an override with `post_max_size` below `upload_max_filesize` is refused (#434); the dev agent container can now check the clock, because a `timedatectl` stand-in in the polygon images replaces the one that needed systemd (#392); `maran dev` now stops on SIGINT as well as SIGTERM when started in the background, and `maran signals` proves it (#395); the `StartupPhpSiteReconciler` doc comment no longer claims the agent keeps on-disk PHP settings when it converges a pool (#431). Removed: the unused `Controllers/Requests/` types; endpoints bind their commands directly, and `maran structure` refuses the folder (#436). Fixed: deleting a site whose vhost is already gone now removes the row instead of answering not found (#439), and the brute-force handler swallows only the unique-violation race of a redelivered event, so any other database failure surfaces (#440). Test: the Armenian service names on the status page are now asserted (#394). **Return the panel to a self-signed certificate on request (#425; #146 closed as delivered by #145's change).** An administrator can replace the panel's trusted certificate with a freshly generated self-signed pair again: a new agent rpc `RestorePanelSelfSigned` (proto revision 20) writes it through the same checked, rolled-back path as `InstallPanelCertificate`, `DELETE /api/v1/panel-certificate` (administrators only, journalled) calls it and forgets the recorded trusted certificate so renewal stops, and the panel certificate page gets a confirmed "Return to a self-signed certificate" action. Browsers warn again until a trusted certificate is installed. **The second security review is OUTSTANDING** (threat note `docs/superpowers/notes/2026-11-07-panel-self-signed-restore-threat-note.md`, tracked under #331); this must not merge to `main`. **Audit journal: export, filters, retention, a tamper-evidence chain, and personal-data export and erasure (#145, #146).** Two issues share one branch by the owner's batch exception. **The second security review of this change is OUTSTANDING and the branch must not merge to `main`** until a human reviewer has read it; the threat note is `docs/superpowers/notes/2026-11-07-audit-retention-erasure-threat-note.md`. Operator documentation: `docs/operations/audit-journal.md` and `docs/operations/personal-data.md`. **Export:** `GET /api/v1/audit/export?format=csv|json` (administrators only, refused while viewing as a customer) streams the journal oldest first with 14 fixed columns, capped at 500,000 rows (a larger window is refused with a 400, never truncated), at most 6 requests per hour per administrator (`RateLimiting__AuditExportMaxRequests` and `RateLimiting__AuditExportWindowSeconds`), journalled as `AuditExported` before the first byte; a CSV cell starting with `=`, `+`, `-`, `@`, a tab or a carriage return gets a leading apostrophe, and JSON is the lossless format. **Filters:** `actor` (exact, case-insensitive) and `account` join the existing ones on the list and the export. **Known gap:** an administrator-initiated entry from the sites, databases, FTP, SFTP, cron, backups, certificates, firewall and monitoring journals carries no account, so the account filter, the account export and the account erasure rule miss it; only customer-session, view-as and five Accounts operations (create, suspend, reactivate, plan change, owner e-mail change) are stamped, and threading the account through the other journals is a follow-up. **Retention:** `GET/PUT /api/v1/audit/retention`, off by default (keep forever), opt-in from 30 to 3650 days; an hourly scheduler runs it at most once a day, only a sealed prefix is purged and an anchor advances in the same transaction, and every run is journalled whether or not it deleted anything. **Chain:** entries are sealed asynchronously (up to 10,000 per hourly tick) into an HMAC-SHA256 chain verified daily and on demand (`GET /api/v1/audit/chain`, `POST /api/v1/audit/chain/verification`). It detects an altered action, outcome or time, a deleted middle entry, reordering and an insertion. **It does not protect** the actor, address, user agent, subject and account (an erasure and a rewrite look the same), the purged prefix or the tail, anything in the unsealed window (up to an hour, and longer after the upgrade while existing entries are sealed), or anything against a holder of the panel key; its key is derived from `Security__EncryptionKey`, so rotating that key reads as tampering; a break is not mailed to anyone; and a retention window is not a backup. **Personal data:** `GET /api/v1/privacy/accounts/{id}/export` and `GET /api/v1/privacy/users/{id}/export` (JSON, allow-listed fields per module, no secrets, same cap and rate-limit bucket); deleting an account, a sub-user's login or a reseller's login pseudonymises the journal rows about the person and the account in the same transaction, a daily sweep does the same for actors that point at no login, and no endpoint erases by id; the user export has no screen. Backups and dumps keep the pre-erasure data. **Schema:** new nullable columns on `identity.AuditEvents` (account id, chain index, row hash, erasable commitment, commitment salt, erased-at) and two singleton tables, expand-only. **Defaults chosen so they can be changed:** retention off (`AuditRetentionPolicy.DefaultRetentionDays`), the `AccountDeleted` entry keeps the account name, and the chain key is derived from the panel key rather than held as a separate secret. **The panel's own certificate is tested in the polygon (#132, partial).** A new real-host suite, `panel_certificate_on_a_real_host`, loads the shipped panel vhost into the real nginx and drives the agent's challenge write, install and challenge removal against it with certificates from a throwaway root: a challenge file is fetched over port 80, an issued pair replaces the self-signed one and a real TLS handshake presents the new serial, a renewal changes that serial again, and a pair the agent or nginx refuses leaves the previous certificate serving byte for byte. It cannot observe the ACME protocol or the API's scheduler. The `maran` CLI verb and the revert to a self-signed pair remain follow-ups. **Browser-run fixes: two-step codes, forced enrolment, language switch, role-gated reads, view-as, panel certificate and invitation links (#412, #413, #414, #415, #416, #417, #421).** Seven issues share one branch by the owner's batch exception (their file sets are disjoint). #412: a wrong code on "Turn on" and on turning two-step verification off now answers 400 (`TwoFactorCodeInvalid`, `TwoFactorCodeClockUnsynchronized`) instead of 401, so the SPA no longer reads it as an expired session, refreshes and replays the request; the sign-in challenge still answers 401. #413: the screen an administrator-required enrolment steers to now says why, offers Sign out, and offers Continue once the recovery codes are shown. #414: a language switch remounts the routed page so server-localized text (status names, feature lists) is requested again with the new `Accept-Language`; the page that shows one-time recovery codes is exempt so the codes are not lost (`rules/vue.md` notes both, for the owner's confirmation). #415: the shell opens the task stream and the polls only for roles that may call them and drops the previous identity's state when view-as starts or ends. #416: view-as for an owner who has not accepted the invitation answers 409 `ViewAsInvitationPending` instead of the suspended-or-locked sentence. #417: `GET api/v1/panel-certificate` answers 200 with `hostNameConfigured: false` and a notice on a panel installed by address, instead of 409. #421: invitation and password-reset links are built from one setting, `Panel__PublicUrl` (replaces `PasswordReset__PanelUrl`, which is no longer read), chosen by the installer from an operator override (`MARAN_PANEL_PUBLIC_URL`), the host's public name, the SSH session address or the default-route address, validated strictly at start, kept on re-run, and never derived from the request's Host header. A new integration test discovers every administrator-only endpoint and proves a reseller and a customer are refused on all of them. **The second security review of the installer change is OUTSTANDING** (threat note `docs/superpowers/notes/2026-11-07-panel-public-url-threat-note.md`), so the branch must not merge to `main` until a reviewer has read it. Not verified: the installer's address detection on a real host of either family. **`maran deploy-local`: put the dev tree on a test server without a release (#410).** One command builds the tree for the host's architecture, signs it with a TEST key kept outside the repository (default `~/.maran-dev/test-signing.key`), packages and verifies the bundle, copies it and a COPY of the installer over `ssh`/`scp`, and runs the installer's offline path on the host under `sudo`. The copy of the installer carries the test public key in place of the release one; the repository file is never modified (its digest is compared before and after). The verb refuses the production release key before it opens it (by path, and for any other file by comparing the DER of its public half with `installer/keys/release-signing.pub`), refuses any key, identity or config path inside the repository, validates host, user and port (a host cannot start with a dash, `--` precedes every destination), uses `BatchMode` key authentication with `sudo -n` by default (`--ask-sudo-password` lets `sudo` itself prompt on the terminal), never reads or stores a password, takes host and login from arguments or an untracked file outside the repository, and removes its temporary files on the host (best effort on a normal exit and on INT, TERM and HUP). Every ssh and scp call sets `StrictHostKeyChecking=yes`, an encrypted key is refused rather than prompted for, and `--bundle` is copied before it is signed so a release-signed bundle is never re-signed in place. `--dry-run` prints the command lines and runs nothing; `--bundle` reuses an already built bundle. A host installed this way trusts the test key, also for `maran update`: use it on development hosts only. `maran deploy-local selftest` drives fake `ssh` and `scp` through nine cases, each refusal paired with an inverse control, and is wired into the backend CI job beside the other release selftests. Guide: `docs/operations/deploy-local.md`; threat note: `docs/superpowers/notes/2026-11-06-deploy-local-threat-note.md`. **The second security review of this change is OUTSTANDING** (rules/security.md), so the branch must not merge to `main`. Not verified: a real run against a server. **The PHP package-install unit gets a sandbox of its own; its security score falls from 9.4 to 4.4 (#408).** `maran-php-install@.service` ran the package manager with no hardening at all. It now sets the kernel, control-group, hostname and clock protections, a private `/tmp`, devices and `/home`, no real-time scheduling, no namespaces, native system calls only, the address families `AF_UNIX`, `AF_INET`, `AF_INET6` and `AF_NETLINK`, a system-call deny-list that answers `EPERM` (so a refused call reaches the failure classifier as package-manager text and not as a kill) and a capability deny-list. Each directive was measured on a booted systemd, a fresh container per trial: Ubuntu 24.04 (systemd 255, `php8.3-fpm`) and AlmaLinux 9 (systemd 252, `php-fpm`) installed through the unit with a clean package database, baseline, base set, base plus each directive and the union all passing, and `ProtectSystem=strict` as the inverse control failing. These directives bound accidents and bugs, not a hostile package (a maintainer script runs as root and can undo them). Not adopted: `NoNewPrivileges`, `RestrictSUIDSGID`, `MemoryDenyWriteExecute`, `ProtectSystem`. **Not observed, and the second security review is OUTSTANDING** (threat note `docs/superpowers/notes/2026-11-06-php-install-unit-hardening-threat-note.md`; the branch must not merge to `main`): an enforcing SELinux host, the real Sury and Remi packages, other releases, and a maintainer script starting php-fpm under the new directives on Debian. The ceiling in `installer/systemd/unit-security-ceilings.txt` is lowered to 4.4. **Four CI flakes fixed or measured (#406, #401, #407, #409, #402).** Five issues share one branch by the owner's batch exception (their file sets are disjoint). #406: `maran release package` refused a valid bundle because `verify_offline_tarball` piped the archive listing into `grep -q` under `pipefail`; `grep -q` exits at the first match, `tar` and `sed` die of SIGPIPE (statuses 141 141 0, measured on a 150000-member archive, 20 of 20 runs) and the pipeline reported failure although the entry exists. Redirecting `grep` to `/dev/null` does not help, because GNU grep stops at the first match then too; the check now reads the whole listing with `awk`, and the package selftest has a case with a 150000-member archive. #401: every axe scan now waits until `document.getAnimations()` is empty before measuring, so colours are never read mid-transition; the reseller banner's contrast was computed in both themes and passes once settled. #407: the handshake waits 180 s for the API instead of 90. #409: the frontend job limit is 40 minutes and the Playwright browser install has a limit of its own (20), because the install alone took 9-10 minutes on a slow runner. #402: the reseller last-slot race test was run 25 times cold (generated handler code deleted before each run) against real PostgreSQL 16 and passed 25 of 25; the production guard is an optimistic revision token on the reseller row and is correct, so it was not moved to a slot gate. The failure stays unreproduced and its cause (a cold-start of the handler chain, already warmed up by an earlier change) unproven. **The root agent writes one journal line per mutating RPC, and the shipped units have security-score ceilings (#106, #107).** Two issues share one branch by the owner's batch exception (their file sets are disjoint). #106: every mutating request the root agent receives now produces exactly one line in the systemd journal under `maran-agent`, written by the agent itself on the target `maran_audit`: `event=rpc method= outcome=> duration_ms= account= site=`. It is a trail independent of the panel's own audit table, for the case where the API and its database cannot be trusted. The line is built only from validated values (an account name, a site domain, an error code, the agent's own method constant) and never contains a password, key, token, file path, file content, error message or request body; read-only calls write nothing. A peer refused at the socket is still recorded, as `transport:PermissionDenied`. Blind spots, stated plainly: journald rate limiting can drop lines under a burst and the agent cannot see it, so a missing line proves nothing; unknown paths and read-only calls are not recorded; the trail does not survive a compromised agent or host; trust a line only with `_SYSTEMD_UNIT=maran-agent.service`. **The second review of the agent change is OUTSTANDING** (the layer sits in the root agent's request path; threat note `docs/superpowers/notes/2026-11-06-agent-audit-lines-threat-note.md`), so the branch must not merge to `main` until a human reviewer has read it. The change also amends `rules/rust.md` "Logging", which described a per-command span and correlation id that were never built. #107: each shipped systemd unit has a `systemd-analyze security` ceiling recorded in `installer/systemd/unit-security-ceilings.txt`, and a new CI job fails when a unit scores worse than its ceiling, when a unit has no ceiling, or when nothing was scored (`maran units`); it reports `DID NOT RUN` rather than a verdict when the runner's systemd major differs from the recorded one. **Certificates: directory URL checks, visible renewal failures and an expiry alert (#161, partial).** `Acme__DirectoryUrl` must now be an absolute https URL with a host (no http, user information, fragment or whitespace) or the panel refuses to start; a private certificate authority is still allowed and its root must be in the operating system's trust store. Administrators see the configured directory (production, staging or custom, and its host) on the Certificates page. A certificate whose renewal failed now shows the reason as one localized sentence, the number of consecutive failures and the time of the last attempt, and a successful renewal clears them. When a certificate has 14 days or fewer left the operator is mailed once, through the panel's outgoing-mail settings, until the certificate is renewed; with no mail configured the alert is skipped and journalled. A new nullable column, `ExpiryAlertedAt`, is added expand-only. **Deferred: DNS-01 and wildcard certificates**, which need a DNS provider hook and an owner decision on the provider and its outbound call, so #161 stays open. Operator guide: `docs/operations/certificates.md`. **API keys, idempotency keys and an OpenAPI description (#144, #136, #143).** Three issues share one branch by the owner's batch exception (their file sets are disjoint); each is described below, and the defaults chosen where the issues left a decision open are named so they can be changed. API keys (#144): an administrator creates, lists and revokes keys (`api/v1/api-keys`, Settings > API keys); a key is shown once, stored only as a digest, carries scopes from a closed list (`self.read`, implicit for every key, and `server.read`) and an optional address allow-list and expiry, and is sent in the `X-Api-Key` header, never as a bearer token. A key reaches only endpoints that opt in with an ApiKey policy: `GET api/v1/api-keys/self` and, with `server.read`, the non-tenant administrator reads named in the issue; every existing endpoint and the SPA stay JWT-only, a key reads no tenant data, and wrong-key attempts are rate limited apart from the login limiter. Every key call is journalled (key id, method, route, status, address; never the key or a body). Idempotency (#136): a new `Idempotency` module and an opt-in `[Idempotent]` filter replay the stored answer for a repeated `Idempotency-Key` from the same caller (`Idempotent-Replayed: true`), refuse the same key with a different request (422) or while the first is running (409), and never store an answer that carries a secret; records expire after 24 hours. OpenAPI (#143): the panel writes its own OpenAPI 3 document (`Maran.Host --cli openapi`, no endpoint serves it); `maran openapi` compares it with `docs/api/openapi-baseline.json` and fails on a change that would break a client, a gate wired as `.github/workflows/openapi.yml`; each release attaches `openapi.json`, checksummed in `SHA256SUMS` and **not signed** (owner decision). Guide: `docs/api/README.md`. **NOT done:** no Provisioning endpoints (the module stays a skeleton), the OpenAPI file is not signed, and no tagged release or CI run of the new workflow has happened. **The second security review of the authentication surface (API keys) is OUTSTANDING** (threat note `docs/superpowers/notes/2026-11-05-api-keys-threat-note.md`), so this branch must not merge to `main` until a reviewer has read it. **SFTP public-key login from the panel, and sessions ended when keys change (#135, #339).** A customer can add and remove SSH public keys on each SFTP login from the SFTP logins page (`GET/POST api/v1/sftp-users/{id}/keys`, `DELETE .../keys/{keyId}`). The panel validates the key with the agent's own grammar (ed25519, ECDSA, security-key types, RSA 2048 to 16384 bits; no options; at most 32 per login), stores label, algorithm and fingerprint, and sends the full set to the agent, serialized per login. Removing a key ends every open session of that login; if that fails the key stays listed and an error says to repeat the removal. A viewing administrator cannot add or remove. Audit entries `SftpKeyAdded` and `SftpKeyRemoved` carry the fingerprint, never the key. No proto or agent change. Known gap: no periodic reconcile of key files against the panel. **The second security review of this privileged change is OUTSTANDING (threat note `docs/superpowers/notes/2026-11-05-sftp-public-keys-threat-note.md`); the branch must not merge to `main` until a human reviewer has read it.** Operator guide: `docs/accounts/sftp-key-login.md`. **Unattended OS security updates, a hold policy and a reboot-required notice (#163).** The installer gains an opt-in flag, `--os-security-updates` (`MARAN_OS_SECURITY_UPDATES`, default off), and a step, `installer/lib/68-os-updates.sh`. With it, the distribution's own unattended-upgrade mechanism is switched on for security updates only: on the Debian family `unattended-upgrades` with `/etc/apt/apt.conf.d/52maran-os-security-updates` (one security origin, `Automatic-Reboot "false"`), on the RHEL family `dnf-automatic` with `/etc/dnf/automatic.conf` written whole (`upgrade_type = security`). Maran adds no daemon, timer, port or outbound call. A hold list (`MARAN_OS_UPDATE_HOLD`: database engines, nginx, PHP, vsftpd) is excluded from the unattended run only, because an unattended restart of those drops customers' connections; held packages stay pending in the panel and a manual `apt-get` or `dnf` run still applies them. A server is never rebooted unattended. Servers > Updates now marks each pending update `security`, `ordinary` or `unmeasured` (a filter, a sort and a `securityCount` that is `null` when not measured), and shows a notice when the host needs a reboot (a new agent operation, `GetRebootRequirement`, contract revision 19, the panel and the agent move together; a probe that cannot run shows nothing and is never read as "not required"). `uninstall.sh` removes the file Maran wrote and leaves the package and the timers. Not observed: a real security run on either family. **The second review of the privileged installer step is outstanding** (rules/security.md), so this branch does not merge to `main` until a reviewer has read it. See `docs/installer/os-security-updates.md`. **Certificate renewal reads its account without a principal (#379).** The scheduled renewal asks the Accounts directory for the account of a certificate, and the directory answers nothing for a caller with no principal, so every renewal recorded `AccountNotFound` and no certificate was ever renewed. A new narrow window, `IAccountDirectory.FindSystemUserAsync`, answers one account's system user name by id with no tenant scope; it is for unattended work only, returns nothing else (no plan, no e-mail, no limits), and `FindAsync` is unchanged for customers. It is the 35th deliberate query-filter bypass (rules/README.md). A real-database test now runs the renewal handler as an anonymous caller, with an inverse control showing `FindAsync` still answers nothing for that caller, and a census test lists the callers of the window. The second security review is OUTSTANDING (threat note `docs/superpowers/notes/2026-11-05-ssl-renewal-account-window-threat-note.md`). **Release pipeline: signed releases, an SBOM per component, generated release notes and a reproducible agent (#154).** A workflow, `.github/workflows/release.yml`, runs on a pushed tag `v*` only (never on a pull request) in five jobs: `check` (tag form and ancestry on `main` only, not CI status), `build`, `sbom` (the third-party generators, apart from the build), `sign` (environment `release`, read-only, the only job that sees the key) and `publish` (the only job that can write, never sees the key). The tag form and ancestry checks are hygiene, not controls against someone who can push a tag, since the tagged workflow file is the one that runs; the real gates are the `release` environment restricted to `v*` tags with a reviewer other than the pusher, the secret `MARAN_RELEASE_SIGNING_KEY`, and a tag ruleset limiting who creates `v*` tags, all configured by the owner. The key is written to a 0600 file in the `sign` job only, passed to `maran release sign --key` by path, and deleted in a step that always runs; the job refuses to publish unless `maran release verify --publish` accepts the staged bundle. A `-beta.N` tag becomes a GitHub pre-release. New `maran release` verbs: `sbom` (one CycloneDX document per component, from the lockfiles, generators pinned to exact versions), `changelog` (release notes from the merged pull requests since the previous tag, offline and deterministic, titles stripped of control characters), `rebuild --tag ... --bundle ...` (verifies the manifest signature first, then rebuilds the agent from a clean `git archive` of the tag in the builder image and compares its sha256 with the signed manifest) and the selftests `sbom-selftest`, `changelog-selftest`, `rebuild-selftest` and `reproducible-selftest`, each with an inverse control. Setting `SOURCE_DATE_EPOCH` makes a build deterministic (sorted archive, fixed mtime, numeric root ownership, `gzip -n`, a path remap for the agent); unset, a build behaves as it did before. Only the agent tarball is claimed reproducible, and only as far as it was measured: the builder pins its base image digest but not the `dnf` compiler packages, and host `tar` and `gzip` matter, so a later rebuild can mismatch. The API publish and the SPA bundle are not claimed. The notes cover changes since the previous tag of any channel and read GitHub merge commits, so a squash-merged pull request would be missing. Nothing in the installed product changes: no manifest field, no daemon, no port, no outbound call from a panel. Publishing to `releases.maran.innovayse.com` is not automated and stays the owner's decision. Operator and maintainer guide: `docs/operations/release-process.md`. Not verified: no tagged run of the workflow has happened (it is linted, and its logic is exercised by the selftests), the SBOM generators were not installed from their registries in this session, and reproducibility of the aarch64 agent has not been measured on a runner. The second security review is OUTSTANDING (threat note `docs/superpowers/notes/2026-11-04-release-pipeline-threat-note.md`), so this change must not merge to `main` until a reviewer has read it. **Panel self-update from the command line (#128).** Two new root-only verbs: `maran update [--check] [--channel stable|beta] [--offline-tarball PATH] [--yes]` and `maran rollback [--restore-database] [--yes]`. Order of an update: the shared lock (with panel backup and restore), the interrupted-update check, the installed version and channel, a fixed 2 GiB free-space floor before any download, the signed manifest (ECDSA P-384 over SHA-384, the key baked into the installed CLI, no override) with its channel and a strictly newer version, the confirmation, the three artifacts (sha256 checked, then checked again before unpacking), adoption of a pre-layout install, a `pg_dump` into `/var/backups/maran-update/`, the unpack beside the live tree, an atomic switch of `current`, migrations from the new release, a restart of the api then the agent, and a health gate with a fixed 120 second limit. A failed gate rolls the code back once and never restores the database by itself; `maran rollback --restore-database` does, after an explicit confirmation, and drops every write since the dump. `maran rollback` also finishes a rollback that was killed half way, and after an automatic rollback it restores only the database; after a manual rollback no previous release is recorded, so a second rollback is refused. `maran update --check` prints the installed and available versions, the channel and whether the available release is newer, and no release-notes address; it takes the lock, creates the staging directory and removes stale partial directories, and can refuse on an interrupted update or low disk space. An offline tarball must be an absolute plain path, a root-owned regular file that group and others cannot write, inside a root-owned directory that group and others cannot write (`/tmp` is refused, use `/root`), and cannot be combined with `--channel`; archives with absolute or `..` paths, backslashes, hard links, device nodes or symlinks are refused. Downloads are capped (manifest 60 s and 1 MiB, each artifact 1800 s, the three together 2 GiB). Layout change: new installs put each release in `/usr/local/maran/releases//` with a `current` symlink and static `api`, `agent`, `frontend` links, and record `/etc/maran/release.state`; a server installed earlier is adopted into `releases/legacy-` by its first `maran update` (the units and the web server are untouched) and can be rolled back to. The signed release manifest gains a `channel` field (`stable` or `beta`), written by the release bundle script and refused when absent or different from the requested channel when fetched from the network. The installer now sources the CLI release libraries (`release-version`, `release-verify`, `release-unpack`, `release-layout`) instead of carrying its own copy, so a first install and `maran update` apply one implementation; re-running it on the same version keeps or repairs the tree and it refuses a downgrade. The online installer fetches from the release host as before; the update check is the new call, and it is operator-triggered only: no timer, no background call. Operator guide: `docs/operations/panel-update.md`. Not in this change: the UI button and automatic security patches. Not verified: the network path against the real release host. The second security review is OUTSTANDING (threat note `docs/superpowers/notes/2026-11-04-panel-self-update-threat-note.md`), so this change must not merge to `main` until a reviewer has read it, and the operator-triggered call to the release host (rules/security.md item 10) still needs the owner's recorded decision. **Changed: the Accounts tables fail closed for anonymous and customer callers (#371).** The Accounts module's persistence used to narrow its accounts, plans and resellers only for a reseller; any other caller, including one with no principal at all, saw every row. It now decides once per kind of caller: an administrator sees everything, a reseller sees only the rows it owns, a customer sees only its own account and that account's plan (and no resellers), and an anonymous caller or a customer with no active account sees nothing. Four deliberate bypasses in three places remain, each marked on its line with its reason: the start-up check that any plan exists, the account list read by the backup run, backup retention, backup reclamation and the PHP reconcile run (all unattended, no principal), and the account switcher's name lookup, which reads the names of a person's other accounts by ids taken from their own memberships. Any unattended job that is not on that list and reads accounts without a principal now sees nothing instead of everything. Threat note `docs/superpowers/notes/2026-11-04-accounts-fail-closed-threat-note.md`; the second review is OUTSTANDING, so this change must not merge to `main` until a reviewer has read it. **The member menu is never drawn from partial access (#366).** After a person switched accounts, the menu could briefly show the new account with the grants of the one just left, or with none. A customer's sidebar now shows a loading skeleton until the account's plan and standing have arrived, then the real menu; once a menu is on screen it is kept until the next answer replaces it. A failed read of the new account's access counts as answered, and the menu then offers everything. **Panel error messages no longer contain en or em dashes (#378).** The sentences the API returns for its error codes use plain punctuation, so the error strings match the website's api-errors tables byte for byte. The firewall options validator's messages were reworded the same way. **Scheduled, retained and off-site panel backups (#129).** `maran panel-backup --scheduled` reads a root-only config (`/etc/maran/panel-backup.conf`, strict allow-list reader, never sourced), writes the encrypted panel archive into `OUT_DIR`, keeps the newest `KEEP` archives (deleted only after a successful backup, exact name pattern, regular files only) and, when an S3-compatible endpoint is configured, uploads the already-encrypted archive over https (credentials through a root-only curl config, never on argv; size checked by a signed HEAD). `maran panel-backup-schedule enable|disable|status` installs or removes the systemd timer; the installer ships `maran-panel-backup.service`/`.timer` (hardened oneshot, timer not enabled until the operator asks) and `uninstall.sh` removes them but never the archives. Restore onto a clean install stays `maran panel-restore`; the procedure, including restoring from an off-site copy, is in `docs/operations/panel-disaster-recovery.md`. The polygon asserts check the units and the uninstaller, and a new `panel-recovery` installer stage (`scripts/lib/install-polygon-recovery.sh`) is the end-to-end proof of the closing criterion: back up a panel with accounts and logins, install fresh on a second server, restore, and see the same administrators, accounts and a decryptable stored secret. Threat note `docs/superpowers/notes/2026-11-04-panel-backup-schedule-threat-note.md`; the second review is OUTSTANDING and the operator-configured S3 upload (rules/security.md item 10) still needs the owner's recorded decision. **Servers > Updates lists the pending OS updates, and the menu shows how many (#324).** A new agent operation, `ListPendingUpdates` (contract revision 17, the panel and the agent move together), reads the package manager's local cache only: `apt-get -s` on the Debian family, `dnf check-update -C` on the RHEL family. It never refreshes the cache and makes no outbound call. A cache that cannot be read is an error with a reason, never "0 updates": the page shows the reason and the badge is absent. New administrator-only endpoints `GET /api/v1/monitoring/updates` and `GET /api/v1/monitoring/updates/count`. The new root-run spawn carries a threat note (`docs/superpowers/notes/2026-11-03-pending-updates-threat-note.md`); its second review is OUTSTANDING, so this change must not merge to `main` until a reviewer has read it. **The audit screen shows who was viewing, and search finds the administrator (#327).** An entry written while an administrator viewed the panel as a customer prints "via " under the actor, on the audit list and on the dashboard's recent activity. The audit search now also matches the administrator's name. **Sub-users: resend an invitation, offer membership to an existing login, and a menu that follows the grants (#328).** The owner can resend a pending invitation (`POST /api/v1/me/sub-users/{id}/invitation`). A login that already exists can be offered membership (`POST /api/v1/me/sub-users/existing`): the answer is the same whether or not that login exists, the person is told by a mail that carries no link or token, and they accept or decline inside the panel (`/api/v1/me/membership-offers`). Offers live in the new table `identity.AccountMembershipOffers` (an expand-only migration) and expire after 14 days. A member's menu now hides what they were not granted, and Sub-users is hidden from members. Sites, SSL certificates, PHP versions and tasks stay unrestricted by design. The resend and consent flows carry a threat note (`docs/superpowers/notes/2026-11-03-membership-offers-threat-note.md`); its second review is OUTSTANDING, so this change must not merge to `main` until a reviewer has read it. **Changed - action needed: existing sub-users lose access to Sites, SSL, Tasks and PHP until the owner re-grants it (#340).** Sub-user grants now cover four more features, Websites, SSL certificates, Background tasks and PHP versions, and the panel checks on every request that a member still belongs to the account, so a removed member's sign-in stops working at once instead of after up to 15 minutes. A member with no grant for a feature no longer reaches it. Sub-users invited before this change keep the features they were given but receive none of the four new ones, so after upgrading, each of them loses the access to sites, certificates, background tasks and PHP versions they had until the account owner opens **Sub-users → Change features** and grants them again. The update does not grant anything on the owner's behalf. Administrators and owners are unaffected. See the sub-users section of the accounts documentation. **Changed: a suspended account is suspended per membership, and its members are refused at sign-in and on every request (#325, #326, #331, #332).** Suspending an account now suspends every login that belongs to it, the owner's and each added member's, instead of the one login the account named. A person whose every membership is suspended is refused at sign-in with the same answer as a wrong password, before a second factor is asked for; a refresh moves to another active account or is refused. A member who already holds a token is closed out at once as well: every customer request that speaks for the suspended account answers 409 `AccountMembershipSuspended`, and a switch to it answers the same. Sessions of the same person speaking for another, still active, account are kept. Reactivating the account restores all of them. `Users.AccountId` is no longer read or written (a new login stores NULL there) but stays in the schema, nullable, until the release after 1.0.0-beta.5 drops it, so rolling back to 1.0.0-beta.4 stays possible: beta.4 still refuses a suspended owner's sign-in, because this release keeps the owner login's old suspension flags in step. A rollback does not carry added members' suspension, and it does not undo suspensions made in beta.4. Account deletion now also deletes every login left with no membership. The one owner per account guarantee moves to a new partial unique index on the memberships; the update applies the migration on its own. Threat note `docs/superpowers/notes/2026-11-03-membership-suspension-threat-note.md` (second review outstanding). **Password refusals name the minimum, and the username rule applies everywhere a password is set (#300).** A password that is too short is now refused with the minimum length the operator's security policy sets, written into the sentence in English, Russian or Armenian, instead of a bare "too weak". The rule that a password may not equal the login's username now also holds when a password is changed, reset by e-mailed link and set by accepting an invitation, not only at first-run setup. Two new codes (400): `PasswordSameAsUsername`, and `PasswordTooLong` for a password over 256 characters, which used to reach the hashing step. A text field with no configured bound can no longer turn a refusal into a 500. **Restore stream drops answer 503 (#299).** A restore whose progress stream was dropped or went idle used to answer 500, as if the panel had broken. `RestoreStreamDropped` and `RestoreStreamIdle` now answer 503, since the host may answer again in a moment; the other restore failures keep 500. A test now compares every code the Backups module raises with the status the error census expects. **Run now is bounded per account and per host, and a run stopped by the output cap really stops (#333, #335, #336).** The panel counts the runs it has handed to the server agent: at most 2 per account and 16 in all, each refused before the agent is called. Past the account's bound the answer is 409 `CronRunAccountLimitReached`, past the panel-wide bound 503 `CronRunServerBusy`, and when the agent itself is full 503 `CronRunAgentSaturated`. The bounds are `Cron__Runs__MaxConcurrentPerAccount` and `Cron__Runs__MaxConcurrentGlobal` (`.env.example`, `installer/panel.env.example`), checked at start-up; they are counted per panel process, so they assume one panel instance per agent. Every run is audited before its history row is written and whether or not the caller stays connected, and a NUL character in a job's output is dropped so the history row can be stored. On the agent, a run that reaches the output ceiling or the deadline used to stop only the waiting client while the job ran on; its systemd unit is now stopped, and a stop that cannot be confirmed is reported as a failure, never as a stopped run: it has its own code, 500 `CronRunNotStopped`, whose sentence says the job may still be running and should be checked before running it again, where it used to be `CronOperationFailed` saying nothing was altered. The agent contract is now revision 16 (`ERROR_CODE_RUN_NOT_STOPPED`); the panel and the agent are upgraded together. Threat notes `docs/superpowers/notes/2026-11-03-cron-run-threat-note.md` and `2026-11-03-agent-run-bound-sessions-keys-threat-note.md` (second review outstanding). **View-as is an allow-list (#334).** An administrator looking through a customer's session used to be refused only on the endpoints someone had marked, which missed SFTP and FTP logins, databases, cron entries and runs, restores, certificates and sites. Now every endpoint must declare `ViewAsAccess` (`Read`, `SafeMutation` or `Refused`); an endpoint that declares nothing, a mutation that is not declared safe, and a non-GET marked `Read` are refused with 403 `HostViewAsNotAllowed` and a sentence saying the action is unavailable while viewing. Only pinning and unpinning a page and leaving view-as are allowed mutations. The attribute lives in the module SDK, so a module this repository never reviews falls under the same default. Support work that changes anything is done as the administrator. Threat note `docs/superpowers/notes/2026-11-03-view-as-allow-list-threat-note.md` (second review outstanding). **Inviting a sub-user no longer tells the owner whether a name or address exists (#341).** `POST /api/v1/me/sub-users` answers 202 with the name and address the owner typed, whether or not another login already holds them; a request that clashes creates nothing and sends no mail. The codes `SubUserLoginTaken` and `SubUserEmailTaken` are gone. An account may hold at most 25 members, pending invitations included (`SubUsers__MaxPerAccount`, 1 to 1000), refused with 409 `SubUserLimitReached`. Sending invitations has its own rate-limit bucket, 20 an hour per account by default (`RateLimiting:InvitationSendMaxRequests`, `InvitationSendWindowSeconds`), so it no longer shares the sign-in-by-link bucket with people accepting their invitations. A timing difference between the two cases is narrowed, not removed; it is stated in the threat note `docs/superpowers/notes/2026-11-03-invitation-threat-note.md` (second review outstanding). **Raising the panel log level no longer raises third-party logging (#337).** The level an administrator sets under Monitoring applied to every library that logs through the panel, including the database driver, the message bus, the HTTP client, EF Core and gRPC, so Verbose could write what a library logs and the panel never would. Those namespaces are now pinned at Warning (`Microsoft.Hosting.Lifetime` at Information); the panel's level moves the panel's own lines only, and a configured override for a longer name cannot lower a pin. Threat note `docs/superpowers/notes/2026-11-03-log-level-threat-note.md`. **Deleting an SFTP login or resetting its password ends its open sessions, and a login's key file cannot outlive it (#338, #339).** A new agent operation, `EndSftpSessions`, ends the sessions of one SFTP login; the panel calls it after a delete or a password reset and journals the outcome. sshd reads a key file only when a session starts, so a session opened with a revoked key used to stay open. In the same slice the agent removes a login's key files before it deletes the login, clears any left under a name before creating a login of that name, and once at start removes key files whose login no longer exists, so a reused name can no longer authenticate with a previous login's keys. The panel and the server agent move to contract revision 15 together, so both must be updated at once. Threat note `docs/superpowers/notes/2026-11-03-agent-run-bound-sessions-keys-threat-note.md` (second review outstanding). **Static analysis and the offline bundle (#309, #310).** CodeQL no longer scans generated sources under `obj/` (`.github/codeql/codeql-config.yml`), and the findings it did report in the project's own code are fixed: a tolerant script closing tag, useless assignments, and mail messages and test objects that were not disposed. `maran release package` also verifies the signature of `integrity-manifest.json` in each bundle, and refuses a bundle in which it or its `.sig` is missing or does not verify. **The remaining screens choose from lists the server provides (#330).** The same change as the accounts and sites lists above now covers the rest of the panel, so the interface holds no copies of these sets and the server still refuses any value outside them. New endpoints, each answering a `value` and a `label` already written in the interface language, from the enums the server validates against: `GET /api/v1/cron-options` and `GET /api/v1/backup-options` (the closed sets the scheduled-task and backup screens select from), `GET /api/v1/firewall/rules/protocols` and `/presets` (the protocols and the port presets of the rule form), `GET /api/v1/accounts/owner-languages`, `GET /api/v1/notifications/smtp/security-modes`, `GET /api/v1/audit/outcomes`, `GET /api/v1/tasks/statuses`, and `GET /api/v1/sites/php-versions/installable` (the PHP versions this host can install, which the install now validates against and the version picker offers). Backups, cron runs, sites, tasks and audit entries carry their status or outcome as a name in the reader's language beside the value, which the badges print. The panel and the server agent move to contract revision 14 together (`PhpService.ListInstallablePhpVersions`). **The account states are named by the server (#330).** The accounts list's status filter, its chips and the status badges used to carry the two states and their names inside the interface. The filter and its chips now read `GET /api/v1/accounts/statuses`, which answers each state as a `status` and a `statusName` already written in the interface language, from the same enum the accounts list validates against; any signed-in caller may read it. Every account the panel sends (list, detail, a customer's own) now carries its own `statusName`, which the badges print. A `?status=` link is checked against the list once it has arrived, and the server still refuses any state outside the set. **The site screens choose backends and states from lists the server provides (#330).** The backend picker on the create-site form and the backend and state filters on the sites list used to carry their options inside the interface. They now read `GET /api/v1/sites/choices`, which answers each option as a `value` and a `label` already written in the interface language, from the same enums the server validates against; the PHP version pickers already listed only the versions installed on the host. The server still refuses any value outside the set. **`maran release package` makes one verified offline bundle per architecture (#310).** After `maran release build --arch both` and `maran release sign`, `maran release package` now writes `maran-offline--x86_64.tar.gz` and `maran-offline--aarch64.tar.gz` (into `-offline/`, or the directory given with `--out`); `--arch` picks one, and a single architecture may take `--out .tar.gz` as before. It used to look for `api.tar.gz`, `agent.tar.gz` and `frontend.tar.gz`, which only a single-architecture build writes, so a release for both architectures had to be assembled by hand. Each bundle carries the already signed manifest and that architecture's three archives under the names the installer's `--offline-tarball` path reads. Before reporting a bundle, the command extracts what it wrote and repeats the installer's checks with `installer/keys/release-signing.pub` (or `--pubkey`): the openssl signature over the manifest, each archive's sha256 against its `-` entry, and the entry file (`Maran.Host`, `maran-agent`, `index.html`) at each archive's root; a bundle that fails is deleted. `maran release package-selftest` proves it against a planted two-architecture bundle, with an inverse control for every refusal and `DID NOT RUN` (status 2) when openssl is missing. **The administrator's menu is grouped by task, and shows what is coming (#311).** The sidebar is now one navigation tree (`frontend/src/router/navigationTree.ts`) that every menu reads: the sidebar, the collapsed rail, the phone drawer, the tab bar, the ⌘K/Ctrl+K palette, the breadcrumb and the page eyebrow. An administrator sees twelve items under four groups — Overview (System status); Hosting (Accounts, Sites and domains, DNS, Mail, Databases, Files and access, Backups); Infrastructure (Security, Servers, Monitoring); System (Settings). Pages keep their addresses while the menu groups them by task: the audit journal is under Monitoring at `/settings/audit`, the security policy under Security at `/settings/security-policy`. Pages that are not built yet (DNS, mail, the server fleet, resellers, a few settings) are drawn greyed out with a "Coming soon" hint, announced as disabled, and left out of the palette. Background tasks shows how many tasks failed. A section with nothing built yet still opens, its pages disabled. The customer's and the reseller's menus are the same tree filtered by role; the reseller's menu is the same tree, now live with the reseller role (#179, #389). The New site form, the signed-in devices screen and the outgoing-mail page are no longer separate sidebar entries: the first is opened from the site list, the second from the account menu, and the third is under Settings. Follow-ups: "View as" (#317), favourites (#318), the server selector (#319), the needs-attention, mail-queue and updates badges (#320) and notifications (#164). **The customer's menu is the same tree, filtered for the account owner, and a plan limit says why (#312).** A customer sees nine items: System status; Sites and domains; DNS; Mail; Databases; Files and access; Backups; Activity (Background tasks, site logs); My account (the account page, Two-step verification, Sessions, sub-users). Pages not built yet are drawn disabled with "Coming soon": domains, SSL and PHP settings per site, My zones (DNS), mail (mailboxes, forwarders, autoresponders, spam filter, webmail), a database admin tool, SSH keys, the file manager, restore, site logs and sub-users. Nothing that is the server's is offered (accounts, plans, security, servers, monitoring, settings). The breadcrumb and the page eyebrow place a screen where the caller's own menu lists it, so a customer's Background tasks reads Activity while an administrator's reads Monitoring. On a phone the customer's bar is Home, Sites, Mail (disabled until #177), Account and More. At a plan limit the Sites, Databases, SFTP, FTPS and Scheduled tasks screens replace their create action with the reason, read from the account's resource summary (#133); the endpoints still refuse a create past the plan on their own. Not in this change: an account selector for one person with several accounts (#313), the "viewing as" banner (#314), sub-users (#315), and hiding items by plan feature, which needs plans to list features (#316). **The server agent can let an SFTP login in with a public key, and takes the key away on suspension (#135, first slice).** A new agent operation, `SetSftpAuthorizedKeys`, replaces the whole key set of one SFTP login. Each key is checked by the agent before root writes it: Ed25519, ECDSA (P-256, P-384, P-521), RSA from 2048 to 16384 bits and the two security-key forms are accepted, at most 32 keys of 8 KiB each; DSA keys, options such as `command="…"` and any control character are refused, the comment is dropped, and every line is written as `restrict `. The keys go to a root-owned file outside the jail, `/etc/ssh/maran-sftp-keys/`, which the installer's SFTP block in `sshd_config` now names as `AuthorizedKeysFile` (re-run the installer to get the line and the directory). A suspended account's key files are moved to a name sshd does not read and come back on resume, because a locked password does not stop a key login under `UsePAM yes`; deleting a login removes its keys. The panel and the server agent move to contract revision 10 together, so both must be updated at once. Not in this slice: storing keys in the panel, its endpoints and the SFTP screen, so nothing calls the operation yet. Threat note: `docs/superpowers/notes/2026-11-01-sftp-public-key-threat-note.md`. **The install log is rotated, and an administrator can change the panel's log level without a restart (#131).** `/var/log/maran/install.log`, which every installer run appends a transcript to, was the one file under `/var/log/maran` that nothing rotated; a new step installs `/etc/logrotate.d/maran-install` (monthly, twelve kept, compressed), and the uninstaller removes it. Re-run the installer to get it. Monitoring → Panel log level shows the level the panel is logging at and changes it: Verbose, Debug, Information, Warning, Error or Fatal, effective for the next line, stored and applied again at every start, and recorded in the audit log as "Panel log level changed" with the level before and after. New endpoints `GET` and `PUT /api/v1/log-level` (administrators only), a new code `LogLevelUnknown` (400) and a new table `monitoring.LogLevelSettings`, applied by the update on its own. A level set in configuration (`Serilog__MinimumLevel__Default`) is now honoured at start-up where it used to be overridden to Information. Not yet: the server agent's own level, which needs a new agent operation. Details in `docs/monitoring/logs.md`; threat note `docs/superpowers/notes/2026-11-01-log-retention-threat-note.md`. ## 1.0.0-beta.4 — 2026-11-02 **Second review outstanding.** Several privileged changes in this pre-release (the agent's root-run operations, account and session handling, the installer's privileged steps, licence and release verification) still carry the second human review that rules/security.md requires, recorded as OUTSTANDING in their threat notes under `docs/superpowers/notes/`. The owner approved shipping this beta to `main` with that review still open on 2026-11-01, as a pre-release exception written into rules/security.md. Run it on servers you can afford to rebuild, and expect the review's findings in a later beta. **A scheduled task can be run now, and its recent runs are kept (#158, first slice).** Each row on the scheduled tasks screen gains "Run now", which runs the entry immediately as the account's own user, inside the account's `user-.slice` so the plan's CPU, memory and process ceilings apply, stopped after two minutes and after 64 KiB on either output stream. The result (outcome, exit code, duration, standard output and error) opens in a dialog, and "Run history" lists the runs started this way: a server-paged list of the newest twenty per entry, searchable in the output and filterable by outcome (succeeded, failed, stopped). A disabled entry still runs by hand; a suspended account's entry is refused (`CronEntrySuspended`, 409). New endpoints `POST` and `GET /api/v1/cron-entries/{entryId}/runs` and `GET /api/v1/cron-entries/{entryId}/runs/{runId}`, a new table `cron.CronRuns` (the module's first), new codes `CronEntrySuspended`, `CronRunNotFound`, `CronRunOutcomeUnknown` and `AgentAccountSuspended`. The panel and the server agent move to contract revision 11 together (`CronService.RunCronEntry`, `ERROR_CODE_ACCOUNT_SUSPENDED`), so both must be updated at once. Not yet: scheduled runs in the history (only the last one is shown, as before) and a run on a real host. Threat note `docs/superpowers/notes/2026-11-01-cron-run-now-threat-note.md`. **The panel watches the server's clock and says when it is not synchronised (#150).** Two-factor codes, sessions, certificates and the licence check all depend on the right time. The server agent now reads `timedatectl` and reports whether the kernel considers the clock synchronised, and whether a synchronisation service (systemd-timesyncd or chrony) is enabled; a clock left drifting shows up once the kernel stops vouching for it. The dashboard's "Needs attention" names a clock that is not synchronised (or that could not be checked), the monitoring screen gains a "Clock" card with the panel's explanation, and the operator is mailed after ten consecutive checks and again when it recovers, through the shared alert path. A refused two-factor code on a server whose clock was found not synchronised now answers `InvalidTwoFactorCodeClockUnsynchronizedUnauthorized` (401) with a sentence saying the clock may be why; the code is still refused. New endpoint `GET /api/v1/monitoring/clock` (administrators only) and a `clock` section in the dashboard's `attention`. The panel never changes the time itself. The panel and the server agent move to contract revision 10 together (new rpc `MonitorService.GetClockSync`), so both must be updated at once. Details in `docs/monitoring/clock-sync.md`; threat note `docs/superpowers/notes/2026-11-01-clock-sync-threat-note.md`. **The new account form asks for the username and the owner's language, and says what it will create (#194, first slice).** The primary domain comes first and is entered without `www.`: the server now refuses a primary domain that starts with `www.` (`AccountDomainWwwPrefix`, 400). The account's system name is labelled Username and is filled in from the domain as you type it (`www.my-shop.example.com` suggests `myshop`) until you change it yourself. A new Owner language field (English, Russian or Armenian, starting as your own interface language) decides the language of the owner's invitation mail, at creation and on every resend; before this the invitation went out in the language of whoever created the account. `POST /api/v1/accounts` takes an optional `ownerLanguage` (`en`, `ru` or `hy`, English when absent; anything else is refused with `AccountOwnerLanguageUnsupported`, 400), stored in a new column `accounts.Accounts.OwnerLanguage` that the update fills with `en` for existing accounts. A panel beside the form lists what creation makes on the server. Not in this slice, and still open on #194: an initial password instead of the invitation, the IP address, the reseller flag, per-account inode, process and open-file limits, shell access, the backup and automatic certificate options, and listing the chosen options in the audit entry. No contract change: the agent is untouched. Details in `docs/accounts/new-account-form.md`. **Accessibility: reduced motion is honoured everywhere, and axe checks the main screens (#149).** With the operating system set to reduce motion, every transition in the panel now finishes at once (one rule, `frontend/src/assets/css/motion.css`); before, only the components that animate on their own respected it, and the buttons' 700ms spring did not. A new Playwright suite, `e2e/accessibility/`, runs axe (WCAG 2.2 AA) on the sign-in screen, the dashboard and the account, site and database lists in the dark and the light theme and fails on any serious or critical violation, with a planted violation as its positive control; it also covers the document language at boot and on the sign-in screen's switcher, which complement the existing `lang` and skip-link specs. The first scan found one real defect, now fixed: the sign-in card was a 5% wash over the accent glow instead of the documented `--s1` at 85%, which left the field labels at 3.39:1 (light) and 3.82:1 (dark), and the glow behind the credit line is lowered so its link clears 4.5:1. New development dependency: `@axe-core/playwright` 4.11.0, pinned. **Four oversized files are split by responsibility, with no change in behaviour (#291).** Each was over the 400-line review trigger. The alert evaluator now commits and announces a round while `AlertObserver` records each reading against its alert row and `AlertDetailMapper` renders the figures a mail and the journal carry (575 lines become 287 + 264 + 129, plus the `PendingAlert` carrier). The agent's backup creation keeps its body and moves the scratch, the `.partial`, publication and the sidecar into `open_backup_scratch.rs`, `create_partial.rs`, `publish_artifact.rs` and `write_sidecar.rs` (637 lines become 355 at most). The restore keeps its entry point, outer body and irreversible half, and moves verification (`prepare_restore.rs`, `require_sidecar_agrees.rs`, `resolve_databases.rs`, `open_restore_scratch.rs`), the database replacement and its rollback (`replace_databases.rs`), the swap (`swap_home.rs`), finalising (`finalise_restore.rs`) and its two carriers (`restore_placement.rs`, `restore_plan.rs`) into their own files (948 lines become 325 at most). The account page draws its details and its actions through `AccountDetailsCard` and `AccountActionsCard` (414 lines become 362). **Test files get their own size trigger (#296).** The rules keep production code at a target under 300 lines with a hard review trigger at 400. Test files now target under 600 lines, with a hard review trigger at 800, because one unit has many cases and splitting them only to meet the production limit scatters those cases across files. **The panel can order a trusted certificate for its own address, and renews it (#132, first slice).** A fresh install serves the panel with a self-signed certificate. Security policy → Panel certificate now orders a certificate for the panel's configured host name (`Panel__HostName` in `/etc/maran/panel.env`) from the same ACME authority the sites use, installs it as `/etc/maran/tls/panel.crt` and `panel.key`, and renews it daily once it is within thirty days of expiry. If issuance fails — the name does not resolve here, port 80 is closed, the authority refuses, or the server is below its free-space floor (`AgentBelowFreeSpaceFloor`, kept as itself) — nothing is replaced and the panel keeps the certificate it was serving. The panel vhost gains one port-80 block that answers only `/.well-known/acme-challenge/` for the panel's host name from `/var/lib/maran-acme` and 404 for everything else; re-run the installer to get it. New endpoints `GET` and `POST /api/v1/panel-certificate` (administrators only), new codes `PanelHostNameNotConfigured` (409), `PanelCertificateNotIssued` (404) and `PanelChallengeWriteFailed` (500), and a new table `ssl.PanelCertificates`. The panel and the server agent move to contract revision 9 together (three new `SslService` rpcs), so both must be updated at once. Not yet: a command-line verb, removing a trusted certificate back to self-signed, and a polygon run against a real authority. Threat note `docs/superpowers/notes/2026-11-01-panel-certificate-threat-note.md`. **A plan caps the CPU, memory and processes of each account on it (#133).** A plan now states three runtime ceilings beside its disk quota and counts: CPU as a share of one core (`cpuQuotaPercent`, 200 is two cores), memory in megabytes (`memoryMaxMb`) and processes (`maxProcesses`), each either a figure or "No limit". `POST /api/v1/plans`, `PUT /api/v1/plans/{id}` and every plan read carry them as `{"kind": "limited" | "unlimited", "maximum": n}`; an absent ceiling, a limited zero and an unlimited ceiling carrying a figure are refused with 400. The server agent writes them onto the account's systemd slice (`user-.slice`) through a new rpc, `SetAccountResourceLimits`: when an account is created, when a plan's ceilings change (to every account on it), and when an account moves to a plan with other ceilings. A lowered ceiling is applied rather than refused: CPU is throttled, the next process past the cap is refused, and memory above a lowered cap is reclaimed from the account and can end the account's own processes. Plans that existed before the update are unlimited on all three, which is what the server enforced for them (three pairs of columns on `accounts.Plans`, applied by the update on its own). What the slice reaches: the account's SSH and SFTP sessions and what runs under them; its PHP workers are still bounded only by the per-pool worker limit, and its cron jobs are outside the slice on the Debian family — both are stated in the threat note and left for a later change. The panel and the server agent move to contract revision 8 together, so both must be updated at once. Threat note: `docs/superpowers/notes/2026-11-01-account-runtime-limits-threat-note.md`. **A pull request's backend lane runs in four parallel shards, and PostgreSQL 13 and the performance budgets moved to dev and nightly (#289).** The whole backend suite used to run twice per pull request, unsharded, about 30 minutes each. Now `maran test backend --shard i/4` runs a deterministic quarter of the integration tests per runner and `maran test backend --combine` proves every test class ran exactly once and compares the summed rows with `scripts/test-baseline.txt`, so the census verdict is unchanged. The performance budgets carry a typed `[PerformanceBudget]` trait and their own baseline row; they and the PostgreSQL 13 lane run unsharded on the push to dev, main and nightly. No behaviour of the panel changed. **Monitoring and backup alerts take one shared mail path (#290).** Journalling an operator alert, finding the alert address, requesting the mail and journalling "Mail skipped: no SMTP settings" when no address is configured used to be written twice, once in the monitoring alert evaluator and once in the backup alert mailer. Both now call one implementation in the module SDK (`Maran.Sdk/Alerts/OperatorAlertMailer`), writing through each module's own audit journal, so the mails and the journal entries are exactly what they were. Nothing changes for an operator. **Every CI job has a time limit, and a new push to a pull request cancels the run it replaces (#289, first part).** A hung job used to idle until GitHub's six-hour default or a manual cancel; each job now has a `timeout-minutes` sized from its measured duration on recent successful `dev` runs plus headroom (backend `ci` 60, `ci-pg13` 50, the agent polygon 40), with the measurement written beside it. Runs on pull requests are grouped per pull request and cancelled when superseded; pushes to `dev` and `main` are never cancelled. No behaviour of the panel changed. **A scheduled backup that fails, cannot reach its destination or misses its hour now mails the administrator (#134, first part).** Until now each of these was silent: a failed nightly run showed only as a red row, a schedule pointing at a destination the panel could not use logged a line and started nothing, and a panel that was down at 03:00 caught up later without saying so. The scheduled sweep now sends one alert for each, to the same address and through the same outgoing-mail settings as the monitoring alerts, in English, Russian or Armenian, and journals it as "Alert raised" (with "Mail skipped: no SMTP settings" beside it when no address is configured). A run counts as missed when it starts more than 30 minutes after its named hour; a panel that was off for several nights sends one alert naming the first missed hour. Manual backups do not mail. Verifying each archive after it is written, which needs a new server-agent operation, is still to come. Details in `docs/backups/alerts.md`. **The server refuses new work before its disk is full, and says so in advance (#148).** The server agent now keeps a free-space floor, 1 GiB unless you set `MARAN_AGENT_FREE_SPACE_FLOOR_MIB` in `/etc/maran/agent.env` (re-running the installer keeps your value). While less than that is available on the filesystem an operation would write to, creating an account, taking or restoring a backup and accepting an upload are refused before anything is written, and the refusal reads "The server is nearly out of disk space…" in your language, in the response and as the failure of a backup or restore task (`AgentBelowFreeSpaceFloor`, HTTP 503). Suspending, deleting and every read keep working, so space can be freed from the panel. A new alert mails the operator when the root filesystem has had less than twice the floor available for ten checks, so it arrives before the refusals begin. A backup whose description file cannot be written in full after the archive was published now removes both, so a disk that fills during a backup leaves no half-written backup behind. The panel and the server agent move to contract revision 7 together, so both must be updated at once. Details in `docs/monitoring/disk-figures.md`; threat note `docs/superpowers/notes/2026-11-01-free-space-floor-threat-note.md`. **The panel itself can be backed up and restored onto a clean server (#129).** Two new root-only commands: `maran panel-backup --passphrase-file FILE [--out DIR]` writes one encrypted archive (gpg, AES-256, mode 0600) holding the panel database, the encryption and signing keys from `panel.env`, the licence and the whole of `/etc/maran` and `/var/lib/maran`; `maran panel-restore --passphrase-file FILE --replace-this-panel ARCHIVE` turns a fresh install into that panel again — database in one transaction, the two keys, the licence, then this release's migrations — so the same administrators sign in and every stored secret decrypts. A wrong passphrase or a damaged archive is refused before anything changes, and a refused restore starts the panel again as it was. Scheduling and S3 upload of these archives are not in this release. How to use them: `docs/operations/panel-disaster-recovery.md`. Threat note: `docs/superpowers/notes/2026-11-01-panel-disaster-recovery-threat-note.md`. **The disk figure matches `df`, and the space kept for the system is shown on its own (#77).** The panel used to call capacity minus available space "in use", so on an ext4 server it read about 5% higher than `df`'s `Used` (12.7 GiB against 7.7 GiB on a 97.9 GiB disk): that 5% is the reserve the filesystem keeps for root, which no hosting account can write. The dashboard and the monitoring screen now show three figures in one stacked bar, "In use" (exactly `df`'s `Used`), "Reserved for the system" (0 on xfs) and "Available" (`df`'s `Avail`, what the accounts can still use), and the three always add up to the disk's size. `GET /api/v1/monitoring/metrics` and the dashboard's `resources` gain `diskReservedBytes` and `diskAvailableBytes`, and `diskUsedBytes` now means `df`'s `Used`. The disk chart plots the same "in use" figure; readings taken before the update stored the old figure and show as a gap until they age out of the seven-day history (a new nullable column, `DiskInUseBytes`, applied by the update on its own). The disk-full alert still fires when the accounts run out of room, reserve included. The panel and the server agent move to contract revision 6 together, so both must be updated at once. The definitions are in `docs/monitoring/disk-figures.md`. **The SPA's sites store is split by concern (#280).** `stores/sites.ts` held the site list, detail, mutations, the host's PHP versions and the live log tail in 503 lines; it now keeps the list, detail, form bounds and mutations, while the installed PHP versions and their install (`phpVersions.ts`) and a site's log tail (`siteLogs.ts`) each have their own store. No store reaches into another, and no behaviour of the panel changed. **Form fields are named after what you choose, not after the identifier sent for it (#80).** The new-account form's plan picker is labelled "Plan" ("Тариф", "Սակագին") and its required message reads "Choose a plan."; in Armenian the form now uses the same word for a plan as the plans page, the navigation and the change-plan dialog, where it had said "Փաթեթ". The account pickers of the create-site, database, SFTP, FTPS and backup forms keep their "Account" label, their locale keys no longer carry the request property's name (`fields.account`, not `fields.accountId`), and two Armenian prompts gained the article the other three had. `maran structure` now refuses a form field, placeholder, filter or table column whose key ends in `Id` or whose English text ends in "ID". **A site carries at most 99 aliases, the names one certificate can cover (#275).** A site used to accept any number of aliases, so its vhost, its claimed names and the certificate the panel would request for it grew with whatever a request carried. One certificate may name at most 100 hosts (the Let's Encrypt limit), and the site's own domain is one of them, so creating a site with more than 99 aliases is now refused with 400 `SiteAliasLimitExceeded`, in English, Russian or Armenian; the server agent refuses the same on its side for every operation that renders a site's configuration, while switching off or winding down an existing site still works whatever it carries. The bound is the server's and the new `GET /api/v1/sites/limits` reports it (`{"maximumAliases": 99}`); the create-site form shows how many aliases are listed against it, says so as soon as the list is too long, and does not let you create the site until it fits. If the limit cannot be read, the form does not guess and the server decides. **The header's running-tasks badge holds one connection, however much work is in flight (#274).** The shell used to read `GET /api/v1/tasks/running` and then open a live stream for every running task, so a busy host cost each signed-in browser one held connection per task. It now opens one stream, `GET /api/v1/tasks/running/stream`, which sends every running task you may see when it opens, each change to one of them (including the change that finishes it), and the set of running task ids whenever it changes; who may see which task is unchanged, and a customer's frames still carry no log. The stream is opened once per signed-in shell, closed on sign-out, reopened when you switch language so task names follow it, and reconnected after a dropped connection with a wait that doubles from one second up to thirty; a refusal is not retried. A task's live view keeps its own stream, because the task it shows may have finished long ago. `GET /api/v1/tasks/running` stays for API clients. Threat note: `docs/superpowers/notes/2026-10-31-running-tasks-stream-threat-note.md`. **A failed task's reason is written by the panel, in your language, for every code (#272).** A background task that failed now carries its reason as a sentence the backend resolves from the stored code each time the task is read: `GET /api/v1/tasks`, `GET /api/v1/tasks/running`, `GET /api/v1/tasks/{id}` and every frame of `GET /api/v1/tasks/{id}/stream` send `failure: {"code": …, "message": …}` (or `null`) in place of `errorCode`, with the message in the reader's own language, so switching language re-reads an old failure in the new one and two people watching one task each read it in theirs. It is the same sentence the refusal itself carries as an error response, so a code raised anywhere in the panel, the certificate authority's and the backup outcomes' included, explains itself; a code no installed module describes reads as a general sentence with the code beneath it. The interface no longer keeps its own table of 51 task failure sentences, and `maran structure` no longer checks it; a backend test now asks the assembled panel for a sentence for every error code it ships, in English, Russian and Armenian. **Every list holds up at more than a thousand accounts, and CI measures it (#196, #162).** The per-account disk view (`GET /api/v1/monitoring/accounts-disk`) is now paged like every other list (`page`, `pageSize`, `search` on the user name, `sort` by `username` or `quotaBytes`), and the server agent measures only the accounts on the page instead of walking every home directory on each request; the panel and the agent move to contract revision 5 together, so both must be updated at once. Sorting by used space is gone, because only the page on screen is measured, and the over-quota notice speaks for that page. The integration suite now seeds 1 200 accounts with their sites, certificates, databases, logins, backups, audit entries and tasks, and fails when any shape of any list (first page, last page, search, each filter, each sort) takes longer than 200 ms at the 95th percentile; the slowest measured was 25 ms on PostgreSQL 16 and 67 ms on 13. Two indexes arrive, `IX_Certificates_SiteId` and `IX_Backups_StartedAt`, and the update applies them on its own. Every list that stays unpaged, and why, is in `docs/performance/list-indexes.md`. **The SPA's sign-in store is split by concern (#270).** `stores/auth.ts` held every account flow in 498 lines; it now keeps only the session (sign-in, second-factor code, renewal, sign-out), and first-run setup (`setup.ts`), the second factor's enrolment (`twoFactor.ts`), the signed-in devices (`sessions.ts`) and the e-mailed password links (`passwordLinks.ts`) each have their own store with their own message. The root component dismisses all of their messages on every screen change, as before, and clears the module catalogue and the devices list when the session ends, so no store reaches into another any more. No behaviour of the panel changed. **The "no placeholder for a fast answer" browser tests hold under load (#265).** These tests timed the page by the wall clock, so on a busy machine a stubbed read that "answered in 50 ms" arrived much later, and the breadcrumb's placeholder, which had honestly waited its 150 ms, was reported as a flash. They now drive the page's own clock: the read is held until the test releases it after 50 ms of page time, then time runs well past the delay, and any placeholder seen at any point fails the test. A new control proves the recorder does see a placeholder once a wait reaches 150 ms. One licence-gating test also looked for a "Sites" link anywhere on the page, and the dashboard's Sites tile sometimes matched too; it now looks only in the sidebar. No behaviour of the panel changed. **Failed tasks explain every refusal, and the panel confirms every create (#222).** A certificate task that failed because its site or account no longer existed, because the domain already had a certificate, or because the server agent refused (unreachable, out of date, a package mirror that refused or could not be reached, and the rest of the agent's refusals) now reads as a sentence in your language with the code beneath it, as do PHP installs whose outcome the server never reported; before, those showed only the generic sentence. `maran structure` now also follows codes that reach a task through a variable in the handler that closes it, and requires a sentence for every refusal the server agent can report. Banning an address and adding a never-banned range are now confirmed by a toast, like every other create, and every confirmation sits in the same corner. A task's progress is drawn by one rule in the list and in its live view: a failed task shows how far it got and no bar. In Armenian, the database grant screen has one name, "Իրավունքների վերականգնում", in the sidebar, on its button and as its title. **The shell follows a language switch and the current page more closely (#221).** Switching the interface language live now renames the modules in the sidebar and the breadcrumb at once, and two quick switches can no longer leave the names in the language switched away from: only the latest read of the module catalogue is kept. A refusal shown on one screen no longer follows you to the next: a refused invitation or reset link followed by "Back to sign in" opens a clean sign-in screen, and the same holds between the signed-in devices and two-factor screens. A section's row (Accounts) is lit on every one of its pages, Plans included, in the collapsed rail as well as the sidebar, and its highlight now runs the full width of the row with the submenu toggle inside it, like its children's. The current row is kept in view when the module list arrives after the page, not only on a navigation, and moves the list only as far as needed. The content panel reserves its scrollbar's lane on every page, so moving between a short page and a long one (the dashboard and Firewall) no longer shifts the content sideways. The role under the user name, which was cut to "Ադմինիստրա…", and the focus ring left by a mouse click were already fixed earlier and are now covered in English, Russian and Armenian. **Small inconsistencies from the full review are gone (#218).** Every confirmation's button now names its action — "Delete backup", "Remove task", "Lift ban", "Remove exemption", "Remove certificate", "End session", "Turn off two-step verification" — instead of "Yes, do it" or "Yes, delete it", and the confirmation component requires that label, so a new one cannot ship without it. The firewall's rules screen is laid out like its bans and never-banned screens: a refusal first, the form in a card of its own, then the list under its own heading ("Rules in force"); the firewall placeholders describe the field ("From 1 to 65535", "Empty for one port") instead of showing a value such as 8080 that looked already typed. Taking a backup now empties the account picker, as every other create form does after an accepted create. The way back from a site, an account, a plan and the upgrade screen is one link style, underlined in the accent colour. Every helper under a form field is the same size (the PHP version picker's was a step smaller), and the invitation sentence on an account is body text rather than a step larger. Points of the review already fixed earlier (one face for the tab labels, the second-factor placeholder, the dates with times on the firewall lists, the "Manual" backup reason, the upgrade screen's module name, the quiet signed-out console) are now held by end-to-end checks. **On a phone, tables are cards, the theme has one labelled menu, and a dialog's hint is never under its buttons (#217).** Below 640 px every list (accounts, plans, sites, databases, SFTP and FTPS logins, scheduled tasks, firewall rules, bans and whitelist, backups and their destinations, the audit journal, background tasks, signed-in devices) draws each row as a card: its name as the title, its actions at the top right where they are always in view, and every other column as a line labelled with the column's heading. Nothing scrolls sideways any more, and a row's actions can no longer be scrolled off screen. Sorting stays available through a "Sort by" select of the sortable columns with a button that flips the order, and the loading placeholders take the card shape. From 640 px up the tables are unchanged. The theme is now one control, the mode's icon and name ("Theme: Light" to a screen reader), opening a menu of Light, Dark and System; it sits in the app bar on the desktop and in the navigation drawer on a phone, and replaces the old switch and the unlabelled sun in the drawer. System follows the operating system's light or dark setting and keeps following it while the panel is open; the choice is remembered as before. In a dialog, focusing a field now also brings the text under it into view, so the hint under the account deletion's confirmation field is no longer hidden beneath the dialog's buttons. **The sidebar, the page eyebrows and the breadcrumb tell one story (#209, #72).** Every sidebar entry is now named for the screen it opens, and that name is the page's own title: "Users and access" is "Signed-in devices", "Notifications" is "Outgoing mail", and "Tasks" is "Background tasks", so it can no longer be confused with "Scheduled tasks" (cron). The security policy has its own entry under System, and PHP versions sit in the Sites submenu; both were reachable only by typing their address. The firewall's submenu (#196) follows the same rule: Rules, Bans and Never banned, the titles of the three pages. The small line over each page title is the sidebar group the page sits in, derived from the navigation instead of written per page, so Background tasks reads Overview, the audit journal System, Backups Security and Accounts Hosting, as the sidebar draws them. The breadcrumb goes one level further: a submenu page ends on its own name (Security / Backups / Schedule), and a site or an account ends on its domain or name, with a placeholder while it loads instead of the id from the address. No crumb is a URL segment any more. Only the current submenu child is highlighted, and the collapsed rail and the jump-to palette keep the expanded sidebar's order (both already fixed earlier and now covered by specs). **The audit journal, background tasks, signed-in devices, firewall bans and the firewall whitelist are paged on the server (#196).** `GET /api/v1/audit`, `GET /api/v1/tasks`, `GET /api/v1/sessions`, `GET /api/v1/firewall/bans` and `GET /api/v1/firewall/whitelist` now answer one page (`{"items":[…],"total":…,"page":…,"pageSize":…}`) instead of a bare list, with `page`, `pageSize` (10, 25, 50 or 100), `search` and `sort` in the query string; the audit endpoint's `limit` parameter is gone. The journal searches the actor, the subject and the address, sorts by `occurredAt` (newest first by default), `actor` or `action`, and filters by `action`, `outcome` (`succeeded` or `failed`) and a `from`/`to` window; `GET /api/v1/audit/actions` lists the actions it holds, named in the caller's language, for the action filter. It has no account filter, because an entry records no account. The tasks list searches the subject, sorts by `startedAt` (newest first by default) or `subject`, and filters by `accountId`, `status`, `kind` and a `from`/`to` window on the start; who may see which task is unchanged. Three companions replace what screens used to read off the whole list: `GET /api/v1/tasks/running` (every running task, for the header badge), `GET /api/v1/tasks/failed-count` and `GET /api/v1/tasks/kinds` (the kinds the caller can see, for the kind filter). A window whose start is later than its end is refused (`AuditDateRangeInverted`, `TaskDateRangeInverted`). Devices search the address and the browser name and sort by `issuedAt` or `expiresAt`; the list still takes no user, because whose devices they are comes from the caller's own sign-in, and a `userId` in the address is ignored. Bans search the address and sort by `address`, `bannedAt` or `expiresAt`; the whitelist searches the range and its note and sorts by `cidr` or `createdAt`. The firewall is now three pages linked from one sidebar entry, rules (`/firewall`), bans (`/firewall/bans`) and never banned (`/firewall/whitelist`); the rules stay one unpaged list, because the confirmation before removing a rule has to see every rule the host runs. The dashboard counts the bans in force and the failed tasks, and reads its five newest audit entries, with queries of their own instead of reading whole lists. The audit screen no longer shows a count of failed entries among the hundred it held; the outcome filter shows them instead. Two indexes arrive with it, `IX_AuditEvents_Action` (the action filter and its list of choices) and `IX_PanelTasks_Status` (the running tasks, the failed count and the status filter); the update applies them on its own. **A signed-in person can change their own password from the profile (#232).** Until now the only way to change a password was the e-mail reset link. `/profile` now has a password card for administrators and customers alike: current password, new password, and the new one again. It calls the new `POST /api/v1/auth/change-password`, which checks the current password, holds the new one to the same minimum length the security policy sets for resets and invitations, and on success ends every other signed-in device while keeping the one in use. A wrong current password answers 400 `CurrentPasswordIncorrect` and counts towards the account lockout exactly as a failed sign-in does, the endpoint shares the sign-in rate limit, and every change or refusal is written to the audit log as "Password changed" without either password. Threat note: `docs/superpowers/notes/2026-10-31-change-password-threat-note.md`. **A refused sign-in burst answers as a problem, and the backend suite measures the shipped limits (#260).** The sixth sign-in attempt from one address within five minutes is refused with 429, code `HostRateLimited` and a `Retry-After` header, as before; its body now arrives as `application/problem+json` like every other refusal, instead of `application/json`. The limiter itself was never broken: a test host started from a shell that had sourced `scripts/dev` took the development `.env` value of 50 attempts, because environment variables outrank `appsettings.json`. `maran test backend` now runs the suite without the values `.env` applied, so a workstation measures the same panel CI does, and a new integration test checks the sixth attempt at the shipped settings. **A request value the panel cannot read is refused with a code and a translated message (#245).** An address such as `?page=abc`, `?pageSize=x` or `?status=7` (a status that does not exist) used to be refused with a 400 that carried no `code` and an English developer sentence. Every endpoint now answers it like any other refused input: code `RequestParameterInvalid` and a message in the caller's language (English, Russian or Armenian) naming the value that could not be read. Refusals that already had their own code, such as a search text that is too long or an unknown sort, keep it. **A PHP install that fails because of a package mirror says so (#251).** Installing a PHP version answered 500 for every failure of the package manager, as if this server had broken, even when the repository mirror had refused the download or could not be reached at all. The server agent now reads the failed run back and tells the cases apart: a mirror that answered with an HTTP error is **502** `AgentPackageSourceRefused`, a mirror nobody could reach is **503** `AgentPackageSourceUnreachable`, and a failure on the server itself (a package no configured repository carries, a held package lock, a failing install script) stays **500** `AgentSystemFailure`. Any other failure the agent reports during an install now keeps its own status as well, so an agent that cannot do the work answers 503 instead of 500. The panel and the server agent move to contract revision 4 together, so both must be updated at once. **The dashboard no longer says "Nothing needs attention" about something it could not check (#248).** When a module could not be read, its part of the landing page used to arrive as an empty list or a zero: a failed backups count read as "0 backups failed", and the attention notice said nothing needed attention. Every dashboard section now says whether it was read: `GET /api/v1/dashboard` sends each one as `{"state":"loaded","data":…}` or `{"state":"unavailable"}`, the counts included (they used to be `{"state":"counted","count":…}`). The attention notice names each source it could not read ("Backups could not be checked") and shows the all-clear only when every source was read. The live reading, the services and the latest activity each say so in their own card instead of disappearing or showing "Nothing has been recorded yet.". For a customer every section is `null`, the list sections included (they used to be empty lists). **The SPA's dependencies carry no known high-severity advisory (#250).** `vue-i18n` moves from 11.4.12 to 11.4.16 (with its `@intlify/*` packages) and `vite` from 8.3.0 to 8.3.5, both pinned exactly; `brace-expansion`, `source-map-js` and `postcss-selector-parser`, which arrive through other tools, are lifted to their fixed releases in the lock file. `npm audit` in `frontend/` now reports 0 vulnerabilities. Vite and the editor launcher it carries are development-server code and never ship in the built panel; `vue-i18n` does. **Databases, backups, plans, certificates and file-transfer logins are paged on the server.** These lists now load one page at a time, like Accounts and Sites: 25 rows by default, with 10, 50 or 100 to choose from, search, sort and filters held in the address. Backups gain status, kind and date-range filters and a notice when an account's latest finished backup failed (`GET /api/v1/backups/attention`); `GET /api/v1/certificates` gains `status` and `expiring` filters. Plan pickers search the server (`GET /api/v1/plans/choices`) instead of loading every plan, an account page names its plan through `GET /api/v1/plans/{id}`, and `GET /api/v1/accounts/plans` is gone. File transfer is two pages under one sidebar entry, SFTP (`/file-transfer/sftp`) and FTPS (`/file-transfer/ftps`), each with its own table and create form. The dashboard counts databases, expiring certificates and failed backups through each module's own count instead of reading a list that now holds only one page. **Screens hold their shape while they load.** Every screen that reads from the server used to show a spinner, or nothing at all (PHP versions, security policy, SMTP settings, the firewall tables), until the answer came. A table now keeps its real header over placeholder rows drawn from its own columns, a form or a detail list keeps its real labels over placeholder fields, and the figure cards keep their labels over placeholder figures, so the content lands in the space already held for it instead of pushing the page around. The placeholders appear only once a wait has lasted 150 ms, so a quick answer shows none; they shimmer gently, and stand still when the system asks for reduced motion. A screen reader hears "Loading…" once and then "Loaded", not each placeholder. A failed read always shows its error, never a placeholder that waits forever, including when the panel could not be reached at all, which now says so where some screens used to show an empty card. Other fixes on the way: a refused delete, drop, sign-out or repair is reported above the list instead of replacing it, an account address the panel does not know shows "Account not found", the running-tasks badge no longer makes the top bar taller, and the "Days left" figure on a certificate no longer moves its label. **Every label reads in sentence case.** Field labels, status badges, table headers, page titles and their eyebrows, sidebar group labels, card and figure labels, the terms in detail lists, the command palette, the sign-in headings, the not-found page and the task pane were set in capitals by the stylesheet, so a task read `COMPLETED` / `ԱՎԱՐՏՎԱԾ` instead of `Completed` / `Ավարտված`. They now show their text as translated, without the wide letter-spacing capitals needed. Acronyms such as TCP, SFTP and PHP keep their capitals. `maran structure` now fails when `uppercase`, `tracking-caps` or `tracking-wide*` appears in the SPA's source. **A refusal from outside the panel is no longer reported as a server error.** When the certificate authority refused this server's registration, an order, a validation or the certificate download, or when the mail server refused a test message, the panel answered 500, as if it had broken itself. It now answers **502** for an expected refusal from an outside party (`AcmeAccountRejected`, `AcmeOrderRejected`, `AcmeValidationFailed`, `AcmeCertificateUnreadable`, `AcmeChallengeTokenInvalid`, `MailDeliveryFailed`), and **503** with its own code, `MailServerUnreachable`, when no mail server answered at all, so a test message tells "your provider said no" apart from "nothing answered, try again later". A request that cannot run in the server's current state answers **409**: turning FTPS on before a TLS certificate exists (`FtpsCertificateMissing`) and running a backup with no destination configured (`BackupDestinationNotConfigured`). A server agent that lacks the feature asked for answers **503** (`AgentNotImplemented`). A real fault inside the panel, such as failing to write a challenge file (`AcmeChallengeWriteFailed`), stays 500. The other messages shown on screen are unchanged; `MailDeliveryFailed` now says only that the mail server refused the message. **A signed-out page load is quiet.** Opening the panel while signed out used to log a 401 for `POST /api/v1/auth/refresh` in the browser console on every page load. When the browser sends no refresh cookie at all, the panel now answers 204 with no body: there is no session, and nothing was refused. A refresh cookie that is sent and is invalid, expired or already used is still refused with 401 and cleared, exactly as before, and the 204 tells a caller nothing it did not already know, since it depends only on what the caller's own request carried. **An address that names no site shows "not found".** `/sites/php`, or any address whose last part is not a site id, used to show a bare "Not Found". It now opens the panel's "Page not found" screen without asking the server anything. A site id the server does not know shows the site's own "Site not found" state, and any other failure still shows the server's message as an error. **The audit log names a dropped database in full.** "Database dropped" recorded only the part of the name the customer typed (`shop`) instead of the database's real name (`acct_shop`). It now records the full name, for a successful drop and for a refused one. **A failed task explains four more failures.** A backup or restore the panel stopped following before the server answered, a scheduled backup that ended without a result, and a task cut short by a panel restart now explain themselves in your language instead of showing their code. `maran structure` now fails when a task failure code the backend names has no reason in the task translations, or when a translated reason names a code that no longer exists. **`Database__Database=x maran dev` works in a shell that already ran `source scripts/dev`.** `scripts/dev` remembered only which names `.env` had set, so on the next source it put `.env`'s database back over the one typed on the command line, and `maran dev` used the wrong database without a word. It now remembers the value `.env` applied for each name, and a different current value is treated as the caller's override. Editing `.env` and sourcing again still picks the edit up. A shell that sourced the previous version should be reopened once. `maran env selftest` proves both directions. **The two-step verification page shows whether it is on.** Settings → Two-step verification used to offer both "Set up" and "Turn off" to everyone, whatever their state. It now shows one thing at a time: "Set up" when two-step verification is off; while setting up, a QR code to scan with an authenticator app beside the secret and the code field; and "Two-step verification is on" with "Turn off" once it is on. Recovery codes stay on screen right after turning it on. The QR code is drawn in the browser from the setup link the panel returns, so the secret is not sent anywhere. An administrator who must set up two-step verification before using the panel sees only "Set up". For integrations: starting or confirming a setup when two-step verification is already on now answers 409 `TwoFactorAlreadyEnabled`, and turning it off (or completing a two-step sign-in) when it is not on answers 409 `TwoFactorNotEnabled`. These were 403 `TwoFactorAlreadyEnabledForbidden` and `TwoFactorNotEnabledForbidden`. **The accounts and sites lists are paged on the server.** Accounts and Sites now load one page at a time instead of every row: 25 rows by default, with 10, 50 or 100 to choose from, and pages to step through at the foot of the table. Search, sort, filters and the page live in the address, so a list can be shared, reloaded and walked back with the browser's Back button. Search matches the account's name or primary domain, and the site's domain or its owner's name; filters (plan and status for accounts, account, backend, PHP version and status for sites) open in a sheet on a phone and a dialog on a wider screen, and each applied filter shows as a removable chip. The owner of a database, backup, SFTP or FTPS login is now named by the server, so it is right however many accounts the server holds, and every account picker searches the server as you type instead of offering the first hundred accounts. For integrations: `GET /api/v1/accounts` and `GET /api/v1/sites` now answer `{ items, total, page, pageSize }` and take `page`, `pageSize` (10, 25, 50 or 100), `search`, `sort` (`name`, `-createdAt`, …) and their filters (`planId`, `status` for accounts; `accountId`, `backendType`, `phpVersion`, `status` for sites), with enum values spelled as the JSON spells them (`suspended`, `reverseProxy`). An empty or blank `search` means no search. A page past the end answers no rows with the true total. A bad bound answers 400 with `ListPageOutOfRange`, `ListPageSizeNotOffered`, `ListSearchTooLong`, `ListSearchInvalid`, `ListSortUnknown`, `AccountStatusUnknown`, `SiteStatusUnknown`, `SiteBackendTypeUnknown` or `PhpVersionInvalidFormat`. `GET /api/v1/accounts/choices` (administrators) pages account ids and names for pickers, and the database, backup, SFTP and FTPS answers carry `ownerName`. On the landing page a count the panel could not take now reads "Unavailable" instead of 0, and so does the sites list's count of disabled sites; `GET /api/v1/dashboard` answers each count as `{ state, count }`, where `state` is `counted` or `unavailable`. Sorting sites by status puts serving sites before suspended ones. **The SSL tab is three parts instead of one file.** `SiteSslTab.vue` had grown to 474 lines. The installed-certificate card and the install-your-own-certificate form are now components of their own (`components/ssl/`), fed by props and answering with events, while the tab keeps every call to the panel. Nothing on screen changes, and the SSL specs pass unchanged. **`maran dev` no longer runs on a stale polygon image.** It used to warn that `maran-polygon-ubuntu24:latest` was "STILL NOT CURRENT" and start anyway, so the stack ran the previous tree's system stand-ins — an old `systemctl` stand-in that reported every unknown unit as active is what made the FTPS card look broken. `maran dev` and `maran dev --selfcheck` now refuse before starting anything when the image is missing or not built from this tree, and print the fix: `maran polygon build ubuntu24`, a new command that rebuilds a polygon image with its source fingerprint recorded and then checks it is current. `maran polygon selftest` proves the refusal and its inverse against a stubbed `docker`. **An account page shows what the account uses.** The account page now has a Resources card listing the account's sites, databases, SFTP logins, FTPS logins, cron entries and backups, each beside its plan's limit with a bar that warns as the limit fills. Backups have no plan limit, so they show a count and "no limit". When the server cannot read an account's crontab, the cron row says "Unavailable" instead of showing zero. A row appears only for the parts of the panel installed on this server. Each row links to that list narrowed to the account (`/sites?account=`, and likewise for databases, file transfer, backups and cron); the narrowed list says "Only " with a "Show all" button. Customers see the same card on their own account page, beside their plan's limits. For integrations: `GET /api/v1/accounts/{id}/resource-summary` (administrators) and `GET /api/v1/accounts/me/resource-summary` (the customer's own account) answer `{ accountId, resources: [{ kind, state, used, limitKind, limit }] }`, where `state` is `counted` or `unavailable` and `limitKind` is `limited` or `unlimited`; `GET /api/v1/sites`, `/databases`, `/sftp-users`, `/ftp-users` and `/backups` take an optional `?accountId=`, and a customer naming another account gets an empty list. **An account's plan and owner email can be changed.** The account page now shows the account's plan and its owner email, and "Change plan" and "Change owner email" open a dialog for each. A plan whose disk quota is smaller than what the account already stores is refused with a sentence saying so, and nothing is changed; otherwise the new plan's quota is applied to the account on the server at once. The other limits of a plan (sites, databases, logins, cron entries) are not reconciled: an account above a new limit keeps what it has and cannot create more. The owner email is the address the owner's login is mailed at, so the invitation and every password reset go to the new address from then on; an address another login already uses is refused. Both changes are administrator-only and recorded in the audit log ("Account plan changed", "Account owner email changed"; the addresses themselves are not logged). For integrations: `PUT /api/v1/accounts/{id}/plan` with `{ "planId" }` and `PUT /api/v1/accounts/{id}/owner-email` with `{ "ownerEmail" }`, each answering the account as it now stands; refusals are 409 `AccountPlanQuotaBelowUsage` and 409 `EmailTaken`, and `GET /api/v1/accounts/{id}` now also answers `planDisplayName` and `ownerEmail`. Renaming an account's primary domain is not offered yet. **A plan that accounts are on cannot be deleted by mistake.** Plans → Delete on a plan that still has accounts used to say the panel would refuse and keep an enabled Delete button that answered with that refusal. The button is now disabled, the dialog says why, explains how to move each account ("Change plan" on the account's page), and links to the accounts list narrowed to that plan (`/accounts?plan=`). **A suspended account offers no invitation.** The invitation card of a suspended account no longer shows "Create invitation link" and "Resend invitation"; it says the account must be reactivated first. The panel refuses both for a suspended account as well: `POST /api/v1/invitations/{accountId}/link` and `/resend` answer 409 `InvitationAccountSuspended`. **The FTPS service card tells one story, and every switch answers.** File transfer → FTPS service used to say "Turned off" beside "Running", keep "Turn FTPS off" enabled in every state, and answer a click with nothing. The panel setting now has three values — never configured, turned on, turned off — so a fresh server no longer reads as one an administrator switched off. "Running" is shown only when the service is active and its control port answered on the host; an active service that does not answer reads "Not answering". When the setting and the service disagree, the card says so in amber: a service running although FTPS is off (the off button is then labelled "Stop the service"), FTPS on but the service not running, or a service that runs but does not answer. When there is nothing to turn off, the button is disabled and the reason is printed beside it. Turning FTPS on or off now shows a message worded from what the server measured afterwards ("turned off, but the service is still running" is not reported as success), a failure is shown on the card in your language — including one that never reached the panel — and turning FTPS off re-reads the firewall as turning it on already did. For integrations: `GET /api/v1/ftps-server` and the two switches answer `desiredState` (`neverConfigured`, `on`, `off`) and `agreement` (`consistent`, `runningButOff`, `onButNotRunning`, `runningButNotAnswering`) in place of `enabled`. In the development polygon images, the panel's own units the installer never starts (`maran-ftps.service`, `maran-php-install@*.service`) now read inactive and disabled instead of active; an image built before this change still shows the old answer until it is rebuilt. **Installing your own certificate says what is wrong with it.** Site → SSL → Install my own certificate used to empty both text areas when the panel refused the material, and then report the text you had just pasted as "required". The pasted certificate and key now stay in the form after a refusal, and are cleared only once the certificate is installed. The refusal is shown under the field it is about and says what is wrong: missing, not valid PEM, too large, unreadable by the server, or a private key that does not belong to the certificate. For integrations: `POST /api/v1/certificates/custom` answers 400 with one of `CertificatePemRequired`, `CertificatePemInvalid`, `CertificatePemTooLarge`, `PrivateKeyPemRequired`, `PrivateKeyPemInvalid`, `PrivateKeyPemTooLarge`, `AgentCertificateUnreadable`, `AgentPrivateKeyUnreadable` or `AgentPrivateKeyMismatch`; the single `CertificateMaterialInvalid` code is gone, and the server no longer answers `AgentInvalidInput` for a certificate pair it could not read. The panel and the server agent move to contract revision 3 together, so both must be updated at once. **An empty backup schedule screen is no longer an error.** Opening Backups → Schedule on a server where no schedule has been saved, for the whole server or for one account, used to make a failed request that the browser logged as an error. The panel now answers normally that nothing is scheduled, and the screen says "Nothing is scheduled here" as before, with a clean console. For integrations: `GET /api/v1/backup-schedules` now always answers 200 with `{ "schedule": … }`, where `schedule` is `null` when nothing has been saved for that scope; it no longer answers 404 `BackupScheduleNotFound`. The "Applies to" card's description is now centred beside the picker instead of sitting low with empty space above it. **Switching on a backup schedule no longer looks like a backup.** Saving a schedule with "Run this schedule" on used to show the save time as "Last run started", although nothing had run. The schedule now records when it was switched on separately from when it last ran. The "What the server holds" card shows the next run the server has planned and says "Never run" until a backup has actually started. The first run is the next named hour after the save. Saving an enabled schedule again does not push its next run back, and switching one off and on again does not start a backup at once. Existing schedules are corrected on upgrade: a switch-on time recorded as a run is cleared where no scheduled backup followed it. **Everyone has a profile.** "Profile and security" in the account menu opens a new Profile page for administrators and customers alike. It shows your username, email and role, whether two-step verification is on, and how many devices are signed in, with links to manage each. A customer also gets a link to their hosting account there. An administrator is no longer sent to a sessions screen in its place, and is never sent to the customer's hosting-account page. **Customers can use their own panel.** A customer creates databases, file-transfer logins, scheduled tasks, backups and sites for their own account. There is no account picker, and the list of every account is never requested. The screens and buttons meant for administrators are no longer shown to a customer. Opening such an address leads to a clear "not available to you" page, not to the licence upgrade page. A customer sees the background tasks about their own account, without the operator's log. A refused request now explains itself in the reader's language instead of a bare "Forbidden". Accepting an invitation shows the login name to sign in with. **Sign-in screens and forms look and behave better.** The signed-out screens put the form on a glass card over the panel's glow, with full-height fields and button. Field labels are small capitals across the panel, and corners are a little rounder. An empty sign-in or reset request is caught on the spot, the error clears as soon as you type, and an old invitation message no longer follows you to the sign-in page. A list with nothing to choose from now says so instead of opening empty. A focused field shows one accent edge with a soft halo, buttons spring gently under the pointer, and the credit line links to Innovayse. **Clearer feedback and wording.** Creating an account, site, plan, database, login, scheduled task or firewall rule confirms with a message. A failed background task explains itself in your language instead of showing an internal code. Sizes read as MiB, GiB and TiB. Counts use the right plural in every language. Scheduled tasks read as sentences ("Every day at 03:05"). Confirmation buttons name what they do ("Delete site"), and they never start focused, so a stray Enter cannot delete anything. **The shell.** The jump-to search opens ready to type. The collapsed rail and search list modules in the sidebar's order. Menu names say where they lead. Switching the language renames the modules immediately. The current page is scrolled into view. The theme switch is on phones too and says what it will do. On phones, tables show that they scroll and keep their row actions in reach. Dialogs, menus and messages move gently, and not at all when you ask for reduced motion. **The sidebar has submenus.** A module with more than one page (Accounts with its list, the new-account form and Plans; Sites; Databases; Backups with its schedule and destinations) is now a section that opens and closes. The section holding the page you are on opens by itself, even from a bookmark; which sections you keep open is remembered in your browser. The chevron says whether it is open to a screen reader, and the arrow keys move through the menu. With the sidebar collapsed, pointing at or tabbing to an icon shows its pages beside the rail, and the jump-to search finds the pages too. Only the page you are on is highlighted inside a submenu (the list entry no longer lights up beside "New account" or "New site"). In Armenian and Russian the menu uses short names, so no entry wraps onto a second line (Կարգավիճակ, Պլանավորիչ, Պահուստներ; Планировщик). **The navigation is denser.** The sidebar is 216 px instead of 240, rows and icons are smaller and the app bar is 48 px tall, so more of a work screen fits without scrolling. On a phone the menu rows stay 44 px tall for touch. **The backend CI lane no longer dies of memory exhaustion.** The integration tests boot one panel per test, and none of them was ever released: the framework's rate limiter keeps a timer alive after its host stops, and timers started while a test panel booted held on to the panel through the test harness's context. The test process grew past 6 GB and took the runner down mid-run, reported only as a shutdown signal. A stopped panel now releases everything it held, and the CI log prints the machine's memory every 30 seconds so a future failure of this kind shows its numbers. **The break-glass `maran` commands work on a real install.** `maran admin-reset-password` exited with a usage error on every call, because the command refused the `--` its own wrapper passes before the identifier. `maran collect-diagnostics` refused every bundle, because it took the panel address setting `PasswordReset__PanelUrl` for a secret (its name contains "password") and found the address in the logs; and the reason was never printed, because an earlier step had sent the rest of the command's error output to nowhere. A setting is now a secret only when its name ends in one of the secret words, as the panel's own redaction rule says. **Signing in with a second factor now works.** After a correct authenticator or recovery code the panel sent the user back to the sign-in screen, and only a reload let them in: the screen read the answer to the code step in the shape of the first step's answer and found no session in it. The browser test that should have caught it answered with the same wrong shape, so both agreed with each other and not with the server; it now answers exactly as the server does. **The install test can now open the panel in a real browser.** `maran installer --browser` creates the first administrator through the setup screen in Chromium, signs in and requires the dashboard, over the panel's own TLS listener. Until now every check reached the panel with a command-line client that accepted a certificate browsers refuse, which is how an installed panel that no browser could open went unnoticed. **Deleting an account left things behind that a new account could inherit.** After the processes were ended, the user's number (uid) still keyed several places `userdel` does not clean: jobs queued with `at`, files in `/tmp`, `/var/tmp` and `/dev/shm`, shared memory, message queues and semaphores, and a lingering `systemd --user` session manager. The next account given that uid would own them. Deletion now removes each before the system user goes, in this order: crontab, `at` jobs, user manager, processes, SysV IPC, shared temporary files, mail spool, then `userdel`. - `userdel --remove` was measured to leave a deleted account's mail spool behind on AlmaLinux 9, so the spool is now removed explicitly (only a regular file owned by the account; anything else refuses). - Every step fails closed: anything left aborts the deletion with a system failure and running it again finishes the job. A temporary directory holding another owner's files is never emptied or chowned; deletion refuses instead. - The agent's own private `/tmp` hid the host's, so a sweep would have found nothing and passed. The unit now bind-mounts the host's temporary directories under `/run/maran-host/` (`PrivateTmp` stays on). - `at` is optional: where it is not installed the step is skipped. Group-owned leftovers and `/dev/mqueue` are not handled yet (follow-up). Threat note: `docs/superpowers/notes/2026-10-29-account-deletion-leftovers-threat-note.md`; its second review is recorded as outstanding. **A published release can no longer ship without both architectures.** A build, sign or verify run with `--publish` now refuses unless both x86_64 and aarch64 are present: `build --publish` requires `--arch both`, and signing and verification refuse a manifest missing any of the api, agent or frontend entries for either architecture. A local build without `--publish` still targets the host only. An installer run on an architecture the release was not built for now says so plainly, naming the architecture, instead of reporting only a missing manifest entry. **Plans can be created, edited and deleted from the panel.** Administrators manage hosting plans under Plans: a name is unique regardless of case, a plan with accounts on it cannot be deleted, and a changed disk quota is pushed to the accounts already on it. A quota cannot be lowered below any account's usage; every account is measured first and a refusal leaves all of them untouched. Count limits only gate new sites, databases and logins, never what an account already has. Reference: the Hosting accounts page of the documentation. **An administrator locked out of the panel now has a supported way back in.** When the only administrator lost a password or an authenticator and no mail was configured, the only options were editing the database by hand, which leaves no audit row, or reinstalling. The installer now places a root-only `maran` command at `/usr/local/sbin/maran` (it adds no daemon, port or network call): - **`maran admin-reset-password `** prints a one-hour, single-use password-reset link for an active administrator, spends any older link, clears the lockout, signs the account out everywhere and writes an audit row and an `authpriv` journal line. The link goes to the terminal only, never to a log; `--clear-two-factor` also removes the authenticator, and is never implied. - **`maran status`** reports service health and the panel's own view of itself even when the database is down. - **`maran collect-diagnostics`** writes a redacted 0600 support bundle from a fixed allow-list and deletes it if a second scan still finds a secret. Customer files, site logs, keys and backups are never read. Operator-facing reference: the Recovery page of the documentation. Security review of this surface is recorded as outstanding in `docs/superpowers/notes/2026-10-29-breakglass-cli-threat-note.md`. **A hung program could freeze the root agent and block every other customer's writes.** Every program the agent runs as root was started without a deadline, without an output limit and in the agent's own process group. Measured with a validator that never exits, held under the host-wide configuration lock: after 75 seconds the call was still running, another tenant's write was still blocked, and both the program and its child were alive. A program that printed 5 MiB was held entirely in memory. The agent now starts every program through one bounded path: - **A deadline per kind of call.** Checks and reads get 60 seconds, ordinary changes 120, service reloads 240, database statements 300, package installs 2100, and backup work up to 12 hours. At the deadline the whole process group is killed, not only the leader, and then collected. Measured: the call returned a typed timeout at 60.0 seconds, the program and its child were gone, the previous file content was restored and the other tenant's write finished 3 ms later. - **A cap on captured output**, 4 MiB per stream by default. Going over it kills the program and is an error that returns none of the output; exactly 4 MiB is still accepted. - **Standard input is never inherited** and the cleared environment is kept. - **Programs are named by a type that only the distro layer can create**, always by absolute path, so a bare string can no longer reach a spawn. `--` now ends the options before a user or unit operand wherever the tool supports it. - **`tar --list` is bounded** (2,000,000 members, 16 KiB per name, 256 MiB of listing), and a backup is listed once as it is written, so one that restore would refuse is refused at creation. Database clients also ask the server to stop waiting on a lock after 240 seconds. A timeout reaches the panel as the existing `SYSTEM_FAILURE` code with no tool output, inside a normal response, not as a gRPC `DEADLINE_EXCEEDED`: the proto is unchanged and no new error code exists. The agent sets no server-wide timeout or concurrency limit, because that would free nothing and would report failure for a write that then succeeds. The systemd sandbox needed no syscall or capability change. Known residuals: a killed program's work already handed to the service manager or a database server may still finish; backups written by earlier releases are not re-checked; a deadline-kill under the real systemd sandbox and the agent-to-API handshake were not measured on the build machine. This change alters how the only root process starts every program and needs a second human review before it reaches `main` (#90). **A signed-in customer could reach any API endpoint that forgot to declare its authorization.** The fallback policy, which applies to every endpoint with no authorization metadata, only required a signed-in user at customer level. Measured against a test-only endpoint with no metadata: a customer got 200, and on an unknown path or a wrong HTTP verb a customer got 404 and 405 instead of a refusal. The fallback is now administrator-only: anonymous callers get 401, customers get 403 (an empty body) and administrators are admitted, so a future endpoint that omits its policy is closed to customers by default. The default policy for a bare `[Authorize]` stays customer-level. Every endpoint now names its policy instead of relying on either default: the dashboard, session listing and revoking, sign-out-everywhere and the two-factor management actions are declared as customer-level, and the setup controller marks its two anonymous actions individually rather than the whole class. Customer behaviour is unchanged, as measured over the dashboard, session, two-factor, account and site flows. New tests fail if an action or route-table endpoint names no known policy, if the set of anonymous endpoints changes, or if the fallback is weakened. This is an authorization change, so it needs a second human review before it reaches `main`. **Reserved host names are refused for new sites, aliases, certificates and FTPS.** A tenant could create a site named for an IP address, `localhost`, a `.local`/`.lan`/`.internal`/`.invalid` name or the panel's own host name, and have nginx serve it and ACME try to issue for it. Measured before the change: the agent's `Domain::parse` accepted `203.0.113.7`, `localhost`, `x.local` and the rest, and the API created a site named `203.0.113.7` and one named for the panel (both 201). The API and the agent now refuse the fixed set (a numeric final label, `localhost` and its subdomains, the special-use suffixes `local`, `localdomain`, `internal`, `lan`, `home.arpa` and `invalid`) on create site, aliases, issue and install certificate and enable FTPS. The API also refuses the panel's own name, and its `www.` form, for sites, aliases and certificates (not for FTPS), read from the new, optional `Panel__HostName` setting that the installer now writes into `panel.env` (a downgrade ignores the extra line). The refusal says only that the name cannot be used, in English, Russian and Armenian, and never echoes the name. The panel vhost's `server_name` is now lower-cased and comes from the same function as the certificate step; it falls back to `localhost` (it was `hostname -f`, or `_`), so re-running the installer can change it on a host whose name is unusable. No database or proto change. Sites that already exist under a reserved name are not removed: they can still be disabled, have their certificate removed, be inspected and be deleted, because those paths check the name's shape only. Not covered: the agent does not know the panel's name, so a compromised API could still ask it for a vhost under that name; the primary domain of a new account is not checked; the nginx and ACME behaviour was not observed on a real host. This change touches tenant isolation on a root-managed host and still needs a second reviewer before it reaches `main`. **The root agent's systemd unit is hardened further.** The unit that runs the agent as root kept the kernel's control-group files writable, let it load kernel modules, accepted 32-bit system calls and let it create setuid and setgid files. It now sets `ProtectControlGroups=true`, `ProtectKernelModules=true`, `SystemCallArchitectures=native` and `RestrictSUIDSGID=true`; `MemoryDenyWriteExecute=true` stays on. Measured on Ubuntu 24.04 (systemd 255) and AlmaLinux 9 (systemd 252): the agent starts and listens as before, and account, home-mode, site (`nginx -t`, `php-fpm -t`, reload), certificate, cron, backup, restore, firewall and SFTP mount-unit operations behave the same with the four directives on. The cgroup view becomes read-only, a 32-bit binary is killed with SIGSYS, `chmod u+s` is refused, and `systemd-analyze security` exposure falls from 4.4 to 3.6. `ProtectProc=invisible` and `ProcSubset=pid` are deliberately not set. `ProcSubset=pid` made `pkill` fail on AlmaLinux 9, so a suspended account's processes stayed alive. The unit comments now say so and mark what is still unmeasured (quota, other distribution versions, module autoload, cgroup v1). Hosts must re-run the installer to receive the new unit. The change needs a second human review before it reaches `main`. **The root FTPS service now runs inside a systemd sandbox.** `maran-ftps.service`, the unit the installer installs for the internet-facing, root vsftpd, restricted nothing but its runtime directory: `systemd-analyze security` scored it 9.4 UNSAFE on Ubuntu 24.04 and 9.6 on AlmaLinux 9. It now sets fourteen groups of directives (kernel tunables, modules, logs, control groups, clock and hostname protection, native system calls only, no personality changes, no realtime scheduling, no write-and-execute memory, no new setuid or setgid files, a short list of address families, a private `/dev`, and a capability bounding set of ten capabilities), and scores 5.9 MEDIUM on both families. Measured on Ubuntu 24.04 and AlmaLinux 9 under systemd as PID 1: a real FTPS session with TLS forced (plaintext login refused, login, upload, passive listing, `SITE CHMOD`, download, logout) passes unchanged, and the process reads back the new bounding set and a seccomp filter. `ProtectHome`, `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` and `RestrictNamespaces` stay off, with the reasons in the threat note: `RestrictNamespaces` in particular makes vsftpd silently drop its per-session isolation on Ubuntu 24.04 while logins still succeed. Hosts must re-run the installer to receive the new unit. A new structure check keeps the rejected directives out of the unit and the capability set within bounds. Not shown by these measurements: the installer itself and the panel's enable-FTPS path were not run, AlmaLinux's per-session isolation and the stock `/etc/shadow` mode 0000 case were not observable, and `RestrictSUIDSGID` cannot be seen from a session because vsftpd masks the mode itself. This change touches a root, internet-facing service and still needs a second reviewer before it reaches `main`. **The panel refuses to start on a published or weak key outside Development and Testing.** The repository's sample `.env.example` ships an encryption key and a token-signing key so a development loop works out of the box, and nothing stopped an install from running on them: measured with the published host and `ASPNETCORE_ENVIRONMENT=Production`, the panel booted on both sample keys, so anyone who has read the source could sign an administrator's access token or decrypt the secrets the panel stores. Outside Development and Testing the host now refuses to start when either key is the published sample, decodes to bytes too structured to be random, or when the signing key equals or contains the encryption key; the message names the setting and the problem and never prints the key. Installs made by the installer are not affected: it generates two separate random keys. A hand-seeded install that copied the sample stops at startup. Replacing the signing key costs nothing but a fresh sign-in; replacing the encryption key makes the values already encrypted under it unreadable, and there is no re-encryption tool yet. **Installing a PHP version from the panel failed, and could leave the package database broken.** The agent ran the package manager itself, inside a sandbox that keeps `/usr` read-only. On Ubuntu 24.04 the install died on "Read-only file system" part-way through and left fifteen packages half-configured in dpkg, which also blocks unattended security updates; on RHEL-family hosts dnf reported the read-only mount as a lack of disk space. The agent now asks systemd to start `maran-php-install@.service`, a one-shot unit that is never enabled and runs only the family's package manager (`--no-remove` on Debian, no `--allowerasing` on RHEL) for that one package, outside the agent's sandbox, which is not widened. The install also survives an agent restart or a dropped browser tab. Measured on Ubuntu 24.04 and Rocky Linux 9: the install now succeeds and the agent's sandbox is unchanged. Hosts must re-run the installer to receive the unit: a host that took a new agent binary alone fails PHP installs with a "unit not found" error, and there is no in-process fallback. The product still provisions no Remi or Sury repository, so RHEL-family hosts and Debian releases without the version in the distribution archive cannot install it regardless of this change. **The root daemon no longer acts on accounts it did not create.** Until now the agent checked only that an account name was spelled `[a-z][a-z0-9_]{2,29}` and that the user existed. Measured on real Ubuntu 24.04 and AlmaLinux 9 containers, suspending, deleting, setting a quota on or rewriting the crontab of `root`, `mysql` or `games` all went through to `usermod`, `userdel`, `setquota` and `crontab -u`: root's shell was changed to nologin, `mysql` was removed from `passwd`, `shadow` and `group`, and `CreateAccount` for `redis` ran `useradd`. Anyone able to reach the panel's API as an administrator, or a compromised API, could do the same to the host's own users. The fix is in two places, and the agent is the one that enforces it. - **A managed-account check in the agent.** Every account lifecycle operation (suspend, unsuspend, delete, quota, usage, suspension state), every cron operation, and the SFTP and FTPS login creation and password changes now require proof that the user is a hosting account: the name is not on a reserved list of 103 names (system, daemon, database, web, mail and cloud-image login names, plus anything starting `maran_`), there is exactly one passwd row of that name, its home is exactly `/home/`, and its uid and gid are at or above the host's `UID_MIN` and `GID_MIN`. A refused request runs nothing, not even `id -u`, and answers `INVALID_INPUT` with a fixed sentence that does not say which rule refused; an absent user is still `NOT_FOUND`. `CreateAccount` refuses a reserved name before asking the host anything, and still refuses a user that already exists. - **Deletion resolves the account inside its lock and again before `userdel`.** If the uid or gid changed while the cascade ran, the deletion stops with a system failure instead of removing a different user. - **The panel refuses the same names, in three languages.** Creating an account through the API with a reserved name now fails validation with `AccountNameReserved`, and the username alphabet is narrowed to exactly the agent's rule: no hyphen, 3 to 30 characters. Both messages exist in English, Russian and Armenian. The account form in the SPA still offers the old hyphen pattern; that is a follow-up. What an operator will notice: a name such as `admin`, `mysql`, `git` or `ubuntu` can no longer be given to a hosting account, and `acme-01` style names are no longer accepted. Existing accounts are not touched. `RepairAccountHomeGroups` now skips users the panel does not manage. Known residual: there is no marker on managed accounts. An ordinary hand-made user at or above the id floor, with home `/home/` and a name not on the list, is still manageable. The decision and its reasoning are in `docs/superpowers/notes/2026-10-26-account-reserved-names-threat-note.md`. This change touches root-run account operations and needs a second human review before it reaches `main` (#83). ## 1.0.0-beta.3 — 2026-10-03 **Maran could not be installed on half the systems it claimed to support, and now it can.** The supported matrix is Debian 12/13, Ubuntu 22.04/24.04/26.04 LTS, and RHEL, AlmaLinux, Rocky Linux and Oracle Linux 8/9/10 — thirteen distributions, both families, every one of them now installed from nothing to a working panel and verified by the panel's own health endpoint rather than by the installer's exit code. The reason so much was broken at once is that nothing had ever executed the documented install path against a real init. Two polygon images run no init at all, which is why a fake `systemctl` had to exist — and its own header admits it "starts or stops NOTHING". A fake `systemctl` cannot fail on a unit directive, a mount namespace, or a binary that will not execute, and every defect below is exactly one of those. `maran installer` closes that hole: a fresh server with real systemd as PID 1, carrying nothing Maran needs, on both families and both architectures. What an operator would have hit: - **On AlmaLinux, Rocky or Oracle 8, the panel never started.** The distribution's default PostgreSQL is 10 and the installer checked no version, so the panel aborted on a system catalogue column that arrived in PostgreSQL 11. There is now a floor of 13 — the oldest version actually proven — and on EL8 the installer enables a newer module stream from the distribution's own AppStream, needing no third-party repository. - **On those same systems no release could even be verified.** The manifest's signature was Ed25519 through `openssl pkeyutl -rawin`, an option that arrived in OpenSSL 3.0; EL8 ships 1.1.1, whose command line cannot verify Ed25519 at all. The installer reported that as a signature mismatch and told the operator to suspect a compromised mirror. Releases are now signed with ECDSA P-384 and verified with `openssl dgst`, which every supported openssl has — one code path, no "verify whichever this host can manage" branch. - **On Oracle Linux the root daemon refused to start on every version.** The installer resolved the family from `ID_LIKE` and accepted the host; the agent kept its own list and answered `unsupported distro: ol`. `rhel` was missing from that list too, which no test could have caught because RHEL's own images carry no nginx or PostgreSQL without a subscription. The two lists are now held equal by a structure check. - **On Ubuntu 22.04, Debian 12 and EL8 the agent could not run at all.** It was built on the release host and linked whatever glibc that machine had. It is now built inside an AlmaLinux 8 container — the oldest glibc in the matrix — and the release refuses to ship an agent that needs anything newer. A static musl build would have sidestepped glibc and broken account lookups wherever SSSD or LDAP answers them, so it was rejected rather than taken. - **An install could report success with the root daemon dead.** Step 70 waited sixty seconds for the panel's socket and did not look at the agent at all, so the one step that installs the privileged daemon could not observe whether it runs. It now waits for the agent's socket with the same specific refusal. That wait is what caught three of the defects above. - **The agent died before running an instruction on a fresh host**, because its unit named `/var/lib/maran-ftps` as a mandatory writable path and step 89 created it nineteen steps after step 70 started the service. systemd does not skip a missing writable path; it fails to build the mount namespace. The directory is created with the rest of the layout, and a structure check now requires every mandatory `ReadWritePaths=` target to exist before the step that starts the units. - **Three dependencies were assumed rather than installed:** `openssh-server`, which step 86 configures and did not install, failing at step 86 of 16 after PostgreSQL, nginx and MariaDB were already in place; `libicu`, without which the self-contained panel refuses to start and says so only in the journal; and the `hostname` binary, absent on Oracle Linux 8, where both halves of `hostname -f || hostname` called the same missing program and the install died at exit 127. - **On EL8 the installer installed MariaDB and then refused to continue because of it.** That distribution's MariaDB leaves root with no password at all — a server where every local uid, including the accounts Maran creates for customers, is root on every customer database. The refusal was right and is kept for a server that pre-existed the install; a server the installer created seconds earlier is now secured instead, with the socket plugin loaded *before* root's login method is changed, because doing it the other way round locks root out entirely. **A suspended login could still obtain working access tokens.** Refreshing a session minted a fresh access token while consulting only whether the user still existed — not whether the login may be used at all. Suspension writes the state and revokes the sessions as two separate operations, so anything that stopped the second (a cancelled request, a failure in the session store, a process that died in between) left a suspended login holding live sessions, and each refresh rotated one into a new token indefinitely. The question now has a single definition on the domain entity, both paths that mint a token ask it, and a structure check refuses any future file that issues one without asking. Measured: the test for it fails without the check and the active-login control passes either way, and only two places in the codebase mint a session or a token at all. **Dependency updates are made deliberately again.** Dependabot is off, and the ten updates worth taking from its last three batches are in — with the EntityFrameworkCore companions brought back into step with the provider, which had drifted a patch version apart. Nine are refused, each with a reason: the .NET 10 line (this tree pins the SDK to 9.0.317 with `rollForward` disabled), TypeScript 7 as a major compiler change, and the coupled tonic/prost/askama breaking releases, which touch the generated proto contract and need their own work. The cost is stated rather than buried: security updates no longer arrive on their own. **The polygon's assertion script is twelve files instead of one.** It was 7381 lines holding 61 assertions — the largest violation in the tree of the rule that one file is one unit. The split is by subject and preserves the original order exactly, verified by the parts concatenating byte-identically to what was removed and by the assertion output being the same 433 lines before and after. **ARM64 is built and runs.** `maran release build --arch both` produces a manifest carrying all six artifacts; the aarch64 agent and panel were verified by executing them on aarch64 with glibc 2.28. `installer/lib/10-preflight.sh` has accepted `aarch64` from the beginning while the release built only x86_64, so an ARM64 operator passed the gate and found nothing to install. One step, the firewall, cannot complete under emulation — an aarch64 `nft` cannot drive an x86_64 kernel — so ARM64 is not yet claimed as verified end to end. ## 1.0.0-beta.2 — 2026-09-23 **The first beta could not install on a Debian-family host.** `30-postgresql.sh` searched `/etc/postgresql` to a depth of two for `postgresql.conf`, and Debian and Ubuntu put it three levels down at `/etc/postgresql///postgresql.conf`. The packages installed, `initdb` created the cluster, and the installer then refused to find its own configuration. Both resolvers had it — `postgresql.conf` and `pg_hba.conf`. It was found by the first real install anybody ran, on Ubuntu 24.04, and it survived because the polygon copies that step into its image without ever executing it: the image installs MariaDB and sshd, not PostgreSQL. An assertion now exercises the resolver against the layout Debian creates, with the inverse control that the old depth must fail on the same tree. The refusal also prints what it searched for and lists what `/etc/postgresql` holds. Diagnosing the original needed a photograph of a console. Nothing else changed: the panel, the agent and the SPA are the same software as beta.1. ## 1.0.0-beta.1 — 2026-09-23 **A beta, and the README says what that means: do not put somebody else's customers on it.** The software installs, runs and does what its screens say. Three things a paid hosting product must be trusted about have been exercised only in this repository's own containers — no certificate has been issued by a real authority, no disk limit has been enforced by a real filesystem, and no restore has been run on real hardware. The threat notes covering the privileged parts were reviewed and accepted on 2026-09-23; the per-note verdict is in `docs/superpowers/notes/2026-09-22-second-review-packet.md`. That discharges `rules/security.md`'s second-reviewer requirement. It is not an external audit. What is absent by design: DNS, mail, and a web database manager. ### Added - **Panel authentication.** First-run setup from the installer's one-time token, sign-in with Argon2id, JWT access tokens of fifteen minutes, refresh rotation in an httpOnly SameSite=Strict cookie where a reused token revokes the whole family, TOTP with recovery codes, and sessions you can list and revoke — including "sign out everywhere". - **Account lockout.** Ten consecutive failures lock an account for fifteen minutes. A locked account is refused before its password is checked and with the same error a wrong password gets, so the response is not an oracle. - **Hosting accounts as real Linux users.** Creating an account provisions its system user, home directory and the plan's disk quota through the agent; suspend, reactivate and delete reach the host too. The agent acts first and the database row follows, so the panel never claims an account the server does not have. - **An append-only audit journal** of every sign-in and every mutation, with an administrators-only screen. - **The `maran` CLI** — one entry point to the whole toolbox (`maran` lists it). - **Websites.** Static, PHP and reverse-proxy sites, with domain aliases, a document root inside the account's home, and enable/disable. The agent renders the vhost, writes it beside the running configuration, makes the server's own `nginx -t` validate the result, and rolls the file back if either the validation or the reload refuses — so a bad render cannot take a server's other sites down with it. Every value written into a configuration file is validated rather than escaped: a domain that is not letters, digits, hyphens and dots is not a domain, so there is no newline to end a directive and start one of the caller's choosing. - **Several PHP versions side by side.** Each account gets its own php-fpm pool per version, running as that account's Linux user, with the worker budget taken from its plan. Customers may change a whitelisted subset of settings; a name outside the list is refused rather than quietly dropped, and the hardening lines above them are written as `php_admin_value`, which a customer setting cannot countermand. - **SSL.** Certificates ordered over ACME with an HTTP-01 challenge served from the site's own document root, custom material an operator can upload, and a renewal pass thirty days before expiry. Key material is written to a root-only store outside every account's home, because a site's PHP runs as that customer and a key inside the home is a key the site could read. - **Live log tailing.** A site's access and error logs stream into the interface. The stream names a site and a log kind, never a path, and is bounded: a per-account limit on how many may be open at once, a heartbeat so a quiet log is not mistaken for a dead connection, and the reverse-proxy timeouts the installer writes to match. - **Databases.** MySQL/MariaDB databases with a dedicated user each, per-plan limits, a size reading per database, and a password reset. The password is generated by the panel, shown once and never stored — not even encrypted — so recovery is a reset rather than a retrieval. The server has no idea what a tenant is, so isolation is built rather than assumed: every name is prefixed with the account, the grant is scoped to that one database, and a real server refuses a neighbouring account's data to the user the panel created. Creating the same database twice reports that it exists and leaves the first password working, because a retry after a lost response must not invalidate a credential the customer has already been shown once. - **SFTP logins.** Per-account file transfer credentials served by the host's own OpenSSH, with per-plan limits and a password reset. Each login is chrooted into a root-owned jail with the account's real home bind-mounted inside it, so nothing about the home's ownership had to change and no other account becomes visible. The login carries the account's own uid, so an uploaded file belongs to the account exactly as one it created itself, and it gets `internal-sftp` and no shell — asserted as a real session being refused a command, not as a directive being present in a file. Port forwarding is off: a file transfer credential that can forward ports is a tunnel into the server's private network. - **Deleting an account now removes what it left on the host.** Its databases, its SFTP logins, its jail and the jail's mount unit go before the system user does, and a cleanup failure aborts the deletion rather than leaving an orphan. A re-created account of the same name inherits nothing — not a database, not a login, not a password. - **Container polygons for the agent.** Ubuntu 24.04 and AlmaLinux 9 images that run the agent's privileged suites against real `nginx -t`, real `php-fpm -t` and real system accounts, so the validation the whole design rests on is exercised against the software it will meet on a customer's server rather than against a fake that always agrees. ### Known limitations shipping with this Named here rather than in a footnote, because each of them is something an operator would otherwise find out the hard way. - **The ACME client has never spoken to a real certificate authority through to an issued certificate.** It reaches Let's Encrypt staging's directory and nonce endpoints and is refused at account registration until a contact address is configured; ordering, challenge validation, finalising and downloading have been exercised only against a fake authority in tests. - **HTTP-01 only.** No wildcard certificates and no DNS-01; those arrive with DNS management. - **A reverse-proxy upstream is checked for shape and not for destination**, so a site can be pointed at a loopback service on the same machine. - **`Acme:CertificateStorePath` is read by nothing**; the store path is fixed in the agent. - **No web database manager.** There is no phpMyAdmin or equivalent; it is planned as a separate deployable with its own vhost and authentication rather than partially done here, so an operator who needs one does not have it. - **The database-grant escape is forward-only.** A database-level `GRANT`'s name is a LIKE pattern, not an identifier, and an unescaped `_` in it matches any single character — so a build before this fix could grant an account access it never issued, to a same-length account whose name collided at the right position. The fix escapes every grant `create_database` writes from now on; it does **not** touch a row a previous build already wrote, because rewriting a live customer's access with nobody watching is worse than leaving it as found. A host that created databases before this change carries unescaped rows until an administrator runs the repair from the panel's Databases screen (`RepairDatabaseGrants`, `agent/crates/ops/src/db/repair_grants.rs`), which reports what it would change before changing anything. See `docs/superpowers/notes/2026-09-13-grant-pattern-threat-note.md` and `docs/superpowers/notes/2026-09-13-grant-repair-threat-note.md`. - **FTPS ships, and this entry used to say it did not.** The line above read "No FTPS … File access is SFTP only — there is no FTP daemon installed and no second listening port … FTPS is tracked as issue #20", and every clause of it was false by the time it was read. The correction is stated rather than made quietly, because an operator who believes no FTP daemon is installed does not go looking for one on their own host. What is true: the installer installs vsftpd (`installer/lib/89-ftps.sh`), and FTPS ships end to end — `agent/crates/ops/src/ftps/`, `backend/src/Maran.Modules/Ftp/`, `backend/src/Maran.Agent.Client/Services/FtpsService/` and `frontend/src/locales/{en,ru,hy}/ftp.json`. It is installed **switched off**: nothing listens, no configuration is rendered, and the distribution's own `vsftpd.service` is masked until an administrator turns FTPS on from the panel. `README.md` describes what "on" then means. - **A dropped database is not backed up first.** There is no snapshot, no recycle bin and no delay: dropping is final. The Backups module ships (`backend/src/Maran.Modules/Backups/`) and this clause used to say it was "not here yet" — but it takes scheduled account backups, and nothing in the drop path consults it: `grep -rni backup backend/src/Maran.Modules/Databases/Commands/DropDatabase/` finds nothing. A database is restorable only from whatever backup already existed. - **Nothing re-checks sshd's configuration after the install.** The installer writes the one `Match Group` block that turns an SFTP account into a jailed file transfer login and is idempotent about it, but nothing looks again afterwards. A hand-edited or upgrade-reset `sshd_config` that loses the block would give every SFTP customer a full shell session and the panel would report every login as healthy. - ~~**Account deletion writes no audit entry.**~~ Corrected: it does, and this entry was wrong. `DeleteAccountCommandHandler.cs:288` records `AuditActions.AccountDeleted` on success and `:423` records the failure, alongside the final-backup outcome at `:343`, `:353` and `:366` (`grep -n AuditActions backend/src/Maran.Modules/Accounts/Commands/DeleteAccount/DeleteAccountCommandHandler.cs`). - **An account created before this release cannot serve a site.** Creating an account now group-owns its home by the web server's group, at mode `0750`, so nginx can traverse into the document root without the home being opened to every other local user. Homes created earlier do not have it and there is no repair command; `chgrp www-data /home/` (`nginx` on the RHEL family) fixes one by hand. ### Fixed - **The installer's panel vhost was never the file `nginx -t` checked, and the vhost it shipped did not load.** Step 80 wrote the rendered vhost to `maran.conf.staging` and then ran `nginx -t`; nginx includes `conf.d/*.conf`, which `.staging` does not match, so the test parsed a tree the new file was not in, printed "test is successful", and the installer moved a file nothing had read over the served path. It hid a second defect for as long as it existed: the shipped vhost asked for HTTP/2 with `http2 on;`, a directive that arrived in nginx 1.25.1, while the supported families ship older ones — so on Ubuntu 24.04 and AlmaLinux 9 the panel's own vhost was an "unknown directive" and nginx would not start with it. The vhost now reaches disk through the same render-swap-validate-roll-back protocol the agent uses for customer configs, so what is validated is the file that will be served; a vhost nginx refuses now stops the install with the previous configuration restored and nothing reloaded, instead of surfacing at the next reload with a successful `nginx -t` in the log as evidence that it was fine. Validating after the swap means there is one `nginx -t` during which a file nobody has approved sits on the served path, so an install interrupted inside it — Ctrl-C, a dropped SSH session, a stopped container — now puts the previous vhost back before the installer dies, and re-raises the signal. After a `SIGKILL`, which nothing can catch, the next run prefers the rollback copy the installer left behind to the file on the served path — and asks `nginx -t` about that copy before serving it, because a copy this step did not write may be a fragment a hard reset left behind. One it cannot load is left where it is, named, with the panel vhost removed rather than replaced by it: the panel is then unreachable until an install finishes, which costs the panel, where an nginx that will not start costs every site on the host. - **Deleting a customer armed a trap for every other customer.** A php-fpm pool names the account it runs as and php-fpm resolves that name at startup, so a pool left behind by a deleted account made the next reload — for any reason, days later — fail with `cannot get uid for user` and take PHP down for every tenant on the server. Nothing in the agent removed a pool at all. Deleting an account now removes every pool it owns, before `userdel` makes their user unresolvable; deleting a site or switching its PHP version removes the pool left behind when, and only when, no other site of that account still uses it. - **Certificate renewal never ran.** The handler was named `…Job`, which Wolverine does not discover, so every daily trigger was dropped with "No routes can be determined" and every certificate the panel issued would have expired unwatched. Renamed to the convention, and a test now asks the running panel whether the message has a handler rather than reading a registration. - **A fresh installation had no plans, so no account could be created at all.** The plan seeder existed, was unit-tested and had no caller anywhere in the product; it now runs at startup, inserting only what is absent so an operator's edited plan survives a restart. - **A newly created PHP site had no php-fpm pool**, so its vhost pointed at a socket nothing had bound and the site answered 502 until somebody changed its PHP version — the only operation that wrote a pool. Creation and the version switch now write it through one shared function. - **No web server could read any document root.** Every account home was `0750` owned by the account, and nginx refused every request with `stat() … (13: Permission denied)`. - **The SSL tab could not be used.** Its API composable was still a seam that rejected every call, so a site whose certificate nginx was serving was shown "Certificates are not available on this server yet". The four endpoints are wired. - **The third plan on the create-account form could not be clicked**, clipped by the card the form is drawn in, and two sidebar entries linked to an upgrade wall for modules the licence includes. - The login rate limiter partitioned on a `username` **query** value while the endpoint authenticates the request **body**, so an attacker could get a fresh partition per request and guess passwords without limit. It now keys on the caller's address alone. - Nothing read `X-Forwarded-For`, so behind the installer's own nginx every request appeared to come from `127.0.0.1`: one rate-limit budget for the entire internet, and an audit journal whose "from where" was always loopback. The header is now honoured for one hop, and only from loopback — it is written by the client, and trusting it from anywhere would hand the limiter a key the caller picks. - Agent operations ran with no timeout at all: the resilience pipeline was registered and resolved by nobody. The health probe's timeout was hard-coded, which made `Agent:ProbeTimeoutSeconds` configuration nothing read. - Command validators were registered but never invoked, so a weak password reached the database. - `.env.example` told developers to copy it to `.env`, and nothing read the result. ## 0.1.0 — foundation The contract, the Rust agent skeleton with `SO_PEERCRED` on its unix socket, the C# modular monolith, the Vue shell, the development environment and CI.