Files
familienarchiv/docs/adr/007-reader-dashboard-permission-discriminant.md
Marcel a072701632
Some checks failed
CI / Unit & Component Tests (push) Failing after 6m33s
CI / OCR Service Tests (push) Successful in 1m7s
CI / Backend Unit Tests (push) Failing after 4m31s
docs(adr): ADR-007 reader-dashboard permission discriminant
Captures the architectural decision behind isReader = !canWrite &&
!canAnnotate, why BLOG_WRITE intentionally lands on the reader
dashboard, the alternatives considered (separate route, AppUser
column, middleware redirect, BLOG_WRITE exclusion), and the
implications for future permission additions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00

5.1 KiB

ADR-007: Reader-dashboard permission discriminant

Status

Accepted

Context

Issue #447 introduced two distinct user cohorts on the home page:

  • Contributors — transcribe, annotate, upload. The existing MissionControlStrip, EnrichmentBlock, DashboardResumeStrip, DashboardFamilyPulse, DashboardActivityFeed, and DropZone are aimed at them.
  • Readers — browse and consume finished content. Older, less technical, on smaller devices. The contribution-focused widgets are noise to them.

AppUser permissions are already derived in +layout.server.ts and exposed via $page.data as canWrite, canAnnotate, and canBlogWrite. The home route needs a single boolean to switch its layout and its data fetch set, and that boolean has to be load-bearing — every future permission introduced has to be classified against it.

Decision

const isReader = !canWrite && !canAnnotate;

Computed at the start of +page.server.ts load(). When true, the loader fetches a lean reader set (stats / top-4 persons / recent docs / recent stories — and drafts when canBlogWrite) via Promise.allSettled and returns a discriminated-union shape the page distinguishes via data.isReader.

BLOG_WRITE is not part of the discriminant. A READ_ALL + BLOG_WRITE user is still a reader and additionally sees the ReaderDraftsModule. Story writers are conceptually closer to readers than to transcribers: they consume the archive, occasionally publish narrative on top of it, and have no business with the transcription queue.

A BLOG_WRITE-only user (no READ_ALL) is also classified as a reader by this formula. Because every reader API requires READ_ALL, all four content tiles degrade to empty via Promise.allSettled. They see the empty reader shell plus the drafts module — acceptable behaviour, since this permission combination is degenerate by configuration. Documented in docs/GLOSSARY.md.

Alternatives Considered

Alternative Why rejected
New /reader-home route with a server-side redirect from / Two routes that mostly answer the same product question (home page). Bookmarks split, breadcrumbs split, header home link has to know which to use. The conditional-render keeps a single canonical URL and lets the auth state drive the layout, matching how canWrite already gates the upload zone in the contributor branch.
AppUser.dashboardVariant column persisted in the DB Permissions already encode the relevant signal; a separate field has to be kept in sync with permission changes. Drift is a feature foot-gun: a user gets WRITE_ALL granted but their dashboardVariant field still says reader and they keep seeing the wrong UI.
Middleware/handle hook redirecting based on permissions Same logical issue as the dedicated route plus a network round-trip on every dashboard hit. The discriminant runs once inside the same load() that's already fetching the user.
isReader = !canWrite && !canAnnotate && !canBlogWrite (exclude BLOG_WRITE from readers) Treats blog writers as contributors. They would land on the MissionControlStrip they cannot meaningfully use (no WRITE_ALL, no ANNOTATE_ALL) and would have to scroll past the transcription queue to find their own drafts. The reader shell + drafts module fits their actual workflow.

Consequences

Easier:

  • Reader and contributor views share one canonical home URL — no redirect, no routing fork.
  • Adding a new content tile to the reader dashboard is a single-file change inside the if (isReader) branch of load() plus a new component import in +page.svelte.
  • Backend @RequirePermission(READ_ALL) on every reader API call remains the load-bearing security gate. isReader is purely a UX flag — manipulating it client-side serves a different layout to the same authenticated user with the same permissions.

Harder:

  • Every future Permission value has to be explicitly classified against this formula. Adding a permission that grants contribution rights but not WRITE_ALL/ANNOTATE_ALL would silently leave its bearers on the reader dashboard. Mitigation: keep this ADR linked from +page.server.ts and from the Permission enum's Javadoc.
  • The discriminated-union return type of load() ({isReader: true} | {isReader: false}) requires every consumer to narrow on data.isReader before accessing branch-specific fields. The current +page.svelte already does this with the top-level {#if data.isReader}; new consumers of the home loader must follow suit.

Future Direction

If a third cohort emerges (e.g. an admin home with system-health tiles), promote the discriminant to a tagged-union: dashboard: 'reader' | 'contributor' | 'admin'. The discriminant computation moves from +page.server.ts into a small helper in lib/shared/server/, callable from any route that needs the same classification (e.g. a future /welcome onboarding flow).

If BLOG_WRITE-only access becomes a real product mode (rather than the degenerate combination it is today), revisit whether the formula should add a canRead precondition: isReader = canRead && !canWrite && !canAnnotate.