ADR-033: Plugin Manifest + Render Contract (Panel + Case-Section)

On this page

Status

Accepted 2026-06-16. Anchors Plan S — Multi-Jurisdiction Foundation Step 16; the implementation lands in Plan W (Plugin Manifest + Render Runtime), umbrella Steps 17–18. This is the first of Plan S Phase 2’s UI-composability ADRs and a sibling of ADR-034 (terminology) and ADR-036 (theme): it mirrors their bundle-axis posture (a per-jurisdiction contribution, materialized BFF-local, boot fail-fast) while adding the discovery + render seam those axes do not need. Builds on ADR-032 §4 + A11 (additive BundleContribution growth; this ADR refines the anticipated plugins field — see §5, recorded as ADR-032 amendment A12) and ADR-038 §1 (Tier-O object-safe trait), §3 (pre-materialized registry), §4 (field-sourcing / orphan rule). Refines the CRAIG_PLUGIN_TEMPLATES sibling-slice sketch the umbrella Step 17 anticipated (see §4).

Context

Plan S Phase 2 makes CRAIG’s UI composability real: a jurisdiction composes its worker surfaces — dashboards, case-detail — from panels and case-sections, some shipped by core, some authored for that jurisdiction. Contract 1 specifies the author-facing shape: a panel or case-section declares itself in a per-plugin Plugin.toml manifest (slug, exports, the jurisdiction’s own data endpoint, permissions, the four required render states), renders server-side, and is CSP-clean. CRAIG has no plugin system today — no manifest type, no discovery mechanism, no render path, no first-party proc-macro, and no compile-time-registration dependency (linkme/inventory/ctor are all absent).

Two standing facts shape the design. First, CRAIG runs a strict Content-Security-Policy (style-src 'self', no 'unsafe-inline'; Plan C F-018) — plugin output must be server-rendered HTML with no inline scripts. Second, CRAIG is one deployment, one jurisdiction: the active bundle (ADR-032 §2.7 CRAIG__ACTIVE_STATE_BUNDLES) is fixed at boot, so the plugin set is fixed at boot.

A third fact is decisive for the architecture: CRAIG intends to move plugins to a WebAssembly backend in a future major version (v2). v1 compiles plugins into the binary; v2 will load sandboxed .wasm modules. The contract this ADR fixes must therefore be expressible across both a compile-time, in-process Rust backend AND an out-of-process WASM sandbox, so the v2 migration is a backend swap rather than a rewrite. The canopy project — CRAIG’s external precedent for this subsystem (crates/canopy-composition, crates/canopy-plugin-macros, canopy ADR-021) — was designed with exactly this seam: a source-agnostic PluginSource trait whose v1 implementation reads a compile-time registry and whose v2 implementations (WasmPluginSource, FilesystemPluginSource) drop in without touching callers. CRAIG ports that intent; it does not consume canopy. Where canopy has each plugin perform its own I/O, CRAIG diverges (§4), because in-plugin I/O is not portable to a sandbox.

This ADR records the manifest + discovery + render contract + boot validation. It deliberately leaves the composition engine (how a jurisdiction’s dashboards.toml and its override layers resolve into a page) to ADR-035 (anticipated) / Plan X, and per-field ownership to ADR-037 (anticipated) / Plan Y.

Decision

1. The stable seam is a source-agnostic PluginSource trait, not the discovery backend

Plugin discovery and rendering are reached only through a PluginSource trait — a Tier-O object-safe trait ([async_trait], the ADR-038 §1 default) living in a new craig-plugin-contracts crate. The craig-web BFF (and, later, the composition engine of ADR-035 (anticipated)) hold Arc<dyn PluginSource> and never name the discovery backend. v1 ships one implementation, CompileTimePluginSource, which walks a compile-time registry (§3); v2 adds WasmPluginSource (scanning sandboxed .wasm modules) as a drop-in with no call-site change. The [craig_plugin] macro and the compile-time slice (§3) are therefore v1 implementation details behind this trait, not part of the contract — they are replaceable wholesale when the WASM backend lands. This is the single most important decision in the ADR: it bounds the v2 migration’s blast radius to one crate.

// crates/craig-plugin-contracts — illustrative; Plan W finalizes signatures and
// guarantees Tier-O object-safety per ADR-038 §1. The seam is stable across
// backends: v1 = CompileTimePluginSource; v2 = WasmPluginSource.
#[async_trait]
pub trait PluginSource: Send + Sync {
    fn get(&self, slug: &str) -> Option<&dyn Plugin>;     // manifest lookup
    fn list(&self) -> Vec<&dyn Plugin>;                    // object-safe (not RPITIT)
    async fn render(&self, slug: &str, ctx: &RenderCtx)
        -> Result<RenderedFragment, PluginError>;
}

