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 PluginSource impl) 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 WasmPluginSource per 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 in composition/*.toml fail at composition load with a clear error.

  • Cons: linkme is platform-dependent at the linker level. Works reliably on Linux/macOS/Windows. Adds linkme + a canopy-plugin-macros proc-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 linkme fallback. If linkme ever bites on a new platform target, this is a smooth migration — same CompileTimePluginSource shape, different population mechanism, composition loader untouched. Cost: mild manifest-handler duplication (plugin author declares the handler name in both Plugin.toml and 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 separate composition.invalidated event 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.

Option B: TTL cache (60s)

  • Rejected. Up to 60s of staleness after a Studio edit breaks the live-mode UX loop (admin saves a change and expects to see it on their own next page load).

Option D: Version-based stale-while-revalidate

  • Rejected for v1. More conceptual complexity than C.i; staleness window still exists. Lands as the natural shape if multi-replica caching becomes important AND a single-event-bus invalidation isn’t sufficient (e.g., replicas span network partitions).

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

  • ComposedSurface carries:

    • surface: the resolved ComposableSurface

    • shell: a per-surface enum (e.g. CaseDetailShell::{Scroll, CardGrid, Tabs}) defaulting to Tabs for case-detail per the locked decision in the plan’s Design section

    • items: a Vec<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):

  1. plugin.slug matches ^[a-z][a-z0-9-]*[a-z0-9]$ and is unique across all registered plugins (compile-time error otherwise).

  2. plugin.version parses as semver.

  3. plugin.canopy_min parses as semver; canopy core’s CARGO_PKG_VERSION is asserted to satisfy it at build time (compile-time error if a plugin pins a canopy version newer than the workspace).

  4. plugin.exports.panels ⊆ defined [panels.*] tables (and same for case_sections).

  5. Per-panel default_spanallowed_spans.

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

  7. Per-panel programs{snap, tanf, medicaid, caps, wic}.

  8. permissions.required_roles is non-empty (a plugin with no required roles renders for everyone — explicitly opt-in with required_roles = ["*"] if so).

  9. data.auth{none, service_class, user_jwt}; if service_class, ADR-019 dictates the JWT shape.

  10. data.cache_ttl_seconds ≥ 0; data.timeout_ms > 0.

  11. i18n.catalogs is non-empty and contains i18n.default.

Composition-time validation (per render):

  1. Every slug referenced in the composition TOML (jurisdiction baseline + live + role + user) resolves in the PluginRegistry. Unknown slug → composition fails to load with a CompositionLoadError::UnknownPlugin { slug } error.

  2. Resolved span value is in the plugin’s allowed_spans. Out-of-range → CompositionLoadError::SpanOutOfRange.

  3. Total span per row ≤ 12 (the grid’s column width). Exceeds → CompositionLoadError::RowOverflow.

Consequences

Positive

  1. Architectural commitment locked. Stage 3 (composition loader + override APIs) can build against a stable contract.

  2. v2 federation is additive, not a refactor. Adding WasmPluginSource later means a new trait impl + a new top-level wiring decision — the composition loader, validation rules, and Plugin.toml schema all stay.

  3. Compile-time safety on manifest-handler alignment. The #[canopy_plugin] macro parses Plugin.toml at build time and asserts the declared data.endpoints URL parameters resolve to the Rust handler’s request type fields. Manifest-handler drift is caught at cargo build, not at first render.

  4. CSP stays strict. No runtime-loaded JS or templates; the inline-script-and-eval prohibition in the project’s .claude/docs/security.md is not weakened.

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

  6. Predictable failure modes. Every composition error is one of a closed-set enum (UnknownPlugin, SpanOutOfRange, RowOverflow, RoleNotFound); jurisdiction admins get actionable Studio errors.

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

  1. 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 WasmPluginSource per Option A′.

  2. linkme is 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.rs scan) — same CompileTimePluginSource shape, different population mechanism.

  3. Adds two new crates. canopy-plugin-macros (proc-macro) + linkme dep. Both small, both isolated.

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

  5. data.cache_ttl_seconds enforcement: 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’s InternalClient JSON-GET seam (dashboard panels + case-detail sections; fail-closed guards run before any cache read; per-key single-flight; credential-hash + full-URL keys). 0 bypasses ("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_seconds in 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, and data.endpoints may now be EMPTY for plugins that perform no upstream fetch (the truthful stub shape).

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

  7. required_states enforcement is Stage-1.5. Pre-1.5 plugins may declare required_states = ["loading", "error", "populated"] (3-state); Stage-1.5 (#505) tightens to 4-state with the EmptyState primitive. 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.

  1. Plugin Studio (#501) plugin authoring — resolved: manifest editor + preview + export-as-bundle. Plugin Studio in v1 lets a jurisdiction admin (a) author a Plugin.toml against 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 (the Plugin.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.

  2. 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.toml per 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".

  3. 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’s Accept-Language chain (already exposed by the canopy-portal LocaleManager) and picks the first catalog the plugin ships, falling back to the plugin’s declared [i18n].default as 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 (one langneg call, no new state); avoids the mixed-language UX trap of "always fall back to the plugin default" once multiple plugins ship partial coverage.

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

Edit this page · default