docs(website): settle phase 4's shape — delivery #141

Merged
whitlocktech merged 1 commits from docs/module-phase4-plan into main 2026-08-12 07:37:16 +00:00
Member

Phase 3 closed with core at 158 routes and module-uo releasable. Phase 4 is the first phase that is not about the boundary — it answers how a module gets onto a box an operator owns, and how it comes off again.

Adds MODULE_SYSTEM.md §2.7.2, and amends §2.4 and §2.5 where this phase changes what they say.

The measured starting state

The producer side is finished and the consumer side is empty. module-uo's release.yml already publishes the tarball, an install manifest ({id, name, version, coreApi, artifact, url, sha256, size}) and SHA256SUMS, and module.json already declares purge.sql. Core already has the installed_modules provenance columns source/sha256 that §2.4 carved out and nothing has ever written non-null, plus the live stateGuard and the model's enable/disable/remove.

Core has no code that fetches, verifies, unpacks, removes or purges anything; no admin route; no client screen; and no container-start resolution — docker-compose.yml's own comment still tells the operator to untar by hand.

The four decisions

  1. Restart is a button, not an instruction. Install, uninstall and re-enable only take effect at boot (§1.12). Recording a pending change and telling the operator to go restart the container contradicts §2.4's "recoverable from the admin panel, with no shell access to the box", so an admin action runs moduleLifecycle.shutdown() and exits cleanly — the restart: unless-stopped the shipped compose file already declares brings it back. Not universally true (a bare npm start does not come back), so the screen says what it is about to do.

  2. The install source is a pasted manifest URL, not a catalog. A catalog would make core's release cadence decide which modules exist, and would make a module from outside this org uninstallable. The safety is not the catalog — it is the declared sha256 (module-uo's own CHANGELOG already calls it the trust anchor) plus an https host allowlist, which is also what stops the endpoint being an SSRF primitive.

  3. Disable stops the module; enable asks for a restart. As Phase 2 built it, disable flips one field and the guard 404s — the module becomes invisible, not stopped. Concretely, module-uo's uoLinkSocket stays connected and keeps ingesting shard events into shard_* tables, and shardBroadcast's SSE streams stay open, because onShutdown is dispatched only from the signal handler. That is fine for "not today" and wrong for the case the button exists for. Disable now dispatches that one module's onShutdown first.
    Enable cannot mirror it: there is no onBoot re-dispatch path and the contract never promised the hooks are re-entrant, so enable flips the row and offers the restart. Re-entrant hooks are recorded as a candidate for a future MODULE_API_VERSION major bump, beside the identity-provider gap the rust dry run found.

  4. The declarative Docker set is an environment variable — declared in the file a compose-managed host already edits and version-controls, rather than a second config file on the volume. Resolution runs before the server starts and is idempotent and offline-safe: an already-unpacked module at the declared version is a no-op, so a restart with the network down comes up unchanged.

Slices

# Slice Repos
0 SonarQube for module-uo (key Module-uo) — 75 files of extracted code that has never been scanned Module-uo, docs
1 The install service and the admin API website, docs
2 The Modules screen website, docs
3 The declarative Docker path website, docs
4 Close the phase docs, website

Slice 0 is independent and lands first. Unlike Phase 3 this is mostly core-only, so the two-repo merge dance does not apply — module-uo is touched only by slice 0.

The unpack is the dangerous part, and it is not the download

The download is solved: allowlisted https, a declared sha256, a size cap. Unpacking is where the archive chooses filenames — and core writes into a bind-mounted directory it shares with the host, so an escape is not confined to the container.

The extractor rejects rather than sanitises (an archive that needs correcting is an archive that should not be trusted): absolute paths, .. segments, any entry that is not a regular file or directory, more than one top-level directory, a top-level name that is not the manifest's id, and caps on unpacked size and entry count. It unpacks to a temporary directory beside the target and moves into place only once the whole archive is accepted, so a rejected or interrupted install never leaves a half-module for the next boot's scan to find. tar is a real dependency rather than a hand-rolled parser — the format has enough shape (long-name extensions, PAX headers, sparse entries) that parsing it is exactly the kind of code this list defends against.

Amendments

  • §2.4 — disable is no longer described as a pure row-and-guard flip. Its underlying claim survives: the row still decides only whether a mounted module answers, never what is mounted.
  • §2.5 — "admin selects the module" and "restart" are made concrete per decisions 1 and 2.

Docs only; no code in this PR.

AI disclosure

  • This contribution was AI-assisted (Claude Code).

🤖 Generated with Claude Code