2. The manifest is a per-plugin Plugin.toml

A panel or case-section declares itself in a Plugin.toml (Contract 1). TOML is parseable by both the v1 and v2 backends, so the manifest format is backend-neutral.

# plugins/<slug>/Plugin.toml
[plugin]
slug = "child_support"
name = "Child Support"
version = "1.0.0"
exports = { panels = ["child_support"], case_sections = ["child_support"] }

[panel.child_support]
display_name = "{term.child_support}"     # terminology key (ADR-034 §6 -> term-child-support)
programs = ["tanf", "medicaid"]           # only renders for these case types
default_span = 4
allowed_spans = [4, 6, 8]

[data]
source = "https://ocse.dhs.ga.gov/v1"     # the jurisdiction's OWN endpoint; the HOST fetches it
auth = "service_token"
cache_ttl = "5m"
timeout = "2s"
endpoints = ["/cases/{case_id}/child-support"]

[permissions]
required_roles = ["eligibility_worker"]   # validated at boot against the active jurisdiction's role set
audit = "ocse_read"

states_required = ["data", "loading", "empty", "error"]

The author-facing schema is Contract 1’s, enriched with name/version for identity, a [case_section.<slug>] subtable parallel to [panel.<slug>], and the [data].endpoints list (the routes the host fetches). display_name is a terminology key, not a literal — "{term.child_support}" resolves to the Fluent message term-child-support per ADR-034 §6. PluginManifest is a typed serde struct with a PluginManifestError enum and a validate() parser, all in craig-plugin-contracts. The exact full field set is finalized in Plan W (as ADR-036 deferred the full token list to the Theme Token Schema); this ADR fixes the load-bearing fields, their meaning, and the invariants of §6.

3. v1 discovery is #[craig_plugin] + a linkme compile-time distributed slice

A plugin author annotates the plugin with [craig_plugin(slug = "…", manifest = "Plugin.toml")]. The macro — in a new craig-plugin-macros crate, CRAIG’s first first-party proc-macro and therefore its own crate (proc-macros must be) — expands to a [linkme::distributed_slice(CRAIG_PLUGINS)] static carrying the slug, the manifest text (include_str!, parsed lazily into the typed PluginManifest), and the plugin’s render entrypoint (§4). linkme becomes a workspace dependency (net-new). The slice is a linker-section concatenation resolved at link time — no life-before-main constructor, no runtime registration — so CompileTimePluginSource simply iterates CRAIG_PLUGINS. This ports canopy’s CANOPY_PLUGINS pattern (crates/canopy-composition/src/source.rs + crates/canopy-plugin-macros). Because discovery is reached only through PluginSource (§1), none of this mechanism is load-bearing on the contract.

4. Render contract: the host fetches, the plugin renders pure data, the I/O is serializable

A plugin does NOT perform I/O. The manifest declares a plugin’s data dependencies ([data], with context placeholders such as {case_id}); at render time the host (the craig-web BFF) executes those manifest-declared, context-parameterized fetches via OutboundTransport (ADR-032 §3.1 + Plan V), honoring the manifest’s auth/cache_ttl/timeout, against the jurisdiction’s own declared endpoint. The host is the sandbox’s I/O executor — it is NOT a data proxy: the endpoint and the data remain the jurisdiction’s (Contract 1: "endpoint is the jurisdiction’s, not core’s"); the host merely runs the call the manifest names. The plugin is then a pure function — it receives a serializable render context (the resolved item’s span/row, the locale, the resolved display_name, the worker’s identity: sub/role/jurisdiction) plus the fetch outcome (Data(json) | Empty | Error), and returns a rendered HTML fragment tagged with the panel state it represents. The plugin owns presentation: it renders the data/empty/error blocks of its compile-time #[derive(askama::Template)] view; the host shows the loading placeholder before the fetch resolves.

// Serializable render I/O — illustrative; Plan W finalizes the exact fields.
// Passed in-process in v1; serialized across the WASM boundary in v2 — same contract.
#[derive(Serialize, Deserialize)]
pub struct RenderCtx { /* item span/row, locale, resolved display_name,
                          worker { sub, role, jurisdiction }, fetch: FetchOutcome */ }
