ADR-024: User-Layer Semantic Delta Schema for Dashboard Composition

On this page

Status

Accepted

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/2 against 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

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

  2. RFC 6902 + test op safety net. Each remove /items/N paired with test /items/N/item value: expected_slug. On baseline shift, test fails 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.

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

  4. 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 sets item.span = override where slug matches. Unknown slugs silent-dropped at apply time; out-of-allowed_spans rejected at write time (422).

  • slug_order — explicit ordering. Loader sorts items by position in this list; items NOT in slug_order keep 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

(jurisdiction_live, *)

RFC 6902 op list

Studio live mode

(role, *)

RFC 6902 op list

Studio role-override mode

(user, case_detail)

RFC 6902 op list (unchanged)

Worker’s case-detail customizations (Stage 6+)

(user, sign_in)

RFC 6902 op list (unchanged)

Worker’s sign-in personalization (post-UAT)

(user, worker_dashboard | supervisor_dashboard | analyst_dashboard)

user_delta_v1 semantic envelope

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):

  1. For each user-layer row read from DB:

    1. Try serde_json::from_value::<UserDelta>(body) first — if successful, apply via apply_user_delta.

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

  1. Body must deserialize to UserDelta::V1. Else 422.

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

  3. Each (slug, span) in span_overrides: span MUST ∈ plugin.allowed_spans[slug]. Else 422 SpanOutOfRange.

  4. Dry-run row sums: clone baseline, apply proposed delta, run the existing inline validation. Else 422 (RowOverflow etc).

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.put events (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 user layer 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_v1 while every other (layer, surface) uses RFC 6902. Documented in this ADR + the customize plan.

  • ADR-022’s test op as concurrency primitive disappears for user-dashboard layers. ADR-022 line 226 noted that Studio uses test ops for optimistic concurrency on baseline shifts. Under user_delta_v1, baseline-shift detection moves from test-op failure to loader-level SlugNotInBaseline 422. Both reject stale writes; the failure surface changes from PatchFailed{op_index} to SlugNotInBaseline{slug,surface}. HTTP-level optimistic concurrency via If-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.ts continues 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_v1 stays 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/replace value — 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

Edit this page · default