Plan X: Composition Layer Engine (child plan of Plan S umbrella)
On this page
- Status
- Context
- Key decisions
- Composition runs in a NEW backend service; the BFF is a client
- Five layers; baselines are runtime-loaded ops config, not bundle-embedded
- Two merge strategies; the user delta is a versioned slug-anchored envelope
- Role-filter after merge; required panels + row budget are hard floors
- Cache: invalidate-on-write, RFC 8785 version, cross-replica fanout
- Step DAG
- Risk register
- Cross-cutting invariants
- Open questions (deferrals)
- Errata
- Related decisions
Status
| Step | Description | Status |
|---|---|---|
X1 |
NEW |
Done — !726 / |
X2 |
NEW |
Done — !727 / |
X3 |
|
Done — !728 / |
X4 |
|
Done — !729 / |
X5 |
NEW table |
Done — !730 / |
X6 |
Invalidate-on-write cache + |
Done — !731 / |
X7 |
The |
Done — !732 / |
X8 |
Studio admin UI for jurisdiction live overrides (layer 3). A craig-web Studio surface (BFF) that calls |
Done — !733 / |
X9 |
User-delta write endpoint + the personalization UI (layer 1). A |
Done — !734 / |
X10 |
Reference baselines + plan-completion audit + archive. NEW |
Done (2026-06-18) — this archive MR. The GA reference baselines ( |
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 |
A ninth backend service is real operational + CI surface area (image, DB, migrations, compose, feature/build matrix). |
X2 reuses the shared |
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 |
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 |
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 |
A user delta hides a |
X4 |
Cache cross-tenant leak (one worker/jurisdiction sees another’s resolved tree). |
The cache key is |
Auth pass-through misconfigured — |
X7 reuses the proven ADR-028 seam ( |
The |
X3 deserializes the merged JSON into the typed |
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 |
Cross-cutting invariants
Each is a runnable check; greps are written to avoid false positives. Verified at X10.
-
The engine crate is I/O-free:
crates/craig-compositionhas nosqlx/reqwest/craig-mqdependency —git grep -nE "^(sqlx|reqwest|craig-mq) " crates/craig-composition/Cargo.tomlreturns 0 (persistence + MQ are the service’s). -
The BFF stays stateless:
services/craig-web/Cargo.tomlgains nosqlx/craig-db/craig-mq—git grep -nE "^(sqlx|craig-db|craig-mq) " services/craig-web/Cargo.tomlreturns 0 (ADR-004). -
Role filter is after merge: the loader calls
filter_items_by_roleon the merged tree, not per-layer; a baseline-added panel disappears cleanly for a role lacking the plugin permission (X3 test). -
required=trueis unhideable: no override or delta can drop a required panel — X4 validation + X3 resolution both enforce it (test). -
Resolved tree is reproducibly hashable: the same logical tree produces the same
versionacross calls + process restarts (RFC 8785 + SHA-256; X1 determinism test). -
Composition is a separate service, not the BFF:
services/craig-compositionexists with its ownmain.rs+ DB + migrations;craig-webreaches it over HTTP (ADR-035 §1). -
Invalidation is transactional + cross-replica: the
composition.invalidatedevent is staged in the write’s DB txn (outbox) and consumed viasubscribe_exclusiveper-replica; a two-replica test proves the non-writing replica drops its entry; the TTL bounds delivery failure. -
Cache is tenant-isolated: the cache key includes
jurisdiction+role+user_sub; cross-key isolation tests pass (no cross-tenant/user leak). -
Auth pass-through, no new IdP deps:
craig-compositionvalidates the bearer + actor JWKS via the existing peer map; it has no Keycloak/IdP admin client (git grepfor an IdP admin client returns 0); a wrong-actor request is rejected. -
The new service compiles under the build/feature matrix:
cargo xtask feature-matrix(and the rootDockerfilebuild) includecraig-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
*_archivetable). X5/X8/X9 settle the concrete endpoints; the ADR-035 §5 schema + ETag concurrency are fixed. -
CompositionContributionpayload — the exact pre-materialized field contents (a surface registry; whether the compiled product-default rides on it or sits beside it incrates/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 composedRenderCtx.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.
-
CompositionContributionpayload (ADR-035 §8/§9) — resolved at X1. -
Baseline hot-reload granularity (ADR-035 §9) — resolved at X3: baselines are read once at
CompositionLoaderboot 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 overRabbitMQ(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 X6composition.invalidatedfanout 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>.tomlwhere<surface>is the engine’s canonicalComposableSurface::as_snake_case()— i.e.dashboard.toml+case_detail.toml(singulardashboard, matching the compileddefaults/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.tomlbaseline convention (the loader reads it) but ships NO repo-rootrulesets//.toml— the first land at X10; the X3 test fixture lives under the service’stests/fixtures/. When the real baselines land they coexist per-jurisdiction with the.jsonJDM rulesets (thextask rules check.json-only gate ignores.toml). -
ComposedItem.requiredfield — added at X3 (additive,#[serde(default, skip_serializing_if)]): the engine’s X1ComposedItem { item, span, row }had no home for the ADR-035 §3required = truefloor, so X3 addedrequired: boolto carry the baseline’s mandatory-placement flag through the merge into the resolved tree (omitted from the wire + content hash whenfalse, 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 newResourceType::Compositionvariant (soruleset_name_foryields{jurisdiction}-authz-composition) plus thegeorgia/texascomposition authz rulesets. Because the boot coverage check iteratesResourceType::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_v1envelope + dashboard-onlyvalidate— resolved at X4: the envelope is a[serde(tag = "type")]enum with theV1variant[serde(rename = "user_delta_v1")](faithful to the canopy port — the wire tag IS the envelope name, souser_delta_v2lands additively; supersedes the body’s loose “rename_all = snake_case” phrasing).apply_user_delta+validate_user_deltalive in the engine (crates/craig-composition-engine/src/user_delta.rs, pure — Depends X1 only).validate_user_deltais dashboard-only (nosurfaceparam — case-detail user layers use RFC 6902, which the write endpoint routes away before calling validate) and scansPluginSource::list()for panel exports (CRAIG has no canopyfind_panel). The required-panel floor is a CRAIG addition over canopy’s 4 arms — aUserDeltaError::RequiredPanelHiddenarm enforces ADR-035 §3 againstComposedItem.required(the field X3 added; canopy’sComposedItemhad none). The loader integration ofapply_user_deltaon the dashboard user layer is the soft X3↔X4 enhancement that lands with X5’s DB override rows. -
composition_overridesschema refinements — resolved at X5: the ADR-035 §5UNIQUE (jurisdiction_code, role, user_sub, surface_key)is materialized asUNIQUE NULLS NOT DISTINCT(PostgreSQL 15+; CRAIG targets 18) — load-bearing, because a plainUNIQUEtreatsNULL`s as distinct, which would let two jurisdiction-live rows (both scope cols `NULL) coexist for one surface AND break theON CONFLICTupsert’s conflict inference on the NULL-scope layers. ACHECK (role IS NULL OR user_sub IS NULL)enforces the three unambiguous layer shapes (a user-delta row is keyed byuser_subalone — the worker’s role is read from the request, never stored — sorole+user_subare never both set, keeping thefetch_db_layersuser-armuser_sub = $4exact). The write helpers useIS NOT DISTINCT FROMfor NULL-safe scope matching (a plain=never matches a NULL scope column) and aGREATEST(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:
resolveis nowasync(fetches the override rows viadb::fetch_db_layers) and delegates to a pureresolve_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 tofetch_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 theuser_delta_v1envelope 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 awarn. -
requiredfloor snapshotted from the trusted tier — resolved at X5 (X5 adversarial-review finding): the floor’s source of truth is the set ofrequired = trueslugs 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 flippingrequiredtofalse(any surface) orremove-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 requiredremove). -
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’sappend/delete/archivewrite 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 with a round-trip[ignore = "requires devstack"]integration test; no endpoint mounts them until X8/X9, and the dashboard read handler still resolves withuser_sub = Noneuntil X9 wires personalization. -
Cache structure + the memory model — resolved at X6: the per-replica cache (
services/craig-composition/src/cache.rs) ports canopy’sRwLock<HashMap<CompositionKey, …>>structure + theinvalidate_*methods, adding the bounded TTL canopy’s single-replica v1 lacked. It uses astd::sync::RwLock(nottokio) because no.awaitis held under the guard —get/insert/retainare synchronous — and recovers a poisoned lock viaPoisonError::into_inner(the workspace deniesexpect/unwrapin production; the cache ops are panic-free so a poison cannot actually occur). Per the X6 adversarial review,insertsweeps the expired entries first, so the map is bounded by the active (within-TTL) keyset rather than growing with every key ever seen — material onceuser_subkeys 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 ofresolve(check → miss →fetch_db_layers+resolve_with_layers→ insert);resolve_with_layersstays cache-free so the loader unit tests are unaffected. Theinvalidate_jurisdiction/invalidate_role/invalidate_userblast radii are each scoped to(jurisdiction, surface)(the event carries the surface, so eviction need not be jurisdiction-wide across surfaces). -
composition.invalidatedfanout (ADR-035 §6; ADR-022/ADR-024) — resolved at X6: an override write stages the event via the transactional outbox (craig_mq::stage_eventinside the write txn — never a directpublisher.publish, so a broker outage cannot lose it relative to the write); the X2-spawned outbox worker drains it tocraig.events. The routing key iscomposition.invalidated.<jurisdiction>and each replicasubscribe_exclusive-binds that exact key on a per-instance auto-delete queue (craig.eventsis a topic exchange), so a peer in another jurisdiction never receives it. The emitter self-skips byinstance_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_idlives on theCompositionLoader— resolved at X6 (review finding 2): the per-replica id is minted once on the loader (the request-pathExtension), not as a free-floating value, so the Step 8-9 write handlers stamp the SAME id (vialoader.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. 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-024subscribe_exclusivefan-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), thehandle_invalidation_eventself-skip + dispatch + malformed-payload unit tests, an[ignore]outbox-stage round-trip DB test, and the pre-push devstack boot exercising the subscriber’ssubscribe_exclusivedeclare/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
ComposedSurfacewire type. craig-web takes a dep oncraig-composition-engine— the PURE engine crate (serde + json-patch + sha2; NOsqlx/craig-mq/craig-db) — used only for that type, exactly as it already depends oncraig-cases-contracts/craig-plugin-contractsfor typed contracts. The load-bearing invariant is ADR-004 (no state dependency), NOT a literal zero-new-deps; atests/architecture_invariants.rsgrep enforces the real invariant (nosqlx/craig-mq/craig-dbin the BFF manifest). The "reuse the sharedreqwest::Client+ Plan-E primitives" intent holds — theCompositionClientrides the existingTransport, 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 itsgrid-columnspan via a per-breakpoint class (composed-grid__cell—span-{1,2,3,4,6,12}), NOT an inlinestyle="grid-column: span N". craig-web’s CSP locksstyle-srcto'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 forstyle="is a sensible future hardening, noted not built.) -
Case-detail surface rendering deferred — resolved-in-part at X7: the
CompositionClientis surface-generic (resolve_surface(surface, …)works fordashboard+case_detail), but only the dashboard surface is wired into a route. Case-detail rendering needs case-section plugins + acase_detailbaseline (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_layersagainst 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 a400at write time. The realm-role set is a local const in the handler mirroring craig-web’sKNOWN_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-compositionrulesets gain ani_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 aclient_credentialstoken +X-Craig-Actor, and craig-authz evaluatesacting_worker().realm_access.roleswithis_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) isAction::Update-gated (admin-only); the resolved-surface read staysAction::Read(any worker). -
Conditional-write transport + the staged write machinery go live — resolved at X8: the X5
db::write::upsert_override+ the X6events::stage_invalidationmodules drop their staged#. The BFF write rides RFC 7232 conditional headers (If-Matchto replace,If-None-Match: *to create) — which the sixTransportverb helpers don’t shape — soCompositionClientbuilds its own request offTransport::http+ a newly-pub(crate)Transport::apply_identity(still carrying the standard ADR-028 outbound identity). A409maps to a "reload" flash, a400surfaces the RFC 9457detail. B4 ratcheted 86→87 for the new route module’s standard Askamafilters-in-scope allow (identical to every existing route module). -
Missing
aud-craig-compositionKeycloak 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.rswith_audience(service_name), ADR-021), so a token reaching craig-composition must carrycraig-compositionin itsaud. The devstack realm seeds a per-clientoidc-audience-mapperfor each service (aud-craig-rules…aud-craig-intake) but X2 never addedaud-craig-composition, so EVERY call to craig-composition failedtoken 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 theaud-craig-compositionmapper to all 12 realm clients indevstack/keycloak/craig-realm.json(it takes effect on the nextdev 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-deltaGET+PUT /v1/compositions/{surface}/user-deltaare gated byAction::Read, the same grant any worker already holds to view the surface — NOTAction::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 (theuser_subis ALWAYSclaims.acting_worker()’s own — never request-supplied, so a worker cannot write another’s delta) and by scope (the X4 `validate_user_deltarejects any panel the role may not see). This needed NO newActionvariant and NO ruleset change — the existing "any authenticated worker may read" rule covers it. The sharedcomposition_user_sub(worker)derives the sub (rejecting service / non-UUID principals); the write path 400-rejects aNone, 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) hardcodeduser_sub: None, so the user-delta was write-only — it never applied on read, defeating "survives reload." Fixed by derivinguser_subvia the sharedcomposition_user_subinresolve_surface, so the engine + cache (already keyed onuser_sub) apply + isolate the worker’s layer. Onceuser_subkeys 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-txnstage_invalidation+ cache eviction viacache::apply_invalidation, the blast radius dispatched by scope),map_write_error,parse_precondition, theOverrideView/OverrideWrittenDTOs,composition_resource_ref, andcomposition_user_subwere extracted toapi/write_support.rs;overrides.rs(X8) +user_delta.rs(X9) +compositions.rs(the read) all consume it. A newcraig_common::ApiError::unprocessable(field, detail)constructor fills the gap where a semantic422(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_v1envelope 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/dashboardroute 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-compositionand the serviceservices/craig-composition, which collide on cargo package name. Resolved at X2 (!726-successor): the engine lib is renamedcraig-composition→craig-composition-engine(git mv); the barecraig-compositionname 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.
Related decisions
-
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_v1envelope; §4 role-filter-after-merge; §5 thecomposition_overridestable; §6 cache + RFC 8785 +composition.invalidatedfanout; §7 ADR-028 auth pass-through; §8 thecompositions: CompositionContributionfield. -
ADR-033 + Plan W — supply the plugins being composed + the
PluginSourceregistry the loader role-filters against + theGET /plugins/<slug>render route the composed surface drives. -
ADR-032 — §4 + A11 the additive
compositions: CompositionContributionfield onBundleContribution. -
ADR-038 — §3 the pre-materialized registry posture (the
compositionsfield; theCompositionLoaderis the boot-time service resource). -
ADR-028 — the
client_credentials+X-Craig-Actoron-behalf-of seam the BFF uses to reachcraig-composition. -
ADR-024 — the zen-engine RabbitMQ
subscribe_exclusivecache-invalidation pattern reused forcomposition.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_v1envelope, role filter, and invalidate-on-write cache; CRAIG diverges on true RFC 8785 hashing + the cross-replica fanout + the separate-service home.