Regression reported in testing: tasks and calendar events created via
the Workbench homepage widgets appeared there but vanished from their
respective module sub-routes (/todo, /calendar).
Root cause: my M4.b + M4.a shipped `defaultVisibilityFor('personal') →
'private'` based on the original plan ("personal space default is
private"). That collides with the pre-existing 2-tier visibility filter
in `apps/mana/apps/web/src/lib/data/scope/visibility.ts`, which treats
'private' records as "only the authorId sees them, even inside the
same space". Its applyVisibility() drops any 'private' record whose
authorId doesn't exactly match getCurrentUserId() — and the homepage-
widget cross-app queries in cross-app-queries.ts don't run that filter
while /todo/useAllTasks() does, creating the asymmetry the user saw.
Why the match can fail in practice: during auth bootstrap,
getEffectiveUserId() returns the 'guest' sentinel (which the Dexie
creating-hook stamps onto authorId), while getCurrentUserId() can
already resolve to the real user id by the time /todo's query runs.
authorId='guest' !== currentUserId=<real> → record filtered out.
Fix: defaultVisibilityFor() now returns 'space' regardless of space
type. Rationale:
- In a personal space there's exactly one member, so 'space' and
'private' are effectively equivalent — both mean "only the owner
sees it".
- In a multi-member space, 'space' is the desired default (otherwise
every collaborative record would need a manual toggle).
- 'private' becomes an *active* user decision for drafts in shared
spaces — click the VisibilityPicker to enable it.
- The parameter is retained (as `_spaceType`) for forward-compat so
future space types can differentiate without touching call sites.
Impact on shipped modules: all 8 consumers (Library, Picture,
Calendar, Todo, Goals, Places, Recipes, Wardrobe) call
defaultVisibilityFor(activeSpace.type) at create time — they inherit
the fix automatically. No store edits required.
Existing records with visibility='private' from the testing window
stay as they are; user can flip them to 'Bereich' via the
VisibilityPicker, or reset the local Dexie to pick up the new default.
Plan doc updated with the full rationale (docs/plans/
visibility-system.md §Entscheidung).
Verified:
- pnpm test @mana/shared-privacy: 15/15 (defaults.test.ts updated)
- pnpm check (web): 7464 files, 0 errors
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scaffold the unified visibility/privacy layer introduced by docs/plans/
visibility-system.md. No module adopts it yet — this is the foundation
PR (M1). Module rollout lands in follow-ups starting with Library (M2).
What ships:
- @mana/shared-privacy package
- VisibilityLevel enum ('private' | 'space' | 'unlisted' | 'public')
- VisibilityLevelSchema + UnlistedTokenSchema (zod)
- defaultVisibilityFor(spaceType): personal → private, else → space
- predicates: canEmbedOnWebsite, isReachableByLink,
isVisibleToSpaceMember, canAiAccessCrossUser (always false in P1)
- generateUnlistedToken() — 32-char base64url, CSPRNG, ~192 bits
- VISIBILITY_METADATA: German labels + descriptions + phosphor icon
names so non-UI surfaces (audit logs, CLI) label levels consistently
- <VisibilityPicker> svelte component: compact lock/globe trigger with
4-option menu, full descriptions, optional compact + disabledLevels
- VisibilityChangedPayload type for the domain-event catalog (consumer
registers it when the first module adopts the system)
- .claude/guidelines/visibility.md — step-by-step for module authors
(schema migrations + store wiring + picker placement + embed resolver +
legacy isPublic migration), with a pre-PR checklist
- Plan-doc "Offene Fragen" section rewritten as "Designentscheidungen"
with the seven resolutions the user approved
- CLAUDE.md: shared-privacy listed in the packages table; visibility.md
listed in the guidelines table
- 15 unit tests covering predicates (one-and-only-one 'public' for
embed; phase-1 AI always-deny), defaults (personal vs multi-member,
null fallback), token uniqueness + schema round-trip
Key constraints honored:
- `visibility` stays plaintext (NOT added to the encryption registry)
so RLS predicates and publish resolvers can read it without the user's
master key
- Publish flow remains "decrypt client-side, inline plaintext into
snapshot" — the pattern picture.board already uses in embeds.ts
- Deny-by-default everywhere (personal default = private; unknown space
type defaults to private; cross-user AI always false)
Not in this PR (per plan):
- No schema migrations in any module (M2–M6)
- No RLS predicate updates (arrives with M2)
- No /settings/privacy overview (M7)
- No unlisted share routes (M8)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Write up the design for a repo-wide visibility layer before building. Today
the state is fragmented: 7 modules carry ad-hoc isPublic booleans (picture,
cards, presi, memoro, times, broadcast.audience, uload.tags) with
inconsistent semantics and mostly no UI; the majority of modules (library,
calendar, todo, places, events, recipes, goals, habits, quiz, wardrobe,
invoices-clients, …) have nothing. Spaces only carry member permissions,
no public tier. The existing encryption layer (27 encrypted tables) is not
a blocker — embeds.ts already demonstrates "decrypt client-side, inline
plaintext into the publish snapshot".
Design:
- @mana/shared-privacy package with `VisibilityLevel = 'private' | 'space'
| 'unlisted' | 'public'`, a `<VisibilityPicker>` svelte component, and
predicate helpers (canEmbedOnWebsite, isVisibleToSpaceMember, …)
- Per-record `visibility text not null default 'private'` on public-capable
tables only; `unlistedToken`, `visibilityChangedAt`, `visibilityChangedBy`
alongside. Field stays plaintext so RLS + publish resolvers can read it
without the user's master key
- Default-per-space-type: personal → private, team/club → space. Never
public/unlisted by default
- Embed resolvers gate hard on `canEmbedOnWebsite`; user filters (tags,
status, date window) stack on top, never replace
- RLS predicate extended: `space_member OR visibility='public' OR
(visibility='unlisted' AND unlisted_token matches header)`
Rollout (soft-first / hard-follow-up per existing migration convention):
M1 shared package · M2 library (pilot) · M3 picture (replaces isPublic) ·
M4 calendar + todo + goals · M5 places/events/recipes/habits/quiz/wardrobe
/invoices · M6 legacy-flag consolidation · M7 /settings/privacy overview
+ kill-switch · M8 (optional) unlisted share links.
Out of scope: per-user sharing, field-level visibility, visibility
cascading, time-boxed public, search-indexing by default. Documented
explicitly so the first implementer doesn't reopen these.
No code yet — waiting on user go-ahead before starting M1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>