Plan W: Plugin Manifest + Render Runtime (child plan of Plan S umbrella)
On this page
- Status
- Context
- Key decisions
PluginSourceis the stable async seam;linkmeis a swappable v1 backend- Render contract: host-fetches + plugin-pure-render, serializable I/O
- No
BundleContributionfield — the plugin axis is sourced viaPluginSource - Manifest schema: Contract 1 author-facing names, canopy structure
linkmeslice replaces canopy’s hardcodedmatchdispatch- Boot fail-fast validation; role-source; loading = shell
- Host-fetch executor: SSRF-guarded, cache-isolated, identity-aware
- Step DAG
- Risk register
- Cross-cutting invariants
- Open questions (deferrals)
- Errata
- Related decisions
Status
| Step | Description | Status |
|---|---|---|
W1 |
NEW |
Done (2026-06-16) — NEW |
W2 |
NEW |
Done (2026-06-16) — NEW |
W3 |
Wire |
Done (2026-06-16) — NEW |
W4 |
|
Done (2026-06-16) — NEW |
W5 |
BFF render runtime: a host-fetch executor + plugin-pure-render + a per-plugin render route. NEW host-fetch executor in |
Done (2026-06-16) — BFF host-fetch render runtime in |
W6 |
Reference plugin: NEW |
Done (2026-06-16) — NEW |
W7 |
Feature-matrix + docs + open-question resolution. Extend |
Done (2026-06-16) — feature-matrix plugin axis ( |
W8 |
Plan-completion audit + archive. A fresh Explore plan-completion-audit subagent verifies every W1–W7 cell carries a concrete |
Done (2026-06-16) — this archive MR. A fresh Explore plan-completion-audit subagent confirmed every W1–W7 cell carries a concrete |
Epic: &48 (Plan W)
Scoped label: Plan::W (filed with this body MR; one Plan::* label per issue — scoped-label collisions 404)
Branch prefix: <type>/plan-w-step<N>- for child code-execution MRs
*Parent: Plan S umbrella Steps 17 (this body) + 18 (execution)
Anchor ADR: ADR-033 (the plugin manifest + render contract this plan implements)
Context
Plan S Phase 2 makes CRAIG’s UI composability real. ADR-033 fixed the plugin contract; Plan W builds the runtime that implements it. A panel or case-section is authored as a Rust crate (#[craig_plugin] + a Plugin.toml manifest + a pure render fn + an Askama template), discovered through a source-agnostic PluginSource trait, and rendered server-side by the craig-web BFF: the host fetches the manifest’s declared data, the plugin renders it as a CSP-clean HTML fragment. The outcome is the first end-to-end plugin pipeline — a reference plugin rendering on a route — with composition (which plugins land on which page) deferred to ADR-035 / Plan X and field ownership to ADR-037 / Plan Y.
Plan W PORTS the canopy project’s plugin subsystem (/home/bitskrieg/code/canopy — crates/canopy-composition + crates/canopy-plugin-macros; canopy ADR-021), NOT consumes it. The manifest types, the #[…_plugin] macro, the PluginSource trait, and the CompileTimePluginSource/distributed-slice discovery port nearly verbatim. The one seam CRAIG changes is the render model: canopy plugins do their own I/O (async fn fetch(clients, …)) and a hardcoded match slug { … } dispatches them; CRAIG flips this per ADR-033 §4 — the host fetches, the plugin is a sync-pure renderer, and the render fn carried in the registration makes dispatch registry-driven (the slice + render-fn eliminate canopy’s match).
This body supersedes the anticipated W-step sketch in the umbrella Step-17 row, which predates ADR-033. Four sketch items are superseded by the accepted ADR:
-
✗ "W1 adds a
plugins/PluginContributionfield toBundleContribution`" → NO field (ADR-033 §5 / ADR-032 A12); the `CRAIG_PLUGINSslice is the source andcraig-state-bundleis untouched. -
✗ "W2 registers templates into a sibling
CRAIG_PLUGIN_TEMPLATESslice" → the render-fn is carried in the singleCRAIG_PLUGINSregistration (ADR-033 §4; an Askama template is a type, not a value that can live in astaticslice, so "register the template" collapses into "register a render entrypoint"). -
✗ "W3
PluginRegistrypopulated fromBundleContribution::plugins`" → populated by walking the slice via `CompileTimePluginSource. -
✗ "W5/W6 plugins fetch their own data via reqwest" → host-fetches + plugin-pure-render (ADR-033 §4).
Key decisions
PluginSource is the stable async seam; linkme is a swappable v1 backend
Discovery and rendering are reached only through a PluginSource trait — [async_trait], Tier-O object-safe (ADR-038 §1; this is ADR-033 §1’s accepted decision, NOT refined here). The BFF (and, later, the ADR-035 composition engine) hold Arc<dyn PluginSource> and never name linkme. v1 ships CompileTimePluginSource (walks the CRAIG_PLUGINS slice); v2 adds WasmPluginSource as a drop-in. The [craig_plugin] macro + the slice are therefore v1 implementation details behind the trait, replaceable wholesale — this confines the planned v2 WASM migration to one crate. The trait’s async render orchestrates the async host-fetch and then calls the sync per-plugin PluginRenderFn; the async trait is also what the v2 WASM invocation needs.
Render contract: host-fetches + plugin-pure-render, serializable I/O
A plugin does NO I/O. The manifest declares its data dependencies ([data] endpoints, with ctx placeholders such as {case_id}); at render time the host executes those manifest-declared, ctx-parameterized fetches (W5), then calls the plugin’s sync render fn with the fetch outcome in the RenderCtx. Every value crossing the plugin boundary — RenderCtx, FetchOutcome, RenderedFragment — is serde-serializable from day one, so v1 passes them in-process and v2 serializes them across the WASM boundary with no contract change. This deliberately diverges from canopy’s in-plugin-I/O fetch() (not portable to a sandbox) and refines the umbrella’s CRAIG_PLUGIN_TEMPLATES sibling-slice sketch into the single render-entrypoint registration.
No BundleContribution field — the plugin axis is sourced via PluginSource
ADR-032 §4 / Contract 1 anticipated a plugins: Vec<PluginManifest> field on BundleContribution; ADR-033 §5 + ADR-032 amendment A12 record that it is NOT added. The manifests live in the CRAIG_PLUGINS slice (v1) / the WASM loader (v2); a hand-built Vec<PluginManifest> on the aggregate would be a second source of truth that drifts. The BFF materializes a PluginRegistry at boot by walking the active PluginSource, holding it in AppState exactly as it holds the materialized theme_css string and the I18n table; the orchestrator validates it in the same atomic boot pass (the "validated alongside the aggregate" property without duplicating data). craig-state-bundle is untouched by Plan W.
Manifest schema: Contract 1 author-facing names, canopy structure
The Plugin.toml follows Contract 1's author-facing names, enriched with the few clearly-needed canopy fields: [plugin] slug / name / version + exports = { panels, case_sections }; [panel.<slug>] / [case_section.<slug>] (CRAIG singular panel, not canopy’s plural) with display_name (a {term.*} key per ADR-034 §6, not canopy’s display_name_key) / programs / default_span / allowed_spans / states_required (not canopy’s required_states); [data] source / auth (none / service_token / user_jwt — ADR-033’s values, not canopy’s service_class) / cache_ttl + timeout (duration strings, not canopy’s _seconds/_ms) / endpoints; [permissions] required_roles / audit; [i18n]. The validators port from canopy (slug regex, span-breakpoint subset, programs/states subsets, non-empty roles/endpoints). W1 finalizes the exact field set.
linkme slice replaces canopy’s hardcoded match dispatch
Because the #[craig_plugin] macro carries the plugin’s render fn IN the registration, dispatch is registry.render(slug, ctx) (walk the slice → call the registration’s render fn) — there is no hardcoded match slug { "x" ⇒ x::fetch(…) } to maintain (canopy needed the match precisely because its registration did not carry the render fn). One consequence requires care: a linkme entry only participates if its crate is linked, so a feature-gated plugin crate needs an explicit use <crate> as _; force-link in craig-web (W6) or its registration is dead-code-eliminated.
Boot fail-fast validation; role-source; loading = shell
Boot validation is atomic and BFF-local (the ADR-036 §8 / ADR-034 §8 posture): slug uniqueness, display_name term-key resolution against the materialized I18n, required_role resolution against CRAIG’s known 6-role realm set (the v1 role-source decision — ADR-033 deferred it; per-jurisdiction role sets are future), structural completeness. A miss is a typed PluginBootError and craig-web refuses to start. The four-state template-block presence is a BUILD-TIME lint (W4), off the boot path; the manifest lists all four states_required but the plugin renders only data/empty/error (the host/shell renders the loading htmx placeholder), so the lint checks the plugin-owned arms and exempts loading.
Host-fetch executor: SSRF-guarded, cache-isolated, identity-aware
The host fetch (W5) is a NEW BFF-local executor over craig-web’s shared reqwest::Client + the Plan-E identity primitives (NOT the hardwired one-mode BffClients::Transport, which has no per-request timeout). It adds: a deny-by-default host-allow-list SSRF guard (the trust anchor) with an http(s)-only scheme check (W6 refined https-only → http permitted for allow-listed hosts — §Errata); URL-encoding of ctx params into the endpoint template; a per-request timeout; the three auth modes; and a cache key (slug, resolved-endpoint, jurisdiction, worker-or-role, auth-mode) so cached data never crosses a worker/jurisdiction boundary. The plugin render route is mounted INSIDE protected_routes (authenticated) — it inherits the strict CSP from the top-level layer but, unlike /assets/theme.css, is not public.
Step DAG
W1 (contracts crate: manifest + render types + PluginSource + CRAIG_PLUGINS slice + registry)
│
├──► W2 (#[craig_plugin] proc-macro) ───────────────────────────┐
│ │
├──► W3 (BFF boot: materialize PluginRegistry + AppState │
│ + fail-fast) ──► W5 (host-fetch executor + render route) ──► W6 (reference plugin)
│ │ │
└──► W4 (four-state lint) ──────────────────────────────────────┘ │
▼
W7 ──► W8 (audit + archive)
W5 does NOT depend on W6: it exercises the render pipeline against a #[cfg(test)] fixture plugin; the real reference plugin (W6) then proves it end-to-end, and is the first real template the W4 lint runs against. W3/W4 depend only on W1’s types; W2 depends on W1; W6 depends on W2 (the macro) + W5 (the route); W7/W8 follow W6.
Risk register
| Risk | Mitigation |
|---|---|
The umbrella Step-17 sketch (the |
The body §Context states all four supersessions up front; the W1/W3/W5 cells cite ADR-033 §4/§5 + ADR-032 A12. |
|
W1 ships a |
A feature-gated plugin crate is never linked → its |
craig-web carries |
Host-fetch SSRF / cache cross-tenant leak (the manifest source is an arbitrary URL; ADR-033 calls |
The W5 executor enforces a deny-by-default host allow-list (the SSRF trust anchor) + an http(s)-only scheme check (W6 refined https-only → http(s) for allow-listed hosts; non-allow-listed hosts + non-web schemes still rejected; see §Errata), URL-encodes ctx params, and keys its cache by |
The plugin route mounted public (like |
The route is mounted INSIDE |
|
W3 decides v1: validate ⊆ the known CRAIG 6-role realm set (reuse the existing role enum/const); request-time enforces role ∈ required_roles; recorded in §Errata; per-jurisdiction role sets are future. |
The current craig-web |
W5 builds a NEW host-fetch executor over the Plan-E identity primitives ( |
Two plugins register the same slug (the linker silently keeps both). |
W3 boot validation emits |
|
The manifest keeps all four; the plugin renders data/empty/error; the shell renders the loading htmx placeholder; the W4 lint checks the plugin-owned arms and exempts |
|
Invariant grep: no |
Plugin HTML breaks the strict CSP (inline script/style). |
The reference plugin uses shared CSS classes only; the route inherits the top-level CSP layer; the W6 e2e asserts no CSP violation. |
|
|
The proc-macro crate trips the strict workspace lints. |
|
W5’s render pipeline can’t be tested before the reference plugin (W6) exists. |
W5 ships a |
Cross-cutting invariants
Each invariant is a runnable check; greps are written to avoid false positives. Checked at W8.
-
No
pluginsfield onBundleContribution(ADR-032 A12):git grep -nE "pub +plugins *:" crates/craig-state-bundle/src/contribution.rsreturns 0 (matches a field declaration, not comments). -
The slice is the source:
CRAIG_PLUGINSis declared exactly once (incraig-plugin-contracts);CompileTimePluginSourcewalks it; the reference plugin registers via#[craig_plugin]. -
No in-plugin I/O: no HTTP client / transport in plugin crates —
git grep -nE "reqwest|hyper|OutboundTransport|BffClients|TcpStream" plugins/*/srcreturns 0 (plugins are pure renderers; do NOT grep.get(/.post(— they false-positive onserde_json::Value::get/HashMap::get). -
The render contract is serializable:
RenderCtx/FetchOutcome/RenderedFragment/PanelStateall deriveSerialize+Deserialize. -
PluginSourceis async + object-safe:craig-plugin-contractsdeclares#[async_trait] PluginSource(ADR-033 §1); the per-pluginPluginRenderFnis sync. -
CSP intact + route protected:
WEB_BFF_CSPis unchanged; the plugin route inherits it AND is mounted insideprotected_routes(authenticated); the W6 e2e is green. -
Host-fetch is SSRF-guarded + cache-isolated: the W5 executor enforces a deny-by-default host allow-list (the SSRF anchor) + an http(s)-only scheme check (W6 refined to permit http for allow-listed hosts — §Errata) and a cache key including jurisdiction/worker/auth-mode; W5/W6 tests reject a disallowed host + a non-web scheme.
-
linkmeforce-link present:git grep -n "use craig_plugin_example as _" services/craig-web/srcreturns at least 1 (under theplugin-examplecfg). -
The four-state lint is blocking:
cargo xtask validateruns the four-state step; it fails on a plugin template missing a data/empty/error arm (loading exempt). -
The two new crates + the reference plugin compile under the feature matrix:
cargo xtask feature-matrixis green incl. thestate-ga,plugin-example+state-tx-stub,plugin-examplecombos.
Open questions (deferrals)
-
The composition engine — which plugins land on which page/surface, the 5-layer override merge, the
craig-compositionservice — is ADR-035 / Plan X. Plan W ships a per-plugin render endpoint (GET /plugins/<slug>) that the composition engine will drive; it does not build the dashboard/case-detail composition. -
Context-parameterized (case-section) plugins — plugins whose
[data]endpoints carry{case_id}etc. need the full page/case context the composition layer supplies; Plan W’s render route is scoped to context-free (dashboard) plugins (ctx from query + session + locale). -
Richer per-plugin component CSS — v1 plugins use existing craig-web design-system classes (CSP-clean by construction); a per-plugin served stylesheet (and its CSP/loading-order implications) is future work.
-
Per-jurisdiction role sets — v1 validates
required_rolesagainst CRAIG’s fixed 6-role realm set; a jurisdiction-contributed role registry is future work (ties to the multi-jurisdiction authz arc). -
The v2 WASM backend —
WasmPluginSource, the component-model / WIT interface, the sandbox + fuel/timeout + capability model, acraig-plugin-wasmcrate — is a future major version; Plan W only makes v1 WASM-ready (thePluginSourceseam + serializable render I/O).
Errata
Deviations from ADR-033’s deferred open questions, resolved during Plan W execution (per writing-adrs — deviations live in the plan, not the immutable ADR). Seeded here; each is filled with its resolving MR/sha during execution:
-
auth-credential modes + the host-fetch executor (ADR-033’s deferredauthopen question) — resolved at W5: a new BFF-local executor over the Plan-E identity primitives, with thenone/service_token/user_jwtmodes + a per-request timeout (the existingTransportcouldn’t serve them). -
Role-source (ADR-033’s deferred role-source/timing open question) — resolved at W3: validate
required_roles⊆ the known CRAIG 6-role realm set at boot; enforce role ∈ required_roles at request time. -
loadingstate — resolved at W4/W5: the plugin rendersdata/empty/error;loadingis the shell’s htmx placeholder; the lint exempts it. -
Per-plugin CSS — resolved (for v1) at W6: shared design-system classes only; richer per-plugin CSS deferred (§Open questions).
-
PluginSourcesync-vs-async — finalized at W1: the trait stays#[async_trait](ADR-033 §1); only the per-pluginPluginRenderFnis sync-pure. -
SSRF scheme: https-only → http(s) for allow-listed hosts — refined at W6 (amends the W5 guard). W5 shipped an https-only scheme check. W6 surfaced that this is stricter than the BFF’s own backend transport — craig-web already calls
http://craig-backends over plaintext viaBffClients/CRAIG_WEB__*_URL, so an https-only plugin guard is inconsistent and makes the feature non-functional in every http-internal environment (devstack, a service mesh). The deny-by-default host allow-list remains the SSRF trust anchor (unchanged);ssrf_checknow permitshttp*andhttpsfor an allow-listed host and still rejects non-allow-listed hosts + non-web schemes (file:/ftp:/…). On-the-wire confidentiality is the deployment’s transport concern (mesh mTLS), orthogonal to the SSRF target-restriction. The reference plugin (W6) therefore renders realdataagainsthttp://craig-cases:8002in devstack. -
Plugin feature-gating is opt-in, NOT in
default— resolved at W6. ADR-033’s model is that a deployment composes its own plugin set, soplugin-exampleis opt-in: it is NOT in craig-web’sdefaultfeatures. devstack + e2e enable it via aCRAIG_WEB_FEATURESDockerfile build-arg (set indocker-compose.yml); generic/production images omit it. The CI feature-matrix (--no-default-features --features state-ga|state-tx-stub) already proves craig-web compiles WITHOUT the plugin (the#[cfg(feature = "plugin-example")]force-link cfg-vanishes). This sets the pattern for every future (jurisdiction) plugin. -
term-open-casesadded to the GA bundle terminology — at W6. The reference plugin’s panel title is{term.open_cases}; the GAworker.ftloverlay (en + es) gainsterm-open-casesso it resolves at boot (ADR-034). This also makes the W1/W3/W5 test fixtures' chosen key real.
Related decisions
-
Plan S — Multi-Jurisdiction Foundation — the umbrella; this body is Step 17, execution is Step 18.
-
ADR-033 — the plugin manifest + render contract this plan implements: §1 the
PluginSourceasync seam; §2 the manifest; §3#[craig_plugin]+linkmediscovery; §4 host-fetches + plugin-pure-render + serializable I/O; §5 noBundleContributionfield; §6 boot validation + the build-time four-state lint; §7 the two-audience model. -
ADR-032 — §4 + A11 additive
BundleContributiongrowth; A12 records that the plugin axis is sourced viaPluginSource, not a field. -
ADR-038 — §1 the Tier-O
#[async_trait]trait object (PluginSource); §3 the pre-materialized registry. -
ADR-034 — §6 the
{term.}→term-resolution fordisplay_name(validated at boot against the materializedI18n). -
Design engineering contracts — Contract 1 (plugin manifest + render contract) is the author-facing schema; Contract 3 (composition) is ADR-035 / Plan X.
-
Four-state UI contract — the
data/loading/empty/errorrequirement the W4 lint enforces. -
canopy
crates/canopy-composition/+crates/canopy-plugin-macros/+ canopy ADR-021 (Composability Runtime + Plugin Model) — the external precedent ported (not consumed); CRAIG diverges on the render seam (host-fetches + sync-pure render) for WASM portability. -
linkme(0.3) /syn(2) /quote/proc-macro2— the new dependencies (MSRV-1.88-compatible, MIT/Apache, musl-safe);linkmewas chosen overinventoryby ADR-033 (a linker-section concatenation, no life-before-mainconstructor).