Plugin System
July 27, 2026 · View on GitHub
Date: 2026-07-27 Status: Proposed Author: Claude Code Audience: maintainer (solo) + Claude Code sessions Decisions of record: ADR-0233, ADR-0242, ADR-0243, plus the two this plan calls for: ADR-0250 (tenant-scoped provider registries) and ADR-0251 (extension-point call sites).
Relationship to the existing plans. PLUGIN_SYSTEM_IMPLEMENTATION_PLAN.md
covers ADR-0233 Phases 0–4 and is complete; it is not superseded. This document
covers a different axis: the mechanisms built since then that have no call site.
PHASE_1_SPRINT_EXECUTION_PLAN.md remains superseded.
1. The finding this plan exists for
Nineteen ADRs (0231–0249) describe the plugin system. Verified against the tree on 2026-07-27, the mechanisms they specify are largely built. What most of them lack is a caller.
This is the defect class ADR-0233 named in the prototype it retired, then found twice
in its own implementation (dead tripwire, unbuilt PluginContext). It is now present
a third time, at larger scale. The pattern is stable enough to state as a rule:
A unit test proves a mechanism works. It does not prove the mechanism is reached. Every mechanism in this plan ships with a test that asserts its call site exists.
Verified gaps
| # | Gap | Evidence (2026-07-27) |
|---|---|---|
| G1 | Four extension points never fire. ep.invoke() appears only in core/plugins/tests/. No production path calls engine.model_selection, engine.engine_selection, delegation.route_selection_policy or workflow.workflow_gate. | repo-wide grep for the point names → tests/ only |
| G2 | user_backend has no consumer. The CLAUDE.md invariant "failure/timeout/rejection = deny, never guest" runs through code no auth path reaches. | surface_map.py:132-147, no hit in console/adapter auth |
| G3 | Three handles are never populated. bootstrap_all(**registries) forwards compute_registry / engine_factory / channel_registry correctly; the sole production caller passes none, so every template's if ctx.<handle> is not None: skips silently. | core/gateway/corvin_gateway/app.py:163-167 |
| G4 | stt_provider / data_connector have no consumer. Registry and handle exist; nothing outside corvin_plugins calls get_active(). | surface_map.py:148-177 |
| G5 | Bridge supervisors are never registered. bridges/registry_entries.py exists and is imported by nothing outside the package. | grep -rn registry_entries outside core/plugins/ → empty |
| G6 | Provider slots are process-wide. One active provider per process across all tenants. Documented in ADR-0233's addendum, not fixed. | providers/*.py module-level state |
| G7 | corvin plugin install is absent, although ADR-0248 describes the install flow through it. | plugin_cmd.py — types / check / new only |
| G8 | No trust anchor is deposited, so origin=vetted is unreachable in practice. | trust.py:153-179 reads a file that does not exist |
| G9 | Eight feature flags have no owner and no target release, which CLAUDE.md requires. | core/console/corvin_console/feature_flags.py |
| G10 | boot_layer=compliance/core have zero instances — a mechanism with no users. | bootstrap.py:243 (_GLOBAL_SPECS = []) |
G10 is not a defect. CLAUDE.md already describes it as "a MECHANISM, today with
zero instances above bundled", and the guard tests in test_layered_boot.py fail on
the first real instance. It is listed so nobody closes it by accident.
Explicitly out of scope — deferred by decision, not by omission
Closing these would reverse a maintainer decision, not complete an implementation:
| Item | Deferred by |
|---|---|
| Plugin process isolation / sandboxing | ADR-0249 § "Not decided here" |
| gRPC admin transport | ADR-0239 |
ADR-0236 core extraction out of operator/bridges/shared/ | ADR-0236 status, ADR-0242 § Overview |
| ADR-0231 Stage 4 (LDD-tuned healing policies) | needs production MTTR data |
corvin plugin list | ADR-0244 — dropped, not deferred |
Marketplace downloader / install <name> / search | ADR-0233 D3, ADR-0248 |
Provider modules for compute_engine / worker_engine / bridge_channel | ADR-0245 § Alternatives — owed its own decision |
Templates for stt_provider / data_connector | ADR-0246 — a scaffold for a dead registry manufactures the silent failure |
| Signature revocation | ADR-0249 — known gap, stated as such |
Note the interaction: the two deferred templates in ADR-0246 become unblocked by Stage 4 of this plan, because "deferred until something consumes them" is exactly the condition Stage 4 changes. They are still not in this plan's scope.
2. The sequencing decision
ADR-0242 ends at Phase 7 with the directory move to core/core_plugins/. This plan
inverts that and drops it.
Activate before relocating. Building Phase 7 now would move dead code across the
path that resolves the GDPR audit writer. ADR-0242 already rates that move the
single highest-risk change in the set and its only benefit is layout conformance with
ADR-0243's target diagram. Risk high, benefit cosmetic, and every stage below makes
the eventual move smaller by proving what is actually reachable. It is therefore
removed from this plan; if it happens it is a project with its own migration gate and
its own voice-audit verify before/after evidence.
Ordering constraint that is load-bearing: Stage 1 must precede Stage 2. Reviving
user_backend while the provider slots are process-wide means a plugin installed by
tenant A authenticates for every tenant. That is strictly worse than the dead state
it replaces.
3. Stages
Stage 0 — The call-site gate (0.5 d)
Deliverable: one test module in the mandatory suite, red on arrival.
- For every member of
KNOWN_EXTENSION_POINTS: assert a production call site exists (aninvoke()reference outsidecore/plugins/). - For every
surface_mapentry withconsumed_by is not None: assert that the named file actually references the registry'sget_active(). - For every entry with
consumed_by is None: assertdead_reasonis set, so the map cannot go quietly stale in the other direction.
Gate: the test is red at the start of Stage 1 and green at the end of Stage 5. It is the regulator for every stage below — no stage counts as done while its row is red.
Why first: without it, every stage below can be declared complete on unit tests alone, which is the exact failure this plan exists to correct.
Stage 1 — Tenant-scoped provider registries (1.5 w)
ADR: 0250 (new). Flag: none for the refusal gate — see the ADR.
Two-part answer, deliberately not one:
- Immediate, this stage: a fail-closed refusal. A non-builtin provider plugin may not be enabled on an install with more than one tenant. Refusal is audited. This protects today's installs without touching the provider protocol.
- Target, its own migration: key the eight registries by tenant, threaded through
PluginContextrather than module globals.
Tests: refusal fires on a 2-tenant install; a builtin passthrough provider is unaffected; single-tenant installs behave exactly as today; the refusal writes an audit event to the refusing tenant's chain.
Docs: docs/claude-ref/layer-plugins.md § "Known limit" changes from "do not
install a third-party provider plugin on a multi-tenant install" (an instruction the
software did not enforce) to a description of an enforced refusal.
Stage 2 — user_backend reaches the auth path — BLOCKED, premise was wrong
Status 2026-07-27: not implementable as written, and building it as written would be harmful. The stage assumed CorvinOS has an authentication path that takes credentials. It does not:
| Candidate | Reality |
|---|---|
GET /v1/console/auth/local-login | The only live login. Localhost-only and credential-less — the TCP peer is the authorisation (_is_localhost deliberately ignores X-Forwarded-For). There are no credentials to hand a backend. |
POST /v1/console/auth/login (gateway/console_api.py) | Hard-coded test@example.com / password123, unsalted SHA-256, in-process _SESSIONS dict — and imported by nothing. Dead demo code, not a path. |
| OIDC / OAuth | auth_routes.py:16 and gateway/auth.py:7: "wired in the cloud deployment phase". Not built. |
Wiring it anyway would be worse than the dead state. A user_backend consulted
from local-login would be handed empty credentials; a correctly-written backend
rejects those; the rejection means deny; and deny on the only login path locks the
local operator out of their own install. The dead mechanism admits nobody wrongly —
that version admits nobody at all.
Note also what the invariant presupposes. "failure/timeout/rejection = deny, never guest" needs a path on which a guest could be admitted. On a localhost-only login there is no guest, so on today's surfaces the invariant is not merely unenforced — it has no subject.
What is actually true, and what ADR-0245's dead_reason should say: user_backend
is unconsumed because its consumer does not exist, not because someone forgot to
call it. That is a different finding from the other five dead types and wants a
different fix.
Decided 2026-07-27 (maintainer): option 3. user_backend stays dead and the tree
says so precisely. The correction is not a doc-only change — surface_map.py:137 still
carries the wrong cause ("no caller exists outside corvin_plugins"), which reads as
"someone forgot to call it" and invites exactly the harmful wiring this stage rejects.
Options 1 and 2 remain open as separate projects, neither is in this plan.
Options, none of them this stage — a maintainer decision:
- Build the credential login (OIDC or local password) that
user_backendwas designed for, and wire the backend into it. This is the honest consumer and it is a feature project, not an activation step. - Re-target the provider at
roles.effective_role()(L18-21), which is a live identity-resolution point with a real deny ("none"). This changes whatUserBackendmeans — from "authenticate a credential" to "resolve a principal" — and needs an ADR, becauseauthenticate()/get_user()would no longer describe what the registry does. - Leave it dead and say so precisely, correcting the
dead_reasonfrom "nothing callsget_active()" to "no credential auth path exists to call it".
Option 3 is free and should happen regardless of which of 1 or 2 is chosen.
Stage 3 — Extension-point call sites (1.5 w)
ADR: 0251 (new). Closes: G1. Flag: plugin_extension_points.
| Point | Call site | Constraint the call site enforces |
|---|---|---|
engine.engine_selection | shared delegation_policy | A hook may not widen the operator's spec.web_chat.worker_engine choice |
engine.model_selection | per-step model selector (ADR-0181) | None = no opinion = no hook |
delegation.route_selection_policy | delegation classifier | Every degrade ladder still ends at native |
workflow.workflow_gate | before the first node executes | fail_closed: a raising hook denies; see ADR-0251 for what a non-bool return means |
Tests: each point fires from the real path; a hook returning an engine the operator
did not select is refused by the call site; a raising hook on workflow_gate
denies the run; a raising hook on model_selection costs the custom model, not the
turn; flag-off = no hook fires anywhere.
Progress 2026-07-27 — bus complete, 1 of 4 call sites
Bus (prerequisite for all four). D3 and D5 were specified but not built:
invoke() returned whatever the hook returned and measured nothing.
- D3 —
ExtensionPointSpecnow declaresreturn_type.Noneabstains everywhere (including the gate); a wrong TYPE is a defect handled exactly like a raising hook — default on an ordinary point,ExtensionPointDeniedon the gate. Only the type NAME is audited, never the value: a misbehaving hook's return could be a credential, and the chain is append-only. - D5 —
latency_budget_msper point, measured around the hook, audited on overrun with the plugin id and elapsed time, deduplicated per (tenant, point, plugin). Not enforced, and the test says so in its name: asserting a slow hook is cut off would assert a guarantee the code does not provide.
One behaviour change to an existing tested contract. Until now the bus passed
a hook's None through verbatim, on the reasoning that a hook must be able to
express a deliberate None. D3 decided the opposite — and the point specs in
extension_points.py had always agreed with the ADR rather than with the test:
engine.model_selection's default_behavior says a None return is "treated
exactly like no hook at all", and the old test cited that spec while asserting
its opposite. None is not a meaningful answer on any of the four points, so
pass-through bought nothing and cost every call site its own is None branch.
Call site 1 of 4 — engine.engine_selection.
delegation_policy.resolve_worker_engine, wired into chat_runtime. The pure
worker_engine_target stays pure — it is the shared routing matrix every surface
unit-tests against, and a hook inside it would make those tests depend on
process-wide bus state. D2's refusal lives at the call site: the bus knows a hook
returned a str, only delegation_policy knows which strings are engines.
permitted_engines admits exactly {bundled, "native"} — confirm or
de-escalate, never escalate. Deliberately narrower than "the operator's
mode": allowing mode would let a hook re-assert tde on a turn the rule
routed to native because TDE was unavailable, overriding an availability
degrade the hook cannot observe.
Two guard tests fired on the good news, as designed.
test_unwired_point_is_still_unwired[engine.engine_selection] went red and forced
the record to move to a new _WIRED_POINTS set, which now carries the reverse
assertion — a wired point that loses its caller fails too, since that
regression looks identical to a point that was never wired.
test_extension_points.py::test_no_call_site_is_wired_yet also went red; it had
named its own successor in a comment ("when the call sites land, THIS test goes
away"), so it was removed and a tombstone comment left in its place. Six
documents claiming "no call site calls invoke()" were corrected in the same
commit.
Verified by mutation: deleting the D2 refusal turns four call-site tests red.
Call sites 2–4, same day — Stage 3 complete
| Point | Call site | The bound, enforced at the call site |
|---|---|---|
delegation.route_selection_policy | delegation_policy.resolve_delegation_route | may suppress delegation, never cause it |
engine.model_selection | model_selector.resolve_step_model | may name any model in the engine's registry, nothing else |
workflow.workflow_gate | routes/workflows.py::_stream_run | may deny a run, never permit one the core refused |
Each bound is different because each subsystem's vocabulary is different, and
that is the argument for keeping the check at the call site rather than in the
bus. The bus knows a hook returned a str; only delegation_policy knows which
strings are routes, only model_selector knows which are installed models.
route_selection_policy needed a vocabulary bridge. The bundled classifier
answers a boolean — "is this ACS-fan-out shaped?" — while the point's declared
type is a route string. resolve_delegation_route maps True → "acs",
False → "native", and admits only the bundled route or native. So a hook can
stop a delegation and can never start one. That direction is not symmetry for its
own sake: a hook answering "acs" on a declined turn would be a plugin spending
the operator's quota through a decision the operator's own classifier refused.
model_selection has no native-shaped floor. There is no ordering among
models, so "may not escalate" has no meaning; the operator's setting is the
engine registry, and membership is the whole bound. It is checked with
engine_models.model_is_registered — lifted out of
resolve_model_for_workload's inner scope so the call site and the bundled tier
ladder apply one rule rather than two that agree until one is edited.
The gate sits before the dry_run branch. A gate answers "may this run at
all"; letting a denied workflow still be enumerated would make the answer depend
on a query parameter. It is handed a structural summary — wid, node ids, node
count, engine — and never inputs or the YAML: those carry the task text, and
passing them would make a gate hook a content tap, which is a different
capability from gating.
Two defects found while writing the tests, both in code that was already green:
resolve_step_modellet an exception from the admissibility check propagate. The test asserting it was named..._refuses_rather_than_admitswhile pinningassertRaises, which made the mismatch obvious. Propagation is wrong twice: a broken check decides nothing, and the console call site turns an escaping exception intomodel = None, silently downgrading the turn to the CLI default. Now fail-closed locally rather than relying on every caller to wrap it.- The console's
engine.model_selectioncall site referenced_os_engine, which was assigned ~130 lines later. The surroundingexcept Exceptionwould have turned theNameErrorinto_os_model = Noneon every turn — a silent degradation with no log line._os_engine's resolution moved above the model block, which also removes a secondshutil.whichprobe per turn.
Verified by mutation: ignoring the gate's deny, making the gate fail-open, dropping the registry bound, and dropping the suppress-only rule each turn their suite red.
Stage 3 gate: _UNWIRED_POINTS is now empty and _WIRED_POINTS holds all
four. The useful assertion from here is the reverse one — a point that loses
its call site fails the suite.
Stage 4 — Populate the three handles — one of three, not three
Closes: G3 in part. Flag: none — passing a registry that was already being forwarded changes nothing until a plugin of that type is installed.
The stage was sized as "pass three objects into bootstrap_all()". Verified
2026-07-27: only one of the three targets is an object that can be passed. ADR-0245
says all three types "have handles", and that is true of the PluginContext field;
it is not true of the thing the field would point at.
| Handle | Target | State |
|---|---|---|
compute_registry | corvin_compute.engine_registry.get_registry() | Exists, has register(engine), and the shipped template calls exactly that. Passing it is the one-line change the stage assumed. |
engine_factory | operator/bridges/shared/engine_registry.py | Engines come from a hard-coded _ENGINE_BUILDERS dict with three entries and there is no register(). A plugin cannot enter itself. Wiring this means designing a registration API for L22 first. |
channel_registry | — | Does not exist. The only references in the tree are in templates/bridge_channel_plugin.py, which calls ctx.channel_registry.register(self) against a class nobody wrote. |
So bridge_channel is not a populated-handle problem at all: its template targets an
API that was never built. worker_engine needs a new registration surface on a
subsystem that predates ADR-0033. Both are the design question ADR-0245 deferred
under "Add the three missing provider modules now — deferred, not rejected. Each is
a real design question." They are not activation work and should not be done under
cover of one.
Attempted 2026-07-27 — and the last handle turned out to be dead too
The stage's own instruction is what killed it. Step 1 below said to verify
the registered engine is reachable before claiming consumed_by. That check
fails:
corvin_compute.engine_registryhas no reader.WorkerServerdispatches only through its ownself._extra_engines, populated at construction or byregister_engine()— which has no production caller. This is not a new discovery;corvin_compute/cli.py:237says so in its own words and predates the plan: "the module-level factories are not an alternative wiring — they populate engine_registry.py's standalone registry, which WorkerServer never reads".- It is in the wrong process.
WorkerServeris constructed only in that CLI, i.e. thecorvin-compute workersubprocess. A plugin loads in the gateway; the console reaches the worker over a socket viaWorkerClient.
So passing the handle would let a plugin register into an object nothing reads, in a process that runs no engines — and it would look like progress, because the plugin would load, register and report healthy.
Resolved the same day — by moving the load, not by passing the handle
The diagnosis above says where the consumer is: the compute worker. So the
plugins are loaded there. corvin_compute/cli.py::_load_compute_engine_plugins
calls bootstrap_declared(only_types={"compute_engine"}, compute_registry=get_registry()) inside _cmd_serve, then hands what actually
landed in the registry to WorkerServer(extra_engines=...). engine_registry
now has a real reader, in the process that dispatches.
only_types is a safety property, not tidiness. Without it the worker would
load the tenant's bridge supervisors as well and start messenger daemons from
the compute process — a second set racing the real ones, which is the
duplicate-start failure ADR-0238 names as its load-bearing invariant. The filter
reads plugin_type off the class, never off the declaration: a declaration
is operator-written YAML and could otherwise let a bridge supervisor into the
worker by claiming one word. It also runs after instantiation and before
on_load, so a filtered-out plugin never executes any of its own code.
The helper reports what it added by diffing registry.discover() before and
after, rather than trusting the plugin's word that it registered — the
registration-is-not-invocation distinction this plan turns on, applied to its own
fix.
Two test-harness defects found on the way, both silent:
- The fake plugin's
class_pathwas written out ascore.plugins.tests.test_…, which imports the test module a second time.on_loadthen set its flag on a different class object than the assertions read, so every observation wasNonewhile the load itself succeeded. The class path now comes from__name__. corvin_compute.engine_registryis a module-level singleton, so an engine registered by one test survived into the next; the before/after diff then came back empty and read as "the call site is broken". Cleaned per test.
Neither was a product defect, and both would have been read as one.
All three "handle" rows now share one cause in three variants: the target is
not wired to anything that dispatches — no reader and wrong process
(compute_engine), no register() (worker_engine), no class at all
(bridge_channel). Closing any of them is ADR-0245's deferred design question.
This row has now been described incorrectly three times. The original
"handle never populated"; Track A's correction calling it "the genuine
unpassed-handle case… one line at the call site"; and both survived review
because the claim was plausible and nobody ran it. Only attempting the fix
falsified it. Two guard tests in test_surface_map.py now fail the day the
registry gains a reader or register_engine() gains a caller.
Superseded — the stage as originally written:
- Pass
compute_registryinapp.py, and verify the registered engine is reachable through the MCP bridge before claimingconsumed_by— a filled handle proves registration, not invocation, which is the distinction this whole plan exists for. - Correct the two templates so they state that their target does not exist, instead
of guarding with
if ctx.<handle> is not None:and skipping in silence. That guard is what turns a missing API into no signal at all. - Leave
worker_engineandbridge_channelinunconsumed_types()with adead_reasonnaming the real cause.
stt_provider and data_connector (G4) remain a third case: handle populated,
registry live, nothing calls get_active().
Stage 5 — Register the bridge supervisors (1 w)
Closes: G5. Flag: bridge_supervisor_plugins.
bridges/registry_entries.pyis reached from the boot path.- ADR-0238's two fail-closed defaults stay: no automatic restart, and
confident=Falsemeans the supervisor does not start. - The Node daemons are untouched.
Tests: start/stop/health per supervisor; a dead daemon surfaces as unhealthy
without taking down the core; flag-off = bridge_manager.py manages bridges exactly as
today; the ADR-0242 guard test that fails if the supervisor ever reads
headless_api_mode stays green.
Before commit: bash operator/bridges/run-all-tests.sh (CLAUDE.md). Budget it —
the previous run exceeded 15 minutes and buffers output until exit.
Shipped 2026-07-27
bootstrap_declared injects the bundled seven from
registry_entries.declaration_entries() — the generator that had existed since
the supervisors were written and was imported by nothing outside its own
package. G5 closed.
Why the boot injects them rather than the operator hand-writing seven dotted
class paths. boot_layer=bundled means "ships with CorvinOS, opt-out per
tenant" (ADR-0243). Requiring a class path in tenant.corvin.yaml for something
that ships in the wheel is the installed contract, and it is a string that goes
stale on the first rename. Three properties are preserved deliberately:
- The operator's entry always wins. A channel already named in
spec.plugins.installedis skipped, so{id: discord-bridge, config: {enabled: false}}parks that bridge and an explicit class path overrides the bundled one. - Off is quiet and total. With the flag off nothing is injected and nothing is instantiated. The supervisor re-checks the same flag in its own start gate; this second check exists so a default install does not construct seven objects to have each decide to do nothing.
- Declaring is not starting. ADR-0238's six-condition gate — credentials present, no duplicate running, runtime provisioned, Node available, no automatic restart — is untouched. There is a test named for this, because "we wired the bridges up" is exactly the sentence that later gets read as "and they start themselves".
A refutation round found the tests insufficient. Deleting the config
injection — so the entry list grew but the dict handed to the loader did not —
left every test green: the declarations were computed and thrown away. That is
this plan's own defect one level in. Three tests now drive bootstrap_declared
itself and assert seven supervisor instances actually load. Four mutations turn
the suite red: no flag gate, no operator precedence, no config injection, and
mutating the caller's config dict instead of copying it.
Stage 6 — install and the trust anchor (1 w)
Closes: G7, G8.
corvin plugin install <path>: local path only, never a URL, never a name resolution (ADR-0248). It writes theclass_pathintotenant.corvin.yamland prints theauto_discover_entry_pointsstep in the same words the generated README uses (ADR-0246).- Under
plugin_trust_enforcement, anorigin=communityinstall requires the explicit per-plugin confirmation from ADR-0249 and writes the audit event (id, version, digest, origin, deciding operator). - Generate a maintainer Ed25519 key, deposit the public half in
<corvin_home>/global/plugin_trust_anchors.txt, and document custody. Without this,origin=vettedis a value nothing can ever legitimately carry.
Explicitly not built: revocation. ADR-0249 names it as an open gap; inventing a revocation channel inside an install command is how it would get the wrong shape.
Tests: a URL argument is refused; a community plugin without confirmation does
not load; a vetted claim with an unpinned key is refused, not downgraded; the
confirmation audit event carries no PII beyond the operator identifier the chain
already records.
Stage 7 — Flag lifecycle — DONE, verified 2026-07-27
Closes: G9. CLAUDE.md: "every flag gets an owner and a target release in which it either flips to default-on or the feature is removed. Flags are not permanent architecture."
Shipped ahead of its place in the sequence: all 15 entries in
core/console/corvin_console/feature_flags.py carry owner and target_release
(both mandatory fields on FeatureFlag), and
core/console/tests/test_feature_flags.py::test_every_flag_has_owner_and_target_release
is the guard that a new flag cannot be registered without them. The count moved from
the eight this plan was written against to fifteen; the dispositions below are the
proposals of record for the plugin-related subset, not a description of what shipped.
Original proposed dispositions:
| Flag | Proposed |
|---|---|
plugin_health_monitoring | flip default-on at v0.12 |
plugin_runtime_lifecycle | flip default-on at v0.12 |
plugin_console_surface | flip default-on at v0.12 |
plugin_extension_points | stays off until Stage 3 has one release of production use |
admin_control_plane | flip default-on at v0.12 |
bridge_supervisor_plugins | stays off — ADR-0238's non-restart posture wants soak time |
headless_api_mode | permanent operator choice, not a rollout flag → document as such |
plugin_trust_enforcement | flip default-on at v0.12 — enforcement off is the weaker default |
headless_api_mode is the one honest exception: it is a deployment shape, not a
staged rollout. Recording that is better than pretending it has a flip date.
4. Summary — re-verified against the tree 2026-07-27
| Stage | Est. | Closes | Flag | State |
|---|---|---|---|---|
| 0 Call-site gate | 0.5 d | — | none | ✅ done (test_extension_point_call_sites.py, 6 pass / 4 skip — the skips are Stage 3) |
| 1 Tenant-scoped providers | 1.5 w | G6 | none (refusal is a compliance gate) | ✅ part 1 only — the refusal gate (tenant_scope.py, 30 tests). Keying the eight registries by tenant is ADR-0250's own migration, not done |
2 user_backend call site | 0.5 d | G2 (as recorded, not closed) | — | ✅ done — option 3 shipped; cause corrected in surface_map.py, CLAUDE.md, 4 docs, ADR-0245 addendum |
| 3 Extension-point call sites | 1.5 w | G1 | plugin_extension_points | ✅ done — bus D3+D5 plus all four call sites |
4 compute_engine reaches its consumer | 1 w | part of G3 | none | ✅ done — not by passing the handle; by loading in the compute WORKER |
| 5 Bridge supervisors | 1 w | G5 | bridge_supervisor_plugins | ✅ done — the boot path injects the bundled seven |
6 install + trust anchor | 1 w | G7, G8 | plugin_trust_enforcement | ❌ CLI has types/check/new only; no anchor deposited |
| 7 Flag lifecycle | 0.5 d | G9 | — | ✅ done — 15 flags, all with owner+target_release, guard test present |
| A Truth-in-tree corrections | 0.5 d | — | — | ✅ done — see § 4.1 |
| E E2E spine | 1 w | — | — | ◑ E1 done (test_lifecycle_e2e.py); E2 rows follow their stages, E3 last |
| Total remaining | ~1 w (Stage 6 only, blocked on key custody) |
Dropped from ADR-0242: Phase 7 directory move (§ 2 above). Deferred, unchanged: everything in § 1's out-of-scope table.
4.0 Track F — the boot sequence reached one host of two (found 2026-07-27)
This plan's own thesis, one level further out than it had looked. Stages 3–5 wired call sites inside the platform. Track F is about which process runs the loader at all, and it was found the only way it could be — by installing the wheel on a clean system and booting it, rather than by reading the tree.
| Symptom | A console with a deliberately corrupted audit hash chain booted and served requests. |
| Mechanism | Sound. Invoked by hand in that same wheel-installed process, assert_all() returned audit_chain_intact: 2 broken record(s) in the last 200 — the audit writer is not sound and raised. |
| Cause | The sequence was inlined in corvin_gateway.app. corvin_console.standalone:create_app — what corvinos-serve runs and what install.sh launches at its final step — had copied the license-load block above it and stopped before the compliance block. |
| Blast radius | The primary pip/uv install path ran no boot tripwire and loaded no plugins. Every provider, every bridge supervisor from Stage 5, and the whole extension-point registration path were inert on that host. |
Why every existing guard stayed green. test_layered_boot.py and
test_post_boot_tripwire.py asserted the call site by reading
core/gateway/corvin_gateway/app.py. That is one of two shipped hosts, and the
tests could not tell "the sequence is reached" from "the sequence is reached
here". This is the same shape as Stage 2's wrong dead_reason and Stage 4's
three-times-wrong "unpassed handle" row: a claim that was well-formed, cited a
real file, and was about the wrong thing.
Fix. One sequence, bootstrap.boot_platform(), called by both hosts;
tripwire → bootstrap_all() → post-boot tripwire, no flag and no override. The
two source-reading guards now follow the sequence to its new home, and
test_boot_platform_call_site.py carries the assertion they could not: every
host in a named table must import and call it. A third host means a third row.
Verified by re-running the E2E on a rebuilt wheel: corrupted chain →
TripwireError, exit 3, port never opens; pristine home → boots normally.
Note for the maintainer — this is a behaviour change on upgrade. An existing
install whose chain has a break within the last TAIL_RECORDS (200) will now
refuse to start the console, where before it started. That is the documented
fail-closed contract and CLAUDE.md forbids an override, so there is no flag to
soften it; but it is a real upgrade hazard and there is no operator-facing way to
seal a historically broken chain today. A sealing/rotation path is the honest
follow-up and is not in this plan.
4.1 Track A — the tree must state what the audit found (0.5 d)
Three findings from 27 July live only in this document. surface_map.py is what
corvin plugin types prints and what a plugin author reads, and it still states causes
the audit disproved. A wrong dead_reason is worse than none: it names a fix that would
be harmful (Stage 2) or that does not exist (Stage 4).
| Correction | Today | Must say |
|---|---|---|
user_backend (surface_map.py:137) | "no caller exists outside corvin_plugins" | "no credential auth path exists to call it — the only live login is localhost-only and credential-less" |
worker_engine (:196) | "Same as compute_engine: engine_factory is never passed" | "engine_registry.py has no register(); a plugin cannot enter itself. Needs an L22 registration API first (ADR-0245 deferral)" |
bridge_channel (:210) | "Same as compute_engine: channel_registry is never passed" | "channel_registry does not exist; the template targets a class nobody wrote" |
templates/bridge_channel_plugin.py, worker_engine_plugin.py | if ctx.<handle> is not None: → silent skip | state that the target does not exist; a scaffold must not manufacture silence |
Test: extend test_surface_map.py so a dead_reason that merely defers to another
type ("Same as …") fails — that phrasing is how the three wrong causes propagated.
Why first: it is half a day, it is the only track with no dependency, and every other track is measured against a map that is currently wrong.
Shipped 2026-07-27
Two guards added to test_surface_map.py, red on arrival on exactly the two rows
that deferred:
test_dead_reason_stands_on_its_own— refuses"same as","see above","ditto", … A deferring cause is the one shape that goes stale invisibly: fix the row it points at and this row still reads as explained.test_dead_reason_names_something_in_the_tree— the cause must cite a file, a call or a config key. Deliberately weak; an unfalsifiable cause is how the wrong ones survived review.
Known limit, stated rather than hidden: the guards caught worker_engine and
bridge_channel. They do not catch user_backend, whose old cause was
self-contained, cited a real file, and was simply wrong. No cheap test
distinguishes "wrong but well-formed" from "right" — that one needed the audit.
Track E is the structural answer, not a third string check.
Also corrected: both templates' else branches now say the target does not
exist instead of logging a None handle, and the ADR-0245 addendum records the
four real causes. Verified through corvin plugin types, which is the surface an
author actually reads. 982 tests green.
4.2 Track E — the E2E spine (1 w, runs alongside Stages 3–6)
Decided 2026-07-27 (maintainer): real lifecycle E2E plus un-mocked Playwright runs.
The only plugin E2E today is web-next/tests/e2e/plugins.spec.ts, and it intercepts its
own routes. It proves the panel renders — it cannot prove a plugin ever ran. That is the
same "unit tests prove a mechanism, not its reach" defect this whole plan exists for,
one level up.
E1 — lifecycle E2E (pytest). One scenario, end to end, no mocks:
corvin plugin new → corvin plugin install <path> → gateway boots → the plugin's hook
fires on a real turn → the audit event lands in the chain and voice-audit verify
still passes. Runs against a tmp CORVIN_HOME (per tests/conftest.py's
VOICE_AUDIT_PATH isolation — a plugin E2E writing into the live GDPR chain is not
acceptable).
E2 — per-stage E2E rows. Each activated stage adds one scenario to E1's harness:
Stage 3 a hook that changes a model selection and one that raises; Stage 4 a compute
engine reachable through the MCP bridge (registration is not invocation); Stage 5 a
supervisor whose daemon is dead; Stage 6 a community plugin without confirmation.
E3 — un-mock the Playwright specs. Run plugins.spec.ts against a real gateway
using the isolated-console harness rather than route interception. Budget it: the
isolated E2E console has a history of hanging in boot, and this is the expensive half
of Track E. If E3 cannot be made reliable it is dropped and said so here — it is not
allowed to become a green suite that boots nothing.
Gate: E1 is a merge condition for Stages 3–6 in the same way Stage 0's gate is. A stage without its E2E row is not done.
E1 shipped 2026-07-27 — and what it could not yet be
core/plugins/tests/test_lifecycle_e2e.py. Six steps, no doubles at the seams:
the real corvin CLI scaffolds an audit_backend; the author's one TODO is
implemented; it is declared in a real tenant.corvin.yaml; bootstrap_all()
boots it as the gateway does; a real audited action goes through
operator/bridges/shared/audit.py; the core chain holds the record, the plugin
holds a copy, and verify_audit() still passes. A second scenario injects a
raising fanout() and proves the core record survives it — ADR-0233's
additive-only invariant measured on the real writer instead of asserted about a
double.
The plan's E1 was not buildable as written, and the reason is instructive.
It specified corvin plugin install and "the hook fires on a real turn". Neither
exists: install is Stage 6 and the extension points are Stage 3. E1 was
sequenced before the stages it depends on. So it runs the same spine through
the install path that does exist (the declarative spec.plugins.installed entry
the generated README already tells authors to write) against audit_backend —
one of the five surfaces with a live consumer, and the compliance-critical one.
_STAGE_ROWS_OWED in the module records the four rows still owed, and a test
asserts it, so closing a stage without extending the harness turns the suite red.
Two defects the refutation round found in the harness itself, both worth recording because both were green first:
- The raising-backend scenario was vacuous. Removing the injected raise
entirely left it passing — "a well-behaved plugin does not suppress the core
record" is trivially true. It now asserts
provider.failure_count() > 0, which is only non-zero if the backend was actually called and actually raised. - The wait predicate was weaker than the assertion: it waited for the sink file
to exist, then asserted on its contents. Boot writes
plugin.loadedthrough the same fan-out, so the file appeared instantly and the loop exited before the event under test drained. It passed on timing luck and failed the moment another test ran first.
Verified by mutation, not by reading: four mutations (TODO unimplemented, raise
removed, class_path broken, audit_event not called) each turn the suite red.
A green E2E for a chain nobody had connected deserves that check.
The finding that outranks the harness: nothing ran the plugin suite
Checking where to register E1 turned up the fourth occurrence of this plan's
defect class, at the level of the gates themselves. core/plugins/tests/ was
run by no automation at all — absent from operator/bridges/run-all-tests.sh
(the CLAUDE.md pre-commit gate) and absent from every .github/workflows/ file.
coverage.yml lists tests/, operator/bridges/shared/, core/console/tests/,
core/compute/tests/ and operator/mcp_manager/tests/, and stops there.
That is 950+ tests that ran only when somebody typed the path by hand. Among them:
- Stage 0's call-site gate — this plan calls it "the regulator for every stage below" and "a merge condition, not a nice-to-have";
test_layered_boot.py's guards, which CLAUDE.md says "fail on the first real instance and force the docs to be updated in the same commit";- the ADR-0233 tripwire and additive-only tests.
Every one of those is a mechanism whose value is that it fires unbidden. None could. The plan was about to add a fifth gate to a set that nothing executed.
Fixed: core/plugins/tests/ added to coverage.yml. Registering the gate is
part of building it — a merge condition that no merge runs is a comment.
4.3 Execution order
A (0.5d, no deps) ─┐
2 (0.5d, no deps) ─┼─→ E1 harness (needs A's map to be true)
E1 (3d) ─┘ │
├─→ 3 (+E2 row) ─→ 4 (+E2 row)
├─→ 5 (+E2 row)
└─→ 6 (+E2 row) ─→ E3 (last, droppable)
Stage 4 follows Stage 3 rather than running parallel: its remaining substance is "prove the registered compute engine is reached", which is the same assertion shape Stage 3 builds first.
Not closed by this plan, by decision: ADR-0250's registry-keying migration, Stage 2 options 1/2, and everything in § 1's out-of-scope table. Completion means every mechanism has a reachable call site or an honest recorded cause — not that every gap is gone.
5. Risks
| Risk | Mitigation |
|---|---|
| A stage ships green unit tests and stays unreached | Stage 0's gate is a merge condition, not a nice-to-have |
| Stage 2 admits a guest through a path the test did not model | The invariant is enforced at the call site and in the provider; both are tested independently |
| Stage 1's refusal breaks a working single-tenant install | The refusal keys on tenant count, and single-tenant is the default install; both states tested |
Stage 3's workflow_gate fails open under an unexpected return type | ADR-0251 fixes the non-bool return semantics before the call site is written |
| Stage 5 destabilizes messaging | Supervisors wrap the existing bridge_manager.py; run-all-tests.sh before commit |
| Trust anchor key is lost | Custody documented in Stage 6; loss means no new vetted plugin, not a broken install |
| Fixes introduce their own defects | Each stage gets a refutation round against its own commits — ADR-0233 found two fix-induced defects that way |
6. Docs and diagrams
Per CLAUDE.md, docs and diagrams update in the same commit as the code. Targets:
| Stage | Docs | Diagrams |
|---|---|---|
| A | surface_map.py docstrings (they are the doc — corvin plugin types prints them), docs/PLUGIN_EXTENSION_POINTS.md | — |
| E | docs/claude-ref/testing-and-docs.md § how to run the plugin E2E | — |
| 1 | docs/claude-ref/layer-plugins.md § Known limit; CLAUDE.md plugin block | plugin boot/scoping SVG |
| 2 | docs/plugin-system.md, docs/claude-ref/layer-16-security.md; CLAUDE.md's user_backend invariant needs the qualifier that it has no subject on today's surfaces | auth flow SVG |
| 3 | docs/plugin-architecture.md, delegation-routing.md | delegation flow SVG |
| 4 | docs/extending.md, generated corvin plugin types output | — |
| 5 | docs/claude-ref/layer-plugins.md, bridge docs | bridge supervision SVG |
| 6 | docs/extending.md, Corvin-Marketplace/plugins/README.md | — |
| 7 | CLAUDE.md § Feature Flags | — |
7. What only the maintainer can do
Two items block completion and cannot be finished by a coding session:
- Trust-anchor key custody (Stage 6). Generating the maintainer Ed25519 key and
deciding where the private half lives is an operator act. Until it exists,
origin=vettedis a value nothing can legitimately carry, and Stage 6 ships withvettedclaims refused, not downgraded — which is the correct fail-closed behaviour, but it means the origin axis stays two-valued in practice. - ADR status transitions. ADR-0250 and ADR-0251 are both
Proposed. Stage 3 is the implementation of 0251; it should not merge while the decision it implements is still a proposal.