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>
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, andDropZoneare 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 ofload()plus a new component import in+page.svelte. - Backend
@RequirePermission(READ_ALL)on every reader API call remains the load-bearing security gate.isReaderis purely a UX flag — manipulating it client-side serves a different layout to the same authenticated user with the same permissions.
Harder:
- Every future
Permissionvalue has to be explicitly classified against this formula. Adding a permission that grants contribution rights but notWRITE_ALL/ANNOTATE_ALLwould silently leave its bearers on the reader dashboard. Mitigation: keep this ADR linked from+page.server.tsand from thePermissionenum's Javadoc. - The discriminated-union return type of
load()({isReader: true} | {isReader: false}) requires every consumer to narrow ondata.isReaderbefore accessing branch-specific fields. The current+page.sveltealready 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.