diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index eb25ad9..7e59a7d 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -700,7 +700,7 @@ directory on the modules volume and its URL segment. | `name`, `version` | the manifest's label and semver, for the admin Modules screen | | `state` | ENUM `installed` / `enabled` / `disabled` / `started` / `startup_failed` | | `failure_stage`, `failure_reason` | the stage a failure happened at (`manifest`, `core_api`, `mounts`, `extensions`, `schema`, `require`, `register`, `boot`) and its recorded reason | -| `source`, `sha256` | the release the bundle came from and the digest verified before unpacking; both NULL for a directory placed on the volume by hand | +| `source`, `sha256` | the release the bundle came from and the digest verified before unpacking; both NULL for a directory placed on the volume by hand. **Written only by an admin-panel install, and `COALESCE`d on upsert** — see below | | `installed_at`, `started_at`, `updated_at` | `started_at` is the last **successful** start | **This table never decides which routes exist.** The module loader scans the filesystem at require @@ -722,6 +722,15 @@ on the volume (with NULL provenance for a hand-placed directory), marks any row overwrite the operator's decision. Every one of those writes is individually caught: a row that will not update is worse reporting, never a failed boot. +**Provenance is `COALESCE`d on upsert, and that is load-bearing.** The boot write above passes NULL +for `source` and `sha256` — honestly, since a scan finds a directory and never where it came from — +so a plain `source = VALUES(source)` overwrites both columns on *every* boot, and an admin-panel +install's provenance survives only until the restart that install asks for. The statement is +`source = COALESCE(VALUES(source), source)`: a value overwrites, a NULL leaves what is there. The cost +is that hand-placing a different bundle over a row installed from a URL keeps the old provenance, +which is stale rather than blank. Found in Phase 4 by installing a module and restarting; it could not +have been found earlier, because until then no caller had ever passed a non-null value. + Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are [`MODULE_API.md`](MODULE_API.md) Part 4. diff --git a/website/MODULE_SYSTEM.md b/website/MODULE_SYSTEM.md index 9288f8c..b851e63 100644 --- a/website/MODULE_SYSTEM.md +++ b/website/MODULE_SYSTEM.md @@ -1608,6 +1608,73 @@ directory left behind — and then core booted against the result and the module mounts, seven streams and eight capabilities with its client chunk resolved. That is criterion 1 minus the screen and the restart, both of which arrive in slice 2. +#### Slice 2 — the Modules screen (website#143, 2026-08-12) + +`lib/moduleAdmin.js`, `ModulesAdmin.jsx`, its nav row and its API bindings. 182 client tests (+21); +manifest and OpenAPI unchanged. + +The presentation logic is plain JS in `lib/moduleAdmin.js` rather than inside the component, for the +reason `lib/adminNav.js` already is: the client's test runner has no DOM, and what is worth testing +here is not the markup but the reconciliation. The JSX renders what it returns. + +**Two shapes deliberately unlike the rest of the admin panel.** The restart is a **banner**, not a +per-row button — a restart is a property of the server, and an operator who installed three modules +should restart once. And purge is a **second confirm inside the uninstall flow** (decision 5), so the +destructive choice is never one you agree to by reflex. + +##### The row, the loader and the volume are allowed to disagree + +This is §2.4 turned into a screen, and it is the one thing here that could not have been simplified. +The row records what the operator decided and what the last boot did; the loader says what is mounted +and answering; the volume says whether there is a directory at all. A screen that picked one and +rendered it would be simpler and would lie — most obviously in the state decision 3 creates on +purpose, where the row says `enabled` and the loader says `disabled` because nothing can start a +stopped module before a restart. Neither "Running" nor "Disabled" is true there. + +##### Three defects the browser found, none of them visible to a test + +The §7.7 smoke earned its place again, and this time twice over — two of the three are older than +this phase. + +- **A fresh install over a failed row rendered the old failure.** Installing on top of a row the + previous boot had left `startup_failed` displayed "Failed at the require stage: module directory + not present on the volume" one second after the files had been written to the volume — and because + that branch is not pending, it **suppressed the restart banner the install had just told the + operator to use**. Every unit test passed; none had modelled a stale row beside a fresh install. + The fix is a derivation rather than a special case: the loader scans once at require time, so a + module on the volume with no live record arrived after that scan and everything the row says about + it predates the install. +- **The boot refresh had been nulling every install's provenance.** `source` and `sha256` exist for + this screen and never survived a restart: `lifecycle.boot()` re-records each scanned module with + neither (correctly — a scan finds a directory, not where it came from) and `upsert` assigned both + columns unconditionally. So the panel described a module installed from a URL as "placed on the + volume by hand". **Nothing could have caught it before Phase 4**, because Phase 4 wrote the first + non-null value those columns ever had — and the model's own test fake reproduced the defect + faithfully, assigning unconditionally exactly like the SQL. Now `COALESCE(VALUES(col), col)`, with + tests pinning both directions: a boot must not wipe it, and a re-install from a new URL must still + replace it. +- **The restart killed the server outright on Windows.** The route reached `server.js`'s + graceful-shutdown handler with `process.kill(process.pid, 'SIGTERM')`, which is correct on Linux + and is *unconditional termination* on Windows, where POSIX signals do not exist. No module + `onShutdown`, no listener close, no pool close, no log flush. `process.on('SIGTERM', …)` is an + ordinary EventEmitter listener, so `process.emit('SIGTERM')` reaches the same handler on every + platform without involving the OS. **The test was worse than useless**: it stubbed `process.kill` + and asserted the call had been made — precisely the call whose *meaning* differs by platform. It + now waits for the SIGTERM event, which is what `server.js` is actually subscribed to. + +The last one is worth generalising: **deployment is Linux containers and would never have shown it.** +A smoke that only ever runs where the code ships cannot find a class of defect that only bites the +people developing it. + +##### What the real release proved + +The published `module-uo` v0.3.0, installed by pasting its manifest URL into the form: restart, and +the module registered its five mounts and seven streams, replayed 37 schema statements, reached +`started`, and put its own nav rows in the sidebar. Disable then ran its `onShutdown` for real — the +uo-link WebSocket closed, its routes answered 404 and it left `/api/v1/public/modules` — and enable +produced the decision-3 state with the banner. That is acceptance criteria 1 and most of 2, through +the screen, with no shell. + ### 2.8 SPA URL namespacing — a deliberate break **Decision: module pages are namespaced, and old paths are not redirected.** The site is not public