Plan X: Composition Layer Engine (child plan of Plan S umbrella)

On this page

Status

Step Description Status

X1

NEW crates/craig-composition: the portable composition engine (no service, no I/O). Port canopy’s composition crate (/home/bitskrieg/code/canopy/crates/canopy-composition/src/) adapted to ADR-035. X1 first verifies the canopy port-source paths exist at canopy HEAD (loader.rs, merge.rs, role_filter.rs, user_delta.rs, cache.rs, types.rs, source.rs) — if any is missing, file a blocker issue and stop. Port: types.rs (ComposedSurface { version: u64, shell, items }, ComposedItem { item, span, row }, ComposableSurface enum of the CRAIG surfaces — dashboards + case-detail, CompositionKey { jurisdiction, role, user_sub: Option, surface }, ShellSpec); merge.rs (RFC 7396 merge-patch for product-default→baseline + RFC 6902 JSON-Patch for the DB layers, via the json-patch crate — canopy’s exact dep); role_filter.rs (filter_items_by_role reading the plugin manifest required_roles). The engine reaches plugins through the PluginSource trait from craig-plugin-contracts (Plan W) — canopy’s PluginSource maps onto CRAIG’s; canopy’s JurisdictionSlug/RoleSlug/UserId map onto CRAIG’s bundle-activation + Claims types. Product default (layer 5) is compiled into this crate (canopy’s system_defaults() precedent; jurisdiction-neutral). Versioning adopts true RFC 8785 (JCS) + SHA-256 (ADR-035 §6 — the hardening canopy deferred). Deps: serde / serde_json / json-patch / sha2 / toml / thiserror / uuid + the JCS crate + craig-plugin-contracts; NO sqlx / craig-mq (this crate is the pure merge/role-filter/hash engine; persistence + cache + MQ are the service’s, X2/X5/X6). Two X1 gating blockers (resolving ADR-035’s deferred §6/§8/§9 open questions): (a) select + validate a JCS/RFC 8785 crate that is MSRV-1.88 / musl-safe — the hash-determinism test is the gate; fallback is a vetted recursive key-sorted canonicalizer (the canonicalization is small + self-contained); (b) fix the CompositionContribution payload shape (the pre-materialized surface declaration per ADR-035 §8 — carries NO baseline trees), since X2/X3 integrate it. Shippable: both blockers resolved; crate compiles + clippy -D warnings clean; unit tests port canopy’s (merge precedence, RFC 7396 vs 6902 boundary, role-filter-after-merge, hash determinism + cross-run reproducibility); axis-markers on all tests. No service yet.

Done — !726 / b7b88a17 (engine crate renamed craig-compositioncraig-composition-engine at X2 — see Errata)

X2

NEW services/craig-composition: the backend service (port 8009, DB craig_composition). The ninth backend service, mirroring 8001-8008. Cargo deps: craig-api / craig-auth / craig-authz / craig-bootstrap / craig-common / craig-db / craig-mq / axum / sqlx / tokio + craig-composition (X1). main.rs = the canonical orchestrator (bootstrap("CRAIG_COMPOSITION", "craig-composition") → run_migrations(sqlx::migrate!()) → shutdown_token → spawn_workers → build_router → ApiServer::serve; § Service Initialization). Infra: NEW craig-composition target stage in the single root Dockerfile (CRAIG has no per-service Dockerfiles) + add -p craig-composition to the builder cargo build line; NEW craig-composition block in devstack/docker-compose.yml (port 8009, CRAIG_COMPOSITION__* env, depends_on postgres + rabbitmq + keycloak, /readyz healthcheck); CREATE DATABASE craig_composition; in devstack/postgres/init.sql; services/craig-composition/migrations/ dir (tables land X5). Workspace Cargo.toml += the crate + service members. Shippable: the service boots in devstack, /healthz + /readyz green (the resolution route is X3); the pre-push devstack health check exercises boot/config. Depends X1.

Done — !727 / 982e6952

X3

CompositionLoader + the resolution endpoint (5-layer walk, role-filter-after-merge). Port canopy’s loader.rs into the service: for a CompositionKey, walk product-default (compiled) → RFC 7396 merge the jurisdiction baseline (read at runtime from rulesets/<jurisdiction>/{dashboard,case_detail}.toml, mirroring CRAIG’s existing JDM-rulesets runtime load) → RFC 6902 apply the DB override layers (jurisdiction-live, role, user; X5 supplies the rows) in precedence order → deserialize to the typed ComposedSurfacerole-filter AFTER merge (filter_items_by_role via the PluginSource registry) → RFC 8785 + SHA-256 version. Mount GET /v1/compositions/{surface} (authz-gated; reads role + jurisdiction from claims / claims.actor per X7’s auth) returning ComposedSurface + version. required=true panels + the 12-column row budget are hard invariants the loader enforces. Shippable: the endpoint returns a merged + role-filtered tree for a fixture jurisdiction; a baseline-added panel is dropped for a role lacking its plugin permission; the version hash is deterministic across calls; unit tests at the loader level (endpoint/integration coverage — the authz gate, surface 404, role plumbing — lands at X7, where the BFF exercises the route end-to-end). Depends X2 (+ X5 for the DB layers; X3 can land against compiled-default + baseline first, DB layers wired when X5 merges).

Done — !728 / 33253e5d

X4

UserDelta envelope + apply + validate (the versioned dashboard delta). Port canopy’s user_delta.rs: the versioned #[serde(tag = "type", rename_all = "snake_case")] user_delta_v1 envelope (hidden_slugs: Vec<String>, span_overrides: HashMap<String, u8>, slug_order: Vec<String>) anchored to plugin slugs (not array indices); apply_user_delta (3-step in-place: drop hidden, apply span overrides, stable-reorder by slug; unknown slugs silently ignored — forward-compatible with baseline additions); validate_user_delta (4-step, run BEFORE persist: every referenced slug ∈ the post-role-filter baseline; the slug’s plugin permitted for the writing role; each span ∈ the panel’s allowed_spans; a dry-run apply keeps every row ≤ 12 columns). Case-detail + other non-dashboard surfaces use RFC 6902 ops (the loader already applies these in X3). required=true cannot be hidden by any delta. Shippable: apply + validate unit tests (port canopy’s, incl. the 4 validation arms + the required-panel guard); axis-markers. Depends X1.

