Plan W: Plugin Manifest + Render Runtime (child plan of Plan S umbrella)

On this page

Status

Step Description Status

W1

NEW crates/craig-plugin-contracts: manifest + render-contract types + the PluginSource trait + the CRAIG_PLUGINS slice + CompileTimePluginSource + PluginRegistry (no consumer). Port canopy’s manifest.rs + source.rs, field-name-reconciled to Contract 1 (see §Manifest schema). PluginManifest (typed serde) + PluginManifestError + parse()/validate(). Render-contract types (serde-serializable from day one, ADR-033 §4): RenderCtx { item: Option<{ span, row }> (None for context-free plugins), locale, resolved display_name, worker { sub, role, jurisdiction } — all OWNED values, no `Arc<dyn _>/handles/pools, fetch: FetchOutcome }`, FetchOutcome { Data(serde_json::Value), Empty, Error }, PanelState, RenderedFragment { state, html }, PluginError. W1 enumerates the exact fields + ships a serde round-trip test (to_stringfrom_str round-trips) proving the v2 WASM-boundary serializability (invariant 4 — a non-serializable field would fail the Serialize derive). Manifest auth enum = ADR-033’s values: none / service_token / user_jwt (NOT canopy’s service_class). FetchOutcome mapping (defined here, tested in W1 + W5): a 2xx with a non-empty body → Data(json); a 2xx with 204 / empty array / empty object → Empty; non-2xx / timeout / connect-fail / malformed-JSON → Error (404Empty). PluginRenderFn = fn(&RenderCtx) → Result<RenderedFragment, PluginError>the per-plugin render fn is sync-pure (no I/O; the host does the async fetch BEFORE calling it). PluginSource stays [async_trait] object-safe (ADR-033 §1 — an accepted decision, NOT refined): the host’s render(slug, ctx) orchestration is async (it fetches, then calls the sync PluginRenderFn); the v2 WASM seam needs the async trait. Plugin + PluginSource ([async_trait], Tier-O per ADR-038 §1), PluginRegistration { slug, manifest_toml, manifest_cache: OnceLock, render: PluginRenderFn }, #[linkme::distributed_slice] CRAIG_PLUGINS, CompileTimePluginSource, PluginRegistry + PluginBootError. Deps: serde / serde_json / toml / thiserror / linkme / regex + async-trait (for PluginSource); NO reqwest. Shippable: compiles + clippy -D warnings clean (incl. the strict gate); unit tests for the parser/validator (port canopy’s) + the FetchOutcome mapping + a CompileTimePluginSource empty-slice test + a linkme smoke test proving one test-registered entry is discoverable (the repo’s FIRST linkme use — verifies linker-section discovery works on the rust:1.94-alpine/musl target before any plugin depends on it). No consumer yet.

Done (2026-06-16) — NEW crates/craig-plugin-contracts shipped: typed Plugin.toml manifest + parser/validator (canopy validators, field-name-reconciled; dep-free slug + duration parsing — no regex/duration crate); the serde-serializable render contract (RenderCtx/FetchOutcome/RenderedFragment/PanelState/PluginError/PluginRenderFn); the #[async_trait] Tier-O PluginSource seam; the CRAIG_PLUGINS linkme slice + PluginRegistration (render-fn carried in-registration) + CompileTimePluginSource; the boot-validated PluginRegistry + PluginBootError. The repo’s FIRST linkme use. 29 unit + 2 cross-crate linkme discovery integration tests; clippy -D warnings clean (B3a held via a // STRUCTURAL-VALUE: marker). !714 / 82f9f250.

W2

NEW crates/craig-plugin-macros: the [craig_plugin] proc-macro (CRAIG’s first first-party proc-macro). Port canopy’s [canopy_plugin] macro: [craig_plugin(slug = "…", manifest = "…")] annotates the plugin author’s render fn → expands to a [linkme::distributed_slice(CRAIG_PLUGINS)] static carrying slug + the include_str!’d manifest (`concat!(CARGO_MANIFEST_DIR, "/", manifest)) + render: <the annotated fn> (CRAIG’s extension over canopy — the render-fn is carried IN the single registration, so dispatch is registry-driven, not a hardcoded match). [lib] proc-macro = true; deps syn = "2" (full) / quote / proc-macro2; [lints] workspace = true (the strict gate applies — no carve-out). Shippable: the macro expands + a downstream test crate registers + discovers a plugin via the slice; clippy-clean; optional trybuild compile-fail tests (bad/missing args). Depends W1.

Done (2026-06-16) — NEW crates/craig-plugin-macros shipped: the #[craig_plugin(slug, manifest)] attribute annotates the author’s sync-pure render fn → emits the CRAIG_PLUGINS registration carrying slug + include_str!’d manifest + the render fn (registry-driven dispatch, no `match). Core split over proc_macro2 for unit-testability (chosen over brittle trybuild .stderr); fully-qualified hygiene paths + auto-doc’d static + ident-safety guard. syn/quote/proc-macro2[workspace.dependencies]. 9 unit + 3 macro→linkme→discovery integration tests. !717 / 0e6328b1.

W3

Wire PluginRegistry into the craig-web boot (materialize + AppState + boot fail-fast). NEW services/craig-web/src/plugins.rs: materialize_plugin_registry(i18n: &I18n, roles: &[&str]) → Result<Arc<PluginRegistry>, PluginBootError> — walks CompileTimePluginSource, parses + validates each manifest, runs the ADR-033 §6 boot validation: slug uniqueness (PluginBootError::DuplicateSlug, naming both registrations); each display_name term-key resolves against the MATERIALIZED I18n key set (NOT the raw TerminologyContribution — resolution happens after the product-default + bundle-overlay merge); each required_role ∈ the role set; manifest structural completeness. Role-source decision (RESOLVES ADR-033’s deferred open question for v1): required_roles validate against CRAIG’s known realm-role set — the 6 fixed roles (admin / supervisor / caseworker / eligibility_worker / icpc_coordinator / readonly); reuse the existing role enum/const in craig-auth/craig-authz if present, else a const; per-jurisdiction role sets are future work. Request-time additionally enforces worker-role ∈ required_roles (W5). ?-propagated in main (the exact twin of materialize_theme_css). Add plugin_registry: Arc<PluginRegistry> to AppState. craig-web gains the craig-plugin-contracts dep. craig-state-bundle is UNTOUCHED — no plugins field (ADR-033 §5 / ADR-032 A12). Shippable: boot wires the registry; a missing term-key / dangling role / duplicate slug fails boot; unit tests for each validation arm (incl. duplicate-slug + unknown-role). Depends W1.

Done (2026-06-16) — NEW services/craig-web/src/plugins.rs::materialize_plugin_registry (twin of materialize_theme_css) walks CRAIG_PLUGINS, holds the boot-validated PluginRegistry in AppState. Fail-fast cross-axis checks: required_role ∈ the fixed 6-role KNOWN_REALM_ROLES (no central const existed → defined one) + display_name {term.*} resolves against the MATERIALIZED I18n (ADR-034 §6 transform {term.open_cases}term-open-cases). craig-state-bundle untouched (A12). Extracted build_clients to keep main under the B2 fn-LOC budget. +10 unit tests. !718 / e9d4baf4.

W4

four-state-contract build-time lint. NEW LintsAction::FourStateContract + run_four_state_contract_blocking() in xtask/src/cmd/lints.rs, wired into validate.rs as a numbered blocking step (+ pre-push). Loading reconciliation (resolves the ADR-033-example-vs-canopy mismatch): the manifest’s states_required keeps all four (per ADR-033 §2’s example), but in the host-fetch model the plugin renders only the three it owns — data / empty / error (the fetch has already resolved by render time); loading is the host/shell’s htmx placeholder (the initial content of the GET /plugins/<slug> htmx target), NOT plugin-rendered. So the lint verifies each plugin’s Askama template handles the data / empty / error arms and EXEMPTS loading (shell-owned); it flags a template missing a plugin-owned state. The lint enumerates plugin crates under plugins/ and resolves each template path from the plugin crate / its Plugin.toml (W4 finalizes the template-location convention + whether it parses the Askama AST or scans the {% if state == … %} / {% match state %} arms textually). Shippable: the lint flags a template missing data/empty/error; green on the reference plugin (W6); blocking in validate. Depends W1 (manifest types); fully exercised at W6.

Done (2026-06-16) — NEW LintsAction::FourStateContract + run_four_state_contract() + pure scan_plugin_templates() in xtask/src/cmd/lints.rs, wired into validate.rs as [4m/14] (+ pre-push). Scans plugins//templates/*/*.html; a template using the {% if state == "…" %} dispatch idiom (≥1 owned arm) must cover data/empty/error; loading EXEMPT (shell-owned). Template-location FINALIZED: crate-local templates/ dir, NO manifest path; textual scan (Askama {% match %} doesn’t parse — canopy precedent). Green-on-empty (no plugins/ dir yet). +8 tests; e2e-verified a bad template exits non-zero. !719 / cbd49277.

W5

BFF render runtime: a host-fetch executor + plugin-pure-render + a per-plugin render route. NEW host-fetch executor in plugins.rs, built on craig-web’s shared reqwest::Client + the Plan-E identity primitives (OidcServiceToken / ActorTokenIssuer) — NOT the hardwired BffClients::Transport (which is one fixed identity mode with no per-request timeout). For a plugin slug: read its manifest [data]; SSRF-guard the source (https-only scheme + a configured host allow-list; reject otherwise); URL-encode ctx params into the endpoint template; fetch with the manifest per-request timeout and the auth mode (user_jwt → the acting worker’s actor/bearer identity via ActorTokenIssuer; service_token → the BFF client_credentials OidcServiceToken; none → no auth header); map the response to FetchOutcome (per the W1 mapping); build RenderCtx { fetch: outcome, worker, locale, … }; call the sync plugin.render(ctx); return the CSP-clean fragment. Cache — in-memory, per-replica; TTL from cache_ttl; lazy expiry; an Error outcome is NOT cached (no transient-failure poisoning); single-flight per key so concurrent requests don’t duplicate the outbound call (W5 finalizes the eviction detail) — keyed by (slug, resolved-endpoint, jurisdiction, worker-or-role, auth-mode) so one worker/jurisdiction never sees another’s cached data. RenderCtx.item (span/row) is None for these context-free plugins (the composition engine supplies it for composed surfaces — Plan X). Route GET /plugins/<slug> mounted INSIDE protected_routes (authenticated — it needs the session worker for authz + fetch credentials; it inherits the strict CSP from the top-level layer like every route, but is NOT public like /assets/theme.css); htmx fragment; authz-gated (worker role ∈ manifest required_roles, else 403). Scope: context-free (dashboard) pluginsRenderCtx is built from query params + the session worker identity + locale; item row/span + full page/case context (and {case_id}-parameterized case-section plugins) come from the composition engine (Plan X). The reference plugin (W6) is context-free. Shippable: the route renders end-to-end via a test fixture — a hand-constructed PluginRegistration fed directly to the registry/executor in [cfg(test)], bypassing linkme discovery (so W5 needs no linked plugin crate; the full [craig_plugin] + force-link + slice-discovery path is proved by the real plugin in W6); the SSRF guard rejects a disallowed host; a failed/timed-out fetch → the plugin’s error state; an unauthorized role → 403; an unauthenticated request → redirect/401. Depends W3 (the real plugin is W6).

Done (2026-06-16) — BFF host-fetch render runtime in services/craig-web/src/plugins.rs: PluginFetcher (shared reqwest::Client + Plan-E identity primitives; SSRF guard = https-only scheme + deny-by-default host allow-list via reqwest::Url::host_str; URL-encode ctx params; per-request timeout; auth none/service_token/user_jwt-mints-actor) + PluginCache (per-replica; keyed by slug/endpoint/jurisdiction/principal/auth-mode; TTL; store_if_cacheable never caches Error; single-flight via dashmap+tokio Mutex) + GET /plugins/<slug> inside protected_routes (authz-gated). AppState += fetcher+cache; bundle::active_contribution now returns (jurisdiction_code, contribution); config += CRAIG_WEB__PLUGIN_ALLOWED_HOSTS; craig-web += dashmap; extracted build_oidc for B2. +21 tests (incl. a #[cfg(test)] PluginRegistration fixture). Both review FAILs (error-not-cached untested; missing fixture pipeline test) closed before merge. !720 / 608ba7c6. (W6 later refined the SSRF scheme — see §Errata.)

W6

Reference plugin: NEW plugins/example/ crate. A minimal context-free (dashboard) panel: Plugin.toml (declares a context-free craig backend endpoint, e.g. an open-cases count — no {case_id}) + a sync render fn + an Askama template rendering the plugin-owned data / empty / error states (loading is shell-provided) using existing craig-web CSS classes (CSP-clean; per-plugin component CSS is a deferred open question). [craig_plugin]. Compiled into craig-web behind a plugin-example Cargo feature (the ADR-033 "feature-gating scopes the compiled-in plugins" model). linkme force-link: craig-web MUST carry [cfg(feature = "plugin-example")] use craig_plugin_example as _; (or equivalent) — a linkme distributed-slice entry only participates if the crate is actually linked; an optional dep that is never referenced is dead-code-eliminated and its registration silently vanishes (canopy’s assert_registered() exists for this reason). NEW workspace member under a plugins/ dir. Shippable: the example renders on GET /plugins/example end-to-end in devstack; the four-state lint is green; an e2e/integration test asserts the rendered fragment + CSP compliance + that the slice actually discovered it (force-link works). Depends W2 + W5.

Done (2026-06-16) — NEW plugins/example/ (craig-plugin-example): CRAIG’s FIRST plugins/ member + first [craig_plugin] consumer. A context-free dashboard panel (open-cases count from craig-cases via user_jwt, endpoint ?status=open&page=1&per_page=1 reading total) + a four-state Askama template (data/empty/error; CSP-clean — classes + data-* only) + Plugin.toml (display_name = "{term.open_cases}"). OPT-IN plugin-example craig-web feature (NOT in default) + the [cfg(feature = "plugin-example")] use craig_plugin_example as _; linkme force-link + a CRAIG_WEB_FEATURES Dockerfile build-arg (compose sets it for devstack/e2e; prod omits). SSRF guard refined (amends W5): ssrf_check now permits http for allow-listed hosts (deny-by-default host allow-list stays the SSRF anchor; non-web schemes still rejected) — see §Errata. term-open-cases added to GA terminology (en/es). Config list-parse fix: the W5 plugin_allowed_hosts Vec was never env-exercised (needs list_separator(",") + with_list_parse_key) — caught by the pre-push devstack health check. +7 crate tests (5 render + 2 force-link/discovery) + 3 SSRF + 2 config-parse + 1 e2e; the W4 four-state lint is GREEN on the first real template; 3-reviewer adversarial pass. !721 / 705826db.

W7

Feature-matrix + docs + open-question resolution. Extend cargo xtask feature-matrix with the explicit craig-web combos --features state-ga,plugin-example and --features state-tx-stub,plugin-example (the current matrix iterates only jurisdiction features for 5 crates — xtask/src/cmd/feature_matrix.rs; W7 adds the plugin-feature axis). The matrix tests the SHIPPED plugin set (currently the one plugin-example) — it does NOT combinatorially explode over every contrib plugin; revisit the strategy if the shipped set grows large. Docs: .claude/docs/shared-crates.md (the 2 new crates), the architecture doc (.claude/docs/architecture.md + the Antora architecture page if applicable — the plugin runtime), .claude/CLAUDE.md (the +2 crates + the plugins/ dir + the plugin-system note; set the workspace member count to the live cargo metadata count — do NOT hard-code a delta). This body’s §Errata records the ADR-033 open-question resolutions (per writing-adrs — deviations live in the PLAN, not the immutable ADR): the auth-credential modes + the new host-fetch executor; loading = shell placeholder; per-plugin CSS = shared classes for v1; the role-source = the known CRAIG realm-role set; PluginSource stays #[async_trait] (only the per-plugin render fn is sync). Shippable: feature-matrix green; docs updated; §Errata complete. Depends W6.

Done (2026-06-16) — feature-matrix plugin axis (xtask/src/cmd/feature_matrix.rs): the craig-web combos --no-default-features --features state-ga,plugin-example + state-tx-stub,plugin-example (the opt-in plugin compiles on EITHER jurisdiction bundle) + a fingerprint test pinning craig-web as the plugin-axis crate; .gitlab-ci.yml unchanged (its feature-matrix job already invokes cargo xtask feature-matrix). Docs: craig-plugin-macros + craig-plugin-example added to the shared-crates index + Antora shared-crates.adoc; a Plugin Runtime section in both architecture docs; CLAUDE.md member count corrected to the live cargo metadata count (29→49) + a plugin-runtime bullet + the plugins/ dir. §Errata confirmed complete (the ADR-033 resolutions were authored across W1/W3/W5/W6). 2-reviewer adversarial pass; 3 honest text fixes. !722 / 9cbd54ed.

W8

Plan-completion audit + archive. A fresh Explore plan-completion-audit subagent verifies every W1–W7 cell carries a concrete !MR / sha cite. Run the §Cross-cutting invariants. cargo xtask docs plan-archive (dry-run then execute): nav.adoc Active→archive move + a plans/archive.adoc § Architecture row + sibling-xref rewrites to the plans/archive/ path; flip :status: Active → Complete. Flip umbrella Step 18 → Done + close epic &48. Finalize the deferred docs. Append a .claude/CLAUDE.md § Completed Plans name. Memory sync. Depends all.

Done (2026-06-16) — this archive MR. A fresh Explore plan-completion-audit subagent confirmed every W1–W7 cell carries a concrete !MR / sha (W1 !714/82f9f250, W2 !717/0e6328b1, W3 !718/e9d4baf4, W4 !719/cbd49277, W5 !720/608ba7c6, W6 !721/705826db, W7 !722/9cbd54ed) and all 10 §Cross-cutting invariants verified PASS (incl. git grep "use craig_plugin_example as _" services/craig-web/src = 1; no plugins field on BundleContribution; no in-plugin I/O; CSP intact + route in protected_routes; deny-by-default SSRF host allow-list; four-state lint blocking; feature-matrix green). Body git-mv’d to plans/archive/; :status: Active→Complete; nav Active entry removed; sibling xrefs repointed; a plans/archive.adoc § Architecture row added (W1–W8 cites). Umbrella Step 18 → Done. Epic &48 closed. .claude/CLAUDE.md § Completed Plans + Active-Work row updated. !MR / sha backfilled at the next umbrella step (ADR-035 / Plan X kickoff).

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/canopycrates/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 / PluginContribution field to BundleContribution`" → NO field (ADR-033 §5 / ADR-032 A12); the `CRAIG_PLUGINS slice is the source and craig-state-bundle is untouched.

  • ✗ "W2 registers templates into a sibling CRAIG_PLUGIN_TEMPLATES slice" → the render-fn is carried in the single CRAIG_PLUGINS registration (ADR-033 §4; an Askama template is a type, not a value that can live in a static slice, so "register the template" collapses into "register a render entrypoint").

  • ✗ "W3 PluginRegistry populated from BundleContribution::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 plugins field / the sibling template slice / in-plugin fetch) contradicts ADR-033.

The body §Context states all four supersessions up front; the W1/W3/W5 cells cite ADR-033 §4/§5 + ADR-032 A12.

linkme is the repo’s first use; distributed-slice discovery under the musl/alpine CI image is unproven.

W1 ships a linkme smoke test proving a registered entry is discoverable on the build target before any plugin depends on it.

A feature-gated plugin crate is never linked → its linkme entry silently vanishes.

craig-web carries #[cfg(feature="plugin-example")] use craig_plugin_example as _; (force-link); the W6 test asserts the slice discovered it.

Host-fetch SSRF / cache cross-tenant leak (the manifest source is an arbitrary URL; ADR-033 calls [data] a trust surface).

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 (slug, resolved-endpoint, jurisdiction, worker/role, auth-mode); an invariant + W5/W6 tests reject a disallowed host.

The plugin route mounted public (like /assets/theme.css) would bypass authz.

The route is mounted INSIDE protected_routes (authenticated); it inherits the CSP from the top-level layer but requires a session; W5 tests an unauthenticated request (→ redirect/401) + a wrong-role request (→ 403).

required_roles boot validation has no role source (ADR-033 deferred it).

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 Transport is one fixed identity mode with no per-request timeout — it can’t serve the three auth modes.

W5 builds a NEW host-fetch executor over the Plan-E identity primitives (OidcServiceToken/ActorTokenIssuer) + the shared client, adding the timeout + the none/service_token/user_jwt switch.

Two plugins register the same slug (the linker silently keeps both).

W3 boot validation emits PluginBootError::DuplicateSlug (fail-fast, naming both registrations); exercised by a W6 two-plugin integration test.

states_required lists four but the plugin can’t render loading in the host-fetch model.

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 loading.

craig-state-bundle accidentally grows a plugins field (against A12).

Invariant grep: no plugins field on BundleContribution; the slice is the sole source.

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.

PluginSource is mistakenly made sync, contradicting ADR-033 §1.

PluginSource stays #[async_trait] (ADR-033 §1, unchanged); ONLY the per-plugin PluginRenderFn is sync-pure — the async render calls it after the async fetch.

The proc-macro crate trips the strict workspace lints.

craig-plugin-macros declares [lints] workspace = true; no carve-out; the macro code meets the deny gates.

W5’s render pipeline can’t be tested before the reference plugin (W6) exists.

W5 ships a #[cfg(test)] fixture plugin registration to exercise the pipeline; the real plugin lands in W6.

Cross-cutting invariants

Each invariant is a runnable check; greps are written to avoid false positives. Checked at W8.

  1. No plugins field on BundleContribution (ADR-032 A12): git grep -nE "pub +plugins *:" crates/craig-state-bundle/src/contribution.rs returns 0 (matches a field declaration, not comments).

  2. The slice is the source: CRAIG_PLUGINS is declared exactly once (in craig-plugin-contracts); CompileTimePluginSource walks it; the reference plugin registers via #[craig_plugin].

  3. No in-plugin I/O: no HTTP client / transport in plugin crates — git grep -nE "reqwest|hyper|OutboundTransport|BffClients|TcpStream" plugins/*/src returns 0 (plugins are pure renderers; do NOT grep .get( / .post( — they false-positive on serde_json::Value::get / HashMap::get).

  4. The render contract is serializable: RenderCtx / FetchOutcome / RenderedFragment / PanelState all derive Serialize + Deserialize.

  5. PluginSource is async + object-safe: craig-plugin-contracts declares #[async_trait] PluginSource (ADR-033 §1); the per-plugin PluginRenderFn is sync.

  6. CSP intact + route protected: WEB_BFF_CSP is unchanged; the plugin route inherits it AND is mounted inside protected_routes (authenticated); the W6 e2e is green.

  7. 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.

  8. linkme force-link present: git grep -n "use craig_plugin_example as _" services/craig-web/src returns at least 1 (under the plugin-example cfg).

  9. The four-state lint is blocking: cargo xtask validate runs the four-state step; it fails on a plugin template missing a data/empty/error arm (loading exempt).

  10. The two new crates + the reference plugin compile under the feature matrix: cargo xtask feature-matrix is green incl. the state-ga,plugin-example + state-tx-stub,plugin-example combos.

Open questions (deferrals)

  • The composition engine — which plugins land on which page/surface, the 5-layer override merge, the craig-composition service — 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_roles against 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 backendWasmPluginSource, the component-model / WIT interface, the sandbox + fuel/timeout + capability model, a craig-plugin-wasm crate — is a future major version; Plan W only makes v1 WASM-ready (the PluginSource seam + 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 deferred auth open question) — resolved at W5: a new BFF-local executor over the Plan-E identity primitives, with the none/service_token/user_jwt modes + a per-request timeout (the existing Transport couldn’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.

  • loading state — resolved at W4/W5: the plugin renders data/empty/error; loading is 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).

  • PluginSource sync-vs-async — finalized at W1: the trait stays #[async_trait] (ADR-033 §1); only the per-plugin PluginRenderFn is 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 via BffClients/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_check now permits http *and https for 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 real data against http://craig-cases:8002 in 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, so plugin-example is opt-in: it is NOT in craig-web’s default features. devstack + e2e enable it via a CRAIG_WEB_FEATURES Dockerfile build-arg (set in docker-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-cases added to the GA bundle terminology — at W6. The reference plugin’s panel title is {term.open_cases}; the GA worker.ftl overlay (en + es) gains term-open-cases so it resolves at boot (ADR-034). This also makes the W1/W3/W5 test fixtures' chosen key real.

  • 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 PluginSource async seam; §2 the manifest; §3 #[craig_plugin] + linkme discovery; §4 host-fetches + plugin-pure-render + serializable I/O; §5 no BundleContribution field; §6 boot validation + the build-time four-state lint; §7 the two-audience model.

  • ADR-032 — §4 + A11 additive BundleContribution growth; A12 records that the plugin axis is sourced via PluginSource, 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 for display_name (validated at boot against the materialized I18n).

  • 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 / error requirement 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); linkme was chosen over inventory by ADR-033 (a linker-section concatenation, no life-before-main constructor).

Edit this page · latest