docs(fase-12): history doc + CLAUDE.md status + new-section convention

Adds docs/history/fase-12-section-visibility.md describing the
COALESCE-precedence design that keeps Fase 13's server-settings layer
to a one-line diff, the realtime publication addition that SV-02
forced, and the hermetic test-reset deviation from the plan.

Updates CLAUDE.md project status (2/6 → 4/6 since Fase 11 and 12 both
shipped on the mvp2 branch) and adds the "new top-level section requires"
rule under Domain Model.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 04:45:36 +02:00
parent 09cd10692e
commit 99096f9f4a
2 changed files with 213 additions and 1 deletions

View File

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Fase 05 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.limonia.net (app + Supabase, external Keycloak); edge proxy now off-stack — internal Caddy fans kong/app on host port 3000, whatever external proxy fronts the host terminates TLS and forwards plain HTTP to ambrosio:3000 (originally NetBird's self-hosted Traefik, now proxy-agnostic); PWA strategy switched to generateSW to unblock the prod build (see the "injectManifest hardcoded filename" gotcha below). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). Plus migration 013 (auth.users role/aud default trigger) — found after the first real self-registration on prod via Keycloak→GoTrue left the user with empty `role`/`aud` columns, breaking every REST call with `role "" does not exist`. Post-fase-7 prod auth hardening (2026-04-20..21, see `docs/history/fase-8-auth-logout.md`): migration 014 extends the role/aud guard to UPDATE (GoTrue's pop-ORM resends role='' right after the INSERT and clobbered the INSERT trigger's backfill); `logout()` now does RP-initiated Keycloak end-session + lands on a new public `/logged-out` route, which both actually ends the Keycloak SSO cookie (so the next login re-prompts) and closes a PKCE-verifier race that produced "PKCE code verifier not found in storage" on prod. 259 tests green: 45 pgTAP + 140 Vitest integration + 15 Vitest unit + 59 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅** **Fase 05 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.limonia.net (app + Supabase, external Keycloak); edge proxy now off-stack — internal Caddy fans kong/app on host port 3000, whatever external proxy fronts the host terminates TLS and forwards plain HTTP to ambrosio:3000 (originally NetBird's self-hosted Traefik, now proxy-agnostic); PWA strategy switched to generateSW to unblock the prod build (see the "injectManifest hardcoded filename" gotcha below). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). Plus migration 013 (auth.users role/aud default trigger) — found after the first real self-registration on prod via Keycloak→GoTrue left the user with empty `role`/`aud` columns, breaking every REST call with `role "" does not exist`. Post-fase-7 prod auth hardening (2026-04-20..21, see `docs/history/fase-8-auth-logout.md`): migration 014 extends the role/aud guard to UPDATE (GoTrue's pop-ORM resends role='' right after the INSERT and clobbered the INSERT trigger's backfill); `logout()` now does RP-initiated Keycloak end-session + lands on a new public `/logged-out` route, which both actually ends the Keycloak SSO cookie (so the next login re-prompts) and closes a PKCE-verifier race that produced "PKCE code verifier not found in storage" on prod. 259 tests green: 45 pgTAP + 140 Vitest integration + 15 Vitest unit + 59 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅**
**MVP2 — in progress (2/6 ✅).** Branch `mvp2`. **Fase 9 complete: dark mode + polish** (migration 015 + 016 + pgTAP 009 + 010 + dark-mode plumbing + max-w-2xl reading column on detail routes + sync_conflicts table/queue logging/debug panel + WebKit Playwright project for the swipe-toggle gestures; see `docs/history/fase-9-polish-dark-mode.md`). **Fase 10 complete: spec completion (H06/H07/H08 + trash restore + account deletion + sidebar-create + reset-from-detail + Accept-Language)** — 5 new migrations (017 leave_collective, 018 dissolve_collective, 019 user-deletion FKs, 020 restore/hard-delete RPCs, 021 delete_account) + 4 new pgTAP suites + 6 new Playwright specs + Accept-Language parser; see `docs/history/fase-10-spec-completion.md`. Test totals: 96 pgTAP + 151 Vitest integration + 38 Vitest unit + 74 Playwright + 1 Vitest rate-limit (gated) = **360 tests green**; 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug `supabase/realtime#1617` still open as of 2026-05-18 / latest v2.86.3; 1 rate-limit — gated). WebKit specs (2 in Fase 9.4) are gated on `RUN_WEBKIT=1` so they stay out of `just test-all` (the binary needs `libicu74` / `libwoff1` / `libmanette-0.2-0` system deps via `sudo pnpm exec playwright install-deps`). Remaining fases (📋): Fase 11 item tags + drag-reorder (`plan/fase-11-item-tags-importance.md`, migration 022) | Fase 12 section visibility — JSONB `feature_flags` on `users`+`collectives`, `section_enabled()` SQL helper (`plan/fase-12-section-visibility.md`, migration 023) | Fase 13 server administration — `/admin` area, `server_admins` table, audit log, server-level visibility defaults, depends on Fase 12 (`plan/fase-13-server-admin.md`, migrations 024+025) | Fase 14 PWA hardening — update toast, offline polish, version chip, depends on Fase 9.3 (`plan/fase-14-pwa-hardening.md`, no migration). Fase 7+8 already shipped as MVP cleanup, not MVP2. **MVP2 — in progress (4/6 ✅).** Branch `mvp2`. **Fase 9 complete: dark mode + polish** (migration 015 + 016 + pgTAP 009 + 010 + dark-mode plumbing + max-w-2xl reading column on detail routes + sync_conflicts table/queue logging/debug panel + WebKit Playwright project for the swipe-toggle gestures; see `docs/history/fase-9-polish-dark-mode.md`). **Fase 10 complete: spec completion (H06/H07/H08 + trash restore + account deletion + sidebar-create + reset-from-detail + Accept-Language)** — 5 new migrations (017021) + 4 new pgTAP suites + 6 new Playwright specs; see `docs/history/fase-10-spec-completion.md`. **Fase 11 complete: item tags + drag-reorder** — migration 022 + pgTAP 014 + tags store + chips/picker + filter bar + realtime re-sort; see `docs/history/fase-11-item-tags-importance.md`. **Fase 12 complete: section visibility (feature flags)** — migration 023 + pgTAP 015 (20 assertions) + Vitest SV-01..SV-06 + Playwright SV-01..SV-03; JSONB `feature_flags` on `users`+`collectives`, `public.section_enabled()` SQL helper with COALESCE precedence chain (Fase 13 server layer is a one-line diff); see `docs/history/fase-12-section-visibility.md`. Test totals: 124 pgTAP + 163 Vitest integration + 45 Vitest unit + 82 Playwright + 1 Vitest rate-limit (gated) = **414 tests green**; 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug `supabase/realtime#1617` still open; 1 rate-limit — gated). WebKit specs (2 in Fase 9.4) are gated on `RUN_WEBKIT=1` so they stay out of `just test-all` (the binary needs `libicu74` / `libwoff1` / `libmanette-0.2-0` system deps via `sudo pnpm exec playwright install-deps`). Remaining fases (📋): Fase 13 server administration — `/admin` area, `server_admins` table, audit log, server-level visibility defaults, depends on Fase 12 (`plan/fase-13-server-admin.md`, migrations 024+025) | Fase 14 PWA hardening — update toast, offline polish, version chip, depends on Fase 9.3 (`plan/fase-14-pwa-hardening.md`, no migration). Fase 7+8 already shipped as MVP cleanup, not MVP2.
## Documentation Map ## Documentation Map
@@ -17,6 +17,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- `docs/history/fase-8-auth-logout.md` — post-fase-7 prod auth hardening (migration 014 + RP-initiated logout) - `docs/history/fase-8-auth-logout.md` — post-fase-7 prod auth hardening (migration 014 + RP-initiated logout)
- `docs/history/fase-9-polish-dark-mode.md` — MVP2 dark mode + reading width + sync-conflicts debug panel - `docs/history/fase-9-polish-dark-mode.md` — MVP2 dark mode + reading width + sync-conflicts debug panel
- `docs/history/fase-10-spec-completion.md` — MVP2 CU-H06/H07/H08 + trash RPCs + account delete + sidebar create + reset from detail + auto language - `docs/history/fase-10-spec-completion.md` — MVP2 CU-H06/H07/H08 + trash RPCs + account delete + sidebar create + reset from detail + auto language
- `docs/history/fase-11-item-tags-importance.md` — MVP2 item tags + drag-reorder
- `docs/history/fase-12-section-visibility.md` — MVP2 section visibility / feature flags (this fase)
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules) - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
- `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 7) - `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 7)
- `design/slate_collective/DESIGN.md` + `design/*/screen.png` — "Monolith Editorial" direction + per-screen Stitch mockups. Consult before changing UI. - `design/slate_collective/DESIGN.md` + `design/*/screen.png` — "Monolith Editorial" direction + per-screen Stitch mockups. Consult before changing UI.
@@ -73,6 +75,14 @@ The central organizing unit is the **Collective** (household group), not the ind
- Trash retains deleted items/notes for 7 days before permanent deletion. - Trash retains deleted items/notes for 7 days before permanent deletion.
- If the sole admin deletes their account, the system auto-promotes the oldest member before allowing deletion. - If the sole admin deletes their account, the system auto-promotes the oldest member before allowing deletion.
**Section visibility (feature flags) — required steps for any new top-level section.** A "section" here is one of the four current navigable areas (lists, tasks, notes, search) — adding a fifth means EVERY one of:
1. SQL migration that updates `public.known_sections()` to include the new key + (if it adds a new RPC) maps onto the planned Fase 13 server-layer extension of `public.section_enabled()`.
2. `SectionKey` union + `SECTION_KEYS` array entry in `packages/types/src/domain.ts`.
3. Default-checked toggle row in `/settings` (per-user) and `/collective/manage` (admin) — both already iterate `SECTION_KEYS`, so this is automatic as long as you add the `section_label_<key>` Paraglide message + the route prefix in `(app)/+layout.svelte`'s `SECTION_BY_PREFIX` array (drives the redirect guard).
4. pgTAP assertion in `supabase/tests/015_section_visibility.sql` that `section_enabled('<newkey>', user, collective)` defaults to true.
Without all four, the section either shows up but can't be hidden, or can be hidden but isn't toggled from the UI — both are confusing failure modes.
## Internationalisation ## Internationalisation
Library: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, zero runtime overhead, tree-shaken per language. Library: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, zero runtime overhead, tree-shaken per language.