Done — !729 / 43b9e608

X5

NEW table composition_overrides + the DB layer. Migration services/craig-composition/migrations/<ts>_create_composition_overrides.sql: composition_overrides (id UUID PRIMARY KEY DEFAULT uuidv7(), jurisdiction_code TEXT NOT NULL, role TEXT NULL, user_sub UUID NULL, surface_key TEXT NOT NULL, delta JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (jurisdiction_code, role, user_sub, surface_key)) (the ADR-035 §5 schema). The layer a row belongs to is determined by which scope columns are non-null: role IS NULL AND user_sub IS NULL ⇒ jurisdiction-live (layer 3); role IS NOT NULL AND user_sub IS NULL ⇒ role override (layer 2); user_sub IS NOT NULL ⇒ user delta (layer 1). Port canopy’s db.rs read (fetch_db_layers returning the layers ordered live → role → user) + write (insert-or-replace / append) + the updated_at-derived RFC 7232 ETag + monotonic-update guard for optimistic concurrency. Shippable: the migration runs on boot; fetch_db_layers returns the ordered override rows; a write round-trips; the loader (X3) consumes the DB layers; integration tests against the devstack DB. Depends X2.

Done — !730 / 5df51b0e

X6

Invalidate-on-write cache + composition.invalidated RabbitMQ fanout. Port canopy’s cache.rs (a per-replica in-process RwLock<HashMap<CompositionKey, Arc<ComposedSurface>>> with invalidate / invalidate_jurisdiction / invalidate_user). On any override write (X4/X8/X9): drop the affected local entries AND stage a composition.invalidated event in the SAME DB transaction as the write (the transactional-outbox pattern, ADR-022; the outbox worker publishes to the craig.events exchange). Every replica subscribes via subscribe_exclusive on a per-instance UUID-named auto-delete queue (the cross-replica cache-invalidation pattern from ADR-024; skip self-emitted events by instance-id) and drops the matching entry on the next render. A bounded 10-minute TTL is the safety net for event-delivery failure (so a dropped invalidation self-heals). Shippable: a write invalidates the local entry + publishes the event; a second replica drops its cached entry on receipt; a TTL-expiry test; a cross-replica integration test (two instances, one writes, the other re-resolves fresh). Depends X3 + X5.

Done — !731 / 0c53d305

X7

The craig-web BFF becomes an HTTP client of craig-composition (ADR-028 auth pass-through). NEW composition client in craig-web (reusing the existing shared reqwest::Client + the Plan-E identity primitives — no new deps on craig-web): call GET /v1/compositions/{surface}, signing the call with the BFF’s client_credentials OidcServiceToken + an X-Craig-Actor JWT for the acting worker (ADR-028); craig-composition validates both JWKS + reads claims.actor for the worker role + jurisdiction. craig-web renders the resolved surface (dashboards + case-detail), driving the Plan W GET /plugins/<slug> render route per resolved item — RenderCtx.item (span/row) is now supplied by the composition tree (it was None for the context-free W6 plugins). NEW CRAIG_WEB__COMPOSITION_URL config. Shippable: a dashboard page renders its composed, role-filtered plugin set end-to-end in devstack; the auth pass-through works (a wrong actor → 401/403); the BFF gains no sqlx/craig-mq/craig-db dep (invariant grep). Depends X3 (+ X6 for cache correctness under writes).

Done — !732 / 3d6ae84f

X8

Studio admin UI for jurisdiction live overrides (layer 3). A craig-web Studio surface (BFF) that calls craig-composition write endpoints to edit the jurisdiction-live layer (DB, layer 3) — jurisdiction-scoped, authz-gated to a jurisdiction-admin role (ADR-023). Studio writes live overrides only — it CANNOT edit baselines (rulesets/), create a PR, or push to git (ADR-035 §2; baseline editing is ops territory). A write triggers the X6 invalidation. Shippable: a jurisdiction admin reorders/hides a panel at the jurisdiction level via Studio; it takes effect on next render (cross-replica via X6); a non-admin is rejected; e2e. Depends X6 + X7.

Done — !733 / c35864bf

X9

User-delta write endpoint + the personalization UI (layer 1). A craig-composition write endpoint persisting the user-delta layer (DB, scoped to user_sub), validated per X4; the craig-web UI for direct personalization (drag-to-reorder, pin, hide) posting a user_delta_v1 envelope. A write triggers the X6 invalidation (user-scoped). Shippable: a worker reorders / pins / hides a dashboard panel; it persists, survives reload, and does not leak to other users (cache-key isolation); an invalid delta (unknown slug / over-budget row / forbidden plugin) is rejected per X4; e2e. Depends X6 + X7 (+ X4).

Done — !734 / 8e6499d7

X10

Reference baselines + plan-completion audit + archive. NEW rulesets/georgia/dashboard.toml + rulesets/georgia/case_detail.toml (the GA baseline composition the loader reads at runtime; demonstrates the full stack end-to-end with a real jurisdiction). A fresh Explore plan-completion-audit subagent verifies every X1-X9 cell carries a concrete !MR / sha. 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 21 (Plan X execution) → Done + close epic &49. Register the service in .claude/CLAUDE.md (§ Service Ports: craig-composition 8009 / craig_composition; § Completed Plans name; the workspace member count to the live cargo metadata count — do NOT hard-code a delta). Memory sync (the BundleContribution compositions field + the new service). Depends all.

