Plugin System
July 27, 2026 · View on GitHub
Date: 2026-07-26
Status: Phases 0–4 implemented (2026-07-26). Phase 5 not started by design.
Author: Claude Code (dialectical review of the 2026-07-26 design docs)
Audience: maintainer (solo) + Claude Code sessions
Decision of record: ADR-0233
Continued by: PLUGIN_SYSTEM_ACTIVATION_PLAN.md
— this document's phases are complete; the work since then built mechanisms that
have no call site, and the activation plan sequences closing that.
Delivery status
| Phase | State | Evidence |
|---|---|---|
| 0 Ground truth | ✅ done | prototype retired, model salvaged to corvin_plugins/manifest.py, 44 real tests replace 22 skips, tsc green again |
| 1 Additive backends | ✅ done | AuditBackend/UserBackend protocols, passthrough providers, 2 templates, boot tripwire, 33 tests incl. hostile-backend chain verification |
| 2 Fault isolation | ✅ done | circuit_breaker.py, breaker state in health_check_all(), plugin_health_monitoring flag, 31 tests |
| 3 Runtime lifecycle | ✅ done | state.py per-tenant registry (atomic, 0600, fail-closed) + real chained audit events, plugin_runtime_lifecycle flag, 45 tests |
| 4 Console surface | ✅ done | routes/plugins.py, /app/plugins page, JsonSchemaForm, plugin_console_surface flag, 17 route tests + 9/9 Playwright specs green |
| 5 Distribution | ⛔ not started | needs its own ADR; the locality/egress prerequisite is now in place |
| ADR gap-closure | ✅ done | hot-reload (0124 Inv. 6), four missing tripwires (0232), locality/egress + two missing handles (0124 Inv. 3), structured logging + correlation ids (0231 Stage 1), health collector + Prometheus metrics (0231 Stage 2) |
| 0231 Stages 3–4 | ⛔ deferred | self-healing / intelligent healing, per ADR-0231's own gate: re-propose after Stage 2 has been stable for one release |
| Refutation round | ✅ done | found two DEAD mechanisms in the phases above — the tripwire was never called and PluginContext was never built — plus a PII leak in health_check_all and a breaker-as-auth-DoS. All fixed; bootstrap.py + 18 tests added. See ADR-0233 § Findings from the adversarial pass. |
Test totals after implementation: 319 green across core/plugins/tests, the
console plugin-route and feature-flag suites, and core/orchestration; 106 forge
audit/chain tests still green; 223 gateway tests green (the 3 failures there are
pre-existing — verified against a clean git worktree at HEAD, not caused by the
lifespan wiring); tsc --noEmit clean; npm run build succeeds.
Playwright: 9/9 green. Getting there required fixing playwright.config.ts,
which could not start its managed dev server at all: webServer.url probed
http://localhost:3000 while vite.config.ts serves on 5173 under the
/console/ base path, so every run without an externally started server died on
"Timed out waiting 60000ms from config.webServer". Both are now derived from
CONSOLE_BASE_URL with the correct default. The spec mocks auth and
/setup/status (SetupGate renders a fixed inset-0 z-50 overlay that swallows
every click while setup is incomplete), so it runs against the dev server alone —
no gateway, no session, no fixture data.
Still not executed: operator/bridges/run-all-tests.sh — it exceeded a
15-minute budget twice and buffers all output until exit. The audit.py fan-out
change is covered instead by three adapter tests plus 106 forge audit/chain tests
run directly.
1. Decision
Do not build a fifth extension system. Put the plugin work on the two paths that already carry production traffic and already pass the compliance gates.
Thesis (rejected)
Finish core/orchestration/plugin_system/ (the Marketplace prototype from
docs/concepts/ADR-0XXX-PLUGIN_SYSTEM.md), wire its REST router into the Console,
register the /plugins page, and continue with the Phase 3 roadmap
(E2E suite → Tier A migration → form generator → ratings UI).
Antithesis
- The prototype has almost no salvageable runtime. 2477 LOC, but
dependency_resolver.py,registry_manager.pyandsettings_manager.pyare 0 bytes; all real logic sits inmodels.py(527 LOC).api.pyis not even importable (NameError: name 'router' is not definedat line 155 — the Phase-2a install endpoint was appended afterreturn router, outside the factory). What is genuinely valuable is the data model: enums,Plugin, registry-YAML round-trip, topological sort,SettingsValidator. That is a library, not a platform. - Every compliance promise in the ADR is absent from the code.
AuditEventis a dataclass withto_dict()and no connection to the hash-chainedaudit.jsonl— thehash_chainfield from the ADR's own example is never computed.lifecycle_manager.install()verifies no checksum, no signature, no consent, and starts no sandbox. Quota is a dataclass; cgroups, bwrap, signing, and the gradated permission model exist only as prose. - The capability already exists, hardened. ADR-0096 (MCP Plugin Manager,
operator/mcp_manager/, M1–M4 shipped) is a working marketplace: install from npm/pip/GitHub/Docker/local, SHA256 + Docker-digest pinning verified on every spawn, four activation scopes, L10 path-gate, L16 audit events, L34 locality and L35 egress checks, vault secret injection, and fail-closedmcp_plugin.spawn_blocked. ADR-0142 (ACCEPTED) + ADR-0156 addext.<vendor>.*layers with a Tier-A/B/C capability boundary and a license gate. The Marketplace ADR references neither. - Asymmetric, irreversible risk. A half-finished installer that downloads and executes third-party code is the most dangerous feature class in this repo; the failure mode is RCE, and it stops being reversible the moment a user has installed a plugin (registry migration). Wiring is the hazardous step — not the refactor.
- Tier semantics already collide. Marketplace Tier A/B/C = trust/provenance. ADR-0156 Tier A/B/C = capability boundary + license gate. Same letters, different meaning, both user-visible. Shipping both guarantees a support incident.
- A reasonable maintainer would reject it on the repo's own rules: "Goal: a stable CorvinOS core", ship-dark-by-default, and a compliance baseline where sandbox, consent, audit-chain and signing are non-negotiable and non-flaggable.
Synthesis (the decision)
The product goal stays: runtime install/enable/configure/uninstall with a Console surface. The implementation strategy changes: build on top of the working lifecycle contract and the hardened distribution path instead of beside them.
| Concern | Owner (load-bearing) | Not |
|---|---|---|
Lifecycle + runtime registration (on_load/on_unload/health_check) | ADR-0030 core/plugins/corvin_plugins/ — live, imported by adapter.py:556-559, 55 tests green | a second PluginRegistry |
| Distribution / install / pinning / spawn verification | ADR-0096 operator/mcp_manager/ for tool-shaped extensions; ADR-0142/0156 for layer-shaped ones | a new downloader without gates |
| Manifest, settings schema, dependency order, registry persistence | salvaged from models.py into corvin_plugins | left in an unwired parallel package |
| Fault isolation, health, structured logging, healing | Compartmentalization ADR stages 1–4, additive on corvin_plugins | extraction of L16/L18-21 |
core/orchestration/plugin_system/ is retired, not finished (§ Phase 0).
2. Target architecture
┌──────────────────────────────────────────────────────────────┐
│ Console: Settings → Plugins (flag: plugin_console_surface) │
│ list · enable/disable · JSON-Schema-generated settings form │
└───────────────────────────┬──────────────────────────────────┘
│ REST, tenant from SessionRecord (never env)
┌───────────────────────────▼──────────────────────────────────┐
│ corvin_plugins (ADR-0030 + ADR-0033, LOAD-BEARING) │
│ protocol.py CorvinPlugin · PluginContext · HealthStatus │
│ registry.py register/unregister/get/health_check_all │
│ loader.py entry_points + explicit class_path │
│ manifest.py NEW — manifest + settings schema + dep order │
│ providers/ notification · recall · summary · router │
│ NEW: audit_backend · user_backend │
└───────────────────────────┬──────────────────────────────────┘
│ plugin.on_load(ctx) self-registers
┌───────────────────────────▼──────────────────────────────────┐
│ Layer registries (authoritative routing, unchanged) │
│ engine_factory · ComputeEngineRegistry · channel_registry │
└───────────────────────────┬──────────────────────────────────┘
│ artifacts arrive only via
┌───────────────────────────▼──────────────────────────────────┐
│ Distribution (hardened, existing) │
│ ADR-0096 mcp_manager — pinning, spawn verify, L34/L35 │
│ ADR-0142/0156 layers — ext.* namespace, Tier A/B/C, gate │
└──────────────────────────────────────────────────────────────┘
│ every transition
┌───────────────────────────▼──────────────────────────────────┐
│ Mandatory core (hardcoded, tripwired, never pluginified) │
│ L16 audit hash-chain · L44 house rules · consent · L10 gate │
└──────────────────────────────────────────────────────────────┘
Invariant that makes this safe: a plugin may add a backend or a rule. It can never
replace, disable, or weaken a mandatory mechanism. Core writes its own audit record
regardless of which audit backend is installed (see ADR-0XXX-COMPLIANCE_HARDENING.md
§ "Extension: Custom Audit Backends" — that document is right and this plan adopts it).
3. What this plan does NOT build
Named explicitly so it cannot be reintroduced as scope creep:
- No second plugin registry, no second lifecycle contract.
- No new HTTP downloader. Artifacts arrive through
mcp_manager(pinned, spawn-verified) or through the layer-extension path. Nomarketplace.corvinlabs.comclient until a real, signed backend exists. - No community Tier C, no revenue share, no ratings UI, no key-rotation authority. Those are the last mile, not the first, and none is reachable before § Phase 5.
- No cgroup quota enforcement written from scratch. Resource limits reuse the forge-bwrap path or they are not claimed.
- No extraction of L16 audit or L18-21 auth out of core. Phase 1 of the Compartmentalization plan is rewritten from "extract" to "additive backend behind a guaranteed core" — extraction would violate the compliance baseline.
4. Phases
Effort is stated in sessions (one focused Claude-Code session, K_MAX = 5 iterations), not engineer-weeks: this is a solo repo. Every phase is independently shippable and ends green.
Phase 0 — Restore ground truth (1 session, no new features)
The repo currently documents a state that does not exist. Nothing else may start until the docs and the code agree.
| Task | Detail |
|---|---|
| Fix or delete the broken router | core/orchestration/plugin_system/managers/api.py:155 — the module cannot be imported. Since the package is being retired, delete api.py + test_api.py rather than repair them. |
| Salvage, then retire | Move models.py (enums, Plugin, PluginManifest, registry YAML round-trip, DependencyResolver, SettingsValidator) to core/plugins/corvin_plugins/manifest.py. Port its tests. Then delete core/orchestration/plugin_system/ including the three 0-byte manager files. |
| Quarantine the dead frontend | PluginsPanel.tsx, MarketplaceInstall.tsx, usePlugins.ts, pages/plugins.tsx are unreachable (no route, no nav) and call a nonexistent /api/plugins. Keep them, but mark them as pre-wiring drafts in a header comment; they are re-adopted in Phase 4 against the real endpoint. |
| Correct the claims | docs/concepts/PLUGIN_SYSTEM_PHASE3_ROADMAP.md claims "Phase 1/1b/2a/2b complete, 56/56 green". Measured: 105 passed / 22 skipped only with test_api.py excluded; with it, collection aborts. Rewrite the status section to the measured state or delete the file in favour of this plan. |
| Move the ADRs | Five ADR-0XXX-*.md files sit in docs/concepts/ and docs/adr/. CLAUDE.md: ADRs live in Corvin-ADR/decisions/ only. Assign real numbers (next free: 0233) — see § 6. |
Gate: uv run pytest core/plugins/tests core/orchestration -q collects without error
and is green. No import of a deleted module remains (grep -rn plugin_system).
DoD: no document in the repo claims a capability the code does not have.
Phase 1 — Additive provider protocols: audit_backend, user_backend (2–3 sessions)
This is Compartmentalization Stage 1, reframed as additive. audit_backend is already
in KNOWN_PLUGIN_TYPES (protocol.py:193); only the protocol and the registry are missing.
Files
core/plugins/corvin_plugins/protocol.py— addAuditBackendandUserBackendProtocols; add"user_backend"toKNOWN_PLUGIN_TYPES; addaudit_registry/user_registryhandles toPluginContext(mirrors the ADR-0033 provider handles).core/plugins/corvin_plugins/providers/audit_backend.py—get_active()/set_active(), default = a no-op passthrough. The default must be a passthrough, not the real chain: core keeps writingaudit.jsonlitself. An installed backend receives a copy of the event and may fan it out (Postgres, S3, SIEM).core/plugins/corvin_plugins/providers/user_backend.py— same shape. Local backend only; LDAP/OIDC are out of scope until someone asks for them (the sprint plan's "scaffolds" are speculative work with a HIGH risk score attached).core/plugins/templates/audit_backend_plugin.py,..._user_backend_plugin.py— matching the existing 7 templates.core/compliance/tripwire.py— boot assertion: the core audit writer is reachable and the chain verifies, independent of any installed backend. Boot fails closed if not.
Hard invariants (review vetoes)
- An
audit_backendplugin can never suppress, rewrite, or reorder a core audit record. Fan-out failure is logged and swallowed; it never fails the caller and never fails core. user_backendnever becomes an auth bypass: an exception, timeout, orNonefrom a backend means deny, never "admit as guest". No auto-admit, no trusted-observer list.- No PII in any provider log line, label, or audit detail — scrubbed signatures only.
Tests: protocol conformance (2 backends × both protocols), fan-out failure isolation,
tripwire fires when core writer is stubbed out, deny-on-error for auth, audit-chain still
verifies with a hostile backend installed. Reuse tests/conftest.py's VOICE_AUDIT_PATH
redirect so no test writes into the real GDPR chain.
Flag: none — the passthrough default is today's behavior; no observable change.
Docs: docs/claude-ref/layer-plugins.md + layer-16-security.md in the same commit.
Phase 2 — Fault isolation + health (2 sessions)
Files
core/plugins/corvin_plugins/circuit_breaker.py— closed → open → half-open, perplugin_id, trip on timeout or N consecutive failures, explicit fallback value. Wraps every provider call site, so a sick plugin degrades instead of cascading.core/plugins/corvin_plugins/registry.py—health_check_all()exists; add breaker state and last-error (type name only) to its output.- Health polling stays pull-based and optional: NerveFiber (ADR-0177) asks; nothing polls on its own. "Monitoring is optional; core works without it."
Flag: plugin_health_monitoring (default false) — when off, no polling, no metrics
endpoint, exactly today's behavior.
Tests: breaker state machine incl. concurrency, degrade-to-fallback per provider type,
health output carries no PII, flag-off = zero polling (assert no timer registered).
Explicitly deferred: Compartmentalization Stages 3 (self-healing) and 4 (LDD-tuned
policies). They are re-proposed only after Stage 2 has been stable for one release — as
the ADR itself says. Do not implement healing in this plan.
Phase 3 — Manifest, settings schema, install/enable lifecycle (2–3 sessions)
The distribution-independent half of the Marketplace idea, on the surviving contract.
Files
core/plugins/corvin_plugins/manifest.py(from Phase 0 salvage) — manifest parse + validate, JSON-Schema settings validation, semver dependency order (topological sort, cycle → error), breaking-change detection betweensettings_schema_versions.core/plugins/corvin_plugins/state.py— per-tenant registry at<tenant_home>/plugins/registry.yamland per-plugin settings under<tenant_home>/plugins/instances/<id>/config.json. Tenant resolution viacurrent_tenant()→validate_tenant_id()→tenant_home()— never an env var, keyword-onlytenant_id(ADR-0007).- Lifecycle:
install/enable/config_change/disable/uninstallon top ofregistry.register/unregister, each emitting a real hash-chained audit event throughbridges/shared/audit.py(not a dataclass). Disable drains in-flight work with a bounded timeout, then force-releases.
Hard invariants
- Registry writes are atomic + mode 0600; a corrupt registry fails closed (no plugins loaded) and is reported, never silently reset.
uninstalldeletes plugin state but never its audit trail (immutable per GDPR; answers open question #4 of the Marketplace ADR: no).- Settings are validated against the schema before persistence, and a rejected write leaves the previous config intact.
Flag: plugin_runtime_lifecycle (default false). Off = plugins load exactly as
today (entry_points / spec.plugins.installed), no runtime mutation.
Tests: both flag states; cross-tenant isolation (tenant A cannot see/enable tenant B's
plugins); audit event lands in the chain and voice-audit verify still exits 0; corrupt
registry fails closed; dependency cycle rejected; breaking-change upgrade requires
explicit confirmation (answers open question #1: user approval, not automatic).
Phase 4 — Console surface (2 sessions)
Files
core/console/corvin_console/routes/plugins.py—GET /plugins,POST /plugins/{id}/{enable,disable},POST /plugins/{id}/config. Tenant from the authenticatedSessionRecord.tenant_id. CSRF + re-auth on mutations, matchingpersonas.py. Registered inapp.pyonly whenplugin_console_surfaceis on.web-next: re-adoptusePlugins.tsagainst the real endpoint (vialib/api.ts, not rawfetch), register the/pluginsroute inApp.tsx, add the nav entry, and buildJsonSchemaForm.tsx(string → text, enum → select, integer+range → slider, boolean → toggle, nested → recursive, validation feedback).npm run buildafter every change tocore/console/web-next— otherwise the served bundle silently keeps the old UI.
Flag: plugin_console_surface (default false).
Tests: route tests incl. cross-tenant denial and CSRF; Playwright E2E for the golden
path (list → enable → change setting → disable) behind the flag; plus a flag-off test
asserting the route 404s and the nav entry is absent.
Phase 5 — Distribution (scope-gated, do not start before Phase 4 ships)
Only now does "install something new from outside" become tractable — and it reuses gates instead of inventing them.
- Tool-shaped extensions → extend
operator/mcp_manager/(already: pinning, per-spawn SHA256/digest verification, L34 locality, L35 egress, vault secrets,spawn_blocked). Deliverable is a catalog surface in the Console, not a new installer. - Layer-shaped extensions → ADR-0142/0156 path (
ext.<vendor>.*, Tier A/B/C, license gate). Reconcile the Tier vocabulary here: one meaning of Tier A/B/C repo-wide, or rename one of them. This is a prerequisite, not a nice-to-have. - Signing, key rotation, community tiers, ratings, monetization: separate ADR, separate decision, needs a real signing authority and a real backend first. Not in this plan.
5. Sequencing and risk
| Phase | Sessions | Risk | Blast radius if wrong |
|---|---|---|---|
| 0 Ground truth | 1 | 🟢 low | deletes unused code; reversible via git |
| 1 Provider protocols | 2–3 | 🟠 medium | audit fan-out + auth deny path — compliance-adjacent, hence tripwire + deny-on-error |
| 2 Fault isolation | 2 | 🟢 low | flag-off = no behavior change |
| 3 Lifecycle | 2–3 | 🟠 medium | per-tenant registry on disk; atomic writes + fail-closed |
| 4 Console | 2 | 🟢 low | flag-gated surface |
| 5 Distribution | TBD | 🔴 high | executes third-party code — do not enter without its own ADR |
Critical path: 0 → 1 → 3 → 4. Phase 2 may run in parallel with 3 (no shared files). Total to a usable Console plugin surface: ~9–11 sessions.
6. Cross-cutting gates (apply to every phase)
Feature flags. Every new surface gets an entry in
core/console/corvin_console/feature_flags.py::REGISTRY with default=False, an owner, a
target_release, and tests for both states. Flags introduced here:
plugin_health_monitoring, plugin_runtime_lifecycle, plugin_console_surface.
Phase 1 gets no flag — the passthrough default is current behavior — and the compliance
mechanisms (tripwire, audit chain, consent, deny-on-error) get no flag, ever.
ADRs. Destination is Corvin-ADR/decisions/ only. Two of the three were already
migrated in c2444f5 — only one new number was needed:
| Number | Title | State |
|---|---|---|
| 0231 | Compartmentalization system (stages 1–4) | existed; corrected by 0233 (Phase 1 is additive; house-rules/data-classification struck from Tier B; Tier naming superseded) |
| 0232 | Compliance hardening — mandatory core vs. extensible | existed; adopted as written and inherited by 0233 |
| 0233 | Plugin system consolidation onto the ADR-0030 contract | written — holds the § 1 decision + the measured findings |
0233 also inherits the six core invariants of ADR-0124 (API-first, audit-first, L34/L35 enforced, secrets are names, schema-validated manifests, hot-reload) instead of restating them, and registers the pre-existing overlap with ADR-0096/0110/0142/0156.
docs/concepts/ADR-0XXX-PLUGIN_SYSTEM.md is not promoted to an ADR; it is retained as
a design study and its status header says so. Stages 3–4 (self-healing, intelligent
healing) are re-proposed later, per ADR-0231's own gate.
Docs in the same commit (no deferral): docs/claude-ref/layer-plugins.md,
docs/plugin-system.md (currently describes only Personas/Forge/SkillForge/Bridge —
needs a fifth section once Phase 4 ships), docs/claude-ref/layer-16-security.md for
Phase 1, plus the diagrams under docs/assets/plugin-*.svg.
Test discipline. bash operator/bridges/run-all-tests.sh before any commit touching
adapter.py / daemon.js / shared/js/. Audit-touching tests must inherit the
conftest.py VOICE_AUDIT_PATH redirect. No git stash in the live worktree for a lint
baseline — use git show HEAD:file | ruff --stdin-filename.
LDD. loop-driven-engineering before the first edit of each phase;
e2e-driven-iteration per bugfix iteration; docs-as-definition-of-done before declaring
any phase done; root-cause-by-layer before any fix whose cause is unknown. K_MAX = 5 per
phase; on the 5th non-converging iteration, stop and re-scope rather than patch.
7. Definition of done (per phase)
- All tests green, both flag states covered for every new flag.
voice-audit verifyexits 0; no PII in any new log line, label, or audit detail.- Docs + diagrams updated in the same commit; no statement in the repo overstates the code.
- ADR written or the skip justified in one sentence.
- No new import of a retired module; no second registry, downloader, or lifecycle.
8. Superseded
These remain in git history as design input; they are no longer execution plans:
docs/implementation/PHASE_1_SPRINT_EXECUTION_PLAN.md— 8 sprints × 2 weeks, 3–4 named engineers, merge monopoly, k8s/Grafana ops. Written for a team that does not exist; its Phase-1 framing ("extract audit out of L16") also conflicts with the compliance baseline.docs/adr/PHASE_1_IMPLEMENTATION_PLAN.md— same objection, week-by-week variant.docs/concepts/PLUGIN_SYSTEM_PHASE3_ROADMAP.md— builds on a retired package and states a test count that no longer holds.docs/concepts/TIER_A_MIGRATION_GUIDE.md— referencescore/orchestration/plugin_system/plugins/and acorvin_pluginSDK that do not exist. Rewrite againstcorvin_pluginswhen Phase 3 lands, or delete.