# Design note — the extension system

**Dated 2026-08-04. Written before the code, per the provenance practice in
[ROADMAP.md](../../ROADMAP.md#proving-independence).**

Phase 5. Everything below is reasoned from the shape of the code that already
exists in this repo — the module manager, the template engine, the migrator —
and from first principles about what a third-party author needs. Where an idea
is simply standard across forum software, it says so.

---

## The job to be done

Somebody who did not write Convoro needs to add a feature to a running site,
ship it as one file, and have the site owner install it without a shell. Then
they need to release version 1.1 and have that same site upgrade cleanly,
including whatever schema the feature owns.

That is the whole job. Everything here follows from it.

## What already exists, and what it is missing

Every built-in feature is already a module: a `manifest.json`, a provider class
extending `Module`, auto-loaded `Routes/{front,admin}.php`, a `Templates/`
directory registered as a namespace, and a `Migrations/` directory the migrator
runs by module key. `ModuleManager` discovers, topologically sorts by
`requires`, registers and boots them.

So the runtime is genuinely there. What is missing is everything around it:

- Nowhere to put third-party code that isn't core
- No install state — a module is loaded because its directory exists
- No package format, so no way to ship one file
- Five template hook points and no domain events, so nothing to attach to
- No developer documentation, so no contract

## Decision 1 — extensions live in `extensions/`, not `modules/`

An extension is a module. Same base class, same manifest, same lifecycle. But it
gets its own root directory and its own autoload prefix, `Convoro\Extensions\`.

Two reasons, and the second matters more than it looks:

1. **Uninstall deletes a directory.** If third-party code sat in `modules/`, an
   uninstall bug could delete `modules/forum`. With separate roots, the
   uninstaller only ever has one root it is allowed to touch, and that is
   checkable in one line.
2. **`modules/` is the product; `extensions/` is guests.** `.gitignore` can
   exclude installed extensions without excluding core, and "what shipped with
   Convoro" stays answerable by looking at the tree.

Core modules are never enabled or disabled. Turning off `member` is not a
feature, it is a broken site. Only extensions have install state.

## Decision 2 — install state lives in `cv_extensions`, and it fails closed

A row per installed extension: key, name, version, enabled flag, the hash of the
package it came from, timestamps. Core modules never appear in it.

`ModuleManager` scans both roots. A directory under `extensions/` loads only if
it has an enabled row. That means:

- A half-extracted upload does not execute, because it has no row yet
- Disabling is instant and does not require deleting anything
- Dropping a directory in by FTP does nothing until it is installed

If the state query throws — database down mid-boot — **no extensions load**.
Failing closed is right here: a site running core-only is degraded, a site
running unknown extensions after an error is a security question.

## Decision 3 — the trust model, stated plainly

*This is the judgement call flagged in the handoff, so here is the answer.*

**An installed extension runs as PHP in the same process as core, with the same
reach: the database, the filesystem, the network, sessions, everything.** There
is no sandbox and there will not be one. A sandbox that PHP could actually
enforce would have to forbid so much that no useful extension could be written,
and a sandbox that is advisory is worse than none because it implies a
guarantee that isn't there.

This is the same position Flarum, IPS, XenForo and WordPress all take, and it is
the honest one. So the design does not pretend otherwise — it says so in the
developer documentation, and again on the upload screen in the admin panel, in
plain words, at the moment somebody is about to do it.

What is defensible without pretending:

- **Only administrators can install.** The route is behind `RequireAdmin`.
- **Extraction is traversal-safe.** Entry names are rejected outright if they
  are absolute, contain `..`, or resolve outside the target directory. A
  `.zip` cannot write to `engine/` or `public/`.
- **The package hash is recorded at install time.** Files changing underneath a
  running site afterwards then becomes detectable rather than invisible.
- **Signatures are supported and optionally required.** An author can sign a
  package; a site owner can list trusted public keys and set
  `extensions.require_signature`, after which unsigned packages are refused.
  Default is off, because requiring it by default with no key infrastructure
  just teaches people to turn it off.

Signing is verification of *origin*, not of *behaviour*. A signed extension from
a trusted author can still do anything. The docs will say that too.

## Decision 4 — the `.zip` package format

A zip. `manifest.json` at the root, source tree beside it, optional
`signature.json`. The extension is `.zip` so it is recognisable and so a browser
does not try to unzip it, but there is no invented container format — a zip is
inspectable with tools people already have, which is a feature when someone is
deciding whether to trust it.

```
hello.zip
├── manifest.json
├── signature.json        (optional)
├── Hello.php             the provider
├── Controllers/
├── Templates/
└── Migrations/
```

The manifest extends what core modules already use, so one shape covers both:

| Field | Required | Notes |
|---|---|---|
| `key` | yes | lowercase, `[a-z0-9_]`, the directory name and the migrator's module key |
| `name` | yes | human name |
| `version` | yes | semver `MAJOR.MINOR.PATCH` |
| `description` | | |
| `author`, `author_url` | | |
| `convoro` | | core version constraint, `^1.0` |
| `requires` | | other extensions or core modules, list or `{key: constraint}` |
| `provider` | | provider class; defaults to `Convoro\Extensions\<Key>\<Key>` |

Constraints support `^`, `~`, `>=`, `>`, `<=`, `<`, `=` and `*`. That is enough
for a forum plugin ecosystem and small enough to implement correctly. It is not
Composer and does not try to be — there is no solver and no lock file, because
extensions install one at a time by hand and a solver has nothing to solve.

`requires` accepts both a list (`["forum"]`, what core modules already write)
and a map (`{"forum": "^1.0"}`). Not breaking the existing manifests is worth
the small amount of branching.

## Decision 5 — hooks are a contract, so version them

Two extension points, deliberately different in kind:

**Template hooks** — `@hook('name')` in a `.cvr`, an extension registers a
callback returning HTML. Presentation only. Today's five become a documented
set spanning the page shell, the forum index, the topic view, the post, the
profile and the admin shell.

The current signature takes no arguments, which makes the interesting hooks
useless — a hook under a post that cannot see the post can only render something
static. So hooks gain optional context: `@hook('post.footer', ['post' => $post])`,
with the callback receiving that array. Existing call sites keep working
unchanged.

**Domain events** — typed classes through the existing `Dispatcher`, dispatched
by core when something happens: a topic created, a post edited, a member
registered, a report filed. Typed rather than string-keyed because the event
object *is* the documentation of what a listener receives, and because a typo in
a class name is an error where a typo in a string is silence.

Events are named for what has already happened. A listener observes; it does not
veto. Cancellable events would mean a broken extension could silently swallow
posts, and "why did my post vanish" is the worst possible bug to debug.

**The contract:** within a major version, a documented hook name is not removed
and its context keys are not renamed. New hooks and new keys may be added. This
is the thing that costs money to break, so it is written down before there are
any extensions rather than after.

## Decision 6 — migrations run both ways, and uninstall asks

The migrator already keys by module, so an extension's migrations are already
isolated. Install runs `up()`, uninstall runs `down()` in reverse, and upgrade
runs whatever is pending.

Uninstall offers a choice: remove the code and keep the data, or remove both.
Default is **keep the data**. Somebody uninstalling to try a different version
should not lose a year of records to a default they didn't read, and a `down()`
that drops a table is the one irreversible thing in the whole system.

Upgrade replaces the directory and does not merge. Files the new version dropped
are gone; that is what "the package is the source of truth" means. Anything an
extension wants to survive belongs in the database or in `content/`.

## What this does not do

Worth naming, so nobody goes looking:

- No marketplace, no remote catalogue, no update checks. Installing is a file.
- No dependency solver. One extension at a time; missing requirements are an
  error message telling you what to install.
- No PHP sandbox. See Decision 3.
- No template *override* mechanism for extensions. Hooks insert; they do not
  replace. Editing another module's markup is a theming concern and belongs to
  the theme editor queued in the roadmap, not here.