Done (2026-06-18) — this archive MR. The GA reference baselines (rulesets/georgia/dashboard.toml placing the example panel + case_detail.toml shell) ship the first non-empty composed surface end-to-end; the example reference plugin is force-linked into craig-composition (plugin-example feature, CRAIG_WEB_FEATURES build-arg) so the loader role-filters + validates against its manifest. Role resolution made deterministic (sorted-first resolve_worker_role, shared by the read + the user-delta validate) so the baseline resolves consistently regardless of IdP role ordering. A fresh Explore plan-completion-audit subagent confirmed every X1–X9 cell carries a concrete !MR / sha; all 10 §Cross-cutting invariants verified PASS. Body git-mv’d to plans/archive/; :status: Active→Complete; nav Active entry removed; sibling xrefs repointed; a plans/archive.adoc § Architecture row added (X1–X10 cites). Umbrella Step 21 → Done; Step 20 backfilled (!725 / 71a4f59c). Epic &49 closed. craig-composition registered in .claude/CLAUDE.md (§ Service Ports + member count). !MR / sha backfilled at the next umbrella step (ADR-037 / Plan Y kickoff).

Epic: &49 (Plan X)
Scoped label: Plan::X (filed with this body MR; one Plan::* label per issue — scoped-label collisions 404)
Branch prefix: <type>/plan-x-step<N>- for child code-execution MRs
*Parent
: Plan S umbrella Steps 20 (this body) + 21 (execution)
Anchor ADR: ADR-035 (the 5-layer composition engine + the NEW craig-composition backend service this plan implements)

Context

Plan S Phase 2 makes CRAIG’s UI composability real across five design-team engineering contracts. ADR-035 fixed the composition contract (Contract 3); Plan X builds the engine that implements it. A dashboard or case-detail surface is not built in code — it is composed in config, resolving top-down through five layers (user delta → role override → jurisdiction live override → jurisdiction baseline → product default), role-filtered after merge, and content-hashed for cache validity. The plugins being composed are the ones Plan W made renderable (GET /plugins/<slug>); Plan X decides which plugins land on which surface, in what order, at what span — and persists the per-jurisdiction, per-role, and per-user overrides that personalize that layout.

Plan X PORTS canopy’s composition subsystem (/home/bitskrieg/code/canopy/crates/canopy-composition; canopy ADR-021), NOT consumes it. The loader, the RFC 7396 / RFC 6902 merge strategies, the slug-anchored user_delta_v1 envelope, the role filter, and the invalidate-on-write cache port closely; the type/identity layer is adapted to CRAIG’s Claims, PluginSource (Plan W), craig-mq, and craig-db. CRAIG diverges from canopy in two deliberate ways set by ADR-035: (1) versioning uses true RFC 8785 canonicalization from the start (canopy’s loader still serializes in insertion order and flags RFC 8785 as future); (2) the engine runs in a NEW backend service, not the BFF — the cross-replica composition.invalidated RabbitMQ fanout (which canopy’s single-process in-memory cache has no need for) is a CRAIG addition built on the ADR-024 subscribe_exclusive precedent.

The defining architectural decision (ADR-035 §1) is where the engine runs. The two sibling bundle-overlay contracts that shipped — terminology (ADR-034) and theme (ADR-036) — resolve inside the craig-web BFF because they are read-only, compile-time bundle assets. Composition is different in kind: it has a mutable persistence dimension (a database), a multi-replica cache-coherence requirement, and a write API (Studio + user personalization). Pushing sqlx + craig-db + craig-mq + an authz-bearing write surface into the BFF would dissolve its defining stateless-aggregator property (ADR-004). So composition gets its own service; the BFF becomes a client.

Key decisions

Composition runs in a NEW backend service; the BFF is a client

services/craig-composition (port 8009, DB craig_composition) owns resolution + the override store + the invalidation publisher. craig-web reaches it over HTTP, signing calls with its client_credentials token + an X-Craig-Actor JWT (ADR-028); the BFF gains no sqlx/craig-mq/craig-db dependency. The portable merge/role-filter/hash logic lives in crates/craig-composition so it is unit-testable without a running service and could later back another host (ADR-035 §1).

Five layers; baselines are runtime-loaded ops config, not bundle-embedded

Precedence (low → high): product default (compiled into crates/craig-composition, jurisdiction-neutral) → jurisdiction baseline (rulesets/<jurisdiction>/ TOML, read at runtime, ops-edited via git — mirroring CRAIG’s existing JDM-rulesets pattern, NOT bundle-embedded) → jurisdiction live override (DB, Studio) → role override (DB) → user delta (DB). The BundleContribution::compositions: CompositionContribution field (added additively per ADR-032 §4 / A11) is a pre-materialized surface declaration — it does NOT carry baseline trees (ADR-035 §8). Baselines change more often than theme/terminology and carry a live-override layer, which is why they are runtime config, not compiled bundle assets.

Two merge strategies; the user delta is a versioned slug-anchored envelope

Product-default → baseline merges via RFC 7396 (structural overlay; nulls remove; arrays replace); the DB override layers apply via RFC 6902 op-lists. Dashboard user deltas use the versioned user_delta_v1 envelope (hidden/span/order anchored to plugin slugs, not array indices — robust to baseline panel additions); case-detail uses RFC 6902. The envelope is versioned so a user_delta_v2 lands additively (ADR-035 §3).

Role-filter after merge; required panels + row budget are hard floors

The five layers merge first; role filtering then drops items whose plugin required_roles exclude the request’s role (silent drop, never a "permission denied" tile). required=true panels cannot be hidden by any override or delta, and every row stays within its 12-column span budget — invariants the loader and the X4 validator both enforce (ADR-035 §3/§4).

Cache: invalidate-on-write, RFC 8785 version, cross-replica fanout

Each replica caches resolved trees keyed by (jurisdiction, role, user_sub, surface); the tree is RFC 8785-canonicalized + SHA-256-hashed into a reproducible version. Writes drop the local entry and publish a transactional-outbox composition.invalidated event; every replica drops the stale entry via subscribe_exclusive fanout (the ADR-024 pattern); a bounded 10-minute TTL is the delivery-failure safety net (ADR-035 §6).

Step DAG

Hard prerequisites are written as prerequisite(s) ──► step; X3 ⇠ X4 is a soft integration (not a blocking prerequisite — see below):

X1  crates/craig-composition — pure engine (merge + role-filter + user_delta_v1 + RFC 8785 hash)

