ADR-024: User-Layer Semantic Delta Schema for Dashboard Composition
On this page
Amends
ADRs are immutable once accepted, so this ADR amends ADR-022 rather than editing it. Read both together: ADR-022 defines where override layers live (one composition_documents table, three DB-backed layers); ADR-024 narrows what body shape is permitted on the user-layer for dashboard surfaces, where the storage primitive is otherwise unchanged.
Context
ADR-022 §Decision specifies: "Override body is an RFC 6902 JSON Patch op list — Studio 'add one panel' maps to one {"op":"add","path":"/items/-",…} op, not a full-document rewrite." That choice fit the Studio domain perfectly — admins author overrides by selecting one operation at a time, and RFC 6902 ops express each selection cleanly.
The Stage 5 MR3 work (#498 — Customize My Dashboard) revealed a domain mismatch for the user-layer specifically:
-
RFC 6902 paths are index-based. A user delta containing
{"op":"remove","path":"/items/2"}references whatever panel sits at index 2 of the resolved composition. -
Baseline panels evolve. New panels ship in Georgia baselines (Stage 5 MR1 added 12 panels; MR2 added 8 more). A new panel inserted at the start of a row shifts every subsequent index.
-
User customizations decouple from intent. A worker who hides "Recent Notices" by emitting
remove /items/2against today’s baseline ends up hiding whatever panel got bumped to index 2 in a future baseline. The user’s saved customization silently maps to the wrong panel.
This is a quiet correctness failure, not a UX inconvenience. The semantic the user expressed ("hide Recent Notices") is preserved nowhere — only the operational shape ("remove the item at index 2") survives, and that shape is brittle.
Domain difference: declarative user preference vs. procedural admin override
ADR-022’s RFC 6902 choice optimizes for the procedural domain (/jurisdiction_live and /role layers — Studio writes specific ops with explicit intent). The user-layer’s domain is different:
-
Workers don’t think in ops; they think in panel preferences ("hide that one", "make that one bigger", "move that one to the top").
-
The UI is gesture-based (drag, click, keyboard pickup) — declarative state captures, not procedural transformations.
-
Forward-compatibility under baseline churn is a correctness property, not a polish item.
Slug-based semantic envelopes match the declarative domain: "hide these slugs", "set spans for these slugs", "order these slugs first". Indices never appear; baselines evolve without breaking user customizations.
Options considered
-
Stay RFC 6902 verbatim. Accept the index-shift problem. UX response: prompt user to reset after every baseline change. Rejected — silent correctness failure is worse than verbose UX.
-
RFC 6902 +
testop safety net. Eachremove /items/Npaired withtest /items/N/item value: expected_slug. On baseline shift,testfails and the user’s customization drops back to baseline (graceful degradation). UX cost: user re-customizes after every baseline change. Architectural cost: stays within ADR-022. Considered — defensible but pays the cost forever. -
Non-standard slug-anchored JSON pointers (e.g.,
/items[slug=foo]/span). Visually RFC 6902 but tooling-incompatible. Hides the deviation. Rejected — compliance theater, not architecture. -
Semantic envelope on the user-layer only, amending ADR-022. New body shape
{"type": "user_delta_v1", …}. Loader detects shape per (layer, surface) and dispatches. Asymmetric: /live + /role + non-dashboard /user stay RFC 6902. Selected — domain-matched primitive, paid once.
The asymmetry isn’t accidental. /live and /role are admin-procedural domains; /user-dashboard is user-declarative. The right architecture matches each layer’s body shape to its domain.
Decision
The composition_documents.patch_ops column on the user-layer for dashboard surfaces only (worker_dashboard, supervisor_dashboard, analyst_dashboard) accepts a JSON object with the following shape:
{
"type": "user_delta_v1",
"hidden_slugs": ["worker-dashboard-recent-notices-panel"],
"span_overrides": {
"worker-dashboard-my-queue-panel": 8
},
"slug_order": [
"worker-dashboard-at-a-glance-panel",
"worker-dashboard-my-queue-panel",
"..."
]
}
Field semantics:
-
hidden_slugs— list of slugs to drop from the resolved composition. Unknown slugs (no longer in baseline) are silent-dropped at apply time. -
span_overrides— slug → span integer (1..=12). Loader setsitem.span = overridewhere slug matches. Unknown slugs silent-dropped at apply time; out-of-allowed_spansrejected at write time (422). -
slug_order— explicit ordering. Loader sorts items by position in this list; items NOT inslug_orderkeep their baseline order at the end (forward-compat: new baseline panels appear at the end of the user’s resolved view).
Scope of the amendment
This ADR amends ADR-022 in exactly one place: the user-layer body shape for dashboard surfaces. Everything else in ADR-022 stands:
| (Layer, Surface combination) | Body shape | Authoring source |
|---|---|---|
|
RFC 6902 op list |
Studio live mode |
|
RFC 6902 op list |
Studio role-override mode |
|
RFC 6902 op list (unchanged) |
Worker’s case-detail customizations (Stage 6+) |
|
RFC 6902 op list (unchanged) |
Worker’s sign-in personalization (post-UAT) |
|
|
Customize My Dashboard UI (Stage 5 MR3) |
Loader dispatch
The composition loader at crates/canopy-composition/src/loader.rs gets a new branch that runs after role-filter (loader.rs:230) and before inline validation (loader.rs:255-274):
-
For each user-layer row read from DB:
-
Try
serde_json::from_value::<UserDelta>(body)first — if successful, apply viaapply_user_delta. -
Otherwise fall through to
apply_json_patch_6902(legacy / unknown-shape fallback — no production rows exist pre-MR3, defensive only).
-
/live and /role layers continue using RFC 6902 dispatch as before (no shape detection — they’re applied in the existing DB-loop at loader.rs:179-190).
Server-side validation
PUT /v1/composition/{surface}/user/me for dashboard surfaces validates the body BEFORE persist:
-
Body must deserialize to
UserDelta::V1. Else 422. -
Every slug referenced (hidden_slugs ∪ span_overrides.keys() ∪ slug_order) MUST appear in the post-role-filter baseline composition. Else 422
SlugNotInBaseline. (Defense-in-depth against direct-API attempts to add slugs the role can’t see.) -
Each
(slug, span)inspan_overrides: span MUST ∈plugin.allowed_spans[slug]. Else 422SpanOutOfRange. -
Dry-run row sums: clone baseline, apply proposed delta, run the existing inline validation. Else 422 (
RowOverflowetc).
PATCH /user/me for dashboard surfaces returns 415 — user deltas are replaced wholesale via PUT, not incrementally patched.
Consequences
Positive
-
User customizations forward-compatible with baseline panel additions. New baseline panels appear at the end of a customized user’s resolved view without breaking saved customizations.
-
Domain-matched primitive. Declarative user preferences map to a declarative storage shape; no lossy procedural-translation layer.
-
Asymmetric but principled. /live + /role authoring (procedural) stays RFC 6902; /user-dashboard (declarative) gets the matching primitive. Each layer’s schema reflects its domain.
-
Audit trail unchanged. Existing
composition.user.putevents (Stage 3 MR2,composition.rs:874-889) carry the new body shape transparently. ADR-014 hash chain extends across composition mutations without special-casing. -
No production rows broken. Pre-MR3, no
userlayer rows exist for any surface in production. Defensive RFC 6902 fallback in the loader handles any legacy or test-fixture rows gracefully.
Negative
-
Two body shapes to maintain. Future plugin authors and Studio tooling must know that /user-dashboard uses
user_delta_v1while every other (layer, surface) uses RFC 6902. Documented in this ADR + the customize plan. -
ADR-022’s
testop as concurrency primitive disappears for user-dashboard layers. ADR-022 line 226 noted that Studio usestestops for optimistic concurrency on baseline shifts. Underuser_delta_v1, baseline-shift detection moves fromtest-op failure to loader-levelSlugNotInBaseline422. Both reject stale writes; the failure surface changes fromPatchFailed{op_index}toSlugNotInBaseline{slug,surface}. HTTP-level optimistic concurrency viaIf-Match: <etag>continues to work on PUT. -
Loader complexity. One new branch in
load_composition(~10 lines + per-shape error handling). Bounded. -
PATCH dispatch becomes surface-dependent. PATCH for dashboard surfaces returns 415; PATCH for non-dashboard surfaces continues working as RFC 6902. Documented in OpenAPI surface.
Mitigations
-
Backward-compat fallback in loader. Unknown-shape user-layer bodies fall through to RFC 6902 apply. No row class becomes unreadable.
-
Conformance via integration tests.
crates/canopy-composition/tests/user_delta_test.rs(apply + validate cases) +services/canopy-web/tests/composition_api_test.rs(round-trip through the loader) pin the schema. -
Surface-scoped change. /case_detail and /sign_in user-layer behavior is byte-stable; existing E2E coverage at
tests/e2e/specs/composition-api.spec.tscontinues to pass.
Amendment 1 — cache_ttl_seconds is excluded from the user layer (#1218, 2026-08-09)
#1218 added a per-item cache_ttl_seconds override to the composition
schema (ComposedItem), authored in the jurisdiction baseline TOML and the
jurisdiction_live / role RFC 6902 layers. It is deployment/operator
authority and deliberately absent from this ADR’s user layer:
-
user_delta_v1stays structurally closed — no TTL field is added. A worker must not be able to grant their own browser session longer-cached (or uncached) upstream data than the deployment chose. -
The case_detail / sign_in user layers still speak RFC 6902, so exclusion there is enforced twice: write-time rejection of any user-layer op touching the field (by leaf path OR embedded inside an
add/replacevalue — the whole-item smuggle), and an apply-time strip at resolution so historical rows predating the validation can never carry one into a render (canopy_composition::ttl_authority).
References
-
ADR-021 — Composability Runtime + Plugin Model (composition primitive)
-
ADR-022 — Composition Override Storage Layering (this ADR amends)
-
ADR-014 — FTI Audit Hash-Chain Integrity (audit chain that composition mutations extend)
-
RFC 6902 — JavaScript Object Notation (JSON) Patch
-
RFC 7232 — HTTP Conditional Requests (ETag / If-Match)