# Plugin Hooks & Lifecycle **Phase 06 Task #22 foundation.** Builds on the WASM sandbox runtime from #21. ## What ships This task delivers the _foundation_ for plugin host integration. Marketplace browsing, hot-reload, per-instruction fuel, the full 15-20 hook-point catalogue and broader marketplace workflows are deferred to follow-up work (`#23` audit-docs + backlog/marketplace). Tenant plugin audit events are now exposed through `apps/web/app/routes/app.$tenant/settings/plugins.$id.audit.tsx`. ### 1. Hook registry — `packages/plugin-runtime/src/hooks/registry.ts` A central, immutable map of hook-name → zod payload schema. Foundation hooks are pre-registered: | Hook name | Outbox event type | When it fires | | ------------------- | ------------------------------- | -------------------------------------------------------- | | `tenant.created` | `plugin.hook.tenant.created` | `createTenant` row insert succeeded | | `user.created` | `plugin.hook.user.created` | `register` user row insert succeeded | | `entity.created` | `plugin.hook.entity.created` | `RowsApi.create` after row + log write | | `entity.updated` | `plugin.hook.entity.updated` | `RowsApi.update` after row + log write | | `entity.deleted` | `plugin.hook.entity.deleted` | `RowsApi.del` after hardDelete | | `workflow.created` | `plugin.hook.workflow.created` | Tenant-scoped workflow definition is created through reviewed service/import paths | | `workflow.updated` | `plugin.hook.workflow.updated` | Tenant-scoped workflow definition is updated through reviewed service paths | | `workflow.deleted` | `plugin.hook.workflow.deleted` | Tenant-scoped workflow definition is deleted through reviewed service paths | | `workflow.executed` | `plugin.hook.workflow.executed` | Tenant-scoped workflow execution reaches terminal status | | `workflow.failed` | `plugin.hook.workflow.failed` | Tenant-scoped workflow execution reaches terminal error status | Adding a new hook is a single `registerHook({ name, outboxEventType, payloadSchema })` call at module load time. The registry validates that the event type matches `plugin.hook..` so the outbox dispatcher's existing routing rules cover it without changes. ### 2. Hook emission — `packages/plugin-runtime/src/hooks/emit.ts` `emitPluginHook(tx, def, payload, publish)` validates the payload and forwards to the outbox publisher. The host-side wrapper `apps/web/app/modules/plugins/services/.server/pluginHookPublisher.ts` owns a one-statement Prisma transaction so callers stay a single line: ```ts await emitPluginHookFireAndForget(PLUGIN_HOOK_TENANT_CREATED, {...}, { tenantId, dispatchKey: `tenant:${id}:created`, }); ``` The wrapper is intentionally **non-fatal to the surrounding business write**: hook delivery failures are logged but never propagate into tenant signup, row CRUD, or workflow service calls. It still returns a promise, and reviewed workflow definition producers await it so the one-shot outbox transaction is attempted before the service returns. A malformed payload triggers `PluginHookValidationError`, which is also swallowed and logged. ### 3. Plugin lifecycle service — `apps/web/app/modules/plugins/services/.server/PluginLifecycleService.ts` `installPlugin`, `enablePlugin`, `disablePlugin`, `listPlugins`. Each mutation writes a hash-chained audit-event row through the existing `createAuditEvent` helper. The service is permission-guarded at the route layer: read-only list/settings views require `app.plugins.use`; capability-use audit trails require `app.settings.auditTrails.view`; install, capability grant, enable, and disable require `app.plugins.manage`. ### 4. Tenant admin UI — `apps/web/app/routes/app.$tenant/settings/plugins.tsx` A minimal settings page that lists installed plugins with their capabilities, manifest preview, and an enable/disable toggle. CSRF-validated form submissions go through the lifecycle service. ### 5. Plugin migration applier — `packages/plugin-runtime/src/migrations.ts` `applyPluginMigration({ pluginId, sql }, deps)` parses the SQL, enforces `plugin__` table prefix, requires a `tenantId` column on every CREATE TABLE, and rejects anything outside the `CREATE TABLE / CREATE INDEX` allowlist. Forbidden tokens (GRANT, DROP DATABASE, COPY, pg\_\*, etc.) are caught at validation time so the host never runs them. ### 6. Crash tracker — `packages/plugin-runtime/src/crash-tracker.ts` SharedStore-backed sliding window per `(pluginId, tenantId)`. Failures increment the counter (TTL = configurable window, default 10 minutes). Successes decrement (floored at 0). Once the threshold (default 10) is crossed the tracker calls the injected `disablePlugin` seam, calls the optional `notify` seam, and resets the counter so a re-enable starts fresh. Wired into `apps/worker/src/jobs/plugin-invoke.ts` via an optional `recordOutcomeForCrashTracking` seam; `plugin_not_found` / `plugin_disabled` outcomes are deliberately skipped so configuration glitches don't trigger auto-disable. ### 7. Workflow definition hooks `apps/web/app/modules/workflowEngine/services/WorkflowsService.ts` emits metadata-only `workflow.created`, `workflow.updated`, and `workflow.deleted` hooks for tenant-scoped service-path definition writes and reviewed template imports. The reviewed create/update/delete/import paths await the publisher attempt after the workflow write succeeds; publisher failures are recorded by the hook publisher logger and remain non-fatal to the workflow mutation. Platform-scoped definitions and writes that bypass those reviewed paths remain outside current producer coverage. ### 8. Workflow execution hook — `workflow.executed` `apps/web/app/modules/workflowEngine/services/WorkflowsExecutionsService.ts` emits `workflow.executed` after `updateWorkflowExecution` returns a terminal tenant-scoped `success` or `error` status. The payload intentionally carries only execution metadata (`tenantId`, `workflowId`, `workflowExecutionId`, `type`, `status`, `durationMs`, `errorCode`, `appliesToAllTenants`, `occurredAt`). `errorCode` is null for `success` and required for `error`. Raw error strings, workflow input, and workflow output bodies are not included because they can contain provider responses, URLs, or secret-like values. Failed executions also emit `workflow.failed` with the same metadata-only shape but a fixed `status: "error"` and required `errorCode`. The separate failure hook lets plugins subscribe only to failure automation while existing `workflow.executed` subscribers continue to receive the full terminal-status stream. Current producer coverage for workflow lifecycle hooks: | Lifecycle event | Hook(s) | Producer coverage | Payload boundary | | --------------- | ------- | ----------------- | ---------------- | | Tenant-scoped definition create | `workflow.created` | `WorkflowsService.create` and reviewed tenant template imports | Metadata only; no blocks, credentials, or raw input | | Tenant-scoped definition update | `workflow.updated` | `WorkflowsService.update` | Metadata plus changed field names only | | Tenant-scoped definition delete | `workflow.deleted` | `WorkflowsService.del` | Metadata only | | Tenant-scoped web/API/manual execution success | `workflow.executed` | `WorkflowsExecutionsService` | Terminal metadata, `errorCode: null` | | Tenant-scoped web/API/manual execution error | `workflow.executed`, `workflow.failed` | `WorkflowsExecutionsService` | Terminal metadata, stable `errorCode`; no raw error | | Worker-native trigger execution | `workflow.executed`, `workflow.failed` | `apps/worker/src/jobs/workflow-trigger-native-executor.ts` | Terminal metadata only; failures use the stable `workflow_execution_failed` code | ### 9. Worker-side hook fanout — `apps/worker/src/jobs/plugin-hook-fanout.ts` The outbox dispatcher routes `plugin.hook.*` events to `outbox.plugin-hook` on the `general` worker group. The fanout consumer loads enabled plugins for the tenant, filters manifests that declare the hook, and enqueues one idempotent `plugin.invoke` job per subscriber on the isolated `toolctrl.plugin-invoke` queue. Plugin execution itself stays on `WORKER_QUEUE_GROUP=plugin`. ### 10. Per-user enablement — `prisma/schema/plugins.prisma` Adds `PluginUserConfig { id, pluginId, userId, enabled, configJson }` with unique `(pluginId, userId)` and FK cascade on Plugin delete. User-scoped hook fanout loads `PluginUserConfig.enabled` when the validated hook payload includes `userId`; `enabled === false` skips that user's plugin invocation while tenant-default subscribers remain eligible. ### 11. Operator CLI — `packages/cli/src/commands/plugins/*` ``` toolctrl-cli plugins list [--tenantId X] [--enabled true|false] toolctrl-cli plugins enable --tenantId X --pluginId com.acme.slack toolctrl-cli plugins disable --tenantId X --pluginId com.acme.slack toolctrl-cli plugins uninstall --tenantId X --pluginId com.acme.slack ``` ## Remaining Slices Current status and operator-facing caveats live in `docs/features/plugins.md` and `docs/operations/plugin-lifecycle.md`. The historical implementation plan in `docs/improvement-plan/06-plugin-system/hooks-lifecycle.md` remains useful context, but it is not the current source of truth. - The remaining hook points (`onWebhookOutbound`, `onEmailSend`, `afterAuthLogin`, billing, integration-connected, api-key-created, chatbot-message, export-run, etc.). - Synchronous output hooks (`emitHookSync` with a 500ms wall-clock budget for `transform` / `cancel` capabilities). - Dedicated marketplace/install dialog UX. The operator-side install path runs through manifest verification, rejects unknown hook names, and tenant settings already approve missing manifest-declared grants via checkbox plus a server-verified digest. - Plugin secret storage and encrypted host bindings remain pending. The reusable settings iframe shell already enforces opaque-origin sandboxing, CSP, and a versioned postMessage protocol; manifest `ui.settings.entry` metadata is routed through the server-side bundle loader, and non-secret user config persists through `PluginUserConfig.configJson`. - Marketplace browsing + plugin signing-key rotation UI. - Hot-reload + per-instruction fuel limit. - Global (cross-tenant) crash counter for operator-level auto-disable. ## Operational notes - `PLUGIN_AUTO_DISABLE_THRESHOLD` (default 10) and `PLUGIN_AUTO_DISABLE_WINDOW_MS` (default 600_000) are read by the worker bootstrap when constructing the crash tracker. - The outbox dispatcher routes `plugin.hook.*` events to `outbox.plugin-hook`; that consumer fans out to `toolctrl.plugin-invoke`. Keep the general worker and the separate plugin worker running in production-like topology so health checks, queue wiring, and isolation are exercised. Before external runtime signoff, the production `worker-plugin` should remain idle/gated unless `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` is intentionally enabled. - Audit events use action codes `plugin.installed`, `plugin.enabled`, `plugin.disabled` and `plugin.crash.auto_disabled` (the last fired by the crash-tracker notifier).