X1 ──► X2   services/craig-composition — host (boot + root-Dockerfile target + compose + DB)
X1 ──► X4   user_delta_v1 apply + validate

X2 ──► X3   CompositionLoader + resolution endpoint
X2 ──► X5   composition_overrides table + db.rs

X3 + X5 ──► X6   invalidate-on-write cache + composition.invalidated fanout
X3      ──► X7   BFF HTTP client + ADR-028 auth + render the composed surface

X6 + X7      ──► X8   Studio live overrides (layer 3)
X4 + X6 + X7 ──► X9   user-delta endpoint + personalization UI (layer 1; X4 validates the write)

{X1..X9} ──► X10   GA reference baselines + plan-completion audit + archive

X3 hard-depends only on X2: it can land against the compiled product-default + the rulesets/ baseline (applying RFC 6902 for the DB layers as canopy’s loader does) before X5’s DB rows or X4’s envelope exist. X5 then wires the DB override rows into the loader, and X4’s slug-anchored user_delta_v1 apply is integrated into X3’s loader dashboard path once both land (the canopy loader’s try-envelope-then-RFC-6902-fallback) — a soft enhancement, not a blocking prerequisite, so X3 and X4 can proceed in parallel after X1/X2. X6 needs X3 (a cache to populate) + X5 (the write path that invalidates). X7 needs X3. X8 needs X6 (its writes must invalidate) + X7 (the BFF surface); X9 needs the same plus X4 (it validates every user-delta write before persist). X10 follows all.

Risk register

Risk Mitigation

The canopy port-source paths have moved/changed at canopy HEAD (canopy is an independently-evolving repo).

X1 verifies crates/canopy-composition/src/{loader,merge,role_filter,user_delta,cache,types,source}.rs exist at canopy HEAD before porting; a missing path files a blocker issue (ADR-035 §9).

A ninth backend service is real operational + CI surface area (image, DB, migrations, compose, feature/build matrix).

X2 reuses the shared craig-bootstrap/craig-api/orchestrator plumbing — the incremental code is the composition logic, not new infrastructure; the service is a near-clone of 8001-8008.

The BFF accidentally grows a DB/MQ dependency (dissolving the ADR-004 stateless-aggregator property).

X7 adds only an HTTP client over the existing shared reqwest::Client + Plan-E identity primitives; an invariant grep asserts no sqlx/craig-mq/craig-db in services/craig-web.

No JCS/RFC 8785 crate is MSRV-1.88 / musl-safe, blocking reproducible hashing.

X1 evaluates a JCS crate up front; fallback is a vetted recursive key-sorted serializer (the canonicalization is small + self-contained); the hash-determinism test is the gate.

Cross-replica cache coherence fails silently (a dropped composition.invalidated serves a stale layout).

X6 stages the event in the SAME txn as the write (transactional outbox, ADR-022) so it can’t be lost relative to the write, and adds a bounded 10-minute TTL safety net; a cross-replica integration test proves a second replica drops its entry.

RFC 6902 index-based deltas break when a baseline panel is added under a stored user delta.

Dashboard user deltas use the slug-anchored user_delta_v1 envelope (X4), not raw RFC 6902; unknown slugs are silently ignored on apply (forward-compatible); validation runs against the post-role-filter baseline.

A user delta hides a required=true panel or overflows a row’s 12-column budget.

X4 validate_user_delta enforces required-panel + per-row ≤12 (dry-run apply) BEFORE persist; the loader (X3) re-enforces at resolution.

Cache cross-tenant leak (one worker/jurisdiction sees another’s resolved tree).

The cache key is (jurisdiction, role, user_sub, surface); X6 tests assert isolation across keys; X9 tests a user delta does not leak to another user.

Auth pass-through misconfigured — craig-composition can’t read the worker role/jurisdiction.

X7 reuses the proven ADR-028 seam (client_credentials + X-Craig-Actor, dual-JWKS, claims.actor); a wrong/absent actor → 401/403 test; no new IdP deps on the new service.

The rulesets/<jurisdiction>/ baseline schema drifts from the engine’s typed model.

X3 deserializes the merged JSON into the typed ComposedSurface (schema-checked at that boundary); X10 ships GA baselines that exercise the real schema end-to-end.

Studio gains the ability to edit baselines / push to git (violating the ops/UI split).

X8 Studio writes the DB live-override layer ONLY; an invariant + test assert Studio has no rulesets/ write / git path (ADR-035 §2).

Cross-cutting invariants

Each is a runnable check; greps are written to avoid false positives. Verified at X10.

  1. The engine crate is I/O-free: crates/craig-composition has no sqlx / reqwest / craig-mq dependency — git grep -nE "^(sqlx|reqwest|craig-mq) " crates/craig-composition/Cargo.toml returns 0 (persistence + MQ are the service’s).

  2. The BFF stays stateless: services/craig-web/Cargo.toml gains no sqlx/craig-db/craig-mqgit grep -nE "^(sqlx|craig-db|craig-mq) " services/craig-web/Cargo.toml returns 0 (ADR-004).

  3. Role filter is after merge: the loader calls filter_items_by_role on the merged tree, not per-layer; a baseline-added panel disappears cleanly for a role lacking the plugin permission (X3 test).

  4. required=true is unhideable: no override or delta can drop a required panel — X4 validation + X3 resolution both enforce it (test).

  5. Resolved tree is reproducibly hashable: the same logical tree produces the same version across calls + process restarts (RFC 8785 + SHA-256; X1 determinism test).

  6. Composition is a separate service, not the BFF: services/craig-composition exists with its own main.rs + DB + migrations; craig-web reaches it over HTTP (ADR-035 §1).

  7. Invalidation is transactional + cross-replica: the composition.invalidated event is staged in the write’s DB txn (outbox) and consumed via subscribe_exclusive per-replica; a two-replica test proves the non-writing replica drops its entry; the TTL bounds delivery failure.

  8. Cache is tenant-isolated: the cache key includes jurisdiction + role + user_sub; cross-key isolation tests pass (no cross-tenant/user leak).

  9. Auth pass-through, no new IdP deps: craig-composition validates the bearer + actor JWKS via the existing peer map; it has no Keycloak/IdP admin client (git grep for an IdP admin client returns 0); a wrong-actor request is rejected.

  10. The new service compiles under the build/feature matrix: cargo xtask feature-matrix (and the root Dockerfile build) include craig-composition; the devstack health check boots it.