#[derive(Serialize, Deserialize)] pub enum FetchOutcome { Data(serde_json::Value), Empty, Error }
#[derive(Serialize, Deserialize)] pub enum PanelState  { Data, Loading, Empty, Error }
#[derive(Serialize, Deserialize)] pub struct RenderedFragment { pub state: PanelState, pub html: String }

Every value crossing the plugin boundary — render context, fetch outcome, fragment + state — is serde-serializable from day one. In v1 these are passed in-process; in v2 they serialize across the WASM boundary with no contract change. This is the concrete debt-avoidance decision: a render entrypoint that took non-serializable Rust handles (an Arc<dyn OutboundTransport>, a live DB pool) could not move to a sandbox.

Two invariants protect this boundary, and Plan W must hold them. First, no value crossing it carries a non-serializable host capability — no Arc<dyn _>, no connection pool, no live handle; the render context carries resolved values only (a capability that cannot serialize cannot cross a sandbox, so admitting one would silently re-couple v1 to the in-process backend and break v2). Second, the plugin never receives credentials: the host resolves the manifest’s auth, owns the outbound call, and is responsible for enforcing jurisdiction boundaries — a worker in one jurisdiction must never be served another’s data, even when both jurisdictions' endpoints are reachable from the host. Relatedly, the plugin’s returned HTML is CSP-bounded, not runtime-sanitized: the strict CSP (no inline script or style) is the runtime defense and the build-time lint (§6) the author-time one; a plugin is trusted to emit token-styled, class-based markup, not arbitrary <script>/<style>.

This deliberately diverges from canopy, whose plugins do their own I/O (a fetch() taking ServiceClients). In-plugin I/O is not portable to a capability-free sandbox; host-fetches-pure-render is. It also refines the CRAIG_PLUGIN_TEMPLATES sibling-slice sketch the umbrella Step 17 anticipated: an Askama template is a type (a struct implementing Template), not a value that can live in a static slice, so "register the template" necessarily collapses into "register a render entrypoint that builds and renders the template from fetched data." v1 carries that entrypoint in the single CRAIG_PLUGINS registration alongside the manifest — there is no second template slice to keep in sync.

5. The plugin axis is sourced via PluginSource, not a BundleContribution field

ADR-032 §4 and Contract 1 anticipated a plugins: Vec<PluginManifest> field on BundleContribution. This ADR does NOT add that field. With discovery sourced through PluginSource (the v1 slice; the v2 WASM loader), a hand-built Vec<PluginManifest> on the aggregate would be a second source of truth competing with both — a manifest baked into the slice by the macro and re-declared on the contribution would inevitably drift. Instead the BFF materializes a PluginRegistry once at boot by asking the active PluginSource, holding it in AppState exactly as it holds the materialized theme_css string and the I18n terminology table (ADR-036 §4 / ADR-034 §4: the consuming host materializes the axis it consumes). The boot orchestrator validates that registry in the same atomic pass it validates the rest of the aggregate (§6), so the plugin axis keeps the "validated alongside the aggregate" property Contract 1 requires without duplicating data.

Per ADR-038 §3 + ADR-032 A11, the registry shape is documented: the PluginRegistry is PRE-MATERIALIZED (a value, not a BootContext factory) — a plugin’s manifest and render entrypoint are known without any boot-time shared resource; the host supplies the transport and the render context per call, not at construction. This refinement is recorded as ADR-032 amendment A12 (A11 had listed the anticipated field as pre-materialized; the amendment records that the field is not added and the axis is sourced via the slice, the registry still pre-materialized). Cargo-feature gating — the established state-bundle mechanism — is the expected means of scoping the compiled-in v1 plugin set to the active jurisdiction; the exact wiring is a Plan W detail, not a contract guarantee.

6. Boot validation is atomic and fail-fast; template + aesthetic checks are build-time lints

At boot the BFF validates the active plugin set (the posture of ADR-036 §8 / ADR-034 §8): every slug is unique; every display_name terminology key resolves in the active bundle’s TerminologyContribution (a cross-axis check tying to ADR-034); every required_role resolves against the active jurisdiction’s role set (conditional on the role source Plan W settles — see Open questions; the other checks are unconditional); and each manifest is structurally complete (states_required fully declared, [data] and [permissions] present). A miss is a typed PluginBootError and craig-web refuses to start — a dangling role reference or an unresolved label must surface at the boundary, not as a broken panel an end user finds.

The presence of the four state blocks in a plugin’s template is a BUILD-TIME lint (xtask lints four-state-contract), not a boot check: templates are compile-time artifacts, so verifying their blocks belongs to the build, exactly as ADR-036 §8 keeps aesthetic checks (contrast, hardcoded hex) in a build-time lint off the boot path. Plan W implements both the boot validation and the lint.