Phase 3 closed with core at 158 routes and `module-uo` releasable. Phase 4 is the first phase that is not about the boundary — it answers how a module gets onto a box an operator owns, and how it comes off again. Adds `MODULE_SYSTEM.md` **§2.7.2**, and amends §2.4 and §2.5 where this phase changes what they say. ## The measured starting state The producer side is finished and the consumer side is empty. `module-uo`'s `release.yml` already publishes the tarball, an install manifest (`{id, name, version, coreApi, artifact, url, sha256, size}`) and `SHA256SUMS`, and `module.json` already declares `purge.sql`. Core already has the `installed_modules` provenance columns `source`/`sha256` that §2.4 carved out and nothing has ever written non-null, plus the live `stateGuard` and the model's `enable`/`disable`/`remove`. Core has **no** code that fetches, verifies, unpacks, removes or purges anything; no admin route; no client screen; and no container-start resolution — `docker-compose.yml`'s own comment still tells the operator to untar by hand. ## The four decisions 1. **Restart is a button, not an instruction.** Install, uninstall and re-enable only take effect at boot (§1.12). Recording a pending change and telling the operator to go restart the container contradicts §2.4's "recoverable from the admin panel, with no shell access to the box", so an admin action runs `moduleLifecycle.shutdown()` and exits cleanly — the `restart: unless-stopped` the shipped compose file already declares brings it back. Not universally true (a bare `npm start` does not come back), so the screen says what it is about to do. 2. **The install source is a pasted manifest URL, not a catalog.** A catalog would make core's release cadence decide which modules exist, and would make a module from outside this org uninstallable. The safety is not the catalog — it is the declared `sha256` (module-uo's own CHANGELOG already calls it the trust anchor) plus an `https` host allowlist, which is also what stops the endpoint being an SSRF primitive. 3. **Disable stops the module; enable asks for a restart.** As Phase 2 built it, disable flips one field and the guard 404s — the module becomes *invisible*, not *stopped*. Concretely, module-uo's `uoLinkSocket` stays connected and keeps ingesting shard events into `shard_*` tables, and `shardBroadcast`'s SSE streams stay open, because `onShutdown` is dispatched only from the signal handler. That is fine for "not today" and wrong for the case the button exists for. Disable now dispatches that one module's `onShutdown` first. Enable cannot mirror it: there is no `onBoot` re-dispatch path and the contract never promised the hooks are re-entrant, so enable flips the row and offers the restart. **Re-entrant hooks are recorded as a candidate for a future `MODULE_API_VERSION` major bump**, beside the identity-provider gap the rust dry run found. 4. **The declarative Docker set is an environment variable** — declared in the file a compose-managed host already edits and version-controls, rather than a second config file on the volume. Resolution runs before the server starts and is idempotent and offline-safe: an already-unpacked module at the declared version is a no-op, so a restart with the network down comes up unchanged. ## Slices | # | Slice | Repos | | --- | --- | --- | | 0 | SonarQube for `module-uo` (key `Module-uo`) — 75 files of extracted code that has never been scanned | `Module-uo`, `docs` | | 1 | The install service and the admin API | `website`, `docs` | | 2 | The Modules screen | `website`, `docs` | | 3 | The declarative Docker path | `website`, `docs` | | 4 | Close the phase | `docs`, `website` | Slice 0 is independent and lands first. Unlike Phase 3 this is mostly core-only, so the two-repo merge dance does not apply — `module-uo` is touched only by slice 0. ## The unpack is the dangerous part, and it is not the download The download is solved: allowlisted `https`, a declared `sha256`, a size cap. Unpacking is where the *archive* chooses filenames — and core writes into a bind-mounted directory it shares with the host, so an escape is not confined to the container. The extractor **rejects rather than sanitises** (an archive that needs correcting is an archive that should not be trusted): absolute paths, `..` segments, any entry that is not a regular file or directory, more than one top-level directory, a top-level name that is not the manifest's `id`, and caps on unpacked size and entry count. It unpacks to a temporary directory beside the target and moves into place only once the whole archive is accepted, so a rejected or interrupted install never leaves a half-module for the next boot's scan to find. `tar` is a real dependency rather than a hand-rolled parser — the format has enough shape (long-name extensions, PAX headers, sparse entries) that parsing it is exactly the kind of code this list defends against. ## Amendments - **§2.4** — disable is no longer described as a pure row-and-guard flip. Its underlying claim survives: the row still decides only whether a mounted module *answers*, never what is mounted. - **§2.5** — "admin selects the module" and "restart" are made concrete per decisions 1 and 2. --- Docs only; no code in this PR. ### AI disclosure - [x] This contribution was AI-assisted (Claude Code). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-08-12 07:32:05 +00:00
Phase 3 closed with core at 158 routes and module-uo releasable; phase 4 is
the first phase that is not about the boundary. It answers how a module gets
onto a box an operator owns, and how it comes off again.

Adds MODULE_SYSTEM.md §2.7.2 recording the measured starting state (the
producer side is finished — module-uo's release.yml already publishes the
tarball, the install manifest and SHA256SUMS, and module.json declares
purge.sql — while core has the installed_modules provenance columns and
nothing that fetches, verifies, unpacks or purges), the four org-lead
decisions, the five slices, the unpack threat model and the acceptance table.

The four decisions:

1. Restart is a button. Install, uninstall and re-enable only take effect at
   boot; recording a pending change and telling the operator to restart the
   container contradicts §2.4's "no shell access to the box", so an admin
   action runs the shutdown and exits, and the supervisor the shipped compose
   file already declares brings it back.
2. The install source is a pasted manifest URL, not a catalog — a catalog
   would make core's release cadence decide which modules exist. Safety is the
   declared sha256 plus an https host allowlist, which also stops the endpoint
   being an SSRF primitive.
3. Disable dispatches that one module's onShutdown before flipping the guard.
   As built, disable makes a module invisible rather than stopped — module-uo
   keeps its sidecar socket open and keeps ingesting into shard_* tables —
   which is wrong for the case the button exists for. Enable is not the mirror:
   there is no onBoot re-dispatch and the hooks were never promised re-entrant,
   so enable flips the row and offers the restart.
4. The declarative Docker set is an environment variable, resolved before the
   server starts, idempotent and offline-safe: an already-unpacked module at
   the declared version is a no-op.

Amends §2.4 (disable is no longer a pure guard flip) and §2.5 (what "admin
selects the module" and "restart" concretely mean). Records re-entrant
lifecycle hooks as a second candidate for a future MODULE_API major bump,
beside the identity-provider gap the rust dry run found.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 976c1b5839 into main 2026-08-12 07:37:16 +00:00
whitlocktech deleted branch docs/module-phase4-plan 2026-08-12 07:37:20 +00:00
Sign in to join this conversation.
No description provided.