Open questions (deferrals)

  • Write-API surface shape — PUT-with-If-Match vs PATCH-append vs higher-level Studio verbs, and whether deletes archive (canopy keeps a *_archive table). X5/X8/X9 settle the concrete endpoints; the ADR-035 §5 schema + ETag concurrency are fixed.

  • CompositionContribution payload — the exact pre-materialized field contents (a surface registry; whether the compiled product-default rides on it or sits beside it in crates/craig-composition); ADR-035 §8 fixes the invariant (baselines runtime-loaded, never bundle-embedded), X1 settles the payload.

  • Baseline hot-reload — whether rulesets/<jurisdiction>/ composition baselines join the JDM-rulesets RMQ reload path or are read once at boot; X3 decides (the live-override layer already covers no-deploy changes).

  • Case-section (context-parameterized) plugins on composed surfaces — case-detail items whose [data] carries {case_id} need the page/case context the composition tree supplies; X7 wires the composed RenderCtx.item + context, extending Plan W’s context-free render route.

  • Per-jurisdiction role sets — role overrides + role-filtering use CRAIG’s fixed 6-role realm set (the Plan W v1 decision); a jurisdiction-contributed role registry is future work (the multi-jurisdiction authz arc).

Errata

Deviations from ADR-035’s deferred open questions, resolved during Plan X 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:

  • Write-API shape (ADR-035 §9) — resolved at X5/X8/X9.

  • CompositionContribution payload (ADR-035 §8/§9) — resolved at X1.

  • Baseline hot-reload granularity (ADR-035 §9) — resolved at X3: baselines are read once at CompositionLoader boot and cached, NOT re-read per request and NOT joined to a hot-reload path. CRAIG’s existing JDM-ruleset "runtime load" reloads from the database over RabbitMQ (ruleset.changed.*, ADR-024), not from files — there is no file-reload precedent to join — and the live-override DB layer (X5/X6) already covers no-deploy changes, so a baseline file edit is an ops action taking effect on the next restart / reseed (exactly like the seeded JDM rulesets). The X6 composition.invalidated fanout remains the natural hook if cross-replica baseline refresh-without-restart is wanted later (additive, not required to mirror existing behavior).

  • Baseline file path/name — resolved at X3: the loader reads rulesets/<jurisdiction>/<surface>.toml where <surface> is the engine’s canonical ComposableSurface::as_snake_case() — i.e. dashboard.toml + case_detail.toml (singular dashboard, matching the compiled defaults/dashboard.json + the surface wire form). This supersedes the body’s earlier loose “dashboards.toml” (plural); the X3 + X10 cells were corrected. X3 establishes the .toml baseline convention (the loader reads it) but ships NO repo-root rulesets//.toml — the first land at X10; the X3 test fixture lives under the service’s tests/fixtures/. When the real baselines land they coexist per-jurisdiction with the .json JDM rulesets (the xtask rules check .json-only gate ignores .toml).

  • ComposedItem.required field — added at X3 (additive, #[serde(default, skip_serializing_if)]): the engine’s X1 ComposedItem { item, span, row } had no home for the ADR-035 §3 required = true floor, so X3 added required: bool to carry the baseline’s mandatory-placement flag through the merge into the resolved tree (omitted from the wire + content hash when false, so existing all-optional trees are unperturbed). The loader’s required-floor enforcement guards Steps 5/4’s override + delta layers; at X3 (no override/delta layers) it is a no-op guard, unit-tested directly.

  • ResourceType::Composition + authz rulesets — added at X3: a new ResourceType::Composition variant (so ruleset_name_for yields {jurisdiction}-authz-composition) plus the georgia/texas composition authz rulesets. Because the boot coverage check iterates ResourceType::iter() for EVERY service, the two rulesets are required for all services' coverage; composition read is granted to any authenticated worker (the post-merge role filter governs visible panels).

  • user_delta_v1 envelope + dashboard-only validate — resolved at X4: the envelope is a [serde(tag = "type")] enum with the V1 variant [serde(rename = "user_delta_v1")] (faithful to the canopy port — the wire tag IS the envelope name, so user_delta_v2 lands additively; supersedes the body’s loose “rename_all = snake_case” phrasing). apply_user_delta + validate_user_delta live in the engine (crates/craig-composition-engine/src/user_delta.rs, pure — Depends X1 only). validate_user_delta is dashboard-only (no surface param — case-detail user layers use RFC 6902, which the write endpoint routes away before calling validate) and scans PluginSource::list() for panel exports (CRAIG has no canopy find_panel). The required-panel floor is a CRAIG addition over canopy’s 4 arms — a UserDeltaError::RequiredPanelHidden arm enforces ADR-035 §3 against ComposedItem.required (the field X3 added; canopy’s ComposedItem had none). The loader integration of apply_user_delta on the dashboard user layer is the soft X3↔X4 enhancement that lands with X5’s DB override rows.

  • composition_overrides schema refinements — resolved at X5: the ADR-035 §5 UNIQUE (jurisdiction_code, role, user_sub, surface_key) is materialized as UNIQUE NULLS NOT DISTINCT (PostgreSQL 15+; CRAIG targets 18) — load-bearing, because a plain UNIQUE treats NULL`s as distinct, which would let two jurisdiction-live rows (both scope cols `NULL) coexist for one surface AND break the ON CONFLICT upsert’s conflict inference on the NULL-scope layers. A CHECK (role IS NULL OR user_sub IS NULL) enforces the three unambiguous layer shapes (a user-delta row is keyed by user_sub alone — the worker’s role is read from the request, never stored — so role + user_sub are never both set, keeping the fetch_db_layers user-arm user_sub = $4 exact). The write helpers use IS NOT DISTINCT FROM for NULL-safe scope matching (a plain = never matches a NULL scope column) and a GREATEST(clock_timestamp(), updated_at + interval '1 microsecond') monotonic guard so every write yields a fresh, strictly-ordered ETag.

  • Loader DB-layer wiring + the dashboard-user envelope-only decision — resolved at X5: resolve is now async (fetches the override rows via db::fetch_db_layers) and delegates to a pure resolve_with_layers(key, &[OverrideRow]) so the merge / role-filter / floor logic stays unit-testable with in-memory layers and no database (the I/O is isolated to fetch_db_layers). The jurisdiction-live + role layers (and the case-detail user layer) merge as RFC 6902 in value-space; the dashboard user layer is the user_delta_v1 envelope by design (Step 4), applied to the typed items after the role filter — and a dashboard user row that does NOT parse as a valid envelope fails closed (MalformedUserDelta) rather than being reinterpreted as index-based RFC 6902 ops (a deliberate divergence from canopy’s defensive try-envelope-then-RFC-6902 fallback, which would apply ambiguous index paths to a role-filtered subset). The X3 trusted/untrusted export-resolution distinction is now implemented: a baseline / jurisdiction-live (trusted) unknown slug hard-fails; a role / user (untrusted) stale slug is dropped with a warn.

  • required floor snapshotted from the trusted tier — resolved at X5 (X5 adversarial-review finding): the floor’s source of truth is the set of required = true slugs captured from the TRUSTED tier (baseline + jurisdiction-live) BEFORE the untrusted role / user layers apply — NOT from the fully-merged tree. Otherwise an untrusted RFC 6902 op could disarm the floor by flipping required to false (any surface) or remove-ing a required section (case-detail) before the post-merge snapshot, defeating the ADR-035 §3 "no override or delta may hide a required panel" invariant. The trusted tier includes jurisdiction-live (trusted ops config, co-equal with the baseline — it may legitimately add/relax a jurisdiction’s required panels); the role + user layers may not. Regression tests cover both vectors (role-flip-then-hide; case-detail required remove).

  • Write-API surface (append / delete / archive deferral) — resolved-in-part at X5: the step ships the core PUT-style upsert_override (create-if-absent + If-Match replace, the ETag + monotonic-guard machinery) needed to round-trip a write; canopy’s append / delete / archive write verbs are deferred to X8/X9, where the concrete Studio + user-delta endpoint shapes decide which they need (per this body’s "Write-API surface shape — X5/X8/X9 settle the concrete endpoints" deferral; YAGNI over porting all verbs speculatively). The write helpers ship behind a scoped ![allow(dead_code)] (staged-delivery, the services/craig-exchange/src/transitions.rs precedent) with a round-trip [ignore = "requires devstack"] integration test; no endpoint mounts them until X8/X9, and the dashboard read handler still resolves with user_sub = None until X9 wires personalization.

  • Cache structure + the memory model — resolved at X6: the per-replica cache (services/craig-composition/src/cache.rs) ports canopy’s RwLock<HashMap<CompositionKey, …>> structure + the invalidate_* methods, adding the bounded TTL canopy’s single-replica v1 lacked. It uses a std::sync::RwLock (not tokio) because no .await is held under the guard — get/insert/retain are synchronous — and recovers a poisoned lock via PoisonError::into_inner (the workspace denies expect/unwrap in production; the cache ops are panic-free so a poison cannot actually occur). Per the X6 adversarial review, insert sweeps the expired entries first, so the map is bounded by the active (within-TTL) keyset rather than growing with every key ever seen — material once user_sub keys go live (X9). Expiry is also lazy on read (an entry past the 10-minute TTL reads as a miss). The cache is consulted at the top of resolve (check → miss → fetch_db_layers + resolve_with_layers → insert); resolve_with_layers stays cache-free so the loader unit tests are unaffected. The invalidate_jurisdiction / invalidate_role / invalidate_user blast radii are each scoped to (jurisdiction, surface) (the event carries the surface, so eviction need not be jurisdiction-wide across surfaces).

  • composition.invalidated fanout (ADR-035 §6; ADR-022/ADR-024) — resolved at X6: an override write stages the event via the transactional outbox (craig_mq::stage_event inside the write txn — never a direct publisher.publish, so a broker outage cannot lose it relative to the write); the X2-spawned outbox worker drains it to craig.events. The routing key is composition.invalidated.<jurisdiction> and each replica subscribe_exclusive-binds that exact key on a per-instance auto-delete queue (craig.events is a topic exchange), so a peer in another jurisdiction never receives it. The emitter self-skips by instance_id (the envelope has no native origin field — the per-replica UUID rides in the payload, the craig-rules precedent). The subscriber’s handler (cache::handle_invalidation_event) parses the payload scope and evicts the matching entries; a malformed payload is a no-op (the TTL backstop still self-heals).

  • instance_id lives on the CompositionLoader — resolved at X6 (review finding 2): the per-replica id is minted once on the loader (the request-path Extension), not as a free-floating value, so the Step 8-9 write handlers stamp the SAME id (via loader.instance_id()) that this replica’s subscriber self-skips on — otherwise the self-skip would silently no-op. Mirrors how craig-rules holds its id on the engine.

  • Invalidate-on-write wiring + the cross-replica test are X8/X9 — resolved-in-part at X6: events::stage_invalidation (the publish primitive) ships staged behind a scoped ![allow(dead_code)] (no write endpoint until X8/X9, which also evict the writer’s own keys synchronously after commit). The shippable’s two-instance cross-replica integration test is deferred: a true two-replica test is not feasible in the single-instance devstack/e2e harness, and the ADR-024 subscribe_exclusive fan-out is already proven in production by craig-authz + craig-rules. X6 coverage instead = the cache unit tests (TTL, insert-sweep, the three blast radii), the handle_invalidation_event self-skip + dispatch + malformed-payload unit tests, an [ignore] outbox-stage round-trip DB test, and the pre-push devstack boot exercising the subscriber’s subscribe_exclusive declare/bind.

  • BFF reaches the engine via a typed-contract dep, not "no new deps" — resolved at X7: this body’s X7 cell said "no new deps on craig-web", but the BFF must deserialize the engine’s ComposedSurface wire type. craig-web takes a dep on craig-composition-engine — the PURE engine crate (serde + json-patch + sha2; NO sqlx/craig-mq/craig-db) — used only for that type, exactly as it already depends on craig-cases-contracts / craig-plugin-contracts for typed contracts. The load-bearing invariant is ADR-004 (no state dependency), NOT a literal zero-new-deps; a tests/architecture_invariants.rs grep enforces the real invariant (no sqlx/craig-mq/craig-db in the BFF manifest). The "reuse the shared reqwest::Client + Plan-E primitives" intent holds — the CompositionClient rides the existing Transport, adding no HTTP/identity machinery.

  • Composed grid span via CSS class, not inline style — resolved at X7 (X7 adversarial-review finding): the dashboard composed-grid cell sets its grid-column span via a per-breakpoint class (composed-grid__cell—​span-{1,2,3,4,6,12}), NOT an inline style="grid-column: span N". craig-web’s CSP locks style-src to 'self' with no 'unsafe-inline' (#414), so an inline style would be silently dropped by the browser and the engine-decided placement lost. The breakpoint set is finite (BREAKPOINT_SPANS), so a fixed class set covers every valid span. (The CSP probe inspects the response header only; it would not have caught a dead inline style — a template-grep lint for style=" is a sensible future hardening, noted not built.)

  • Case-detail surface rendering deferred — resolved-in-part at X7: the CompositionClient is surface-generic (resolve_surface(surface, …) works for dashboard + case_detail), but only the dashboard surface is wired into a route. Case-detail rendering needs case-section plugins + a case_detail baseline (neither exists until X10) AND the case-section context ({case_id}) substitution into the plugin endpoint — a distinct lift. Wiring an always-empty, untestable case-detail composed region now would be speculative (YAGNI); it lands with X8/X9/X10 when there is something to render. The body’s "renders the resolved surface (dashboards + case-detail)" is satisfied for the dashboard end-to-end; case-detail is plumbing-ready on the client side.

  • Composed dashboard is empty until X10 — noted at X7: with no GA baselines (rulesets/georgia/*.toml) and an empty force-linked plugin registry in craig-composition, the engine resolves the dashboard to the compiled product-default (grid shell, zero items). So X7 ships the full wiring + ADR-028 auth pass-through end-to-end, but the rendered grid is empty until X10 populates baselines + force-links plugins. The composed region renders only when non-empty, so the existing static dashboard tiles are unaffected meanwhile (no regression).

  • Studio scope at X8 = the dashboard-layout control, not panel reorder/hide — resolved at X8: the X8 cell’s shippable ("a jurisdiction admin reorders/hides a panel") presumes panels, but the composed surface is empty until X10 (no GA baselines, no force-linked plugins in craig-composition). So X8 ships the FULL backend write path (PUT/GET /v1/compositions/{surface}/override, write-time validation, same-txn invalidation, cache eviction, admin-gated authz) + a foundational authz-gated Studio surface whose concrete control is the jurisdiction dashboard layout (grid/stacked) — a real jurisdiction-live override (add /shell <layout>) that exercises the entire write→validate→invalidate→authz chain end-to-end WITHOUT panels. Per-panel reorder/hide is X10’s layer on top (when there are panels to manipulate + e2e). The e2e proves the authz boundary (admin 200 + layout round-trip; non-admin 403 + nav hidden) now. Building a panel-manipulation UI against an empty surface would be speculative (YAGNI).

  • Write-time validation = per-role dry-run resolve — resolved at X8 (the X5/X6 carry-forward): a jurisdiction-live override is role-agnostic but its floor/grid violations surface per-role at resolve time, so the write handler dry-runs loader.resolve_with_layers against EVERY realm role with the prospective override row; any resolution failure (removed required panel, over-budget row, span out of range, malformed delta, unknown export slug) is a 400 at write time. The realm-role set is a local const in the handler mirroring craig-web’s KNOWN_REALM_ROLES (no central role registry exists in craig-auth/craig-authz yet — the same duplication the BFF acknowledges).

  • Override write authz: admin-gated Action::Update, evaluated against the ACTOR — resolved at X8: the {jurisdiction}-authz-composition rulesets gain an i_admin ('admin' in claims.roles) input + an admin-full-access rule (read stays open to any worker; the service-caller rule is unchanged). The BFF calls with a client_credentials token + X-Craig-Actor, and craig-authz evaluates acting_worker().realm_access.roles with is_service = is_service() && actor.is_none() (engine.rs) — so a non-admin actor (e.g. caseworker) falls through to default-deny (403) and the service-caller rule only admits actor-less internal calls. Authz is enforced at BOTH the BFF (require_admin_only) and craig-composition. The override sub-resource (GET+PUT /override) is Action::Update-gated (admin-only); the resolved-surface read stays Action::Read (any worker).

  • Conditional-write transport + the staged write machinery go live — resolved at X8: the X5 db::write::upsert_override + the X6 events::stage_invalidation modules drop their staged #![allow(dead_code)] (now mounted on the write endpoint). The BFF write rides RFC 7232 conditional headers (If-Match to replace, If-None-Match: * to create) — which the six Transport verb helpers don’t shape — so CompositionClient builds its own request off Transport::http + a newly-pub(crate) Transport::apply_identity (still carrying the standard ADR-028 outbound identity). A 409 maps to a "reload" flash, a 400 surfaces the RFC 9457 detail. B4 ratcheted 86→87 for the new route module’s standard Askama filters-in-scope allow (identical to every existing route module).

  • Missing aud-craig-composition Keycloak audience mapper (an X2 service-add miss, latent through X7) — found + fixed at X8: every inbound bearer is audience-checked against the service’s own name (craig-api/src/bootstrap.rs with_audience(service_name), ADR-021), so a token reaching craig-composition must carry craig-composition in its aud. The devstack realm seeds a per-client oidc-audience-mapper for each service (aud-craig-rulesaud-craig-intake) but X2 never added aud-craig-composition, so EVERY call to craig-composition failed token validation: InvalidAudience. This was invisible through X7 because the dashboard’s composition resolve is best-effort (it degrades to the static tiles on error, and no X7 e2e asserted on the composed region); X8’s Studio write round-trip — which asserts the write succeeds — surfaced it. Fix: add the aud-craig-composition mapper to all 12 realm clients in devstack/keycloak/craig-realm.json (it takes effect on the next dev reseed, which wipes the keycloak volume + re-imports). This is the missing item in the X2 service-add gotcha list (a new backend service needs its audience mapper added to the realm, alongside the realm client + service role + RULES_ENGINE_URL).

  • User-delta authz = Action::Read (self-service), not a new action — resolved at X9: the user-delta GET+PUT /v1/compositions/{surface}/user-delta are gated by Action::Read, the same grant any worker already holds to view the surface — NOT Action::Update (which stays admin-only for the jurisdiction-live override). Personalizing one’s own view is not a privilege escalation over viewing it; the write is kept harmless by structure (the user_sub is ALWAYS claims.acting_worker()’s own — never request-supplied, so a worker cannot write another’s delta) and by scope (the X4 `validate_user_delta rejects any panel the role may not see). This needed NO new Action variant and NO ruleset change — the existing "any authenticated worker may read" rule covers it. The shared composition_user_sub(worker) derives the sub (rejecting service / non-UUID principals); the write path 400-rejects a None, the read path falls back to the role baseline.

  • Read-apply wiring (the X9 review catch) — resolved at X9 (adversarial-review finding, high): the X3 resolution read handler (compositions.rs) hardcoded user_sub: None, so the user-delta was write-only — it never applied on read, defeating "survives reload." Fixed by deriving user_sub via the shared composition_user_sub in resolve_surface, so the engine + cache (already keyed on user_sub) apply + isolate the worker’s layer. Once user_sub keys go live, the X6 insert-time cache sweep bounds memory by the active-worker set within the TTL (anticipated by the X6 errata).

  • Shared write/identity support extraction (DRY across X8/X9) — resolved at X9: the persistence shape is identical across the jurisdiction-live override and the user-delta write, so persist_override (write + same-txn stage_invalidation + cache eviction via cache::apply_invalidation, the blast radius dispatched by scope), map_write_error, parse_precondition, the OverrideView/OverrideWritten DTOs, composition_resource_ref, and composition_user_sub were extracted to api/write_support.rs; overrides.rs (X8) + user_delta.rs (X9) + compositions.rs (the read) all consume it. A new craig_common::ApiError::unprocessable(field, detail) constructor fills the gap where a semantic 422 (a business-rule violation like an invalid user delta) had no constructor outside garde.

  • Personalization UI scope at X9 = reset only (panel controls → X10) — resolved at X9 (mirrors the X8 layout-control deferral): the user_delta_v1 envelope is entirely panel-scoped (hide / re-span / reorder slugs), but the composed surface is empty until the X10 GA baselines, so the only panel-free control is a reset (write an empty envelope, clearing any prior personalization). X9 ships the full self-service write + read-apply path + validation end-to-end; the rich drag-to-reorder / pin / hide UI lands at X10 when there are panels on the surface to manipulate. The self-service /personalize/dashboard route is NOT admin-gated (unlike Studio) — every authenticated worker personalizes their own dashboard.

  • JCS/RFC 8785 crate choice (ADR-035 §6) — resolved at X1.

  • Engine crate name — ADR-035 + this body both named the engine lib crates/craig-composition and the service services/craig-composition, which collide on cargo package name. Resolved at X2 (!726-successor): the engine lib is renamed craig-compositioncraig-composition-engine (git mv); the bare craig-composition name goes to the SERVICE, matching CRAIG’s taxonomy (services are bare; a domain lib that backs a service carries a role qualifier — craig-cases-contracts, craig-exchange-transport). Pre-1.0 with no consumers yet, the rename is mechanical. References to “crates/craig-composition” in the X1 cell + earlier ADR/CHANGELOG text predate this rename.

  • Plan S — Multi-Jurisdiction Foundation — the umbrella; this body is Step 20, execution is Step 21.

  • ADR-035 — the composition engine this plan implements: §1 the NEW service vs BFF; §2 the five layers + runtime-loaded baselines; §3 merge semantics + the user_delta_v1 envelope; §4 role-filter-after-merge; §5 the composition_overrides table; §6 cache + RFC 8785 + composition.invalidated fanout; §7 ADR-028 auth pass-through; §8 the compositions: CompositionContribution field.

  • ADR-033 + Plan W — supply the plugins being composed + the PluginSource registry the loader role-filters against + the GET /plugins/<slug> render route the composed surface drives.

  • ADR-032 — §4 + A11 the additive compositions: CompositionContribution field on BundleContribution.

  • ADR-038 — §3 the pre-materialized registry posture (the compositions field; the CompositionLoader is the boot-time service resource).

  • ADR-028 — the client_credentials + X-Craig-Actor on-behalf-of seam the BFF uses to reach craig-composition.

  • ADR-024 — the zen-engine RabbitMQ subscribe_exclusive cache-invalidation pattern reused for composition.invalidated.

  • ADR-022 — the transactional outbox staging the invalidation event.

  • ADR-023 — the authz stack the write endpoints (Studio + user-delta) enforce against.

  • ADR-004 — the stateless-BFF property X7 protects (the BFF stays an HTTP client, no DB/MQ).

  • Contract 3: Composition + persistence — the author-facing contract this plan realizes.

  • canopy crates/canopy-composition/ + canopy ADR-021 (external; ported, not consumed) — the working precedent for the loader, merge strategies, user_delta_v1 envelope, role filter, and invalidate-on-write cache; CRAIG diverges on true RFC 8785 hashing + the cross-replica fanout + the separate-service home.

Edit this page · latest