Status: Design-locked 2026-05-20. Q1 and Q4 resolved for v0.8.0. Q2 (event-hook design) and Q3 (richer PluginState) shipped in v0.9.0 — see v0.9.0 design doc. Implementation can proceed on feature/v0.8.0-improvements.
Date: 2026-05-20
Target release: v0.8.0 (P0 + P0.5 + selected P2/P3), v0.9.0+ (P1 + deferred items)
Source: pluginforge-improvements-brief-2026-05-20.md
Reviewers: PluginForge maintainer (Aster), Bibliogon Strategic-Advisor (chat-side), Bibliogon CC agent (insider)
The Bibliogon brief proposes ten improvement areas grouped P0..P3. Per the brief's own "Deliverables order" and the project .claude/rules/ai-workflow.md "Pre-Coding-Reality-Check" rule, this triage:
- Records the verified baseline state of PluginForge at HEAD
bfacced(v0.7.0). - Decides ACCEPT / EXTEND / DEFER / REJECT per area with rationale anchored in the code.
- Surfaces open architecture questions before any per-area Pre-Inspection.
- Proposes a sequencing for v0.8.0 and v0.9.0.
No code is written in this pass.
- Version: 0.7.0 (pyproject +
__version__). - Test sweep:
poetry run pytest --tb=no -q-> 257 passed, 3 skipped, 96% coverage (pluginforge/manager.py97%,lifecycle.py100%,fastapi_ext.pynot separately reported but exercised). - Public API (
pluginforge/__init__.py__all__):BasePlugin,CircularDependencyError,DiscoveryDiff,DiscoveryResult,InvalidPluginNameError,PluginError,PluginManager,PluginState. - Already shipped surfaces the brief asks for (or partially asks for):
PluginManager.get_last_discovery_result()->DiscoveryResult | None(manager.py:306)PluginManager.health_check()aggregator overplugin.health()(manager.py:861)BasePlugin.health()default-OK with override hook (base.py:88)PluginManager.reload_plugin(name)with deactivate + re-import + re-init + re-activate (manager.py:875)PluginLifecycle.activate_pluginwrapsplugin.activate()in try/except, captures the exception in_last_errors, logs at ERROR, returnsFalse. One broken plugin does not crash startup (lifecycle.py:99-108).DiscoveryResultcarries per-pluginPluginStatewith activation status, errors, and filter reasons (v0.7.0).
These shipped surfaces narrow the scope of three brief items (P1.1, P3 Health-Check, P3 error-recovery) — see per-area triage below.
- CLAUDE.md drift: says "v0.5.0", "162 tests", "93% coverage". Real values are v0.7.0, 257+3, 96%. CLAUDE.md needs a maintenance pass alongside any v0.8.0 work; not blocking triage.
| # | Area | Brief priority | Triage | Target | Notes |
|---|---|---|---|---|---|
| P0 | Recursion-safe plugin-loading | P0 | ACCEPT | 0.8.0 | Q1 resolved: module-level WeakKeyDictionary in fastapi_ext.py. Red-verify canary against unfixed code before applying fix. |
| P0.5 | Single-router-per-plugin convention | P0.5 | ACCEPT (doc + warning only) | 0.8.0 | Cheap. Defer hard-fail to 0.9.0+. |
| P1.1 | Lifecycle state visibility | P1 | EXTEND (narrowed) | 0.9.0 | get_last_discovery_result() and per-plugin PluginState already exist. Open scope: event hooks, richer state fields. |
| P1.2 | Plugin cross-communication (Service-Registry) | P1 | DEFER (RFC) | 0.9.0 | Brief's own insider caveat: lazy-import works for two plugins. Needs an RFC before code. |
| P2.1 | Config schema validation | P2 | DEFER | 0.9.0 | Q3 resolved: stay framework-agnostic, no Pydantic dependency in 0.8.0 or 0.9.0. |
| P2.2 | Test-helper library (pluginforge.testing) |
P2 | ACCEPT (scoped) | 0.8.0 | Ship IsolatedPluginManager + MockPlugin in 0.8.0. Defer the broader fixture catalog to 0.9.0. |
| P3.1 | Unified hot-reload + ZIP path | P3 | DEFER | 0.10.0+ | ZIP-install path is Bibliogon-specific. Document the two paths; do not unify yet. |
| P3.2 | Plugin metrics | P3 | DEFER | 0.10.0+ | Opt-in metrics need a consumer outside Bibliogon to validate shape. |
| P3.3 | Bidirectional compat matrix | P3 | DEFER | 0.10.0+ | Precondition: Bibliogon-side PLUGIN-VERSION-GATING-ENABLE-01 ships first (confirms the existing one-direction gate works in practice). |
| P3.4 | Health-Check API | P3 | PARTIAL ACCEPT (URL surface only) | 0.8.0 stretch | BasePlugin.health() already exists. New work is just the canonical FastAPI endpoint. |
| P3.5 | Error-recovery during activate() |
P3 | REJECT (already shipped) | n/a | lifecycle.py:99-108 already wraps in try/except. Brief author likely working from a pre-v0.6.0 mental model. |
| P3.6 | FilterReason i18n hook | P3 | ACCEPT | 0.8.0 stretch | Cheap. Canonical English keys + translator-registration hook. |
| P3.7 | Documentation gaps | P3 | ACCEPT (staged) | 0.8.0 + 0.9.0 | C7: consumer guide complete + plugin-author guide skeleton. Full plugin-author depth deferred to 0.9.0. |
Evidence in PluginForge code:
pluginforge/fastapi_ext.py mount_plugin_routes (line 32-38) iterates plugin.get_routes() and calls app.include_router(router, prefix=prefix) unconditionally. No idempotency check, no _mounted_plugins tracking. The brief's "Path A" diagnosis (include_router accumulating across TestClient lifespans) matches the implementation.
What the brief asks for that I agree with:
- Idempotent
mount_plugin_routes(Path A) as the root-cause fix. - A PluginForge-side canary test that mounts N plugins x M routes x K
TestClientlifespans and asserts noRecursionErrorunder defaultsys.setrecursionlimit(). - Defense-in-depth
IsolatedPluginManagertest fixture (Path B), shipped alongside inpluginforge.testing(see P2.2).
What requires more investigation before fix-shape lock-in:
- The brief's hypothesis ("FastAPI's recursive route-resolution exceeds Python's default recursion limit") is plausible but not verified from PluginForge's side. The recursion stack in the Bibliogon traceback (
starlette.testclient.TestClient.wait_startup) implicates theTestClientlifespan stack, not the route table lookup. Possible alternate or compounding causes:starlette/anyiotask-group recursion across nested lifespan contexts.- Each
app.include_routercall re-running every plugin's startup hook through the lifespan manager. - A reference cycle on
PluginManagerretaining references to lifespan-scoped objects.
- Before writing code, the canary regression test (deliverable #1) must reproduce the cascade in PluginForge's own test suite. If idempotent mounting closes the cascade, ship Path A. If it does not, the issue is upstream-FastAPI or
TestClientlifecycle and Path A alone is insufficient. - State-tracking question (Open Question Q1 below): the idempotency check needs to live somewhere. On the FastAPI
app(attribute), on thePluginManager(set), or via routes-list inspection per call. Each has trade-offs.
Recommended Pre-Inspection deliverable (when this area opens for code-work):
- Reproduce the cascade in
tests/test_fastapi_ext_cascade.py(new). Parametrized fixture: N plugins, M routes/plugin, KTestClientlifespan cycles. Without the fix, the test must fail withRecursionErrorunder default recursion limit. This is the canary. - Implement Path A idempotency. Confirm canary passes.
- Add
IsolatedPluginManagertopluginforge.testing(overlap with P2.2). Document the fixture as the recommended pattern for consumers running long pytest sweeps. - Confirm 257 baseline tests stay green. No public API breakage.
Decision (2026-05-20): Approach Q1 resolved as modified-(b) — module-level WeakKeyDictionary in pluginforge/fastapi_ext.py, NOT on PluginManager (preserves the framework-agnostic posture of the manager; see Q1 decision below for full rationale). Canary threshold: default 20 plugins x 10 routes x 50 lifespans (10,000 includes) for CI runtime; full-scale 50 x 20 x 100 (100,000 includes) lives in the same test file under @pytest.mark.slow and runs when PLUGINFORGE_CANARY_FULL=1. The default smaller threshold MUST reproduce the cascade against unfixed code; if it does not, STOP and escalate before reducing further.
Evidence in PluginForge code: base.py:72 get_routes(self) -> list permits returning multiple routers. Today no warning is emitted when a plugin returns more than one.
Scope:
- Add a
DeprecationWarninginmount_plugin_routeswhenlen(plugin.get_routes()) > 1, with a clear suggestion to useparent_router.include_router(sub_router). - Document the convention in the plugin-author guide (part of P3.7).
- Do not hard-fail. The contract today permits multiple; deprecating it across one full minor (0.8.0 warn, 0.9.0 still warn, 0.10.0 optionally error) gives downstream consumers time to adopt.
Optional, deferred: pluginforge.routing.compose_routers(parent_prefix, *subs) helper. Stretch; ship if cheap, skip if it complicates the routing API surface.
Decision (2026-05-20): Deprecation cycle confirmed: warn in 0.8.0, continue warning in 0.9.0, optionally error in 0.10.0. One full minor between warn and error minimum. Plugin-author convention also documented in the plugin-author guide skeleton (P3.7).
Evidence in PluginForge code:
PluginManager.get_last_discovery_result() -> DiscoveryResult | Nonealready exists (manager.py:306). The brief's request #1 is already shipped.DiscoveryResult.states: dict[str, PluginState]already carries per-plugin activation status, errors, and filter reasons (v0.7.0 shape).PluginManager.get_load_errors() -> dict[str, str]exists (manager.py:718).
What the brief asks for that is genuinely new:
- Event hooks:
on_plugin_activated(callback),on_plugin_deactivated(callback),on_config_refreshed(callback). Polling-to-push migration for consumer hot-reload UX. Useful, but adds an event-bus to the manager — design decision (Q2). - Richer
PluginStatefields:activated_at,last_config_change,source("entry-point" vs "direct-register"). Cheap additions to the existing dataclass. inspect_plugin(name) -> dict: debug-comprehensive snapshot. Convenience surface; could equally be a third-party helper outside the framework.
Recommendation: v0.9.0 ships (a) richer PluginState fields, (b) inspect_plugin. Event-hook bus is a separate RFC (Q2). All additive, no breaking changes.
Decision (2026-05-20): Q2 resolved as (a) — simple synchronous callbacks. Deferred to v0.9.0. No work in v0.8.0.
Brief's own insider caveat: "the lazy-import pattern Bibliogon shipped in C3 is good enough for two plugins with a one-way dependency. The Service-Registry's value compounds with N plugins."
PluginForge today provides depends_on (declarative) and pluggy hooks (implicit pub-sub). The brief's Service-Registry is a third coordination primitive sitting between these two. Adding a third primitive without an RFC risks API-surface bloat.
Recommendation: treat as 0.9.0 RFC. Defer to a separate design doc. Open the RFC against Bibliogon Mobile-Sync-Plugin (which the brief flags as the upcoming case that would need it) and validate the shape against that workload.
STOP gate: RFC accepted before any implementation. Out of scope for 0.8.0.
Evidence in PluginForge code: BasePlugin.config_schema: dict[str, type] | None already exists, validated in PluginLifecycle._validate_config (lifecycle.py:65-85). Today this is a minimal type-check, not a full Pydantic schema.
Question: the brief proposes a pluginforge.schemas.PluginConfigYAML Pydantic schema for the plugin metadata YAML itself (the plugin.yaml file shape), separate from per-plugin app config. Two different things.
Recommendation: defer to 0.9.0 with two prerequisite decisions (Q3):
- Does PluginForge take a hard dependency on Pydantic? (Today it has none. Adding one is a non-trivial decision for a single-purpose-library.)
- Is the metadata-YAML schema first-party PluginForge concern, or a consumer convention?
Until these resolve, the existing config_schema (type-based) is the supported path.
Decision (2026-05-20): Q3 resolved as (c) — stay framework-agnostic. No Pydantic dependency in v0.8.0 or v0.9.0 unless a second consumer requests it.
Recommendation for 0.8.0: ship a minimal pluginforge.testing namespace with:
IsolatedPluginManagercontext manager. Creates a freshPluginManager+ fresh FastAPI app + automatically deactivates plugins on exit. The recursion-safe sibling to P0.MockPluginbuilder. Useful for unit-testing both PluginForge itself (the canary fixture uses it) and downstream consumers.- A
py.typedmarker on the package (currently missing; should be there regardless for PEP 561 compliance).
Deferred to 0.9.0:
plugin_discovery_smoke()programmatic helper (Bibliogon's script).- Larger fixture catalog (
isolated_plugin_fixture,class_scoped_test_client).
Insider caveat noted: Bibliogon's BIBLIOGON_TEST=1 sentinel discipline is consumer-side. IsolatedPluginManager should be neutral: do not couple to consumer-app markers.
STOP gate: maintainer confirms pluginforge.testing as the public namespace (vs alternatives like pluginforge.test_helpers, pluginforge.testkit).
Evidence: PluginManager.reload_plugin(name) already exists (manager.py:875) for entry-point-installed plugins. The brief acknowledges the ZIP path is Bibliogon-specific.
Recommendation: defer. Document the two installation paths as part of P3.7 (docs); do not unify yet. Wait for a second consumer to hit the same gap before generalizing.
Opt-in only with no overhead by default is correct. But the metric shape depends on what consumers actually need. Wait for a second consumer signal. No work in 0.8.0.
Precondition: Bibliogon-side PLUGIN-VERSION-GATING-ENABLE-01 must ship first. That confirms the existing one-direction min_app_version gate works in production. Adding the other direction (min_plugin_versions from the app side) without proving the first direction is premature.
The brief's own insider nuance flags this: for entry-point plugins, pip already adjudicates. The bidirectional case is mostly for ZIP-installed plugins (Bibliogon-specific path). 0.10.0+.
Evidence:
BasePlugin.health() -> dict[str, Any]already exists (base.py:88) with default{"status": "ok"}.PluginManager.health_check() -> dict[str, dict[str, Any]]already aggregates (manager.py:861).
What's genuinely new: the canonical FastAPI URL surface (/api/plugins/health, /api/plugins/{name}/health) and an optional HealthStatus dataclass to formalize the shape.
Recommendation for 0.8.0 stretch:
- Add
HealthStatusdataclass (status literal, message, version, extras) inpluginforge/state.py. - Add an optional
mount_health_routes(app, prefix="/api/plugins")inpluginforge.fastapi_extthat exposes the canonical endpoints. Consumers opt in by calling it. - Keep
BasePlugin.health()returning a dict for backward compat; the newHealthStatusis an additionally supported return type, not a forced migration.
Decision (2026-05-20): HealthStatus shape accepted with a name: str field added (populated from BasePlugin.name by the manager, parallel to version). Rationale: the aggregated /api/plugins/health endpoint returns per-plugin entries; each entry must be self-identifying without relying on the dict key from the aggregation response. Self-contained dataclass over positional coupling. Final shape recorded in Q4 decision below. mount_health_routes opt-in pattern confirmed, mirrors mount_plugin_routes.
Evidence: PluginLifecycle.activate_plugin (lifecycle.py:99-108) already:
- Wraps
plugin.activate()intry/except Exception. - Captures the exception in
_last_errors(retrievable viaget_last_error(name)). - Logs at ERROR level:
"Failed to activate plugin %s: %s". - Returns
False, allowing the manager to continue with other plugins.
A broken plugin does NOT bring down the rest in v0.7.0. The brief's claim ("currently PluginForge bubbles the exception up and the whole app startup crashes") was true in 0.5.x but was fixed in 0.6.0+.
Recommendation: no work. Note in the v0.8.0 changelog that this behavior has been in place since v0.6.0; consumer documentation should reflect this so future briefs do not repeat the misunderstanding.
Scope:
- Ship canonical English keys for each
FilterReasonvalue inpluginforge.i18n. - Register a translator hook:
pluginforge.i18n.register_filter_reason_translator(fn). The hook receives(reason, lang)and returns a translated string. - Default translator returns the English key.
Cheap. No public API breakage. 0.8.0 stretch.
STOP gate: stretch-only for v0.8.0. If shipped, registration pattern proposal is the module-level function (pluginforge.i18n.register_filter_reason_translator(fn)), consistent with the framework-agnostic posture of pluginforge.i18n. Confirm at implementation time if scope reaches this commit.
0.8.0 (C7 scope, locked):
- Consumer-app integration guide — ships complete. "How to wire PluginForge into your FastAPI app." Covers
PluginManagerconstruction, lifespan integration,mount_routes, config loading, hot-reload viareload_plugin, surfacingDiscoveryResultin a Settings UI, and the reference topluginforge.testingfor test wiring. - Plugin-author guide — ships as a skeleton with explicit TODO markers for sections that need v0.9.0 content. The skeleton has correct headings, one-paragraph summaries per section, and
TODO: expand in v0.9.0markers where content is deferred. Single-router convention (P0.5) gets full content in the skeleton; other sections are stubs.
0.9.0: Plugin-author guide gets full depth on the deferred sections.
Ongoing: the CLAUDE.md drift noted in baseline (v0.5.0 -> v0.7.0, 162 -> 257 tests, 93% -> 96% coverage) gets fixed in C2, not deferred to release.
The four open questions raised during triage are now resolved. Decisions below are fixed inputs to implementation; rationale is recorded so future readers understand the basis.
Decision: state lives as a module-level WeakKeyDictionary in pluginforge/fastapi_ext.py, NOT on PluginManager.
# pluginforge/fastapi_ext.py
from weakref import WeakKeyDictionary
_mounted_plugins: "WeakKeyDictionary[FastAPI, set[str]]" = WeakKeyDictionary()mount_plugin_routes checks and updates this dict per call. No changes to PluginManager's constructor or attributes.
Rationale: PluginManager is framework-agnostic by design. The v0.6.0 decision around pre_activate_rejected naming and the absence of FastAPI imports in manager.py is a load-bearing invariant. Idempotency-tracking is a FastAPI-extension concern; mount_plugin_routes lives in fastapi_ext.py; the tracking state belongs next to it.
Test isolation: the P2.2 IsolatedPluginManager fixture clears _mounted_plugins on setup. That's cleaner than contaminating the manager with FastAPI knowledge.
STOP condition during implementation: if module-level state creates a testing problem IsolatedPluginManager cannot solve, surface the specific issue before switching to a manager-attribute approach. Do not silently move it onto the manager.
Decision: simple synchronous callback registry on PluginManager (manager.on_plugin_activated(cb) etc.). Pluggy hookspecs reserved for the case where a second consumer asks for hook-spec-style observers.
No work in v0.8.0.
Decision: no Pydantic dependency in v0.8.0 or v0.9.0. Metadata-YAML validation stays consumer-side until a second consumer requests a first-party schema. The existing BasePlugin.config_schema type-based validation remains the supported path for per-plugin config validation.
Decision: shape accepted with name: str field added.
@dataclass(frozen=True, slots=True)
class HealthStatus:
status: Literal["ok", "degraded", "error"]
name: str = ""
version: str = ""
message: str | None = None
extras: dict[str, Any] = field(default_factory=dict)name and version are populated by the manager from BasePlugin.name / BasePlugin.version so that each entry in the aggregated /api/plugins/health response is self-identifying without relying on the dict-key.
mount_health_routes(app, prefix="/api/plugins") opt-in pattern confirmed, mirrors mount_plugin_routes. Plugins continue to return raw dicts from BasePlugin.health() for backward compat; the route handler wraps the dict into HealthStatus for the response shape.
Scope clarification: Q4 is a v0.8.0 stretch, not core. Ships if C3-C7 scope completes with runway.
v0.8.0 scope (must-have):
- P0 Recursion-safe
mount_plugin_routes+ canary test. - P0.5 Single-router warning + plugin-author convention doc.
- P2.2 (scoped)
pluginforge.testing.IsolatedPluginManager+MockPlugin+py.typedmarker. - P3.7 (partial) Consumer-app integration guide.
- CLAUDE.md drift fix (version, test counts, coverage).
v0.8.0 stretch (ship if scope allows):
- P3.4 Health-check URL surface +
HealthStatusdataclass. - P3.6 FilterReason i18n hook.
v0.9.0 scope:
- P1.1 Richer
PluginStatefields +inspect_plugin. Event hooks if Q2 resolved. - P1.2 Service-Registry RFC outcome (if RFC accepted).
- P2.1 Config-schema decision per Q3.
- P3.7 Plugin-author guide.
v0.10.0+ deferred:
- P3.1 unified hot-reload + ZIP path
- P3.2 plugin metrics
- P3.3 bidirectional compat matrix (after Bibliogon-side
PLUGIN-VERSION-GATING-ENABLE-01)
- C3 canary at the default 20x10x50 threshold does NOT fail-Red against unfixed code — try 30x15x75 or full 50x20x100. If even full-scale does not reproduce, STOP. The hypothesis is wrong or the test setup does not match the real-world cascade. Surface before continuing.
- C3 idempotent fix passes canary but the methodology is suspect (e.g. requires
sys.setrecursionlimitmanipulation to trigger) — surface. The canary must reproduce the bug under conditions Bibliogon actually encountered. - C3 idempotent mount passes PluginForge canary but Bibliogon's sweep stays red after upgrade — ship Path A anyway (defensible improvement) and reopen P0 root cause as a follow-up.
- C4 module-level
_mounted_pluginscreates a test-isolation problemIsolatedPluginManagercannot solve — surface before moving state onto the manager. Q1 decision is locked. - C5
py.typednot in the built wheel — fix before C6. - Brief item REJECTED here turns out to be wrong — reopen via comment on this doc. P3.5 specifically: if a downstream consumer demonstrates a path where
activate()does crash startup despite lifecycle.py:99-108, reopen.
Evidence-based answers taken from PluginForge's own code:
- P3.5 already shipped: verified by reading
pluginforge/lifecycle.py:99-108. No ambiguity. - P1.1 first request already shipped: verified by reading
pluginforge/manager.py:306-308. - P3.4 method already exists: verified by reading
pluginforge/base.py:88-96+manager.py:861-873.
Maintainer-resolved during this design pass (2026-05-20):
- Q1, Q2, Q3, Q4 above — all resolved in this doc.
- Single-router deprecation cycle confirmed (warn 0.8.0, continue warning 0.9.0, optionally error 0.10.0).
pluginforge.testingas the public namespace (nottest_helpers, nottestkit).- Canary threshold default 20x10x50 with full-scale variant under
@pytest.mark.slow. - P3.5 REJECT stands; reopen only via issue if a downstream consumer demonstrates a counterexample.
- CLAUDE.md drift fix lands as C2 on the branch (not deferred to the release commit).
- Milestone discipline: push through C1-C9 without pause after C3. Bibliogon verifies against the complete v0.8.0 build.
Open assumptions (still conservative, not blocking):
- The brief's claim that
mount_plugin_routesis the root cause of the recursion is plausible from PluginForge's side but unverified until the C3 canary fails-Red against the unfixed code. Treating as a strong hypothesis, not a settled diagnosis. The Red-verification step in C3 is the proof.
- C1 (this commit) — lock architecture decisions, commit triage doc on
feature/v0.8.0-improvements. - C2 — refresh CLAUDE.md to reflect v0.7.0 state (version, test counts, public API).
- C3 — canary regression test for recursion cascade. Red-verify against unfixed code. Apply idempotent
mount_plugin_routesfix. Green-verify. STOP gate if canary methodology is suspect. - C4 —
pluginforge.testingnamespace:IsolatedPluginManager,MockPlugin. - C5 —
py.typedmarker, verify in built wheel. - C6 — single-router-per-plugin deprecation warning.
- C7 — consumer-integration guide (complete) + plugin-author guide (skeleton).
- C8 — CHANGELOG v0.8.0 entry (append to existing root CHANGELOG.md).
- C9 — version bump to 0.8.0.
- Final check chain — ruff + format + mypy + codespell + pytest + pre-commit on all files.
- Push branch, open PR, maintainer review, merge, release per release-workflow.md.
End of triage.