View File

@@ -0,0 +1,202 @@
# Fase 12 — Section visibility / feature toggles (MVP2 4/6)
**Status: complete · 2026-05-18.**
Fourth slice of MVP2. Lets each user hide top-level sections they don't use, and lets a collective admin hide them for every member. No plugin system — JSONB `feature_flags` on `users` + `collectives` resolved through a single STABLE SQL function, mirrored client-side for nav rendering.
Pre-requisite for Fase 13 (server administration). The SQL helper is designed so the Fase-13 server layer is a one-line diff.
Final test counts: **124 pgTAP + 163 Vitest integration + 45 Vitest unit + 82 Playwright = 414 green; 3 skipped (2 upstream Realtime presence + 1 gated rate-limit).** Delta vs Fase 11:
| Suite | Before | After | Δ |
|---|---|---|---|
| pgTAP | 104 | 124 | +20 |
| Vitest integration | 157 | 163 | +6 |
| Vitest unit | 45 | 45 | 0 |
| Playwright E2E | 79 | 82 | +3 |
| **Total** | **385** | **414** | **+29** |
---
## 12.1 — Model + SQL helpers
**Migration `023_section_visibility.sql`** adds:
- `public.users.feature_flags jsonb NOT NULL DEFAULT '{}'::jsonb`
- `public.collectives.feature_flags jsonb NOT NULL DEFAULT '{}'::jsonb`
- `public.known_sections() returns text[] IMMUTABLE` — canonical 4 keys (`lists`, `tasks`, `notes`, `search`). Adding a section is a fresh migration; nothing else queries the function at runtime, but it documents the contract.
- `public.section_enabled(p_section text, p_user uuid, p_collective uuid) returns boolean STABLE SECURITY DEFINER` — the precedence resolver.
The function body is structured as a single `COALESCE` so the **Fase-13 server layer is a literal one-line prepend** — no callers change shape, no extra branching. The expected end state once Fase 13 lands:
```sql
SELECT COALESCE(
(SELECT (value ->> p_section)::boolean -- Fase 13 server layer (FUTURE)
FROM public.server_settings
WHERE key = 'default_sections'),
(SELECT (feature_flags ->> p_section)::boolean
FROM public.collectives WHERE id = p_collective),
(SELECT (feature_flags ->> p_section)::boolean
FROM public.users WHERE id = p_user),
true
);
```
A comment in the migration spells out the chain (server → collective → user → default true) so the next contributor can see the slot before having read this doc.
RLS: writes to `feature_flags` piggyback on the existing `users_update_own` (migration 003) + `collectives_update` (admin-only) policies. **No new policy was needed** — the JSONB column is a normal column from RLS's perspective. This was the rule-7 stop condition; it did not trigger.
**Realtime**: also added in this migration. `public.users` and `public.collectives` are now in the `supabase_realtime` publication with `REPLICA IDENTITY FULL`. Without them, the client-side subscriptions in `features.ts` see zero events — caught by SV-02 (the dual-context realtime test) before it ever passed locally.
**pgTAP `015_section_visibility.sql`** — 20 assertions:
- SV-T01..SV-T06: column shape (existence, type `jsonb`, NOT NULL) for both tables.
- SV-T07..SV-T09: `known_sections()` exists, is IMMUTABLE, returns the canonical 4 keys in order.
- SV-T10..SV-T12: `section_enabled()` exists with the right `(text, uuid, uuid) → boolean` signature, is STABLE.
- SV-T13: empty flags on both layers → default ON.
- SV-T14: user OFF + collective absent → OFF.
- SV-T15: collective OFF wins over user ON.
- SV-T16: unknown section key → default true (forward-compat — a newly-deployed front-end can call `section_enabled('newkey', …)` before the migration that adds the key lands and the call doesn't error).
- SV-T17..SV-T20: realtime publication membership + REPLICA IDENTITY FULL for both new tables.
`packages/types/src/database.ts` hand-extended with `feature_flags: Json` on the `users` + `collectives` Row/Insert/Update shapes plus the two new Functions (`known_sections`, `section_enabled`). `just db-types` remains gated (Supabase CLI not installed on this host) — same convention as Fase 11.
---
## 12.2 — Stores + types
`packages/types/src/domain.ts`:
```ts
export type SectionKey = 'lists' | 'tasks' | 'notes' | 'search';
export const SECTION_KEYS: readonly SectionKey[] = ['lists', 'tasks', 'notes', 'search'];
export type FeatureFlags = Partial<Record<SectionKey, boolean>>;
```
`apps/web/src/lib/stores/features.ts` (new) — the client mirror of `section_enabled()`:
- `currentUserFeatures` writable, loaded by root `+layout.svelte` after sign-in.
- `enabledSections` derived `Record<SectionKey, boolean>` applying the same precedence (collective > user > default true). Used as `$enabledSections.tasks` in components.
- `enabledSectionList` derived `SectionKey[]` — the ordered list of visible keys with one client-side hard override: if every layer says OFF for everything, force `['lists']` so the user always has a landing page (§12.3.4).
- `setUserFeature(key, value)` — optimistic local patch, rolls back on RLS denial.
- `setCollectiveFeature(key, value)` — optimistic patch, throws `forbidden` when the UPDATE returns zero rows (RLS denied), which the UI surfaces by snapping the toggle back.
- `loadCurrentUserFeatures(userId)` + `subscribeCollectiveFeatures(collectiveId)` — set up one Realtime channel each per target. The user channel is keyed by user id; the collective channel is rebuilt whenever `$currentCollective.id` changes (subscribed via `currentCollective.subscribe(...)` in the root layout).
Per CLAUDE.md "Supabase JS + onAuthStateChange deadlock" gotcha, `loadCurrentUserFeatures` is called from inside the already-deferred `setTimeout(..., 0)` block in the auth callback — same shape as the existing `loadUserCollectives` path. The collective channel is set up outside that callback so collective switches don't need an auth event.
`Collective` interface (`apps/web/src/lib/stores/collective.ts`) grows a `feature_flags: FeatureFlags` field. `loadUserCollectives` extends its SELECT to fetch it. Every other place that fabricated a Collective by hand (`CreateCollectiveModal`, `onboarding/+page.svelte`, `collective/manage/+page.svelte`) now includes `feature_flags: {}` for fresh rows or selects it on rename/recolor.
**Vitest integration `tests/section-visibility.test.ts`** — 6 assertions:
- SV-01 default ON for every known section.
- SV-02 user OFF affects only that user, not peers.
- SV-03 collective OFF beats user ON.
- SV-04 user can update own `feature_flags` (RLS write piggyback).
- SV-05 admin can update collective `feature_flags`; non-admin member's UPDATE returns 0 rows (RLS denied).
- SV-06 a user in two collectives sees the per-collective override resolution independently.
---
## 12.3 — UI gating + redirect guard
**Nav components** (`DesktopSidebar`, `BottomTabBar`):
- Each nav entry declared with its `SectionKey` so the runtime filter is one line: `allItems.filter((it) => it.section === 'lists' || $enabledSections[it.section])`.
- "lists" is force-shown unconditionally (the §12.3.4 always-one-landing rule).
- Both components add `data-section="…"` on each entry; `BottomTabBar` adds `data-section-count="N"` on the nav so tests can assert grid changes.
`MobileDrawer` doesn't carry nav entries today (only the collective switcher + Manage + Settings + Logout) so it didn't need a change. If a future iteration adds nav links there, the same filter pattern applies.
**Route guard** in `(app)/+layout.svelte`: a `$effect` watches `$page.url.pathname`. If it matches a disabled section's prefix (`/tasks`, `/notes`, `/search``/lists` is always allowed), it calls `goto('/lists')` and surfaces a transient `section_disabled_for_collective` toast (top-centered, 3.5 s auto-dismiss). Adding a new section requires extending the `SECTION_BY_PREFIX` array — flagged in CLAUDE.md.
The toast is intentionally separate from the existing `UndoToast` so the two can stack without colliding.
Dev-only hook: `+layout.svelte`'s `onMount` exposes the Supabase client singleton on `window.__sb` when `import.meta.env.DEV` is true so the Playwright spec can patch rows directly via the same client (and the same JWT) the app uses. Vite dead-code-eliminates this in production builds.
---
## 12.4 — Settings + manage UI
`/settings`:
- New "Visible sections" block with one checkbox per `SectionKey`, bound to `setUserFeature`.
- When the active collective has explicitly turned a section OFF, that user toggle is disabled and a one-line "Hidden by the collective" hint replaces the visible state — explaining why the per-user toggle is moot.
`/collective/manage` (admin-only, inside the existing `isAdmin` block):
- New "Collective sections" block with one checkbox per `SectionKey`, bound to `setCollectiveFeature`.
- Flipping any of them propagates to every member in realtime — that's exactly what SV-02 verifies.
Both helpers fall back to default-ON semantics when the JSONB key is missing, mirroring `section_enabled()`. So untouched rows render exactly as before fase 12 — nothing is forced on or off.
11 new Paraglide messages in `en` + `es`: `section_disabled_for_collective`, `settings_section_visibility_{title,blurb,overridden}`, `collective_section_visibility_{title,blurb}`, `section_label_{lists,tasks,notes,search}`.
---
## 12.5 — Convention rule
Added to `CLAUDE.md` under "Domain Model" — adding a new top-level section requires all four of:
1. Migration that updates `public.known_sections()`.
2. `SectionKey` + `SECTION_KEYS` entry in `packages/types/src/domain.ts`.
3. Paraglide `section_label_<key>` message + route prefix in `(app)/+layout.svelte`'s `SECTION_BY_PREFIX`.
4. pgTAP assertion that `section_enabled('<newkey>', …)` defaults to true.
The settings + manage toggles iterate `SECTION_KEYS`, so they're automatic once steps 2 + 3 are done.
---
## 12.6 — Tests
**E2E `tests/e2e/section-visibility.test.ts`** (SV-01..SV-03):
- SV-01 Ana flips the user toggle for Tasks in /settings → sidebar + bottom tab bar lose the entry → visiting /tasks bounces to /lists + the toast surfaces.
- SV-02 Dual-context. Ana (admin) hides Notes for the collective in /collective/manage; Borja's BottomTabBar loses the Notes entry within the 15 s realtime window, no reload.
- SV-03 Borja sets `tasks: false` for himself; Ana then sets `tasks: true` at the collective level. Tasks reappears for Borja in that collective (collective ON beats user OFF — the integration suite's SV-06 covers the inverse where the collective has no opinion).
Each test resets both `users.feature_flags` (Ana + Borja) AND `collectives.feature_flags` (seed collective) in **beforeEach AND afterEach** so the suite is hermetic regardless of what ran before in `just test-all`. Without the beforeEach we observed bleed from the prior tasks tests into SV-02 + SV-03 on the first full-suite run; the early "passes in isolation, fails in the full run" failure mode is captured in CLAUDE.md's existing "Realtime waitFor timeout" gotcha shape — same root cause family.
---
## Deviation note: e2e cleanup hardening (vs plan)
The plan didn't call out the beforeEach reset, but the `just test-all` first run surfaced state leakage from the broader suite into SV-02 (collective row had stale `feature_flags`). Adding a symmetric beforeEach reset (Ana resets her row + the seed collective via a fresh context; Borja resets his row via his own context) made the suite deterministic across 5 sequential full runs. Cost: ~6 s per spec in setup overhead; acceptable.
---
## Forward-compat for Fase 13
The single design decision that makes Fase 13's server-settings layer a clean diff:
- `section_enabled()` is a single COALESCE chain. Fase 13 only prepends a new SELECT branch from `server_settings.default_sections` at the top of the COALESCE. No call-site shape changes anywhere in the codebase. The migration body comment spells this out so the next contributor sees it.
- The client-side `enabledSections` derived store reads from two sources today (`currentUserFeatures` + `currentCollective`). Fase 13 will add a third (`serverSettings`), and the derived shape stays `Record<SectionKey, boolean>`. No consumer changes.
- `setCollectiveFeature` throws a typed `'forbidden'` error already, which Fase 13 can reuse for the server-layer admin endpoint.
Nothing about the current design makes Fase 13 harder. The one place a server-layer pgTAP test will need extra wiring is `SV-T15` — once the server layer exists, an "admin server override OFF wins over collective ON" case becomes the new top of the precedence ladder.
---
## File map (new)
- `supabase/migrations/023_section_visibility.sql`
- `supabase/tests/015_section_visibility.sql`
- `packages/test-utils/tests/section-visibility.test.ts`
- `apps/web/src/lib/stores/features.ts`
- `apps/web/tests/e2e/section-visibility.test.ts`
- `docs/history/fase-12-section-visibility.md`
Modified:
- `packages/types/src/domain.ts``SectionKey`, `SECTION_KEYS`, `FeatureFlags`
- `packages/types/src/database.ts``feature_flags` on `users` + `collectives` Rows, `known_sections` + `section_enabled` Functions
- `apps/web/src/lib/stores/collective.ts``feature_flags` on `Collective`
- `apps/web/src/lib/components/CreateCollectiveModal.svelte` — fabricate `feature_flags: {}` on create
- `apps/web/src/routes/onboarding/+page.svelte` — same
- `apps/web/src/routes/(app)/collective/manage/+page.svelte` — collective toggle UI + extended SELECT for `feature_flags`
- `apps/web/src/routes/(app)/settings/+page.svelte` — user toggle UI
- `apps/web/src/routes/(app)/+layout.svelte` — route guard + toast
- `apps/web/src/routes/+layout.svelte``loadCurrentUserFeatures` wiring, `subscribeCollectiveFeatures`, `window.__sb` dev hook
- `apps/web/src/lib/components/layout/DesktopSidebar.svelte` — nav filter + `data-section`
- `apps/web/src/lib/components/layout/BottomTabBar.svelte` — nav filter + `data-section` + `data-section-count`
- `apps/web/messages/{en,es}.json` — 11 keys
- `CLAUDE.md` — Domain Model "new section requires" rule