# DocWright Plugin & Extension Architecture

DocWright is a **thin, stable core plus composable plugins.** The core owns only
what must be central; every feature area is an extension point that a bundled
first-party provider or a third-party plugin fills, replaces, or deepens. This is
what lets subsystems grow to arbitrary depth — and lets you add bespoke modules —
without forking the core.

This document covers the kernel API, the full extension-point catalogue, the
`plugin.json` manifest, the plugin lifecycle, the capability security model, the
`plugin` CLI commands, and a full walk-through of the reference plugin
`plugins/example-hello/`.

---

## 1. The kernel API (the only stable surface)

Plugins depend on nothing but the kernel, which is **semantically versioned**
(`DocWright\Kernel\Kernel::API_VERSION`, currently `1.0.0`). A plugin declares
the kernel range it needs; the loader refuses to load an incompatible plugin.

The kernel exposes six primitives (`app/src/Kernel/`):

| Primitive | Accessor | What it does |
|---|---|---|
| **Container** (DI) | `$kernel->container()` | `bind` / `singleton` / `instance` / `alias` / `tag` / `tagged`, plus reflection autowiring with a circular-dependency guard. The only way plugins obtain services. |
| **EventBus** | `$kernel->events()` | `listen($event, $cb, $priority)`, `observe($cb)`, `dispatch($event)`, `defer($event)` + `flushDeferred()`. Fire-and-observe notifications (`document.saved`, `render.completed`, `hello.pinged`, …). |
| **HookRegistry** | `$kernel->hooks()` | `addAction`/`doAction` (do-on-event) and `addFilter`/`applyFilters` (transform-a-value). Filters are how a plugin deepens a subsystem without forking it. |
| **ExtensionPoints** | `$kernel->extensions()` | `define`, `contribute`, `all`, `get`, `has`, `withdrawPlugin`. The typed registry (see below). |
| **Router** | `$kernel->router()` | Register HTTP routes + middleware. |
| **Config / Logger** | `$kernel->config()` / `->logger()` | Read config; structured logging. |

Events vs hooks: **events are notifications** (any number of listeners react);
**hooks/filters transform values** in a pipeline. Both are ordered by priority.

### Extension points, typed

`ExtensionPoints` holds a set of named points, each with an optional **contract**
(an interface every contribution must implement). Contributing a value that does
not satisfy the contract throws — the seam is type-safe. Every contribution is
tagged with the id of the plugin that provided it, so `withdrawPlugin($id)`
cleanly removes everything a plugin added when it is disabled, uninstalled, or
throws.

```php
$ext = $kernel->extensions();
$ext->contribute('render.backends', 'weasyprint', $backend, $pluginId, $priority);
$all  = $ext->all('render.backends');   // priority-ordered
$one  = $ext->get('render.backends', 'weasyprint');
```

Five points carry an object contract (declared in `Kernel::declareExtensionPoints()`):

| Point | Contract (`app/src/Kernel/Contract/`) |
|---|---|
| `render.backends` | `RenderBackend` — `id/name/isAvailable/renderPdf(html, out, opts)` |
| `export.formats` | `ExportFormat` — `id/extension/mimeType/isAvailable/export(context)` |
| `auth.providers` | `AuthProvider` — `id/displayName/authenticate(request)/isConfigured` |
| `api.modules` | `ApiModule` — `id/routes(router, kernel)/openApiFragment()` |
| `workflow.packs` | `WorkflowPack` — `id/definitions()` |

The remaining points accept declarative arrays/definitions (validated by their
consumers).

---

## 2. The full extension-point catalogue

Copied from `ExtensionPoints::CATALOGUE` (`app/src/Kernel/ExtensionPoints.php`),
with a one-line description of each:

| Extension point | What a plugin contributes |
|---|---|
| `ast.node_types` | Document AST node schemas (new block/inline node types) |
| `editor.commands` | Editor commands + keymaps (surface in the command palette) |
| `editor.panels` | Editor UI panels |
| `render.backends` | PDF/print render backends (Chromium, WeasyPrint, Tectonic, …) |
| `export.formats` | Export formats (DOCX, static web, single-file HTML, …) |
| `import.filters` | Import filters (DOCX, ODT, Markdown, LaTeX, xlsx-as-data) |
| `workflow.packs` | Workflow definitions: states / transitions / guards |
| `auth.providers` | Authentication providers (password now; OIDC/SAML later) |
| `session.policies` | Session policies (IP pinning, geo rules, SSO bridging, …) |
| `rbac.permissions` | Permissions + roles contributed by a plugin |
| `translit.schemes` | Transliteration schemes (Tamil, Devanagari, …) |
| `spellgrammar.engines` | Spell / grammar engines (Hunspell, LanguageTool) |
| `citation.connectors` | Citation styles / connectors (Zotero, BibTeX, CSL) |
| `bim.adapters` | BIM adapters (IFC / IfcOpenShell) |
| `smartart.generators` | Smart-art generators (Mermaid, Graphviz, Excalidraw) |
| `api.modules` | REST resources + OpenAPI fragments |
| `webhook.events` | Webhook event types |
| `quota.strategies` | Quota / priority strategies |
| `templates` | Document templates / themes |
| `feedback.integrations` | Feedback / tracker integrations |
| `jobs.handlers` | Scheduled / queued job handlers |