7. Two audiences: authors write Rust, operators write config

Plugin authoring is a Rust task: core engineers and external contributors write a crate with #[craig_plugin] + Plugin.toml + a pure render function + an Askama template carrying the four state blocks. Jurisdiction operating is config only: an operator references plugin slugs in rulesets/<jurisdiction>/dashboards.toml — no Rust. The design language’s "everything is config" describes the operator experience; authoring remains Rust. This ADR fixes only that the operator surface selects plugins by slug; how dashboards.toml and its override layers resolve into a rendered page is the composition engine of ADR-035 (anticipated) / Plan X, out of scope here.

Consequences

Positive

  • WASM-ready by construction: the PluginSource trait is the only seam the v2 backend must satisfy, and the render I/O is serializable, so the migration is a new trait implementation, not a rewrite of every call site.

  • CSP-clean by construction: plugins emit server-rendered HTML fragments with no inline scripts; the strict CSP is unweakened.

  • The pure-render contract is trivially testable — a render is (ctx, outcome) → fragment, with no network and no live-client mocks.

  • One discovery axis and one registry: a plugin is self-contained (manifest + render + template in its own crate), discovered through a single trait, materialized once at boot like every other bundle axis.

  • No second source of truth for manifests: the slice (v1) / loader (v2) is authoritative; there is no aggregate field to drift.

Negative

  • Two new crates and CRAIG’s first proc-macro + first linkme dependency.

  • The divergence from canopy’s in-plugin-I/O model means canopy’s render code is ported in spirit, not line-for-line.

  • In v1 the plugin set is compile-time-known: adding a plugin is a rebuild (until the v2 WASM backend allows out-of-process modules).

  • The host now executes outbound calls on a plugin’s behalf — the manifest’s [data] declaration is a trust + validation surface.

Mitigations

  • The two new crates are isolated; the proc-macro + linkme surface is confined to them and never enters the contract (the PluginSource seam).

  • craig-web already gained a craig-state-bundle dependency (ADR-036 / Plan U), and OutboundTransport is the Plan V seam — the new dependencies sit on edges the project already sanctioned.

  • The compile-time-known v1 plugin set matches every other bundle axis’s "rebuild to change" property; runtime loading is the explicit v2 goal this ADR prepares for.

  • The [data] trust surface is bounded by boot validation (§6) and the existing OutboundTransport controls; the endpoint is the jurisdiction’s own, declared in its bundle.

Amendment — #1552 (E4): the first shipped case_section export (2026-08-22)

The case_section half of this contract, dormant since W6, is now LIVE: plugins/ssa-screening (crate craig-plugin-ssa-screening, feature plugin-ssa-screening in BOTH craig-web and craig-composition) exports the ssa-screening section, placed by rulesets/georgia/case_detail.toml (span 12, row 0, tabs shell). As-built findings the first export surfaced, recorded as contract semantics:

  • One render = one fetched endpoint. The W5 route fetches only data.endpoints.first() (the §"Open questions" dependent-fetch item stays open). The SSA section therefore consumes a purpose-built ONE-fetch composite (GET /v1/cases/cases/{id}/ssa-screening/summary, craig-cases — the relay 404 folds to run: null so never-screened is a DATA shape). A section needing several reads composes them server-side; it does not declare multiple endpoints expecting a fan-out.

  • Action forms are host chrome, never fragment content. A cached fragment cannot mint a fresh ADR-062 per-render client_request_id, and inline onsubmit confirm handlers are CSP-forbidden inside fragments (§4). craig-web’s case detail renders the request/cancel chrome itself (keyed to the composed slug, backed by a slim run-status read) around the fragment mount; the section stays read-only. The fragment’s CSP unit test additionally pins <form-freedom.

  • The W5 display-name resolution now consults case_section defs — it was panel-only, so a case-section-only manifest rendered an empty display_name (the recon gap this export exposed).

  • craig-web consumes the case_detail surface (resolve_surface best-effort, degrade-to-zero-sections — the contested designed-silence posture for TRANSIENT outages): each composed section renders as a tab whose body lazy-loads through GET /plugins/{slug}?case_id=… (the {case_id} placeholder). This replaced the native E1 SSA tab per the 2026-08-21 ratified fork — the SSA surface is now jurisdiction-composable.

  • Boot-verified baselines. The STANDING misconfiguration class (a trusted baseline naming an export no registered plugin provides — e.g. the GA baseline without the plugin-ssa-screening feature) now refuses craig-composition BOOT (UnknownBaselineExport) instead of 500ing every resolve while the BFF silently drops the surface.

  • Zero-TTL = never cached, never gated. cache_ttl = "0s" is the contract for ACTION-COUPLED sections (a cached copy would serve the pre-action state after a PRG); the render route skips both the cache insert and the single-flight gate entry for zero-TTL plugins — a per-case section’s keyspace is unbounded and dead entries/gates would grow forever.

  • Recorded v1 parity residue (vs the retired native tab): fragment strings are hardcoded English (RenderCtx.locale delivered but unconsumed; the manifest [i18n] seam is the future home), timestamps render %Y-%m-%d %H:%M UTC rather than the host’s format_date style, and the section tab label resolves in the default locale (the same v1 posture as the W5 route).

