ADR-021: Worker Portal Composability Runtime + Plugin Model
On this page
Context
canopy-web is reframed around three composability surfaces — dashboard, case detail, identity — where each jurisdiction edits TOML in rulesets/{jurisdiction}/ rather than forking canopy. The runtime resolves a (jurisdiction, role, user, surface) request by walking a five-layer override stack (user delta → role override → jurisdiction live override → jurisdiction TOML baseline → system defaults) and renders only the panels and sections the jurisdiction references.
The runtime contract has to be ratified before any Stage-3 code lands (composition loader, DB migrations, live override read/write APIs). Without ADR ratification the codebase risks landing half-migrated when the override merge semantics, plugin manifest schema, or sandboxing posture get re-litigated mid-build.
This ADR defines the composition loader contract, the Plugin.toml schema, validation rules, plugin sandboxing posture, and plugin lifecycle. It does not define the storage layer for overrides (deferred to ADR-022) or the promote-live-to-baseline mechanism (originally scoped as ADR-023; deferred 2026-05-20 in favor of #507, a broader unified config-backend ADR across canopy domains).
This ADR is part of group epic &51 (#460). It lands as Stage 2 of 7 and gates Stage 3.
Options considered
Sandboxing posture
Option A′: In-process trusted Askama partials + PluginSource trait (selected for v1)
Plugins compile into the canopy-web binary as Askama partials. The composition loader fetches plugins via a PluginSource trait; v1 ships only CompileTimePluginSource. v2 federation adds new trait impls (WasmPluginSource, FilesystemPluginSource) without rewriting the loader.
-
Pros: simplest v1 shipping shape; existing Askama + htmx + Alpine.js (CSP build) stack unchanged; type safety from compile-time template parse; CSP stays strict (no plugin-injected JS at runtime); plugin permissions enforced by Rust handler boundaries; every reference panel/section in the design surfaces (dashboard panels, case-detail sections) compiles down to one Askama partial; v2 federation is additive (new
PluginSourceimpl) not a refactor. -
Cons: installing a new plugin in v1 requires a canopy-core PR. Jurisdictions cannot ship plugins without engaging canopy maintainers until v2. Acceptable — Plugin Marketplace federation (#501) is out-of-scope per #460. The trait abstraction costs ~50 lines of code in v1 against the v2 migration cost it amortizes.
Option A: In-process trusted Askama partials, no trait abstraction (rejected)
Same v1 shipping shape but the composition loader hard-codes against the compile-time registry. Rejected for v2 migration cost — federation becomes a re-litigation of the runtime rather than an additive change.
Option B: WASM-sandboxed plugins (deferred to v2)
Plugins package as .wasm modules loaded at runtime via wasmtime / wasmer. Allows jurisdictions to ship plugins without a canopy-core rebuild.
-
Rejected for v1. Adds the wasmtime dep tree, a sandboxing-policy spec (which host functions exposed, which CPU/memory limits), and a WASM-friendly template engine (Askama is compile-time — would need Tera or Liquid as a runtime alternative). ~6 months of v1-blocking architecture work. Revisit when Plugin Marketplace federation becomes a real concern; lands as
WasmPluginSourceper Option A′.
Option C: Out-of-process sidecar plugins (deferred to v2+)
Each plugin runs as its own service; canopy-web orchestrates via HTTP. Maximum isolation, maximum operational cost.
-
Rejected. Operationally heavy for what it buys; SNAP UAT and Phase-3 work cannot bear this complexity. Same revisit-with-marketplace gate as Option B.
Plugin discovery (within Option A′'s CompileTimePluginSource)
Option A: #[canopy_plugin] macro + linkme distributed slice (selected)
Plugins register themselves via a #[canopy_plugin] proc-macro on the Askama partial wrapper struct. The macro emits a linkme::distributed_slice entry containing the plugin handler + parsed Plugin.toml. CompileTimePluginSource::new() iterates the slice at startup. The macro also validates Plugin.toml at compile-time — parses the manifest, asserts the declared data.endpoints URL parameters resolve to the handler’s request type fields, and rejects slug-vs-handler-name mismatch.
-
Pros: compile-time slug uniqueness; compile-time
Plugin.toml↔ Rust handler signature validation prevents manifest-handler drift (the #1 source of "this plugin renders nothing" debugging in plugin runtimes); no boot-time IO; mis-typed slugs incomposition/*.tomlfail at composition load with a clear error. -
Cons:
linkmeis platform-dependent at the linker level. Works reliably on Linux/macOS/Windows. Addslinkme+ acanopy-plugin-macrosproc-macro crate.
Option B: build.rs scans plugins/*/Plugin.toml, generates plugins.rs
Build script walks the plugins directory, validates each Plugin.toml, generates a plugins.rs literal at the crate root with the static PluginRegistry. Plugins manually export their Rust handler; the build script wires it.
-
Rejected as v1 default but documented as the
linkmefallback. Iflinkmeever bites on a new platform target, this is a smooth migration — sameCompileTimePluginSourceshape, different population mechanism, composition loader untouched. Cost: mild manifest-handler duplication (plugin author declares the handler name in bothPlugin.tomland as a Rust export).
Option C: inventory::submit!() in each plugin’s mod.rs
Same shape as A but using the inventory crate; Plugin.toml parsed at runtime instead of compile-time.
-
Rejected. Loses compile-time manifest-handler drift detection — the load-bearing benefit of Option A. Same linker-dependent behavior as
linkme(no portability win).
Composition reload posture
Plugin Rust handler reload is never supported — compile-time linkage means hot-reload would require libloading dynamic libraries, which collides with linkme + AGPL static-linking guarantees. Plugin changes require rebuild + restart.
The real question is composition TOML reload (jurisdiction baseline TOML + live override DB writes from Studio).
Option C.i: Invalidate-on-write, single-replica v1 (selected)
CompositionLoader holds a tokio::sync::RwLock<HashMap<CompositionKey, ComposedSurface>> cache. The live-override write API (#491) invalidates the relevant CompositionKey after a successful write. Workers in the editing jurisdiction see the change on their next render.
-
Pros: fast (cache-hit per render); never stale within a single canopy-web replica; Studio live-mode UX (#500 — "edit and see immediately") works correctly out of the box.
-
Cons: multi-replica invalidation needs more machinery. Implemented by #1225 (scale audit M7): every canopy-web replica runs a broadcast consumer (per-replica exclusive queue, the #458 fan-out primitive) over the nine
composition.*WRITE events the studio handlers already stage transactionally, evicting jurisdiction-wide on receipt — plus a clear-on-(re)attach gap net so an invalidation published while a replica was detached can never pin stale content past the reconnect (#510’s TTL is the complementary defense-in-depth backstop). No separatecomposition.invalidatedevent was needed — the write events carry the invalidation signal.
Option A: No caching, read on every render
-
Rejected as v1 default. Always fresh, simplest. Per-render TOML parse + DB read for overrides is fine at SNAP UAT scale but adds up at 50+ jurisdictions with 500+ overrides each. Worth documenting as the fallback if C.i’s invalidation logic ever produces a correctness bug — single-line revert.
Decision
Option A′ (PluginSource trait, v1 ships only CompileTimePluginSource) + Option A discovery (#[canopy_plugin] macro via linkme) + Option C.i composition cache (invalidate-on-write, single-replica v1).
The composition loader is source-agnostic: it depends on dyn PluginSource. v1 wires CompileTimePluginSource only. v2 federation adds new trait impls additively.
Plugin source trait
#[async_trait]
pub trait PluginSource: Send + Sync {
/// Resolve a plugin slug to its handler + manifest.
fn get(&self, slug: &PluginSlug) -> Option<&dyn Plugin>;
/// Iterate all plugins this source knows about (for validation + registry dump).
fn iter(&self) -> Box<dyn Iterator<Item = &dyn Plugin> + '_>;
}
pub struct CompileTimePluginSource; // v1 — reads `linkme::distributed_slice`
// Future: pub struct WasmPluginSource; pub struct FilesystemPluginSource;
Composition loader contract
The composition loader is a single async function on a struct that holds a Arc<dyn PluginSource>:
pub async fn load_composition(
&self,
jurisdiction: &JurisdictionSlug,
role: &RoleSlug,
user_id: Option<&UserId>,
surface: ComposableSurface,
) -> Result<ComposedSurface, CompositionLoadError>;
Where:
-
ComposableSurface∈ {WorkerDashboard,SupervisorDashboard,AnalystDashboard,CaseDetail,SignIn}. -
ComposedSurfacecarries:-
surface: the resolvedComposableSurface -
shell: a per-surface enum (e.g.CaseDetailShell::{Scroll, CardGrid, Tabs}) defaulting toTabsfor case-detail per the locked decision in the plan’s Design section -
items: aVec<ComposedItem>(panel for dashboards, section for case-detail, IDP entry for sign-in) in render order -
version: a u64 derived from the SHA-256 of the canonical-serialized resolved composition document (the post-merge tree itself, after all five layers + role filter have been applied), for cache validation downstream. Canonical serialization uses the same RFC 8785-style key-sorted JSON form canopy already uses elsewhere so the hash is stable across deserializer roundtrips.
-
The loader walks the five layers top-down (user delta → role override → jurisdiction live → jurisdiction baseline → system defaults), merges per the semantics defined in ADR-022, and returns the resolved tree. Role filtering applies after merge: items whose Plugin.toml permissions.required_roles exclude the request’s role are silently dropped (not rendered as "permission denied" — the jurisdiction’s composition should not surface items the role cannot use).
Plugin.toml schema
Each plugin’s manifest lives at services/canopy-web/plugins/{slug}/Plugin.toml. Schema:
[plugin]
slug = "snap-overpayment-summary" # unique across registry; kebab-case
name = "SNAP Overpayment Summary" # human-readable; i18n via [i18n] catalogs
version = "1.0.0" # semver
author = "canopy-core" # free-form
license = "AGPL-3.0-or-later" # SPDX identifier
canopy_min = "0.1.0" # minimum canopy version compatible
[plugin.exports]
panels = ["snap-overpayment-summary-panel"]
case_sections = [] # this plugin contributes only to dashboards
[panels.snap-overpayment-summary-panel]
display_name_key = "panels.snap_overpayment_summary.title" # i18n key
icon = "💰" # unicode glyph or token (orchard-icon-NN)
programs = ["snap"] # subset of {snap, tanf, medicaid, caps, wic}
default_span = 4 # 1..12 grid columns
allowed_spans = [3, 4, 6, 12] # subset of {1..12}; jurisdiction can resize within this set
required_states = ["empty", "loading", "error", "populated"]
# Stage-1.5 (#505) enforces all four; pre-1.5 plugins may omit "empty"
# — Stage 1 utility classes used instead
[data]
source = "canopy-snap" # which canopy service the panel fetches from
auth = "service_class" # {none, service_class, user_jwt}; per ADR-019
cache_ttl_seconds = 30 # enforced (#1218); 0 disables caching — see Consequences
timeout_ms = 5000
endpoints = ["/v1/overpayments/summary?household_id={household_id}"]
[permissions]
required_roles = ["eligibility_worker", "supervisor"] # role slugs from idp.toml + jurisdiction role config
audit = "read" # {none, read, write}; emits AuditEvent on render
[i18n]
default = "en"
catalogs = ["en", "es"] # Fluent catalogs at plugins/{slug}/i18n/{lang}.ftl
case_sections exports follow the same shape but with [case_sections.<slug>] tables and an additional applicable_to field for cross-program filtering.
Validation rules
At plugin registration time (#[canopy_plugin] macro expansion):
-
plugin.slugmatches^[a-z][a-z0-9-]*[a-z0-9]$and is unique across all registered plugins (compile-time error otherwise). -
plugin.versionparses as semver. -
plugin.canopy_minparses as semver; canopy core’sCARGO_PKG_VERSIONis asserted to satisfy it at build time (compile-time error if a plugin pins a canopy version newer than the workspace). -
plugin.exports.panels⊆ defined[panels.*]tables (and same forcase_sections). -
Per-panel
default_span∈allowed_spans. -
Per-panel
allowed_spans⊆{1, 2, 3, 4, 6, 12}(the 12-column grid breakpoints — non-breakpoint values rejected because grid alignment depends on them). -
Per-panel
programs⊆{snap, tanf, medicaid, caps, wic}. -
permissions.required_rolesis non-empty (a plugin with no required roles renders for everyone — explicitly opt-in withrequired_roles = ["*"]if so). -
data.auth∈{none, service_class, user_jwt}; ifservice_class, ADR-019 dictates the JWT shape. -
data.cache_ttl_seconds≥ 0;data.timeout_ms> 0. -
i18n.catalogsis non-empty and containsi18n.default.
Composition-time validation (per render):
-
Every slug referenced in the composition TOML (jurisdiction baseline + live + role + user) resolves in the
PluginRegistry. Unknown slug → composition fails to load with aCompositionLoadError::UnknownPlugin { slug }error. -
Resolved
spanvalue is in the plugin’sallowed_spans. Out-of-range →CompositionLoadError::SpanOutOfRange. -
Total span per row ≤ 12 (the grid’s column width). Exceeds →
CompositionLoadError::RowOverflow.
Consequences
Positive
-
Architectural commitment locked. Stage 3 (composition loader + override APIs) can build against a stable contract.
-
v2 federation is additive, not a refactor. Adding
WasmPluginSourcelater means a new trait impl + a new top-level wiring decision — the composition loader, validation rules, andPlugin.tomlschema all stay. -
Compile-time safety on manifest-handler alignment. The
#[canopy_plugin]macro parsesPlugin.tomlat build time and asserts the declareddata.endpointsURL parameters resolve to the Rust handler’s request type fields. Manifest-handler drift is caught atcargo build, not at first render. -
CSP stays strict. No runtime-loaded JS or templates; the inline-script-and-eval prohibition in the project’s
.claude/docs/security.mdis not weakened. -
Role-based item filtering is invisible to the role. A worker doesn’t see ghosts of supervisor-only panels (no aria-disabled clutter); the composition simply doesn’t include them. Cleaner UX + smaller wire payload.
-
Predictable failure modes. Every composition error is one of a closed-set enum (
UnknownPlugin,SpanOutOfRange,RowOverflow,RoleNotFound); jurisdiction admins get actionable Studio errors. -
Studio live-mode UX works out of the box. Invalidate-on-write means a jurisdiction admin editing a composition in Studio (#500) sees their change on the next page load — no 60s TTL surprise.
Negative
-
Plugin installation requires canopy-core PR. Jurisdictions cannot ship plugins without engaging canopy maintainers in v1. Plugin Marketplace federation (deferred per #460) is the long-term answer; lands as
WasmPluginSourceper Option A′. -
linkmeis platform-dependent at the linker level. Works reliably on canopy’s Linux Alpine production target. If a new target ever breaks, the documented migration is Option B (build.rsscan) — sameCompileTimePluginSourceshape, different population mechanism. -
Adds two new crates.
canopy-plugin-macros(proc-macro) +linkmedep. Both small, both isolated. -
No filesystem hot-reload for plugin code. Changing a plugin’s Rust handler requires rebuild + restart. Composition TOML changes do not — invalidate-on-write means writes from Studio are visible to the editing replica immediately.
-
data.cache_ttl_secondsenforcement: RESOLVED (#1218, 2026-08-09). The field was ratified here but consumed by nothing until #1218 built the enforcement: a process-local, byte-bounded panel-data TTL cache at canopy-web’sInternalClientJSON-GET seam (dashboard panels + case-detail sections; fail-closed guards run before any cache read; per-key single-flight; credential-hash + full-URL keys).0bypasses ("0 disables caching", as documented in the schema above). The manifest value is the plugin author’s default; deployments override per item through the composition layers (ComposedItem.cache_ttl_secondsin baseline TOML / jurisdiction-live / role — the USER layer is excluded, see the ADR-024 amendment). This does not revisit the rejected composition-document TTL option above — that concerned layout caching, not panel data. Manifests declaring endpoints they never call were also re-aligned in the same MR, anddata.endpointsmay now be EMPTY for plugins that perform no upstream fetch (the truthful stub shape). -
Multi-replica cache invalidation: RESOLVED (#1225, 2026-07-29). canopy-web is safe to run multi-replica: a write through replica A is visible via replica B within one event propagation (the composition WRITE events fan out to every replica’s broadcast consumer; jurisdiction-wide eviction on receipt; clear-on-reattach closes detach gaps). The single-replica deployment constraint is lifted. Missed-event defense-in-depth (TTL eviction) remains tracked as #510.
-
required_statesenforcement is Stage-1.5. Pre-1.5 plugins may declarerequired_states = ["loading", "error", "populated"](3-state); Stage-1.5 (#505) tightens to 4-state with theEmptyStateprimitive. The plugin manifest carries the declared states so the runtime knows what to expect.
Implementation
Tracked under Stage 3 of group epic &51 (#460):
-
#489 — DB migrations for composition override layers
-
#490 — Composition loader (this ADR’s runtime)
-
#491 — Live override APIs (read/write/archive; no promote in v1)
#492 (originally Stage-3 promote-live-to-baseline implementation) was closed-deferred to #507 when ADR-023 was reframed as a canopy-wide config-backend ADR. Studio’s v1 "promote" affordance is admin-driven (admin uses jurisdiction’s existing baseline-edit workflow external to canopy until #507 lands a write-capable backend).
The canopy-plugin-macros crate is in-scope for #490 (composition loader). The compile-time registry construction is in #490’s first commit; downstream plugin Rust handlers convert from their current ad-hoc shape over the course of Stages 5-7 as each surface migrates.
Design-question resolutions (resolved 2026-05-24)
The 3 design questions filed on #486 + the related #499 Step 5 question all resolved before Stage 6 implementation begins. Captured here as a self-contained amendment so a contextless reader of ADR-021 gets the full picture without round-tripping to #486 / issue comments.
-
Plugin Studio (#501) plugin authoring — resolved: manifest editor + preview + export-as-bundle. Plugin Studio in v1 lets a jurisdiction admin (a) author a
Plugin.tomlagainst a live manifest editor with schema validation, (b) preview the plugin’s panel/section against fixture data in the live composer, and (c) export the result as a downloadable bundle (thePlugin.toml+ handler stubs in a zip / tarball) for the admin to integrate into a canopy-core fork manually. Studio does NOT open a PR against canopy-core in v1; the export-bundle path keeps Studio decoupled from the write-capable backend descoped to #507. Marketplace federation (publish / install from registry / signing) explicitly deferred to v2. This mirrors the #499 Step 5 export-bundle pattern below — Studio surfaces converge on the same export shape. -
Multi-jurisdiction plugin visibility — resolved: compile-time global. If a plugin is compiled into canopy-core’s binary, every jurisdiction can reference it in its composition TOML. No
plugin-allow.tomlper jurisdiction. Rationale: one fewer TOML surface to maintain, one fewer source of mistakes, jurisdiction admins can simply ignore plugins they don’t want. If a per-jurisdiction allow-list ever becomes necessary (e.g. once federated WasmPluginSource ships), it can be added forward-only — the v1 contract is "compiled in == globally visible". -
i18n catalog fallback — resolved: RFC 7231 §5.3.5 best-match Accept-Language negotiation via Fluent’s
langneg. When a user’s session locale is not in the plugin’s[i18n].catalogs, the loader walks the user’sAccept-Languagechain (already exposed by the canopy-portalLocaleManager) and picks the first catalog the plugin ships, falling back to the plugin’s declared[i18n].defaultas the final step. Rationale: this is the established web standard for content negotiation; produces best-available locale for users with multiple preferred languages; scales to additional locales without revisiting logic; complexity is bounded (onelangnegcall, no new state); avoids the mixed-language UX trap of "always fall back to the plugin default" once multiple plugins ship partial coverage. -
#499 Studio onboarding wizard Step 5 (PR generation) — resolved: downloadable diff bundle. Original spec called for a real PR against canopy-core via the Stage-3 promote-PR component. That component was descoped to #507. Replacement: Step 5 generates the new jurisdiction’s
rulesets/{new-slug}/files in-memory and offers a downloadable zip / tarball. The admin applies the bundle locally and PRs through their own git workflow. Same export-bundle pattern as Plugin Studio (Q1 above) — Studio surfaces converge.
The 3 #486 questions + the #499 Step 5 question were the entire open-design-question surface for Stages 6 + 7. No remaining open design questions block Stage 6 / 7 implementation as of 2026-05-24.
References
-
Plan: Worker portal redesign (#460)
-
ADR-019: Service Identity (the
service_classauth mode) -
Epic &51 — Worker portal redesign: composability runtime + design-system extraction.
-
Issue #486 — this ADR’s tracking issue.