The core kernel declares all of these at construction. A subsystem provider may
`define()` additional open points at boot — e.g. `SessionServiceProvider`
ensures `session.policies` is defined.

---

## 3. The core-vs-plugin boundary

**Core** = the kernel, the canonical AST + its schema versioning, the
project/Git store, the auth/session/RBAC primitives, the job queue, the realtime
substrate, and the extension-point registry.

**Everything else is a bundled first-party provider or plugin** — styles, paged
layout, images, math/LaTeX, references, transliteration, spell/grammar,
smart-art, PDF/DOCX/web exporters, sharing, feedback tracker, BIM, and workflow
packs. First-party subsystems are wired as `ServiceProvider`s in
`app/bootstrap.php`; a `ServiceProvider` and a `Plugin` are the same shape
(`register()` + `boot()`), so there is no privileged path — the example plugin
uses exactly the seams a bundled feature uses.

---

## 4. The `plugin.json` manifest

A plugin is a self-describing directory containing a `plugin.json`. It is parsed
into `DocWright\Plugins\PluginManifest` (`app/src/Plugins/PluginManifest.php`).
Fields:

| Field | Required | Meaning |
|---|---|---|
| `id` | yes | Stable plugin id (also the extension-point tag) |
| `version` | yes | Semver version of the plugin |
| `name` | yes | Human-readable name |
| `description` | no | One-line description |
| `kernel` (or `kernelApi`) | no (default `*`) | Kernel-API semver constraint, e.g. `^1.0.0` |
| `license` | no (default `GPL-3.0-or-later`) | Must be GPL-3.0-compatible |
| `thirdParty` | no (default `false`) | Marks a non-first-party plugin (list it in `THIRD_PARTY.md`) |
| `dependencies` | no | Other plugins, `"<id>:<semver-range>"` |
| `capabilities` | no | Capabilities the plugin requests (see [§6](#6-capability-based-security)) |
| `entryPoints` (or `entry_points`) | no | `{ "php": "...", "js": "...", "python": "..." }` — a plugin may ship parts for any runtime |
| `migrations` | no (default `migrations`) | Directory of SQL migrations, run namespaced as `plugin:<id>` |
| `config` | no | `{ "schema": { …JSON Schema… } }` for the plugin's config |

Example (`plugins/example-hello/plugin.json`):

```json
{
  "id": "example-hello",
  "version": "1.0.0",
  "name": "Example — Hello",
  "description": "Reference plugin proving every extension seam ...",
  "kernel": "^1.0.0",
  "license": "GPL-3.0-or-later",
  "thirdParty": false,
  "dependencies": [],
  "capabilities": ["ast.contribute", "api.register", "workflow.contribute", "webhook.register"],
  "entryPoints": { "php": "php/ExampleHelloPlugin.php", "js": "js/index.mjs" },
  "migrations": "migrations",
  "config": { "schema": { "type": "object", "properties": {
    "greeting": { "type": "string", "default": "Hello from DocWright" } } } }
}
```

---

## 5. Lifecycle & dependency resolution

`DocWright\Plugins\PluginManager` (`app/src/Plugins/PluginManager.php`) drives
**discover → enable → load → (migrate) → withdraw**, all with fault containment:

1. **Discover** — scan each plugin root (default `plugins/`) for `*/plugin.json`;
   an invalid manifest is logged and skipped, never fatal.
2. **Enable set** — first-party plugins default to enabled; the `plugins` table
   overrides this (`id`, `enabled`, `config`, granted `capabilities`). If the
   table is not yet migrated, defaults apply.
3. **Compatibility** — each enabled plugin's `kernel` constraint is checked
   against `Kernel::API_VERSION` (semver); incompatible plugins are marked and
   skipped.
4. **Order** — dependencies are resolved into a safe load order by a topological
   sort (Kahn's algorithm, deterministic); cycles are broken and reported rather
   than hanging the loader. A plugin loads only after all its dependencies have
   loaded successfully.
5. **Load** — the PHP entry point is `require`d, the declared `Plugin` class is
   located and instantiated with a **capability-scoped** `PluginContext`, and it
   is registered with the kernel (adapted to a `ServiceProvider`). Its
   `register()` runs immediately; its `boot()` runs in the kernel boot phase.
6. **Migrate** — `migrateLoaded()` runs each loaded plugin's migrations under a
   namespaced key `plugin:<id>` (invoked by `bin/console migrate` after the core
   schema).
7. **Containment (§4A A5)** — if a plugin throws while loading or booting, the
   manager **withdraws all its contributions** (`ExtensionPoints::withdrawPlugin`),
   records an `error` status, logs it, and continues. One bad plugin never
   crashes the core.

Load status per plugin is one of: `loaded`, `disabled`, `incompatible`,
`skipped` (unmet dependency), or `error` — visible via `plugin:list`.

A plugin may ship a **frontend** entry (`js`) and/or a **Python** entry
(`python`) with no PHP at all; such a plugin loads as `loaded (no php entry)`.
Enabling/disabling pure UI/API additions is hot; worker/CLI changes require a
worker restart (the CLI reminds you).

The plugin's PHP entry implements `DocWright\Plugins\Plugin`:

```php
interface Plugin {
    public function id(): string;
    public function register(Kernel $kernel, PluginContext $context): void; // bind services
    public function boot(Kernel $kernel, PluginContext $context): void;     // contribute, add routes/listeners
}
```

---

## 6. Capability-based security

Plugins have **no ambient authority**. A plugin receives a `PluginContext`
(`app/src/Plugins/PluginContext.php`) carrying only the capabilities its manifest
declared **and** an admin granted (persisted in `plugins.capabilities`). It
gates which core services a plugin may touch:

```php
$context->has('api.register');       // bool
$context->require('api.register');   // throws RuntimeException if not granted
$context->configValue('greeting', 'Hi');  // validated plugin config
```

Guarantees:

- **Least privilege.** A capability the manifest did not declare, or an admin
  did not grant, cannot be used — `require()` throws.
- **No RBAC escalation.** Capabilities gate *which core services* a plugin may
  touch, **never who the acting user is.** All plugin actions remain subject to
  RBAC, quotas, priority, and the audit log exactly like core actions — a plugin
  cannot push a user past their role.
- **Containment.** A throwing plugin is isolated and reported, its contributions
  withdrawn.
- **Locked-down installs.** Plugins are FOSS/GPL-3.0-compatible; optional
  signing and an allowlist are supported for restricted deployments; third-party
  plugins are recorded in `THIRD_PARTY.md`.
- **Any depth, safely.** Plugins may depend on and extend other plugins (a
  "structural-report" plugin can build on a "BIM" plugin's node types and a
  "workflow" plugin's states), composing to arbitrary complexity while the core
  stays small.

---

## 7. The `plugin` CLI

From `DocWright\Console\Console` (`app/src/Console/Console.php`):

```bash
bin/console plugin:list                # discovered plugins + version + load status + detail
bin/console plugin:enable  <plugin-id> # enable  (upsert into the plugins table)
bin/console plugin:disable <plugin-id> # disable (restart workers to apply)
```

`plugin:list` prints, per plugin, the id, version, status (`loaded` / `disabled`
/ `incompatible` / `skipped` / `error`), and a detail string. Enabling/disabling
writes the `plugins` row (`ON CONFLICT (id) DO UPDATE`) and reminds you to
restart workers so worker/CLI changes take effect. Plugin migrations are applied
by the ordinary `bin/console migrate` (core first, then each loaded plugin's
`migrations/` under `plugin:<id>`). `bin/console doctor` reports overall health.

An RBAC-gated admin UI for plugin management (permission `plugins.manage`, seeded
in `0010`) is part of the API/admin surface (planned).

---

## 8. Worked example: `plugins/example-hello/`

The reference plugin proves **every** first-party seam end to end, so integrators
have a pattern to copy. It contributes an AST node type, an editor command, an
API resource + OpenAPI fragment, a workflow pack, and a webhook event — and
requests only the four capabilities its manifest declared.

### 8.1 AST node type — `ast.node_types`

```php
$context->require('ast.contribute');
$ext->contribute('ast.node_types', 'hello_box', [
    'type' => 'hello_box', 'group' => 'block', 'content' => 'inline*',
    'attrs' => ['tone' => ['default' => 'info']],
    'schema' => ['type' => 'object'],
], $this->id());
```

This registers a boxed-callout block. The document schema's open `genericBlock`
(`spec/document.schema.json`) is intentionally a superset of the built-in node
types, so plugin-contributed nodes like `hello_box` validate without a core
schema change.

### 8.2 Editor command + keymap — `editor.commands`

```php
$ext->contribute('editor.commands', 'insertHelloBox', [
    'id' => 'insertHelloBox', 'title' => 'Insert Hello box',
    'keymap' => 'Mod-Alt-h', 'category' => 'Insert',
], $this->id());
```

Consumed by the frontend command palette. The **frontend half** lives in
`js/index.mjs` and mirrors the contribution on the browser-side plugin kernel
(`web/js/kernel.mjs`, planned), registering a node view and the command's `run`
implementation:

```js
export default function register(dw) {
  dw.astNode('hello_box', {
    group: 'block', content: 'inline*', attrs: { tone: { default: 'info' } },
    toDOM: (n) => ['aside', { class: 'dw-hello-box', 'data-tone': n.attrs.tone }, 0],
    parseDOM: [{ tag: 'aside.dw-hello-box' }],
  });
  dw.command('insertHelloBox', {
    title: 'Insert Hello box', category: 'Insert', keymap: 'Mod-Alt-h',
    run: (editor) => editor.insertBlock('hello_box', { tone: 'info' }, 'Hello …'),
  });
}
```

### 8.3 API resource + OpenAPI fragment — `api.modules`

```php
$context->require('api.register');
$ext->contribute('api.modules', 'hello', new class ($greeting) implements ApiModule {
    public function id(): string { return 'hello'; }
    public function routes(Router $router, Kernel $kernel): void {
        $router->get('/api/v1/hello', function (Request $r) use ($kernel): Response {
            $kernel->events()->defer(new Event('hello.pinged', ['at' => gmdate('c')]));
            return Response::json(['message' => $this->greeting, 'plugin' => 'example-hello']);
        });
    }
    public function openApiFragment(): array { /* paths→/hello→get→200 schema */ }
}, $this->id());
```

The module registers `GET /api/v1/hello` and returns an OpenAPI 3.1 fragment
that the spec builder merges into the published document — the API grows with the
platform, no core edit needed. Core resources are themselves `ApiModule`s.

### 8.4 Workflow pack — `workflow.packs`

```php
$context->require('workflow.contribute');
$ext->contribute('workflow.packs', 'hello-review', new class implements WorkflowPack {
    public function id(): string { return 'hello-review'; }
    public function definitions(): array {
        return [[ 'id' => 'hello-review', 'object_type' => 'hello_box',
          'version' => 1, 'initial' => 'draft',
          'states' => [ ['id'=>'draft','label'=>'Draft'],
                        ['id'=>'greeted','label'=>'Greeted','terminal'=>true] ],
          'transitions' => [ ['id'=>'greet','from'=>'draft','to'=>'greeted',
                              'label'=>'Greet','roles'=>['editor','admin']] ] ]];
    }
}, $this->id());
```

A tiny `draft → greeted` state machine, role-gated, in the same data shape as the
built-in `document-control` / `comment-rfi` / `suggestion` definitions
(`migrations/0010`).

### 8.5 Webhook event type — `webhook.events`

```php
$context->require('webhook.register');
$ext->contribute('webhook.events', 'hello.pinged', [
    'event' => 'hello.pinged',
    'description' => 'Fired when the example /hello endpoint is called.',
], $this->id());
```

Calling `GET /api/v1/hello` **defers** a `hello.pinged` event, demonstrating the
event → webhook fan-out path.

### 8.6 Try it

```bash
bin/console plugin:list          # example-hello  1.0.0  loaded
bin/console migrate              # applies plugins/example-hello/migrations (plugin:example-hello)
# with the server running:
curl -s https://<host>/api/v1/hello
# → {"message":"Hello from DocWright","plugin":"example-hello"}
```

Disabling the plugin (`plugin:disable example-hello`) withdraws all five
contributions cleanly; the `hello_box` node, the command, the route, the workflow
pack, and the event type all disappear together.

> Note on current state: the PHP-side kernel, `ExtensionPoints`, `PluginManager`,
> and this example plugin are implemented and load today. The consumers that turn
> some contributions into user-visible behaviour (the browser plugin kernel
> `web/js/kernel.mjs`, the API router/spec builder, and the workflow engine) are
> in progress — see the status table in [`ARCHITECTURE.md`](ARCHITECTURE.md).
> The seams, contracts, and containment are real and exercised now.