Open questions

Deferred to the implementation (Plan W), not blocking acceptance:

  • The required_roles role source and validation timing. CRAIG has no roles.toml today (it has Keycloak realm roles + the craig-authz model); whether role references validate against the Keycloak realm roles, a bundle-contributed role set, or a future roles.toml — and therefore whether the §6 role check runs at boot or defers to request time — is a Plan W decision. This ADR fixes that role references ARE validated; the source + timing are Plan W’s.

  • Per-plugin CSS delivery. Plugin output is CSP-bounded (§4) and consumes the ADR-036 theme tokens; how a plugin ships component styles beyond those tokens CSP-cleanly (a host-served per-plugin stylesheet vs token-only styling) is a Plan W render-runtime detail.

  • Dependent / cascading fetches. The manifest declares its data endpoints upfront and the host fetches them before render; whether a panel that needs a fetch dependent on a prior result is supported — and how, without reintroducing in-plugin I/O — is a Plan W decision.

  • The exact credential the host presents for a manifest auth (the acting worker’s token vs the jurisdiction’s service token, per ADR-028) and the FetchOutcome::Error shape — Plan W finalizes both alongside the render I/O types.

  • The v2 WASM interface itself — the component-model / WIT shape, the capability and fuel/timeout model, the craig-plugin-wasm crate. Out of scope here; this ADR only makes v1 WASM-ready.

  • The host’s per-plugin data-fetch cache strategy (the manifest declares cache_ttl; the cache implementation is Plan W).

  • Whether the render context carries the full worker claims or a reduced, serialization-minimal view.

Alternatives considered

  1. Make linkme (or the discovery backend) the public surface. Rejected: it would bind the BFF and the composition engine to the compile-time mechanism, forcing a rewrite when the WASM backend lands. The PluginSource trait is the whole point.

  2. Plugins do their own I/O (canopy’s fetch() model). Rejected: in-plugin I/O cannot move to a capability-free sandbox without a capability interface heavier than v1 needs; host-fetches-pure-render is portable, testable, and keeps the data path in one audited place.

  3. A sibling CRAIG_PLUGIN_TEMPLATES distributed slice (the umbrella sketch). Rejected: an Askama template is a type, not a value; registering it necessarily reduces to registering a render entrypoint, which the single CRAIG_PLUGINS registration already carries — a second slice would only add a sync hazard.

  4. A plugins: Vec<PluginManifest> field on BundleContribution (the anticipated shape). Rejected: it duplicates the manifests the slice/loader already own; the registry is materialized from PluginSource and validated in the same atomic boot pass without a field. Recorded as ADR-032 amendment A12.

  5. inventory instead of linkme for discovery. Rejected: inventory registers via life-before-main constructors; linkme is a pure linker-section concatenation (no startup cost, no ordering surprises) and is canopy’s choice. Either way the choice is a v1 detail behind PluginSource.

  6. Validate the four state blocks at boot. Rejected: template blocks are compile-time-knowable; the check belongs in a build-time lint off the boot path (the ADR-036 §8 split).

Out of scope

  • The 5-layer composition engine (dashboards + override layers → resolved page), the composition_overrides store, and the new composition backend service — ADR-035 (anticipated) / Plan X.

  • Per-field ownership and field-level authorization — ADR-037 (anticipated) / Plan Y.

  • The WASM runtime (sandbox, capabilities, fuel, the WIT interface, craig-plugin-wasm) — a v2 concern; this ADR only makes v1 WASM-ready.

  • The render runtime implementation — the BFF rendering pipeline, the per-plugin data-fetch cache, the four-state-contract lint, and a reference plugin — Plan W.

  • Per-jurisdiction [data] endpoint binding for a shared core plugin reused across jurisdictions — a Plan W / composition detail.

Edit this page · latest