================================================================
FILE: CLAUDE.md
================================================================

# Synops — Claude Code Prosjektguide

## Prosjektoversikt
Synops er en plattform for redaksjonelt arbeid og podcast-produksjon.
Self-hosted på Hetzner VPS med full datakontroll.

Synops er plattformen. Sidelinja (podcastredaksjonen) er en tenant —
en organisasjon som bruker Synops. Denne distinksjonen er bevisst:
plattformkode og infrastruktur er skilt fra tenant-data og -innhold.

## Arbeidsflyt
- **Standard arbeidsmodus:** Start i planleggingsmodus. Lag en grundig plan,
  få godkjenning, deretter implementer. Jobbene er ment å kunne kjøre
  lenge autonomt uten input underveis.
- **Utvikling mot server.** Ingen lokale databaser eller tjenester.
  Frontend (SvelteKit) utvikles lokalt med HMR mot server-API.
  Rust bygges lokalt, deployes til server for integrasjonstest.
- **Browser-testing:** Claude har ikke tilgang til browser. Visuell testing
  gjøres av Vegard. Claude verifiserer backend (kompilering, API, DB-state).
- **Commit og push:** Bruk egen vurdering. Trygt og reverserbart.
- **Deploy til produksjon:** Krever alltid eksplisitt godkjenning fra Vegard.
- **Diskusjon:** Forklar og diskuter før arkitekturendringer.
  For implementering innenfor eksisterende spec — bare kjør.

## Dokumentasjonstre
CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:

- `docs/arkitektur.md` — Overordnet arkitektur, lagmodell, teknologivalg
- `docs/retninger/` — Arkitektoniske teser og vedtatte retninger:
  - `README.md` — Oversikt og status
  - `status_quo.md` — Hva v1 var: ambisiøse primitiver, tradisjonell overflate
  - `rom_ikke_forum.md` — Opplevelse-først, to-lags-modell, administrativ opplevelse
  - `universell_input.md` — Tre primitiver (input, mottak, kommunikasjon), noder+edges
  - `maskinrommet.md` — Rust-orkestrator: fang, prosesser, lever
  - `bruker_ikke_workspace.md` — Noder er sentrum: brukere, team, innhold er noder. Tilgangsmatrise fra edges.
  - `datalaget.md` — PG(+AGE) som graf og arkiv, sanntid via LISTEN/NOTIFY + WebSocket
- `docs/primitiver/` — Spesifikasjoner for kjerneprimitivene:
  - `nodes.md` — Node-skjema, node_kind, visibility, CAS-noder, eierskap
  - `edges.md` — Edge-skjema, typer, metadata, systemedges
  - (kommer: input, mottak, kommunikasjonsnode)
- `docs/concepts/` — Brukeropplevelser/produktområder:
  - `studioet.md`, `møterommet.md`, `redaksjonen.md`, `podcastfabrikken.md`,
    `kunnskapsgrafen.md`, `valgomaten.md`, `den_asynkrone_gjesten.md`
- `docs/features/` — Tekniske byggeklosser:
  - Se individuelle filer for chat, kanban, kalender, meldingsboks,
    kunnskapsgraf, whiteboard, live transkripsjon, m.fl.
- `docs/proposals/` — Idébank med 32+ uimplementerte forslag (se README.md)
- `docs/setup/` — Oppsett og drift:
  - `produksjon.md` — Steg-for-steg oppsett av Hetzner VPS fra scratch
  - `lokal.md` — Lokalt utviklingsmiljø (WSL2, mot server)
  - `migration_safety.md` — Sjekkliste for PostgreSQL-migrasjoner (v1 workspace-RLS, trenger omskriving til node_access)
- `docs/infra/` — Infrastruktur og drift:
  - `ai_gateway.md` — LiteLLM som sentralisert AI-ruter (BYOK + fallback)
  - `api_grensesnitt.md` — Kommunikasjonskart: SvelteKit er web-API, Rust er worker
  - `jobbkø.md` — PostgreSQL-basert køsystem for bakgrunnsjobber
  - `synkronisering.md` — Historisk: PostgreSQL ↔ SpacetimeDB (utgått, STDB fjernet mars 2026)
- `docs/erfaringer/` — Lærdommer fra v1 (adapter-mønster, Svelte 5, SpacetimeDB, Authentik)
- `reference/` — Kode fra v1 med gjenbruksverdi (Editor.svelte)
- `ops/` — Repeterbare vedlikeholdsjobber (ryddejobb, doc-audit, drift-sjekk)

## Aktører
- **Vegard** — serveradmin, utvikler, bruker. SSH: `vegard@157.180.81.26`
- **Claude** — AI-agent, utvikler. SSH: `claude@157.180.81.26`
- Begge har sudo + docker-tilgang på serveren.

## Stack
- **Orkestrator/Backend:** Rust (maskinrommet)
- **Frontend:** SvelteKit (TypeScript, PWA)
- **Sanntid:** PG LISTEN/NOTIFY + WebSocket
- **Database/Graf:** PostgreSQL (+Apache AGE ved behov)
- **Binærlagring:** CAS (content-addressable store)
- **AI:** LiteLLM (AI Gateway), faster-whisper (STT), ElevenLabs (TTS)
- **Infra:** Docker Compose, Caddy, Authentik (SSO)

## Produksjonsserver
- **IP:** 157.180.81.26
- **SSH:** `ssh vegard@157.180.81.26` / `ssh claude@157.180.81.26`
- **Root-login:** Deaktivert
- **SSH-nøkkel (lokal WSL2):** `/home/vegard/.ssh/id_ed25519`
- **Server-filer:** `/srv/synops/` (docker-compose.yml, .env, config/, data/)
- **Domener:**
  - `sidelinja.org` — Tenant-app (Sidelinja podcastredaksjonen)
  - `auth.sidelinja.org` — Authentik SSO
  - `git.sidelinja.org` — Forgejo (SSH port 222)
  - `vegard.info` — Separat nettsted
  - `synops.no` — Plattformdomene (reservert, ikke i bruk ennå)

## Git
- **Repos i Forgejo:**
  - `vegard/synops` — plattformkode og arkitektur: `ssh://git@git.sidelinja.org:222/vegard/synops.git`
  - `sidelinja/sidelinja` — podcastinnhold: `ssh://git@git.sidelinja.org:222/sidelinja/sidelinja.git`
- **Git-identitet:** vegard / vnotnes@pm.me
- **Forgejo-bruker:** vegard (admin)
- **CLI:** Bruk `tea`, ikke `gh` (vi bruker Forgejo, ikke GitHub)

## Viktige regler
- Aldri eksponere databaseporter mot internett
- All AI-trafikk via maskinrommet, aldri direkte til leverandør-APIer
- Tunge jobber (Whisper, LLM, TTS) blokkerer aldri brukerforespørsler
- Sjekk alltid relevant doc i `docs/` før implementering
- Dokumenter lærdommer i `docs/erfaringer/`

## Lagmodell
```
GUI (SvelteKit)
  │ skriv              │ les (sanntid, direkte WebSocket)
  ▼                    ▼
Maskinrommet (Rust) ──→ WebSocket ──→ GUI
  │
  ▼
Tjenester: PG+AGE, CAS, Whisper, LiteLLM, LiveKit ...
```

## Kjerneprinsipper
1. **Alt er noder og edges.** Ingen separate tabeller for chat, kanban,
   kalender, notater. Visninger er spørringer mot grafen.
2. **Tre primitiver:** Input (fanger), Mottak (presenterer), Kommunikasjon
   (samler folk). Alt annet er visninger og edges.
3. **Maskinrommet orkestrerer alt.** Fang, prosesser, lever. Edge-drevet
   ressursallokering. Tjenester under er utbyttbare.
4. **Noder er sentrum.** Brukere, team, innhold — alt er noder.
   Du ser dine edges. Tilgang via materialisert tilgangsmatrise.
5. **Privat er default.** Input uten mottaker-edge er privat. Security
   by design, ikke konfigurasjon.
6. **PG er eneste datakilde.** Sanntid via LISTEN/NOTIFY + WebSocket.


================================================================
FILE: docs/arkitektur.md
================================================================

# Arkitektur — Synops

## Visjon

Synops er en plattform for redaksjonelt arbeid og podcast-produksjon.
Ikke en webapp med features — en plattform med primitiver som kan bli
hva som helst. Sidelinja (podcastredaksjonen) er en tenant som bruker
Synops.

Alt er noder og edges i en graf. En bruker er en node. Et team er en
node. En mediefil er en node. Hva noe "er" bestemmes av edges, ikke
av noden selv. Maskinrommet eier alle skrivinger. Frontend er et tynt
lag som mottar sanntidsdata via WebSocket.

## Lagmodell

```
┌─────────────────────────────────────┐
│  GUI (SvelteKit)                     │
│  Visninger: sanntid via WebSocket   │
└────────┬──────────────────┬─────────┘
         │ intensjoner       │ les (sanntid)
         │                   │ WebSocket
┌────────▼──────────────────▼─────────┐
│  Maskinrommet / Portvokteren (Rust)  │
│  Eier alle skrivinger               │
│  PG LISTEN/NOTIFY → WebSocket       │
└──┬─────────┬──────────────────┬─────┘
   │         │                  │
   ▼         ▼                  ▼
┌─────┐  ┌─────┐  ┌─────────────┐
│ PG  │  │ CAS │  │ Whisper,    │
│     │  │     │  │ LiteLLM,   │
│     │  │     │  │ LiveKit ... │
└─────┘  └─────┘  └─────────────┘
```

### Skrivestien
GUI → intensjon → Maskinrommet (Rust) → PG → NOTIFY → WebSocket → GUI

Frontend sender intensjoner (ikke data). Maskinrommet validerer,
skriver til PG, og NOTIFY-triggere propagerer endringen til frontend
via WebSocket i sanntid. Maskinrommet leser edges og bestemmer
hvilke tjenester som trigges.

### Lesestien (sanntid)
PG LISTEN/NOTIFY → Maskinrommet → WebSocket → GUI

PG er eneste datakilde. Frontend mottar sanntidsoppdateringer via
WebSocket. Initial sync ved tilkobling, deretter inkrementelle events.

### Lesestien (tunge spørringer)
GUI → Maskinrommet (Rust) → PG

Søk, statistikk, semantisk søk (pgvector), graftraversering
(AGE/Cypher). For tunge operasjoner som ikke passer sanntidsstrømmen.

## Datamodell

### Alt er noder
Én `nodes`-tabell. Alt er noder: brukere, team, meldinger, oppgaver,
notater, mediefiler, kommunikasjonsrom, samlings-noder. En bruker er
en node som tilfeldigvis kan logge inn.

Felles skjema: id, innhold, created_at, content_hash (→ CAS).
Modalitetsspesifikk metadata i JSONB.

### Edges definerer alt
Én `edges`-tabell. Edge-typer er frie strenger. Hva en node "er"
bestemmes utelukkende av dens edges:

- Node + edge til kanal = chatmelding
- Node + edge til board + status-edge = kanban-kort
- Node + edge til dato = kalenderoppføring
- Node + edge til kun bruker = privat notat
- Node uten edges = løs tanke

### Visninger er spørringer
Visninger er spørringer mot grafen med edge-filtre:
- Chat = noder med kanal-edge, sortert på tid
- Kanban = noder med board-edge, gruppert på status
- Kalender = noder med dato-edge, på tidslinje
- Mottaksflate = noder med edge til deg, vektet på relevans

Ingen forhåndsdefinerte visningstyper. Nye visninger er nye filtre.

## Input

Én universell input-komponent, gjenbrukt overalt. Fanger tekst, lyd,
bilde, AI, URL. Konteksten (hvor du er) bestemmer hvilke edges som
legges til. Output er alltid en node.

## Struktur uten workspaces

Ingen workspace-velger. Ingen `workspace_id`. Samlings-noder gir
struktur: et team er en samlings-node, et prosjekt er en samlings-node.
Du ser noder du har tilgang til via dine edges.

### Aliaser
En bruker kan ha alias-noder (f.eks. for ulike roller). Koblet med
system-edges som er usynlige for traversering.

## Maskinrommet

Rust-tjeneste med tre operasjoner: **fang**, **prosesser**, **lever**.

Eier alle skrivinger. Frontend sender intensjoner, maskinrommet
validerer og utfører. Edge-drevet ressursorkestrering: maskinrommet
leser edges og bestemmer hvilke tjenester som spinnes opp.

Forvalter også CAS (binærlagring) med intelligent pruning basert
på modalitet, edges og aksessmønstre.

## Sikkerhet

### Synlighet (visibility)
Noder har en visibility-egenskap med fire nivåer:
- **hidden** — usynlig, kun tilgjengelig via system-edges
- **discoverable** — kan finnes, men innhold skjult
- **readable** — innhold lesbart for de med tilgang
- **open** — tilgjengelig for alle med traverseringssti

Traversering respekterer visibility. Du kan ikke følge edges gjennom
noder du ikke har lov til å se.

### Materialisert tilgangsmatrise
`node_access`-tabell som cacher beregnet tilgang fra edge-grafen.
Oppdateres ved edge-endring, ikke ved lesing. Rask oppslag — ingen
rekursiv graftraversering per forespørsel.

### Privat er default
Input uten mottaker-edge er automatisk privat. Ingen ser det.
Deling er å legge til edges.

## Datalag

### PostgreSQL
Persistent backup og arkiv. Alle noder og edges. Fulltekstsøk,
pgvector (semantisk søk), JSONB. Apache AGE for Cypher ved behov.

### Sanntid (PG LISTEN/NOTIFY + WebSocket)
PG NOTIFY-triggere på noder, edges og node_access propagerer endringer
til frontend via WebSocket i portvokteren. Ingen separat sanntidstjeneste.

### CAS (Content-Addressable Store)
Binærdata (lyd, bilde, video) lagret med hash. TTL basert på
modalitet, edges og aksesslog. Generert innhold (TTS, thumbnails)
er en cache som regenereres on-demand.

## Teknologivalg

| Rolle | Teknologi | Begrunnelse |
|-------|-----------|-------------|
| Orkestrator | Rust | Ytelse, typesikkerhet, eier alle skrivinger |
| Frontend | SvelteKit | PWA, SSR, tynt lag mot WebSocket |
| Database | PostgreSQL | Eneste datakilde, pgvector, fulltekstsøk, AGE |
| Sanntid | PG LISTEN/NOTIFY + WebSocket | Triggere på PG-tabeller, portvokteren relay-er |
| Binærlagring | CAS (filsystem) | Enkel, deduplisering, ingen ekstern avhengighet |
| AI Gateway | LiteLLM | Multi-provider, BYOK, OpenRouter fallback |
| STT | faster-whisper | Lokal, god norsk kvalitet |
| TTS | ElevenLabs (→ lokal) | Kommersiell start, lokal når kvaliteten holder |
| Auth | Authentik | SSO, OIDC, self-hosted |
| Reverse proxy | Caddy | Auto-TLS, enkel config |
| Lyd/video | LiveKit | WebRTC, self-hosted |

## Retninger

Arkitekturen er basert på vedtatte retninger dokumentert i
`docs/retninger/`. Se `docs/retninger/README.md` for oversikt.


================================================================
FILE: docs/retninger/README.md
================================================================

# Retninger

Store, åpne spørsmål om prosjektets identitet og arkitektoniske retning.

Dette er ikke features, ikke proposals, ikke spesifikasjoner — det er **teser** som
utforsker hvordan Sidelinja bør tenke om seg selv. En retning kan påvirke alt fra
teknologivalg til UX-filosofi, men den er ikke en beslutning. Den er en pågående
diskusjon.

## Pipeline

```
retninger/     →     kan informere alt:
(tese)               concepts/, features/, infra/, arkitektur.md
```

En retning "forfremmes" ikke — den modnes, og det den konkluderer med påvirker
andre dokumenter. En retning kan også forkastes eller parkeres.

## Oversikt

| Retning | Status | Kjernespørsmål |
|---------|--------|----------------|
| [Status quo](status_quo.md) | Referanse | Hva er Sidelinja i dag? Ankerpunkt for de andre retningene. |
| [Rom, ikke forum](rom_ikke_forum.md) | Åpen | Bør Sidelinja være en oppslukende sanntidsopplevelse fremfor en tradisjonell webapp? |
| [Universell input og mottak](universell_input.md) | **Besluttet** | Én multimodal input-primitiv, én mottaksflate, kommunikasjonsnoder. Edges definerer alt. |
| [Maskinrommet](maskinrommet.md) | **Besluttet** | Én Rust-tjeneste: fang, prosesser, lever. Eier all skriving. Edge-drevet ressursorkestrering. |
| [Noder er sentrum](bruker_ikke_workspace.md) | **Besluttet** | Alt er noder (brukere, team, innhold). Edges definerer relasjoner og tilgang. Materialisert tilgangsmatrise for RLS. |
| [Datalaget](datalaget.md) | **Revidert** | PG er eneste datakilde, sanntid via LISTEN/NOTIFY + WebSocket, CAS for binærdata, AGE ved behov |

## Format
- Hva er tesen?
- Hva motiverer den? (observasjoner, frustrasjoner, inspirasjon)
- Hva ville vært annerledes hvis vi fulgte den?
- Spenninger og åpne spørsmål
- Ingen krav om konklusjon


================================================================
FILE: docs/retninger/bruker_ikke_workspace.md
================================================================

# Noder er sentrum

**Status: Besluttet.**

> Alt er noder. En bruker er en node. Et team er en node. Et møte er
> en node. Sidelinja er en node. Relasjoner mellom dem er edges.
> Tilgangskontroll via materialisert tilgangsmatrise beregnet fra
> edge-grafen.

## Beslutningen

1. **Alt er noder.** Brukere, team, prosjekter, innhold, møter —
   alt er rader i `nodes`-tabellen. En bruker er en node som
   tilfeldigvis kan logge inn.
2. **Relasjoner er edges.** Vegard → Sidelinja (`owner`).
   Trond → Sidelinja (`member`). Møtereferat → møte (`belongs_to`).
3. **Ingen containere.** Hva du ser er summen av dine edges.
4. **Samlings-noder gir struktur** — de er vanlige noder som
   fungerer som gravitasjonspunkt.
5. **Privat er default** — en node uten edges til andre er kun din.
6. **Tilgangskontroll via `node_access`-matrise**, oppdatert ved
   edge-endring, brukt av RLS ved lesing.

## Visibility

Visibility er en egenskap på noden som definerer hva som gjelder
for alle *uten* eksplisitt edge. Eksplisitte edges overrider alltid
oppover.

```sql
CREATE TYPE visibility AS ENUM ('hidden', 'discoverable', 'readable', 'open');
```

| Nivå | Oppdagbar | Lesbar | Interagerbar |
|------|-----------|--------|-------------|
| `hidden` | Nei | Nei | Nei — kun via eksplisitt edge |
| `discoverable` | Ja (søk/katalog) | Nei | Kontaktforespørsel |
| `readable` | Ja | Ja | Nei — krever edge |
| `open` | Ja | Ja | Ja |

Eksempler:
- Spøkelsesbruker: `hidden` — usynlig, kun invitasjon
- Katalogbruker: `discoverable` — finnes i søk, profil krever edge
- Podcast-episode: `readable` — alle kan lytte, bare teamet kan redigere
- Kunnskapsgraf-entitet: `open` — alle kan se og bidra

### Traverseringsregelen

**Visibility er en hard grense ved traversering.** Når du følger
edges i grafen, kan du bare se noder hvis deres visibility tillater
det — eller du har en eksplisitt edge.

Eksempel: Episode #42 (`readable`) har en `host`-edge til Peter
(`hidden`). Anonym bruker leser episoden, følger `host`-edge →
Peter er `hidden` → **stopp, usynlig.** Trond (som har edge til
Peter) følger samme edge → **ser Peter.**

Ingen transitivitet bryter denne regelen. Uansett hvor mange
offentlige noder som har edges til en `hidden` node, forblir den
skjult for de uten eksplisitt edge.

## Brukere er noder

En brukernode er en node med en kobling til Authentik for
autentisering. Alt annet — navn, preferanser, roller, relasjoner —
er noden og dens edges.

`users`-tabellen krymper til autentisering:

```sql
CREATE TABLE auth_identities (
    node_id       UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    authentik_sub TEXT UNIQUE NOT NULL,  -- Authentik subject ID
    email         TEXT UNIQUE NOT NULL
);
```

Brukerens profil, innstillinger og metadata lever på noden (JSONB)
eller som edges. Autentiseringstabellen er en tynn bro mellom
"denne HTTP-sesjonen" og "denne noden i grafen."

## Aliaser

En bruker kan opprette aliaser — separate noder som representerer
en offentlig persona eller anonym identitet.

```
Aleks (hidden) ──alias──→ Bjørn (readable)
```

Bjørn er en egen node med eget navn, egen profil, egen visibility.
Omverdenen ser bare Bjørn. `alias`-edgen er en **systemedge** —
usynlig ved traversering, aldri eksponert utad. Ellers ville
"hvem kontrollerer Bjørn?" lekke Aleks' identitet.

### Én flate, kontekst bestemmer identitet

Aleks ser *alt* i sin mottaksflate — egne noder og alt som
kommer til Bjørn. Han svarer fra sin egen flate. Systemet
bestemmer `created_by` basert på konteksten:

- NN skrev til Bjørn → Aleks svarer → meldingen stemplet
  med Bjørn som `created_by`. Automatisk, ingen bytte.
- Vegard skrev til Aleks direkte → svaret kommer fra Aleks.

**Ingen manuell identitetsbytte.** Aleks trenger aldri å "logge
inn som Bjørn" eller bytte modus. Konteksten (hvilken samtale,
hvilken kanal, hvem mottakeren kjenner) bestemmer hvilken node
som er avsender.

### Identitetsvalg ved tvetydighet

Noen ganger kjenner mottakeren *både* personen og aliaset.
Vegard kjenner Aleks og vet hvem Bjørn er. Hvis Vegard sender
en melding til Bjørn under en episode, og Aleks svarer:

- **Default:** svaret kommer fra Bjørn (konteksten er Bjørn).
- **Valg:** systemet vet at Vegard har edge til Aleks. Aleks
  kan velge "svar som Aleks" — da startes en ny, separat
  samtale mellom Vegard og Aleks.

**Alias-grensen brytes aldri automatisk.** Bare med eksplisitt
handling fra personen bak aliaset.

### Flere aliaser

Ingenting stopper en bruker fra å ha flere aliaser — Bjørn for
podcast, et anonymt alias for publisering, seg selv for privat.
Hver er en node med en `alias`-edge fra brukernoden. Alle samles
i én mottaksflate.

### Alias vs. owner

| Edge | Betydning | Eksempel |
|------|-----------|----------|
| `alias` | Jeg *er* denne noden. Usynlig systemedge. | Aleks → Bjørn |
| `owner` | Jeg *forvalter* denne noden. Synlig for medlemmer. | Vegard → Sidelinja |

`alias` = identitet. `owner` = ansvar. Aleks *er* Bjørn. Vegard
*eier* Sidelinja, men han *er* ikke Sidelinja.

## Team er noder

Et team er en node med `member`-edges til brukernoder. Gi teamet
tilgang til en samlings-node → alle teammedlemmer arver tilgang
via transitiv traversering. Ingen egen team-mekanisme.

```
Vegard (node) ──member──→ Podcastteamet (node) ──member──→ Sidelinja (node)
Trond  (node) ──member──→ Podcastteamet (node)
```

Trond får tilgang til alt under Sidelinja fordi han er medlem av
Podcastteamet som er medlem av Sidelinja. Tilgangsmatrisen beregner
dette transitivt.

## Samlings-noder

En samlings-node er en vanlig node som fungerer som gravitasjonspunkt.
"Sidelinja" er en samlings-node Vegard oppretter og eier. Trond
kobles på — direkte eller via et team. Andre inviteres inn.

Det kan finnes mange samlings-noder — eller få. Et podcast-prosjekt,
en research-samling, en vennegjeng. De er ikke noe spesielt i
datamodellen — bare noder med edges til andre noder.

En innholdsnode kan ha edge til flere samlings-noder. En faktoid om
en gjest er relevant for både podcast-prosjektet og research-samlingen.
Ikke kopi — samme node, to edges.

### Felles kontekst

En samlings-node bærer kontekst (i JSONB) som arves av tilknyttede
noder:

- **Pruning-profil** — hvor aggressivt slettes binærdata?
- **Tema** — visuelt uttrykk
- **AI-konfigurasjon** — prompts, modell, regler
- **Default synlighet** — for nye noder opprettet i denne konteksten
- **Kapasitet** — ressursgrenser for maskinrommet

Konflikter (node med edge til to samlings-noder med ulike
pruning-profiler) løses med: mest konservativ vinner, eller
eier-edge bestemmer.

## Tilgangsroller

| Rolle | Hva den gir |
|-------|------------|
| `owner` | Full kontroll — slette, endre tilgang, endre innstillinger |
| `admin` | Kan invitere/fjerne andre, endre konfigurasjon |
| `member` | Kan gi input og motta |
| `reader` | Kan kun motta (observatør, lytter) |

Roller er en egenskap på edgen, ikke på noden. Vegard har `owner`-edge
til Sidelinja og `member`-edge til Research-gruppa. Samme node, ulike
roller i ulike kontekster.

## Tilgangsmatrise — spesifikasjon

### Skjema

```sql
CREATE TYPE access_level AS ENUM ('reader', 'member', 'admin', 'owner');

CREATE TABLE node_access (
    subject_id  UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    object_id   UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    access      access_level NOT NULL,
    via_edge    UUID REFERENCES edges(id) ON DELETE CASCADE,
    PRIMARY KEY (subject_id, object_id)
);

CREATE INDEX idx_na_subject ON node_access (subject_id);
```

`subject_id` er noden som har tilgang (bruker eller team).
`object_id` er noden det gis tilgang til.
`via_edge` peker på edgen som ga tilgangen — for debugging og
deterministisk revokering.

### RLS-policy

```sql
CREATE POLICY node_read ON nodes FOR SELECT
    USING (
        created_by = current_node_id()
        OR id IN (
            SELECT object_id FROM node_access
            WHERE subject_id = current_node_id()
        )
        OR visibility >= 'discoverable'
    );
```

`current_node_id()` returnerer brukernodens id fra sesjonen.
Tre sjekker i prioritert rekkefølge:
1. Egne noder (`created_by`) — instant
2. Eksplisitt tilgang via matrisen — indeksert lookup
3. Offentlig synlige noder — kolonne-sjekk

Merk: `discoverable` gir kun at noden *finnes* i søkeresultater.
Innholdet filtreres i applikasjonslaget basert på visibility-nivå.

### Matrise-oppdatering

Matrisen oppdateres **når edges endres**, ikke ved lesing.
Én transaksjon: edge + matrise-oppdatering. Alltid synkront —
ingen vindu med stale tilgang.

**Transitiv tilgang:** Når Trond får `member`-edge til Sidelinja,
beregnes hans tilgang til alle noder med edge til Sidelinja.

```
Brukernode → samlings-node → innholdsnoder:         2 hopp
Brukernode → team → samlings-node → innholdsnoder:  3 hopp
Brukernode → team → samlings-node → komm.node → noder: 4 hopp
```

**Beregningsfunksjon:**

```sql
CREATE OR REPLACE FUNCTION recompute_access(
    p_subject_id   UUID,   -- bruker- eller teamnode
    p_root_node_id UUID,   -- noden det gis tilgang til
    p_access       access_level,
    p_via_edge     UUID
) RETURNS void AS $$
BEGIN
    -- Direkte tilgang til roten
    INSERT INTO node_access (subject_id, object_id, access, via_edge)
    VALUES (p_subject_id, p_root_node_id, p_access, p_via_edge)
    ON CONFLICT (subject_id, object_id)
    DO UPDATE SET access = GREATEST(node_access.access, p_access);

    -- Transitiv: noder som tilhører roten
    INSERT INTO node_access (subject_id, object_id, access, via_edge)
    SELECT p_subject_id, e.source_id, p_access, p_via_edge
    FROM edges e
    WHERE e.target_id = p_root_node_id
      AND e.edge_type = 'belongs_to'
    ON CONFLICT (subject_id, object_id)
    DO UPDATE SET access = GREATEST(node_access.access, p_access);

    -- Hvis subject er et team: propager til alle teammedlemmer
    INSERT INTO node_access (subject_id, object_id, access, via_edge)
    SELECT e.source_id, na.object_id, na.access, na.via_edge
    FROM node_access na
    JOIN edges e ON e.target_id = p_subject_id
      AND e.edge_type = 'member_of'
    WHERE na.subject_id = p_subject_id
    ON CONFLICT (subject_id, object_id)
    DO UPDATE SET access = GREATEST(node_access.access, EXCLUDED.access);
END;
$$ LANGUAGE plpgsql;
```

Detaljer (hvilke edge-typer som gir transitiv tilgang, maks dybde)
avklares ved implementering.

### Matrisestørrelse

Sparse matrise. Typisk bruker med tilgang til 2-3 samlings-noder
med noen tusen noder hver: ~10k rader per bruker. Med 50 brukere:
~500k rader. Trivielt for PG.

## Brukeropplevelse

Når du logger inn ser du:
- **Dine aktive samtaler** — kommunikasjonsnoder med edge til deg
- **Dine noder** — alt du har skapt eller er koblet til
- **Dine samlings-noder** — grupperer kontekst, filtrering er frivillig
- **Din mottaksflate** — det som er relevant for deg nå

Å filtrere etter samlings-node ("vis bare podcast-prosjektet") er et
filter, ikke en modebytte.

## Forhold til andre retninger

- [Rom, ikke forum](rom_ikke_forum.md) — "rommet" er summen av dine
  edges, ikke en container du går inn i
- [Universell input og mottak](universell_input.md) — mottaksflaten
  er "noder med edge til *meg*", filtrert og vektet
- [Maskinrommet](maskinrommet.md) — eier matrise-oppdatering og leser
  samlings-node-edges for kontekst (pruning, kapasitet, AI-konfig)
- [Datalaget](datalaget.md) — matrisen lever i PG, indeksert for
  RLS-ytelse


================================================================
FILE: docs/retninger/datalaget.md
================================================================

# Datalaget

**Status: Besluttet.**

> SpacetimeDB holder hele grafen i minne og mottar skrivinger først.
> PostgreSQL er persistent arkiv og backup. CAS lagrer binærdata.
> Apache AGE legges til ved behov for Cypher-traverseringer.

## Lagmodell

```
GUI (SvelteKit)
  │ skriv                    │ les (sanntid, WebSocket)
  ▼                          ▼
Maskinrommet (Rust)          SpacetimeDB ──→ GUI
  │ validering                 ▲
  ├──→ SpacetimeDB (først)  ───┘
  └──→ PostgreSQL (asynk, persistent)
```

### Skrivestien
GUI → Maskinrommet → validering → SpacetimeDB (instant) → PG (asynk).
Frontend oppdateres umiddelbart. PG persisterer i bakgrunnen.

### Lesestien (sanntid)
SpacetimeDB → GUI (direkte WebSocket, ~10μs).
Hele grafen er i SpacetimeDB. Frontend har alltid alt tilgjengelig.

### Lesestien (tunge spørringer)
GUI → Maskinrommet → PG.
Fulltekstsøk, pgvector (semantisk søk), statistikk, AGE-traverseringer.

## SpacetimeDB — hele grafen i minne

Node- og edge-skjemaet er minimalt (åtte kolonner hver). For en
liten brukerbase er hele grafen triviell å holde i minne. Fordeler:

- Ingen sync-logikk for å bestemme hva som er "aktivt"
- Ingen henting fra PG når noen åpner noe gammelt
- Frontend har alltid alt via WebSocket
- Én lesekilde — ingen tvetydighet

Når dette blir problematisk (hundretusenvis av noder), innføres
eviction. Det er en optimalisering, ikke en arkitekturendring.

## PostgreSQL — arkiv og kraftspørringer

PG er persistent backup for hele grafen, pluss hjemsted for
tunge operasjoner SpacetimeDB ikke er laget for:

- **Fulltekstsøk** — `tsvector` på `nodes.content` og `nodes.title`
- **Semantisk søk** — pgvector for embedding-basert likhet
- **Graftraversering** — rekursive CTEs, Apache AGE ved behov
- **Statistikk** — aggregeringer, tidsserier
- **Tilgangsmatrise** — `node_access` beregnes her, speiles til STDB

### Apache AGE — ved behov

De fleste spørringer er grunne (1-3 hopp) og håndteres av CTEs.
AGE legges til som PG-extension når Cypher-semantikk faktisk trengs:

1. **Nå:** PG med nodes/edges-tabeller og CTEs
2. **Når CTEs blir smertefulle:** Legg til AGE
3. **Usannsynlig:** Evaluer Neo4j hvis AGE ikke holder

AGE er en extension, ikke en migrering — boltes på uten å endre
eksisterende kode.

## CAS — binærlagring

Lyd, bilde, video lagres content-addressable på disk. CAS-noder
i grafen bærer metadata (`cas_hash`, `mime`, `size_bytes`).
Selve biten lever utenfor PG.

Pruning-regler basert på modalitet, edges og aksessmønstre.
Se [maskinrommet](maskinrommet.md).

## SpacetimeDB som utbyttbar

SpacetimeDB er en sanntidscache, ikke en avhengighet. Hvis den
fjernes:

- **Sanntid:** PG `LISTEN/NOTIFY` → SvelteKit SSE
- **Skriving:** Maskinrommet → PG direkte
- **Lesing:** Maskinrommet → PG → GUI

Trenger ikke implementeres, men arkitekturen skal aldri gjøre
det umulig. Se [synkronisering](../infra/synkronisering.md).

## Forhold til andre retninger

- [Noder er sentrum](bruker_ikke_workspace.md) — tilgangsmatrise
  beregnet fra edge-grafen, speiles til SpacetimeDB
- [Universell input og mottak](universell_input.md) — noder og edges
  er datamodellen for alle tre primitiver
- [Maskinrommet](maskinrommet.md) — CAS-pruning, edge-drevet
  ressursorkestrering, validering før skriving


================================================================
FILE: docs/retninger/maskinrommet.md
================================================================

# Maskinrommet

**Status: Besluttet.**

> Én Rust-tjeneste med et fast grensesnitt. Alt som krever tunge
> ressurser eller eksterne tjenester går gjennom dette laget.
> Fang, prosesser, lever. Maskinrommet er det eneste som skriver
> noder og edges.

## Tre operasjoner

### 1. Fang (input-absorpsjon)
Ta imot råmateriale i alle modaliteter:
- Tekst (melding, URL, dokument)
- Lyd (voice memo, live stream, filopplasting)
- Bilde (foto, skjermbilde, tegning)
- Video (stream, opptak)
- Strukturert data (JSON, metadata, edges)

### 2. Prosesser (transformasjon)
Analyser, transformer, berik og systematiser:
- **STT** — lyd → tekst (Whisper)
- **TTS** — tekst → lyd (ElevenLabs / lokal modell)
- **AI-analyse** — oppsummering, klassifisering, edge-forslag
- **Beriking** — URL → metadata, bilde → beskrivelse
- **Søk** — fulltekst, semantisk (pgvector), graftraversering
- **Mediaprosessering** — transcode, thumbnail, waveform

### 3. Lever (output-distribusjon)
Lever resultat i riktig modalitet til riktig mottaker:
- Tekst (melding, notifikasjon, digest)
- Lyd (TTS-opplesning, lydstream)
- Video/bilde (stream, thumbnail)
- Strukturert data (noder, edges tilbake i grafen)
- Push (SpacetimeDB-reducer)

## Maskinrommet eier all skriving

Frontend sender intensjoner. Maskinrommet utfører.

```
Frontend: "legg til Trond i møtet"
  → Maskinrommet validerer
  → Skriver edge til SpacetimeDB (instant)
  → Oppdaterer tilgangsmatrise
  → Persisterer til PG (asynk)
  → Reagerer på konsekvensene (koble inn LiveKit, starte transkripsjon)
```

Alt i én operasjon. Maskinrommet er ikke reaktivt i en pub/sub-
forstand — det orkestrerer hele sekvensen. Enklere å forstå,
enklere å debugge.

Skrivestien: validering → SpacetimeDB (instant) → PG (asynk).
Se [synkronisering](../infra/synkronisering.md).

## Edge-drevet ressursorkestrering

Maskinrommet leser edges for å vite hva det skal gjøre. Noden
er alltid enkel. Edges bestemmer hvilke ressurser som spinnes opp.

### Privat er default
Input uten mottaker-edge er automatisk privat. Ingen ressurser
kobles inn utover det grunnleggende (fang + transkriber).

### Ressurser er proporsjonale med edges

```
Dagboknotat (privat voice memo):
  node → fang lyd → transkriber (Whisper) → lagre
  Ressurser: minimal

Samtale med Trond:
  node + mottaker-edge(Trond)
  → fang lyd → transkriber → lever tekst/lyd til Trond
  Ressurser: STT + levering til én

Redaksjonsmøte (5 deltakere):
  node + mottaker-edges(5) + rolle-edges
  → fang lyd fra alle → transkriber → lever til alle → AI-referent
  Ressurser: STT + levering til 5 + LLM

Livesending (1000 lyttere):
  node + mottaker-edges(∞) + stream-edge
  → fang lyd → transkriber → stream via LiveKit → distribuer
  → generer segmenter → kjør live AI → publiser
  Ressurser: STT + LiveKit + LLM + mediaprosessering
```

### Naturlig eskalering
Du starter en privat voice-note. Deler den med Trond → legg til
edge, maskinrommet begynner å levere. Trond foreslår møte → flere
edges, maskinrommet kobler inn sanntidsstrømming. Møtet blir
innspilling → publiserings-edge, maskinrommet aktiverer
produksjonspipeline. Hvert steg er bare å legge til edges.

## Grensesnittet

```
fang(input: RåInput) → NodeId
prosesser(node: NodeId, operasjon: Operasjon) → Resultat
lever(node: NodeId, mottaker: Mottaker, format: Format) → Status
```

I praksis er mye av dette implisitt: maskinrommet ser hvilke edges
som ble skrevet og handler deretter. Primitivene manipulerer edges
via maskinrommet, og maskinrommet kobler inn riktige ressurser.

## CAS og intelligent pruning

### CAS som lagringsprimitiv
All binærdata (lyd, bilde, video) lagres content-addressable.
CAS-noder i grafen bærer metadata (`cas_hash`, `mime`, `size`).
Selve biten lever på disk.

- **Deduplisering gratis** — samme fil delt i tre kontekster = én kopi
- **Separasjon** — "innholdet eksisterer" er adskilt fra "innholdet
  er tilgjengelig"
- **Enkel opprydning** — slett filen fra CAS, noden beholder metadata

### Lagringsregler per modalitet

| Modalitet | Default levetid | Begrunnelse |
|-----------|----------------|-------------|
| Tekst | Evig | Billig, essensen av innholdet |
| Transkripsjon | Evig | Tekstlig representasjon — bevarer meningen |
| Lyd | 30 dager | Transkripsjon bevarer innholdet |
| Bilde | 30 dager | Beskrivelse/metadata bevarer kontekst |
| Video | 14 dager | Dyrest, transkripsjon + thumbnail bevarer det meste |

### Signaler som forlenger levetid

- **Edges.** Lydfil med publiserings-edge = beholdes. Privat
  voice-memo uten edges = 30-dagers TTL.
- **Aksesslog.** Avspilt i løpet av TTL-perioden = forlenges.
- **Transkripsjonsstatus.** Utranskribert lyd kan trenge lengre TTL.
- **Edge-type.** Publisert = behold. Arkivert møte = transkripsjon
  holder.

### Generert innhold er en cache
TTS, thumbnails, AI-oppsummeringer, waveforms — alt som kan
regenereres er en cache i CAS med samme TTL-mekanisme.

### Samlings-node-styrt aggressivitet
Hver samlings-node kan justere sin pruning-profil:
- **Konservativt** — behold alt lenge (arkiv-node)
- **Aggressivt** — tekst bevares, binærdata prunes raskt
- **Tilpasset** — egne regler per modalitet og edge-type

### Disk-nødventil
Maskinrommet overvåker diskbruk:
- **>85 %:** Genererte filer slettes (kan regenereres)
- **>90 %:** Aggressiv pruning for alle samlings-noder
- **>95 %:** Kritisk alarm. Alt uten publiserings-edge slettes.
  Tekst og transkripsjoner bevares alltid.

## Isolasjon og observerbarhet

### Isolasjon
Bytt Whisper med noe annet? Endre maskinrommet. Frontend vet
ingenting. Legg til bildegenerering? Ny operasjon. Primitivene
kaller den uten å vite hva som skjer under.

### Observerbarhet
Alt går gjennom ett punkt. Logging, metrikker, kostnadsrapportering
— alt på ett sted. "Hva bruker vi AI-ressurser på?" har ett svar.

### Kapasitetsstyring
Prioritering, kø, rate limiting, fallback mellom leverandører.
Live transkripsjon prioriteres over bakgrunns-oppsummering.

## Compute-separasjon

Maskinrommet orkestrerer — tunge jobber trenger ikke kjøre på
samme maskin.

- **Nå:** Alt på én VPS. Jobbkøen prioriterer sanntid over batch.
- **Snart:** Trekk ut tunge workers til separat node (billig
  ARM-instans) som poller jobbkøen. Maskinrommet ruter transparent.
- **Kildevern-modus:** Lokal LLM krever dedikert compute med
  fysisk isolasjon.

Compute-separasjon er en konfigurasjon, ikke en arkitekturendring.

## Forhold til andre retninger

Maskinrommet er infrastrukturen *under* de tre primitivene i
[universell input og mottak](universell_input.md):
- Input-primitiven → `fang()` + `prosesser()`
- Mottak-primitiven → `lever()`
- Kommunikasjonsnoden → alle tre

- [Noder er sentrum](bruker_ikke_workspace.md) — maskinrommet
  eier tilgangsmatrise-oppdatering
- [Datalaget](datalaget.md) — maskinrommet skriver SpacetimeDB
  først, PG asynk


================================================================
FILE: docs/retninger/rom_ikke_forum.md
================================================================

# Rom, ikke forum

> Hva om Sidelinja ikke er en webapp med sanntidsfunksjoner, men en oppslukende
> sanntidsopplevelse som tilfeldigvis leverer tradisjonell funksjonalitet?

## Observasjoner

Sidelinja har vokst organisk. Vi har bygget chat, kanban, kalender, notater,
kunnskapsgraf — hver som sin feature, hver med sin spec. SpacetimeDB ble lagt til
for sanntid, men arkitekturen er fortsatt "PostgreSQL-app med sanntidskrydder."

Resultatet:
- **Forum-følelsen.** Ting er organisert i tråder, kort, lister. Brukeren
  navigerer mellom sider. Det føles som et tradisjonelt verktøy med litt polish.
- **Databasespenning.** PG og SpacetimeDB har et komplisert eierforhold.
  SpacetimeDB-loven løser grensesnittet, men ikke det underliggende spørsmålet:
  hva *er* primæropplevelsen?
- **Feature-fragmentering.** Chat, kanban, whiteboard, notater — hver lever i sin
  boks. "Universell overføring" og "meldingsboks" prøver å lime dem sammen, men
  utgangspunktet er fortsatt separate primitiver.

## Tesen

Snu gravitasjonen:

**I stedet for:** Tradisjonell webapp → bolt på sanntid der det trengs
**Tenk:** Sanntidsrom er default → persistens er en egenskap ved ting som skjer

Forskjellen er subtil men fundamental:
- Et "dokument som flere kan redigere" vs "et rom der folk er sammen og ting
  de gjør blir husket"
- "Gå til kanban-brettet" vs "åpne kanban-laget i rommet du allerede er i"
- "Send en melding i chat" vs "si noe i rommet"

## Hva ville vært annerledes?

### SpacetimeDB som verden, PG som arkiv
SpacetimeDB er ikke en "sanntidskache foran PG" — det er verdenen brukerne
lever i. PG er arkivet som husker hva som har skjedd.

Rollene blir klare og adskilte:
- **SpacetimeDB** = sanntidslaget. Aktivt samarbeid, live interaksjon,
  ting som skjer *nå*.
- **PostgreSQL** = arkivet. Alt som noensinne har skjedd. Søk, historikk,
  statistikk, revisjon.

Men viktig: sanntidslaget er *bare* et sanntidslag. Ikke alt trenger
sanntid. En kunnskapsgraf-utforsker, et søk i gamle episoder, en
statistikkside, en offentlig publisert artikkel — disse snakker rett med
PG-arkivet som tradisjonelle nettsider. De trenger ikke gå gjennom
SpacetimeDB. Begge lagene leser og skriver til det samme arkivet.

Dermed har vi to parallelle overflater:
- **Sanntidsopplevelsen** (via SpacetimeDB) — for alt som er levende,
  aktivt, samarbeidende. Chat, whiteboard, live redigering.
- **Tradisjonelt lag** (rett mot PG) — for alt som er retrospektivt,
  utforskende, statisk. Arkiv, søk, publisering, statistikk.

Dataflyt mellom dem: ting som oppstår i sanntidslaget synkes til PG.
Ting i PG kan løftes inn i sanntidslaget når de blir aktive igjen.
Men det er ingen eierskapskonflikt — de to lagene har fundamentalt
forskjellige roller. Det er ikke to konkurrerende sannheter, det er
*nåtid* og *arkiv*, med to overflater som passer til hver sin rolle.

### Rommet som primitiv, ikke siden
I dag navigerer brukeren mellom `/chat`, `/kanban`, `/kalender`. I "rom"-modellen
er brukeren alltid *et sted*, og funksjonalitet er lag som kan slås av og på:
chat-laget, oppgave-laget, tidslinje-laget. Alt eksisterer i samme sanntidsrom.

### Tilstedeværelse som førsteklasses konsept
Hvem er her nå? Hva ser de på? Hva jobber de med? I en tradisjonell webapp er
dette en "feature" (online-indikator). I rom-modellen er det fundamentet alt
annet bygger på.

### Interaksjon før organisering
I dag: lag et kanban-kort → fyll ut feltene → flytt det. I rom-modellen: si noe,
tegn noe, del noe → det som oppstår kan *bli* et kort, en oppgave, en notis —
organisering skjer etterpå, ikke som forutsetning.

## Den administrative opplevelsen

Tesen ovenfor kan føles abstrakt. Mer konkret: Sidelinja bør være en
**administrativ opplevelse** — ikke et spill med avatarer, men et arbeidsmiljø
der produktive ting er lette å oppnå og strukturen følger deg, ikke omvendt.

### Formløs input, struktur etterpå
I dag velger du visning først: "nå er jeg i chat", "nå er jeg i kanban." Men
meldingsboksen er allerede designet for at input ikke trenger å vite hva den
*er* på forhånd. En tanke bør kunne starte som en løs setning og bli et
kanban-kort, en faktoid, en kalenderoppføring — uten at brukeren måtte
bestemme det på forhånd. Visninger er flyktige linser. Innhold er permanent.

### Trylle frem, legge fra seg
Trenger du et whiteboard? Det dukker opp. En videosamtale? Den starter.
En dagbok? Den er der. Og når fokuset tar en annen retning legger du det
fra deg uten at det blir borte — kunnskapsgrafen holder styr på det. Du
trenger ikke "lukke" noe eller "lagre" noe. Ting eksisterer i verden
og kan gjenfinnes.

### Siloer forsvinner
"Universell overføring" som eksplisitt feature blir overflødig fordi det
ikke finnes separate steder å overføre *mellom*. Du endrer ikke *hvor*
noe er — du endrer *hvordan* du ser på det som allerede er der. Chat,
kanban, kalender er ikke apper — de er visninger av samme tilstandsrom.

### Privat og delt som lag, ikke separate systemer
Privat/delt er bare en
synlighetsbryter på alt du gjør. Du chatter med en kollega og samtidig skriver
du dagbok — begge er meldingsbokser, den ene er delt, den andre er privat.
De eksisterer side om side i samme rom.

Dette åpner for naturlig flyt mellom kontekster: du sitter på bussen, snakker
inn en tanke via voice, den transkriberes automatisk og lander som en privat
meldingsboks — dagboknotis, oppgavepåminnelse, idé til neste episode. Du
trenger ikke åpne "dagbok-appen" eller "notat-appen." Du bare snakker, og
systemet tar vare på det riktig sted basert på kontekst og synlighet.

Input-metode (tekst, voice, tegning) og synlighet (privat, delt, publisert)
er ortogonale egenskaper. Ingen av dem bør diktere *hva* innholdet blir.

### SpacetimeDB som naturlig motor
SpacetimeDB tenker "dette eksisterer i verden nå" — ikke "lagre dette i
riktig tabell." Å trylle frem et whiteboard er naturlig i en verden-modell:
det er bare et nytt objekt med en tilstand. I PG-modellen må du opprette
rader, definere relasjoner, sette opp persistens. SpacetimeDB låner seg
til flytende, formløs interaksjon på en måte PG ikke gjør.

PG briljerer i rollen som arkiv: relasjonelle spørringer over historikk,
fulltekstsøk, pgvector for semantisk søk, aggregeringer og statistikk.
Når du spør "hva snakket vi om i mars?" er det PG som svarer. Når du
spør "hva skjer nå?" er det SpacetimeDB.

## Spenninger og åpne spørsmål

- **Ytelse.** En alltid-på sanntidsopplevelse krever mer av både klient og
  server enn tradisjonelle sideinnlastinger. Er SpacetimeDB klar for dette?
- **Kompleksitet.** "Alt er et rom" høres elegant ut, men kan bli kaotisk.
  Hvordan unngår vi at det blir uoversiktlig?
- **Discovery vs fokus.** "Alt kan bli hva som helst" er kraftig, men kan
  bli overveldende. Et whiteboard du tryller frem må være like lett å finne
  igjen om tre uker. Kunnskapsgrafen er kanskje svaret — den er allerede
  designet for å koble ting uavhengig av type.
- **~~Gradvis overgang.~~** *(Løst — se "Innebygd utviklingsstrategi" nedenfor.)*
- **Solo-bruk.** Mye av verdien i "rom" kommer fra å være der sammen. Hvordan
  føles det for én person som jobber alene?
- **Er det vi allerede har?** Meldingsboks-konseptet, universell overføring og
  SpacetimeDB-loven peker allerede i denne retningen. Kanskje dette ikke er en
  ny retning, men en artikulering av det vi ubevisst har bygget mot?
- **Inspirasjon.** Spillverdener (MMO-lobbyer, shared spaces), Figma (alle i
  samme canvas), tldraw, Gather.town. Hva kan vi lære fra disse?

## Innebygd utviklingsstrategi

To-lags-modellen gir en viktig implikasjon for utviklingen: **innebygd fallback
og to veier inn til alt.**

Ny funksjonalitet kan alltid starte i det tradisjonelle laget — rett mot PG,
vanlig request/response, kjent terreng. Den fungerer med en gang. Når det
senere gir verdi (samarbeid, sanntid, live interaksjon) kan den løftes inn
i sanntidslaget. Men den trenger ikke det for å være nyttig.

Dette betyr:
- **Ingenting vi har bygget er bortkastet.** Eksisterende PG-baserte features
  er ikke "gammel arkitektur" — de er det tradisjonelle laget, og det er et
  fullverdig lag.
- **Ingen stor omskriving.** Retningen er ikke en migrasjon med en frist. Den
  er en utviklingsstrategi: bygg tradisjonelt, løft til sanntid ved behov.
- **Risikoen er lav.** Hvis SpacetimeDB skuffer, har vi fortsatt et komplett
  tradisjonelt system. Hvis det leverer, får ting gradvis en rikere opplevelse.
- **Primitivene er nøkkelen.** Så lenge meldingsboksen og kunnskapsgrafen er
  fleksible nok, kan begge lag bruke dem. Arkitekturen holder uavhengig av
  hvilket lag en feature lever i.

## Kritisk vurdering

### SpacetimeDB er en ung teknologi
Å lene seg tungt på SpacetimeDB er et veddemål. Hvis prosjektet stagnerer,
endrer API, eller har skaleringstak vi ikke ser ennå — er vi eksponert.
*Mildnet* av at det tradisjonelle laget mot PG alltid finnes som fallback:
SpacetimeDB er ikke alt-eller-ingenting, men et lag for det som trenger
sanntid. Resten lever trygt mot PG uansett.

### "Formløs input" er vanskelig i praksis
Det høres elegant ut, men noen må bestemme hva noe *blir*. AI? Brukeren
etterpå? Automatiske regler? Notion, Roam Research og mange andre startet
med lignende ambisjoner og endte opp med å gi brukeren eksplisitte
strukturverktøy — fordi ambiguitet i praksis skaper friksjon, ikke flyt.
Risikoen er at vi bygger noe som føles magisk i demoen og forvirrende i
hverdagen.

### Grensen mellom "nåtid" og "arkiv" er uklar
En samtale fra i går som fortsatt er relevant — er den "nå" eller "arkiv"?
Et kanban-kort som har stått stille i to uker? Vi trenger regler for når ting
flyttes mellom lagene, og de reglene kan bli like komplekse som
SpacetimeDB-loven de erstatter. "Tid og arkiv" er renere *i prinsippet*,
men i praksis er "aktiv" et spektrum, ikke en binær tilstand.

### Solo-bruk er underadressert
Vegard er primærbruker. "Rom"-konseptet henter mye av sin kraft fra
tilstedeværelse og samarbeid. For én person som produserer podcast er det
en reell risiko at sanntidslaget er overhead sammenlignet med et godt
organisert tradisjonelt verktøy. *Mildnet* av to-lags-modellen: solo-bruk
kan lene seg tyngre på det tradisjonelle PG-laget, og sanntidslaget
aktiveres når det faktisk gir verdi (live innspilling, samarbeid).

### Omfanget
*Betydelig mildnet* av utviklingsstrategien ovenfor. Retningen krever ikke
en omskriving — den er kompatibel med inkrementell utvikling. Den
gjenværende risikoen er mer subtil: at vi bruker mental energi på å
vurdere "bør dette være sanntid?" for hver feature i stedet for å bare
bygge. Pragmatisk default bør være: bygg tradisjonelt, løft senere.


================================================================
FILE: docs/retninger/status_quo.md
================================================================

# Status quo — Hva Sidelinja er i dag

> **Historisk dokument (v1).** Denne teksten beskriver tilstanden i v1 —
> workspace-basert arkitektur, CRUD-mønster, fragmentert navigasjon.
> Den er bevart som referanse for å forstå utgangspunktet for de vedtatte
> retningene i `docs/retninger/`.

> En redaksjonell webapp med ambisiøse primitiver og tradisjonell overflate.

## Hva fungerer

### Meldingsboksen som universell primitiv
Den viktigste arkitekturbeslutningen. Én datamodell — meldingsboksen — er
underlag for chat, kanban-kort, kalenderoppføringer, notater og faktoider.
I stedet for fem separate domenemodeller har vi én fleksibel primitiv med
view-konfigurasjoner oppå. Dette er genuint uvanlig og gir oss muligheter
de fleste redaksjonelle verktøy ikke har.

### Kunnskapsgrafen
Nodes og edges i PostgreSQL gir en rik struktur for å koble alt med alt —
personer, temaer, episoder, fakta. Dette er ryggraden i det redaksjonelle
arbeidet og skiller Sidelinja fra enklere verktøy.

### Self-hosted med full kontroll
Hetzner VPS, Caddy, Authentik, Forgejo. Ingen avhengighet til skytjenester
vi ikke kontrollerer. For et journalistisk verktøy er dette ikke bare
en preferanse — det er et prinsipp.

### AI som infrastruktur, ikke feature
LiteLLM som gateway, BYOK-modell, Whisper for transkripsjon. AI er ikke
en knapp i UI-et — det er en del av maskineriet. Jobbkø-arkitekturen gjør
at tunge operasjoner aldri blokkerer brukeropplevelsen.

## Hva som er tradisjonelt

### Navigasjon
Brukeren beveger seg mellom `/chat`, `/kanban`, `/kalender` som separate
sider. Til tross for at datamodellen er universell, føles opplevelsen
fragmentert — som et sett med separate verktøy som deler database.

### Interaksjonsmodell
CRUD-mønsteret dominerer: opprett, rediger, slett, flytt. Interaksjonen
er form-basert og eksplisitt. Brukeren "administrerer innhold" mer enn
de "jobber sammen i et miljø."

### Sanntid som tillegg
SpacetimeDB er lagt til for å gi sanntidsoppdatering, men arkitekturen
er PostgreSQL-først. Sanntid er noe som *skjer med* tradisjonelle
operasjoner, ikke noe som er *grunnlaget* for opplevelsen.

## Spenninger

### To sannhetskilder
PG og SpacetimeDB har et komplisert forhold. SpacetimeDB-loven definerer
klare regler for hvem som eier hva, men selve eksistensen av loven vitner
om en arkitektonisk spenning: vi har to systemer som begge vil være
primærkilde, og vi bruker konvensjoner for å holde dem fra å kollidere.

### Ambisiøs bunn, forsiktig topp
Meldingsboksen og kunnskapsgrafen åpner for opplevelser vi ikke leverer
ennå. Datamodellen sier "alt henger sammen" — men UI-et sier "her er
chatten, her er kanban-brettet, her er kalenderen." Grunnmuren er mer
spennende enn det brukeren ser.

### Produksjonsverktøy vs opplevelse
Sidelinja er designet for podcast-produksjon, men pendler mellom å være
et effektivt arbeidsverktøy og noe mer oppslukende. Studioet og
møterommet peker mot sanntidsopplevelser. Redaksjonen og kanban peker
mot tradisjonelt prosjektstyringsverktøy. Begge er gyldige, men de
trekker i ulike retninger.

## Oppsummert

Sidelinja har en sterkere grunnmur enn overflaten viser. Meldingsboksen,
kunnskapsgrafen og AI-infrastrukturen er genuint interessante primitiver.
Spørsmålet er ikke om vi har bygget feil — men om overflaten utnytter
det fundamentet faktisk tillater.


================================================================
FILE: docs/retninger/universell_input.md
================================================================

# Universell input og mottak

**Status: Besluttet.**

> Én multimodal input-primitiv. Én personlig mottaksflate. Alt som
> fanges er en node. Hva det "er" bestemmes av edges. Hvordan det
> presenteres bestemmes av mottakeren.

## Input-primitiven

Én overflate som fanger alt:

- **Tekst** — skriving, Markdown, kodeblokker
- **Lyd** — voice memo, diktering → automatisk transkribert
- **Bilde** — foto, skjermbilde, tegning
- **AI-støtte** — spør AI, få forslag, la den transformere input
- **URL** — lim inn lenke, den berikes automatisk

Brukeren gjør det samme uansett kontekst — skriver, snakker eller
tegner i input-feltet. Forskjellen mellom "dagbok", "chatmelding"
og "kanban-kort" er ikke hva brukeren gjør — det er hvilke edges
som knyttes til resultatet.

### Én pipeline

All input går gjennom samme tekniske pipeline:
maskinrommet → SpacetimeDB (instant) → PG (asynk).

Konteksten bestemmer routing, ikke en teknisk modus:

- Du er i et møte → inputen streames live til andre deltakere
- Du er alene på bussen → inputen lander som privat node
- Du er i en podcast-kanal → inputen går inn i produksjonspipeline

Samme pipeline. Ulike edges.

### Input-metode og innholdstype er ortogonale

Du kan snakke inn et kanban-kort. Du kan tegne en kalenderoppføring.
Input-primitiven bryr seg ikke om hva det *blir* — den fanger det
som kommer inn. Alt etterpå er edges.

### Én overflate å perfeksjonere

All UX-investering konsentreres ett sted. Én perfekt input-opplevelse
— responsiv, multimodal, med god AI-støtte — i stedet for ti
middelmådige spesialgrensesnitt.

## Editoren

Input-komponenten er en TipTap-editor (ProseMirror-basert) som
konfigureres med ulike extensions basert på kontekst.

### Kontekst setter default, brukeren bestemmer

Konteksten (kommunikasjonsnoden du er i, visningen du bruker)
setter en *default* editor-konfigurasjon. Men brukeren kan alltid
overstyre — utvide til full editor eller forenkle. Ingen kunstig
grense mellom "chatmelding" og "artikkel."

Du *kan* skrive en gjennomformatert tekst med overskrifter, bilder
og blockquotes i chatten. Om du vil publisere den etterpå er det
bare å legge til en publiserings-edge. Innholdet er det samme.

Editoren husker brukerens valg per kontekst via preferanser på
brukernoden.

### Presets

| Kontekst | Default extensions | Eksempel |
|----------|-------------------|----------|
| Chat | Tekst, markdown, kodeblokker, lenker | Enkel melding |
| Artikkel/blogg | + overskrifter, bilder, embeds, blockquotes, tabeller | Publisert tekst |
| Show notes | + lister, tidskoder, lenker | Episodenotater |
| Kanban-kort | Tekst, sjekklister | Oppgavebeskrivelse |

Presets er bare default — brukeren kan utvide eller forenkle med
en knapp eller tastatursnarvei. Ikke en modebytte, bare at flere
verktøy blir tilgjengelig.

### Tekstlagring

Noden lagrer to representasjoner:

- `content TEXT` — ren tekst uten formatering, for fulltekstsøk
  og enkel visning
- `metadata.document JSONB` — strukturert TipTap/ProseMirror-
  dokument for rendering

```json
{
  "type": "doc",
  "content": [
    { "type": "paragraph", "content": [
      { "type": "text", "text": "Her er en intro." }
    ]},
    { "type": "image", "attrs": {
      "node_id": "uuid-av-cas-node", "alt": "Diagram"
    }},
    { "type": "paragraph", "content": [
      { "type": "text", "text": "Teksten fortsetter." }
    ]}
  ]
}
```

`content` genereres automatisk fra dokumentet ved lagring — bare
teksten, uten markup. Editoren produserer begge.

For enkle meldinger (ren tekst uten formatering) er
`metadata.document` null — `content` er alt som trengs.

### Bilder og media i tekst

Bilder i dokumentet refererer til CAS-noder via `node_id`.
CAS-noden er en egen node med `has_media`-edge til innholdsnoden.
Dokumentstrukturen bestemmer *hvor* bildet plasseres i teksten.

Tekst er *på* noden. Binærfiler er *andre* noder koblet med edges.
Ren separasjon: tekst er innhold, binærfiler er vedlegg som kan
plasseres inline.

## Edges definerer alt

Hva en node "er" bestemmes utelukkende av edges:

- Node + `belongs_to` → kanal = chatmelding
- Node + `belongs_to` → board + `status` = kanban-kort
- Node + `scheduled` → tidspunkt = kalenderoppføring
- Node uten edges til andre = privat notat
- Node + `mentions` → topic = faktoid i kunnskapsgrafen
- Node uten edges = løs tanke, ennå uorganisert

**Retyping er trivielt.** Chatmelding → kanban-kort = legg til
board-edge og status-edge. Ingen datamigrering, bare edges.

**Multitype er naturlig.** En node kan være *både* kanban-kort
*og* kalenderoppføring *og* faktoid. Det er ikke en edge case —
det er arkitekturen.

### Edge-tildeling

Når du "bare sier noe" — hvem bestemmer edges?

1. **Kontekst gir det meste.** Du er i en samtale → `belongs_to`-
   edge til samtalen. Du er alene → privat. Dekker 80%.
2. **Eksplisitt handling.** Du drar en node til kanban-brettet.
   Du tagger noe. Du setter en dato.
3. **AI-foreslått.** Systemet foreslår `mentions`-edge når du
   nevner en person. Foreslår kanban når noe ligner en oppgave.

Detaljer for AI-foreslåtte edges avklares ved implementering.

## Mottak-primitiven

Der input er "én overflate som fanger alt", er mottak "én overflate
som presenterer alt tilpasset *deg*."

### Mottaker bestemmer format

All lyd transkriberes. All tekst kan leses opp (TTS). Noden har
alltid begge representasjoner. Mottaker setter sin preferanse:

- Trond snakker inn en tanke → node med lyd + transkripsjon
- Peter har tekst-preferanse → ser transkripsjonen
- Vegard har lyd-preferanse → hører originallyd

Modalitet er ikke en egenskap ved meldingen, men ved *lesningen*.

### Dimensjoner ved mottak

**Format.** Lyd, tekst, visuelt — mottaker bestemmer.

**Filtrering.** Mottaksflaten filtrerer basert på dine edges:
kanaler du følger, personer du samarbeider med, topics du er
interessert i.

**Prioritering.** AI-assistert vekting: ubesvarte meldinger,
oppgaver med frist, noder endret siden sist. Ikke en
notifikasjonsliste — en vektet visning av det som er relevant.

**Tempo.** Sanntid (ting streamer inn) eller asynkront (digest,
oppsummering).

### Mottaksflaten er en visning av grafen

"Noder med edge til *meg*, vektet på relevans og tid." Ikke en
egen mekanisme — en spørring mot grafen med deg som sentrum.

## Kommunikasjonsnoden — den tredje primitiven

Input fanger. Mottak presenterer. Kommunikasjonsnoden er *stedet*
der folk møtes — en node som samler deltakere, definerer
tilgangsregler, og fungerer som kontekst.

### Én node, mange former

| Variant | Deltakere | Input | Mottak |
|---------|-----------|-------|--------|
| Én-til-én | 2 | Begge | Begge |
| Gruppechat | N | Alle | Alle |
| Møte | N | Alle | Alle |
| Allmøte | 1 + N | Leder | Alle lytter |
| Podcastinnspilling | 2-4 + N | Verter | Alle lytter |
| Livesending | 1-4 + ∞ | Verter | Streamet |
| Asynkron gjest | 1 + 1 | Gjest | Redaksjonen |

Forskjellen er edge-konfigurasjoner, ikke ulike systemer:
- `owner`-edge — kontrollerer noden
- `member`-edge — kan gi input og motta
- `reader`-edge — kan kun motta

### Kontekst arves automatisk

Input i en kommunikasjonsnode arver kontekst-edges. Sier du noe
i et møte → noden får `belongs_to`-edge til møtet automatisk.

### Livssyklus

- **Live** — deltakere til stede, input streames
- **Asynkron** — deltakere gir input i eget tempo
- **Avsluttet** — arkivert, alt som ble sagt er noder med edges
- **Gjenåpnet** — reaktivert ("vi tar opp tråden fra forrige møte")

### Skalering er edge-endring

Samtale → møte = flere deltaker-edges.
Møte → livesending = offentlige mottak-edges.
Livesending → podcast = publiserings-edges på arkivert innhold.

## Tekniske forutsetninger

### STT (tale → tekst): løst
Faster-whisper kjører lokalt, god norsk kvalitet.

### TTS (tekst → tale): løsbart
Start med ElevenLabs bak AI Gateway, bytt til lokal modell når
kvaliteten holder. Backend-swap bak gatewayen — brukeren merker
ingenting.

## Visninger er spørringer

Chat = noder med kanal-edge, sortert på tid.
Kanban = noder med board-edge, gruppert på status.
Kalender = noder med dato-edge, på tidslinje.
Dagbok = private noder, sortert på tid.
Mottaksflate = noder med edge til deg, vektet.

Alle leser fra samme graf. Ingen har "sin egen" data.

## Forhold til andre retninger

- [Noder er sentrum](bruker_ikke_workspace.md) — visibility,
  tilgangsmatrise, aliaser
- [Datalaget](datalaget.md) — SpacetimeDB holder hele grafen,
  PG persisterer asynkront
- [Maskinrommet](maskinrommet.md) — validering, routing, CAS,
  tunge jobber (Whisper, TTS, AI)
- [Rom, ikke forum](rom_ikke_forum.md) — kommunikasjonsnoden
  er den konkrete realiseringen av "rommet"


================================================================
FILE: docs/primitiver/edges.md
================================================================

# Edges — spesifikasjon

**Status: Besluttet.**

> Edges er relasjoner mellom noder. De er retningsbestemte, typede med
> freeform strenger, og kan bære metadata. Hva en node "er" bestemmes
> av dens edges, ikke av noden selv.

## Skjema

```sql
CREATE TABLE edges (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    source_id   UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    target_id   UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    edge_type   TEXT NOT NULL,
    metadata    JSONB NOT NULL DEFAULT '{}',
    system      BOOLEAN NOT NULL DEFAULT false,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_by  UUID REFERENCES nodes(id),

    UNIQUE (source_id, target_id, edge_type)
);

CREATE INDEX idx_edges_source ON edges (source_id);
CREATE INDEX idx_edges_target ON edges (target_id);
CREATE INDEX idx_edges_type   ON edges (edge_type);
```

## Retning

Edges er retningsbestemte: `source_id → target_id`. "Vegard eier
Sidelinja" er en annen edge enn "Sidelinja eier Vegard" (som ikke
gir mening). Retningen leses naturlig:

```
Vegard  ──owner──→   Sidelinja       (Vegard eier Sidelinja)
Referat ──belongs_to──→ Møte         (referatet tilhører møtet)
Episode ──mentions──→ Jonas Gahr Støre (episoden nevner ham)
```

Symmetriske relasjoner (f.eks. vennskap) modelleres som to edges
eller som en edge-type maskinrommet vet er symmetrisk.

## Edge-typer

Freeform strenger med konvensjoner — ikke en hard enum. Nye
relasjonstyper krever ingen migrering. Systemkritiske typer
valideres i maskinrommet.

### Kjente edge-typer

| Type | Betydning | Metadata |
|------|-----------|----------|
| `owner` | Eier noden | — |
| `admin` | Administrerer noden | — |
| `member_of` | Er medlem av | — |
| `reader` | Kan kun lese | — |
| `belongs_to` | Tilhører (innhold → samling) | — |
| `alias` | Identitet (systemedge) | `system: true` |
| `mentions` | Refererer til | — |
| `reply_to` | Svar på | — |
| `scheduled` | Tidsplanlagt | `{ "at": "2026-03-20T14:00Z" }` |
| `status` | Tilstand | `{ "value": "done" }` |
| `tagged` | Merket med | `{ "tag": "urgent" }` |
| `host_of` | Vert i | — |

Listen vokser organisk. Nye typer legges til ved behov uten
skjemaendring.

## Metadata

JSONB-feltet bærer kontekstspesifikk data om relasjonen:

- `status`-edge: `{ "value": "in_progress" }`
- `scheduled`-edge: `{ "at": "2026-03-20T14:00Z" }`
- `tagged`-edge: `{ "tag": "urgent", "color": "#ff0000" }`

Metadata er fleksibelt og spørrbart uten migrering.

## Systemedges

Edges med `system: true` er usynlige ved traversering. De
eksisterer for systemet, ikke for brukere.

Kjente systemedges:
- `alias` — identitetskobling, aldri eksponert

## Unique-constraint

`UNIQUE (source_id, target_id, edge_type)` betyr: én edge av
hver type mellom to noder. Vegard kan ha `owner`-edge *og*
`member_of`-edge til Sidelinja, men ikke to `owner`-edges.

## Hva edges gjør med noder

Edges definerer hva en node "er" — ikke noden selv:

```
Node + member_of → kanal       = chatmelding
Node + belongs_to → board
     + status → "todo"         = kanban-kort
Node + scheduled → tidspunkt   = kalenderoppføring
Node + belongs_to → kun bruker = privat notat
Node + mentions → topic        = faktoid i kunnskapsgrafen
Node uten edges                = løs tanke
```

Retyping er å endre edges. Ingen datamigrasjon.

## Tilgangsmatrise-oppdatering

Når en edge med tilgangsrolle (`owner`, `admin`, `member_of`,
`reader`) opprettes eller slettes, oppdaterer maskinrommet
`node_access`-matrisen i samme transaksjon. Se
[noder er sentrum](../retninger/bruker_ikke_workspace.md)
for full spesifikasjon.


================================================================
FILE: docs/primitiver/nodes.md
================================================================

# Nodes — spesifikasjon

**Status: Besluttet.**

> Alt er noder. Brukere, team, prosjekter, innhold, møter, mediefiler,
> kunnskapsgraf-entiteter — alt er rader i `nodes`-tabellen. Hva en
> node "er" bestemmes av dens edges, ikke av noden selv.

## Skjema

```sql
CREATE TYPE visibility AS ENUM ('hidden', 'discoverable', 'readable', 'open');

CREATE TABLE nodes (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    node_kind   TEXT NOT NULL DEFAULT 'content',
    title       TEXT,
    content     TEXT,
    visibility  visibility NOT NULL DEFAULT 'hidden',
    metadata    JSONB NOT NULL DEFAULT '{}',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_by  UUID REFERENCES nodes(id)
);

CREATE INDEX idx_nodes_kind ON nodes (node_kind);
CREATE INDEX idx_nodes_created_by ON nodes (created_by);
CREATE INDEX idx_nodes_visibility ON nodes (visibility);
```

## Kolonner

### `id`
UUID, generert. Primærnøkkel for alt i systemet.

### `node_kind`
Hint om hva noden primært er. Freeform streng — ikke en enum,
samme filosofi som edge-typer. Edges kan gi noden flere roller
utover dette.

Kjente node_kinds:

| Kind | Eksempel |
|------|----------|
| `person` | Vegard, Trond, Bjørn (alias) |
| `team` | Podcastteamet |
| `collection` | Sidelinja Podcast, Research-gruppa |
| `content` | Chatmelding, bloggpost, notat, dagboknotis |
| `communication` | Møte, samtale, livesending |
| `topic` | Kunnskapsgraf-entitet (Jonas Gahr Støre, Skolepolitikk) |
| `media` | CAS-node (lydfil, bilde, video) |

Listen vokser organisk etter behov.

### `title`
Det du viser overalt: i lister, søkeresultater, mottaksflaten.
Universelt — navnet på en person, tittelen på en bloggpost,
navnet på en podcast.

- Vegard: `'Vegard'`
- Sidelinja: `'Sidelinja Podcast'`
- Bloggpost: `'Hvorfor noder er sentrum'`
- Chatmelding: `NULL` (har sjelden tittel)

### `content`
Ren tekst uten formatering. Brukes til fulltekstsøk og enkel
visning. For rike dokumenter (formatert tekst med bilder) genereres
`content` automatisk fra `metadata.document` ved lagring.

- Bloggpost: teksten uten markup (generert fra `metadata.document`)
- Chatmelding: `'Hei, er du klar?'`
- Voice memo: `NULL` ved opprettelse, fylles etter transkribering
- Brukernode: `NULL`
- CAS-node: `NULL` (binærdata lever på disk)

For formatert innhold: se `metadata.document` under metadata.

### `visibility`
Default synlighet for alle uten eksplisitt edge.
Se [noder er sentrum](../retninger/bruker_ikke_workspace.md)
for full spesifikasjon av visibility-nivåer og traverseringsregelen.

### `metadata`
JSONB for alt typespesifikt som ikke er tittel eller ren tekst:

- Person: `{ "display_name": "Vegard", "preferences": { ... } }`
- CAS-node: `{ "cas_hash": "abc123", "mime": "audio/mp3", "size_bytes": 84000000 }`
- Kommunikasjonsnode: `{ "started_at": "...", "ended_at": "..." }`
- Samlings-node: `{ "pruning_profile": "conservative", "theme": "dark" }`
- Rikt dokument: `{ "document": { "type": "doc", "content": [...] } }`

`metadata.document` inneholder TipTap/ProseMirror JSON for formatert
innhold (overskrifter, bilder, blockquotes, etc.). Bilder refererer
til CAS-noder via `node_id`. For enkle meldinger (ren tekst) er
`document` null — `content` er alt som trengs. Se
[universell input](../retninger/universell_input.md) for detaljer.

### `created_at`
Tidsstempel, automatisk.

### `created_by`
Referanse til noden som opprettet denne noden. For alias-bruk:
`created_by` settes til aliasnoden, ikke brukernoden bak.

## Brukernoder

En brukernode er en node med `node_kind = 'person'` og en rad i
`auth_identities`:

```sql
CREATE TABLE auth_identities (
    node_id       UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    authentik_sub TEXT UNIQUE NOT NULL,
    email         TEXT UNIQUE NOT NULL
);
```

Alt annet — profil, preferanser, relasjoner — er noden og dens
edges. Autentiseringstabellen er en tynn bro mellom HTTP-sesjonen
og grafen.

## CAS-noder (mediefiler)

Binærfiler er noder med `node_kind = 'media'`. Selve biten lever
på disk i CAS-lageret. Noden bærer bare metadata.

```
Episode #42 (content)
  ──has_media──→ CAS-node (media, metadata: { cas_hash: "abc123", mime: "audio/mp3" })
  ──has_media──→ CAS-node (media, metadata: { cas_hash: "def456", mime: "text/srt" })
  ──belongs_to──→ Sidelinja Podcast (collection)
```

En innholdsnode kan ha mange mediefiler via edges. Pruning-logikken
i maskinrommet opererer på CAS-noder — sletter binærfilen fra disk,
men noden kan leve videre som tombstone.

## Eierskap og tilgang

`created_by` gir redigeringsrett til egne noder. Men sletting og
strukturelle endringer scopes av rollen i konteksten:

- Du kan alltid redigere noder du opprettet.
- Sletting krever `owner` eller `admin` i samlings-noden
  innholdet tilhører — selv om du opprettet det.
- En privat node (ingen samlings-edge) kan slettes fritt av creator.

Flere noder kan ha `owner`-tilgang til samme node via
tilgangsmatrisen (direkte og transitiv). Forretningslogikk for
hva ulike tilgangsnivåer tillater lever i maskinrommet, ikke i
databasen.


================================================================
FILE: docs/concepts/den_asynkrone_gjesten.md
================================================================

# Konsept: Den Asynkrone Gjesten
**Filsti:** `docs/concepts/den_asynkrone_gjesten.md`

## 1. Konsept
Mange interessante gjester har ikke tid til å stille i studio. Den Asynkrone Gjesten lar redaksjonen sende en unik lenke til en gjest som kan svare på spørsmål via tale — fra mobilen, når det passer dem. Svarene lander direkte i redaksjonens arbeidsflyt, transkriberes automatisk, og kan brukes i podcasten.

## 2. Brukeropplevelse

### 2.1 Redaksjonens side
1. Redaksjonen oppretter en "Gjestesesjon" knyttet til et Tema.
2. Legger inn spørsmål (tekst) som gjesten skal svare på.
3. Systemet genererer en unik, tidsbegrenset URL.
4. URL-en sendes til gjesten via e-post, SMS eller chat.
5. Gjestens svar (lydmeldinger) dukker opp i Tema-chatten som `voice_memo`-meldinger, automatisk transkribert.
6. Redaksjonen triagerer svarene — kan tagge, klippe inn i episode, eller bruke som research.

### 2.2 Gjestens side
1. Gjesten åpner lenken i mobilnettleseren. Ingen app, ingen konto, ingen registrering.
2. Ser en enkel, ren flate: podcast-logo, spørsmålene fra redaksjonen, og en opptaksknapp per spørsmål.
3. Trykker record, snakker, trykker stopp. Kan lytte tilbake og ta om igjen.
4. Ved innsending lastes lydfilene opp og gjesten ser en bekreftelse.
5. Lenken utløper etter gitt tid eller antall besøk.

### 2.3 Minimal friksjon
- Ingen Authentik-innlogging — tilgang via signert token
- Ingen app — ren PWA/nettleser
- Ingen redigering — gjesten snakker bare
- Responsivt, mobil-first design

## 3. Komponenter

| Feature | Rolle |
|---|---|
| Lydmeldinger | Opptakskomponent gjenbrukes (se `docs/features/lydmeldinger.md`) |
| Chat (channels) | Svarene lander i en channel knyttet til Temaet |
| Live transkripsjon | Whisper transkriberer via jobbkø (se `docs/features/live_transkripsjon.md`) |
| Podcastfabrikken | Lydklipp kan trekkes inn som segment (se `docs/concepts/podcastfabrikken.md`) |

## 4. Autentisering: Gjeste-tokens

### 4.1 Datamodell

```sql
guest_tokens (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    node_id      UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,  -- Samlings- eller tema-node
    guest_name   TEXT NOT NULL,               -- Visningsnavn ("Erna Solberg")
    questions    JSONB NOT NULL,              -- [{ "sort": 1, "text": "Hva tenker du om...?" }]
    token        TEXT UNIQUE NOT NULL,         -- Kryptografisk sikker, URL-safe token
    expires_at   TIMESTAMPTZ NOT NULL,
    max_recordings SMALLINT DEFAULT 10,       -- Maks antall opptak
    recordings_count SMALLINT DEFAULT 0,
    created_by   TEXT NOT NULL REFERENCES users(authentik_id),
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
)
```

### 4.2 Sikkerhet
- Token er kryptografisk tilfeldig (256-bit, URL-safe base64).
- SvelteKit validerer token ved hvert request: sjekker expiry, recordings_count < max_recordings, og node-tilhørighet.
- Gjestens meldinger merkes med `author_id = NULL` og `metadata.guest_name` + `metadata.guest_token_id` for sporbarhet.
- Ingen tilgang til andre noder eller funksjoner.
- Tokenet kan revokeres manuelt av redaksjonen.

### 4.2b Sikkerhetsdybde (mot token-lekkasje og misbruk)
Et lekket gjeste-token gir direkte filopplasting uten autentisering — dette er høyrisiko. Følgende tiltak begrenser skadepotensialet:

| Tiltak | Implementering | Formål |
|---|---|---|
| **Rate limiting per token** | SvelteKit middleware: maks 1 opplasting per 30 sek per token | Forhindrer spam/flooding |
| **Filtype-validering** | SvelteKit: kun `audio/*` MIME-typer aksepteres, filstørrelse maks 50 MB | Blokkerer malware-opplasting |
| **Malware-scanning** | ClamAV sidecar-container scanner opplastede filer før de lagres | Fanger kjent malware |
| **Auto-revoke** | Token deaktiveres automatisk når `recordings_count >= max_recordings` | Begrenser eksponering |
| **IP-logging** | Logger klient-IP per opplasting i `guest_token_usage`-tabell | Sporbarhet ved misbruk |
| **Geo-begrensning** (valgfritt) | Caddy-nivå: blokker requests fra uventede geolokasjoner | Reduserer angrepsflate |

**ClamAV Docker-oppsett:**
```yaml
clamav:
  image: clamav/clamav:latest
  restart: unless-stopped
  volumes:
    - /srv/synops/media:/scan:ro
  networks:
    - sidelinja-net
```
SvelteKit kaller ClamAV via `clamdscan` (socket) etter filopplasting, før filen flyttes til endelig plassering. Infiserte filer slettes umiddelbart og tokenet flagges for manuell gjennomgang.

**Fremtidig hardening — prosess-isolasjon:**
Ved økt eksponering (mange aktive guest-tokens, offentlige lenker) bør opplastede filer prosesseres i en isolert kontekst per token. Mulige tilnærminger:
- Firejail/bubblewrap-sandbox for Whisper-prosessering av gjeste-audio
- Dedikert temp-mappe per token som slettes etter prosessering
- Docker sidecar-container for uautentisert filopplasting med egne cgroups

Dette er komplementært til ClamAV (som fanger kjent malware) — sandboxing beskytter mot ukjente angrep. Implementeres når gjeste-tokens eksponeres bredere enn redaksjonell bruk.

### 4.3 Flyt (teknisk)
```
Gjest åpner URL med token
  → SvelteKit validerer token
  → Viser spørsmål + opptaksknapp
  → Gjest tar opp svar
  → SvelteKit streamer lydfil til CAS (content-addressable store)
  → Oppretter message (voice_memo) i channelen
  → Oppretter whisper_transcribe-jobb i jobbkøen
  → Inkrementerer recordings_count
  → Redaksjonen ser svaret i Tema-chatten
```

## 5. Dataklassifisering

| Data | Kategori | Detaljer |
|---|---|---|
| Gjestens lydopptak | Kritisk (backup) | Unikt innhold |
| Guest tokens | Flyktig (TTL) | Utløper automatisk, slett expired tokens periodisk |
| Spørsmål (JSONB) | Kritisk (PG) | Redaksjonelt innhold |

## 6. Instruks for Claude Code
* `guest_tokens`-tabellen er **ikke** en node i grafen — den er ren tilgangsstyring.
* Gjeste-UI er en egen SvelteKit-rute (`/guest/[token]`) med minimal layout (ingen navbar).
* Gjenbruk lydmeldinger-komponenten — ikke bygg en egen opptaksflyt.
* Meldinger fra gjester har `author_id = NULL`. Frontend må håndtere dette gracefully (vis `guest_name` i stedet).
* Tokenet skal **aldri** gi tilgang til å lese andre meldinger i channelen — gjesten kan kun skrive.
* Tilgang er styrt via node_access. Token bærer node_id eksplisitt.


================================================================
FILE: docs/concepts/kunnskapsgrafen.md
================================================================

# Konsept: Kunnskapsgrafen (Utforsking og redigering)
**Filsti:** `docs/concepts/kunnskapsgrafen.md`

## 1. Konsept
Kunnskapsgrafen er Sidelinjas kjerne — et levende nettverk av Temaer, Aktører, Faktoider, Episoder og Segmenter. Inspirert av Logseq og Obsidian bygger den seg opp organisk gjennom daglig bruk og skaper "serendipity" (lykketreff) i research-fasen ved å synliggjøre uventede forbindelser.

## 2. Brukeropplevelse

### 2.1 Organisk vekst
Grafen vokser gjennom daglig bruk av Sidelinja:
1. **Chat-meldinger** med `#`-tags oppretter automatisk `MENTIONS`-relasjoner i grafen.
2. **AI-behandling i editoren** trekker ut aktører og faktoider fra innlimt tekst (se `docs/proposals/editor.md`).
3. **Podcastfabrikken** kobler episode-segmenter til temaer og aktører.
4. **Møtereferater** trådes automatisk mot temaer og aktører av AI-referenten.

### 2.2 Visuell utforsking
En interaktiv graf-visning (se `docs/features/visuell_graf.md`) lar redaksjonen:
- Navigere nettverket rundt en Aktør eller et Tema (2-3 ledd ut)
- Dra streker mellom noder for å opprette nye relasjoner
- Filtrere etter nodetype, relasjonstype, tidsperiode eller fritekst
- Bruke `PART_OF`-hierarkier for fleksibel prosjektorganisering uten stive mappestrukturer

### 2.3 Søk
Full-text search på norsk (`to_tsvector('norwegian', ...)`) gjør det mulig å søke på tvers av alle episoder, segmenter og faktoider.

## 3. Komponenter

| Feature | Rolle i Kunnskapsgrafen |
|---|---|
| Kunnskapsgraf datamodell | Nodes/edges i PostgreSQL (se `docs/features/kunnskapsgraf_og_relasjoner.md`) |
| Visuell graf | Interaktiv D3.js/Vis.js-visning (se `docs/features/visuell_graf.md`) |
| Chat | Mentions (`#`/`@`) oppretter edges automatisk (se `docs/features/chat.md`) |
| Editor (AI-knapp) | Trekker ut aktører/faktoider til grafen (se `docs/proposals/editor.md`) |

## 4. Entity Resolution (Merge Entities)

Grafen vokser organisk via `#`-mentions, men dette skaper uunngåelig fragmentering: `#Jonas`, `#Støre` og `#Jonas Gahr Støre` ender som tre separate noder. Uten en strategi for sammenslåing dør serendipity-effekten — faktoidene spres over duplikater som AI-en tror er ulike konsepter.

**Løsning: Merge Entities admin-verktøy (Lag 2)**

1. Velg autoritativ node (Node A) og duplikat(er) (Node B, C, ...)
2. Flytt alle `graph_edges` som peker på Node B → Node A (`UPDATE graph_edges SET source_id/target_id = A WHERE ... = B`)
3. Flytt alle `messages`-mentions som refererer til Node B → Node A
4. Legg til `name` fra Node B som alias i `entities.aliases` på Node A
5. Slett Node B (`DELETE FROM nodes` → cascader)

`aliases`-arrayet i `entities`-tabellen finnes allerede og er indeksert med GIN — autocomplete søker i både `name` og `aliases`, noe som forebygger fremtidige duplikater.

**Forebygging:** Autocomplete bør vise eksisterende entiteter med matchende aliases *før* brukeren oppretter nye. "Mente du #Jonas Gahr Støre?" ved skriving av `#Støre`.

## 5. Datamodell
Den tekniske datamodellen (nodes-supertabell, graph_edges, detailtabeller, deterministiske UUIDs, tilgangsstyring via node_access) er dokumentert i `docs/features/kunnskapsgraf_og_relasjoner.md`.


================================================================
FILE: docs/concepts/møterommet.md
================================================================

# Konsept: Møterommet (Interne redaksjonsmøter)
**Filsti:** `docs/concepts/møterommet.md`

## 1. Konsept
Et fullverdig virtuelt møterom for Sidelinjas redaksjon. Bygget på LiveKit for lyd/video, med AI-referent som automatisk genererer referat og action points, delt whiteboard for visuell brainstorming, og søkbar møtehistorikk i Kunnskapsgrafen.

## 2. Brukeropplevelse
1. En bruker oppretter et møterom i SvelteKit. Alle deltakere kobler seg til via LiveKit (WebRTC).
2. Under møtet er følgende verktøy tilgjengelige:
   - **Video/lyd** — LiveKit-strøm mellom deltakere
   - **Whiteboard** — Frihåndstavle for skisser (se `docs/features/whiteboard.md`)
   - **Delt scratchpad** — SpacetimeDB-drevet tekstfelt for kjappe notater
   - **Aha-markør** — Markerer viktige øyeblikk med tidsstempel
   - **Off-the-record** — Pauser AI-lytting og opptak
3. Whisper transkriberer møtet via live transkripsjonspipelinen (se `docs/features/live_transkripsjon.md`).
4. Ved møteslutt genererer AI-referenten (se `docs/features/live_ai.md`, møte-modus):
   - Strukturert referat (Markdown)
   - Action points → foreslåtte Kanban-kort (se `docs/features/kanban.md`)
   - Identifiserte `#Temaer` og `@Aktører` → automatisk tråding i Kunnskapsgrafen

## 3. Komponenter

| Feature | Rolle i Møterommet |
|---|---|
| LiveKit | WebRTC lyd/video |
| Live transkripsjon | Whisper-pipeline for møtetranskripsjon (se `docs/features/live_transkripsjon.md`) |
| Live AI (møte-modus) | Referat, action points, tråding (se `docs/features/live_ai.md`) |
| Whiteboard | Frihåndstavle for visuell brainstorming (se `docs/features/whiteboard.md`) |
| Chat | Scratchpad / tekstnotater (se `docs/features/chat.md`) |
| Kanban | Mottaker av foreslåtte action-point-kort (se `docs/features/kanban.md`) |

## 4. Off-the-record
Når off-the-record er aktivt:
- Whisper-strømmen stopper — ingen data lagres
- Visuell indikator i alle deltakeres grensesnitt
- Transkripsjonen får et gap i tidslinja
- Whiteboard forblir aktivt (visuelt, ikke tale)

## 5. Søkbar møtehistorikk
Møter transkriberes i segmenter (samme modell som episode-segmenter) og indekseres i PostgreSQL med full-text search. Møtereferater lenkes til Temaer og Aktører via `graph_edges`, slik at intern møtehistorikk blir søkbar i Kunnskapsgrafen.


================================================================
FILE: docs/concepts/podcastfabrikken.md
================================================================

# Konsept: Podcastfabrikken (Lyd & Publiserings-Pipeline)
**Filsti:** `docs/concepts/podcastfabrikken.md`

## 1. Konsept
Den automatiserte "samlebåndet" som tar over når en ferdigklippet episode er klar, samt verktøyet for å **oppdatere eksisterende episoder** (f.eks. en rullerende intro-episode). Målet er at maskinen gjør 90 % av grovarbeidet (transkripsjon, metadata, kapittelinndeling), men at redaksjonen alltid kan overstyre resultatet manuelt før publisering.

## 2. Arkitektur & Dataflyt
Dette er en asynkron arbeidsflyt som kombinerer filsystem, AI, databaser og CI/CD.

1. **Trigger (Opplasting/Oppdatering):** Brukeren laster opp en `.mp3`-fil via SvelteKit-grensesnittet. Dette rutes enten som en *ny* episode (`INSERT`), eller en *oppdatering* av en eksisterende (`UPDATE`).
2. **Kø-system (PostgreSQL jobbkø):** Siden lydprosessering tar tid (CPU-intensivt), legges oppgaven i den felles jobbkøen (se `docs/infra/jobbkø.md`). Opplastingen oppretter to jobber i sekvens: først `whisper_transcribe`, deretter `openrouter_analyze` (som trigges automatisk ved fullført transkripsjon).
3. **Transkripsjon (faster-whisper):** Rust-worker kaller faster-whisper-server (OpenAI-kompatibelt API, `POST /v1/audio/transcriptions`) med `response_format=srt` og mottar SRT direkte. Modell: `Systran/faster-whisper-medium` med `initial_prompt` (navneliste).
4. **Lagring av transkripsjon (Git):** Rust-worker committer SRT-filen til Forgejo. SRT er master-formatet — redigerbart, tidsstemplet, og et etablert standardformat. Git gir diff, historikk og sporbarhet. Redaksjonen kan redigere SRT direkte.
5. **Avledede formater (PostgreSQL):** Ved commit (via Forgejo webhook) parser en Rust-worker SRT-filen og genererer:
   * **Ren tekst** — strippes fra SRT (fjern tidsstempler/sekvensnummer) for lesbart publiseringsdokument
   * **Segmenter** — tidsstemplede utdrag koblet til Aktører/Temaer i kunnskapsgrafen
   * **Full-text søkeindeks** — for oppslag på tvers av episoder
6. **AI-Analyse (OpenRouter):** Transkripsjonen sendes til OpenRouter (Claude-modell) for uttrekk av forslag til tittel, sammendrag, show notes og kapittler.
7. **Manuell Godkjenning & Fletting (SvelteKit):**
   * *For nye episoder:* Presenteres som et ferskt utkast.
   * *For oppdateringer:* Viser AI-ens nye forslag side-om-side med eksisterende metadata. Redaksjonen kan da velge hva som skal beholdes eller flettes (merge).
8. **Publisering (PostgreSQL):** Ved "Godkjenn" lagres metadataene permanent i databasen.
9. **RSS-Generering:** SvelteKit-appen genererer en oppdatert `/feed.xml`.

### 2.1 Episodeside (publisert visning)
Hver publisert episode får en side med:
* Lydavspiller + sammendrag + kapitler + stikkord
* Personreferanser og artikler (koblet via kunnskapsgrafen)
* Fane: **SRT** (nedlastbar undertekstfil — master-kopi fra Git)
* Fane: **Ren tekst** (lesbart transkripsjonsdokument — avledet fra SRT, lagret i PG)

### 2.2 Live-to-Archive (Studio/Møterom → Episode)

Mye av innholdet som ender i podcastfabrikken starter som live-innspilling
i Studioet eller Møterommet. Denne flyten unngår manuell opplasting:

1. Under innspilling i LiveKit produserer Whisper chunks i sanntid
2. Når innspillingen stoppes → automatisk jobb `studio_to_episode`:
   - Konsoliderer live-chunks til én SRT-fil
   - Oppretter episode-node med storyboard basert på markører satt under innspilling
   - Trigger AI-analyse (samme pipeline som ved vanlig opplasting)
3. Lydfilen lagres i CAS med edge til episode-noden
4. Episoden dukker opp i podcastfabrikken som et utkast — klar for
   godkjenning, redigering og publisering

Fordel: aldri behov for «last opp MP3 etter innspilling» — flyten er
live → arkiv → publisering.

## 3. Spesialhåndtering: Oppdatering av eksisterende episoder (Cache-busting)
Podcast-apper (Apple, Spotify) og CDN-er cacher innhold aggressivt. For at en endring i f.eks. "Introepisoden" skal slå gjennom hos lytterne, MÅ følgende tekniske regler følges:

* **Filnavn-versjonering (Viktigst!):** Den nye lydfilen skal *aldri* overskrive det gamle filnavnet på disken. Systemet må legge til en hash, UUID eller et tidsstempel (f.eks. `intro_v2_1710289000.mp3`). Dette tvinger appene til å laste ned filen på nytt.
* **RSS `<guid>` (Global Unique Identifier):** Denne taggen MÅ forbli 100% statisk/uendret fra originalepisoden. Den forteller appene at "Dette er fortsatt samme episode, ikke lag en duplikat".
* **RSS `<enclosure>`:** URL-en i `enclosure`-taggen (som peker på `.mp3`-filen) oppdateres i databasen til å reflektere det *nye* filnavnet.
* **RSS `<pubDate>`:** SvelteKit-grensesnittet skal gi redaksjonen en toggle-knapp ved oppdatering: 
  * Alternativ A: "Behold opprinnelig dato" (Episoden oppdateres i det stille for nye lyttere).
  * Alternativ B: "Sett dato til NÅ" (Episoden spretter til toppen av feeden som en ny utgivelse).

## 4. Whisper-konfigurasjon
* **Tjeneste:** `fedirz/faster-whisper-server` (Docker, OpenAI-kompatibelt API)
* **Endepunkt:** `POST /v1/audio/transcriptions` med `response_format=srt`
* **Beslutning:** SRT direkte fra Whisper, ikke verbose JSON. Verbose JSON inneholder diagnostikk (tokens, logprob, temperatur) som ikke har verdi for oss. SRT gir tidsstempler + tekst i et etablert format som er redigerbart, diffbart i Git, og trivielt å parse til ren tekst og segmenter.
* **Modeller (benchmarket med E277.mp3, 32:45 norsk tale, CPU i7-13900K):**

| Konfigurasjon | Tid (CPU) | Seg | Tegn | Kommentar |
|---|---|---|---|---|
| `small` | ~6 min | 777 | 25851 | Rask, men hyppige feil i egennavn |
| `medium` | ~18 min | 442 | 26938 | God balanse, noen navnefeil |
| `medium` + prompt | ~17 min | 455 | 26957 | Riktige egennavn, anbefalt standard |
| `large-v3` | ~24 min | 520 | 14559 | Hallusinerer uten VAD — IKKE bruk uten VAD |
| `large-v3` + VAD | ~31 min | 964 | 28291 | God kvalitet, men noen navnefeil |
| `large-v3` + VAD + prompt | ~31 min | 964 | 28295 | Best kvalitet, riktige egennavn |

* **Anbefaling:** `medium` + `initial_prompt` som standard. `large-v3` + VAD + prompt for best mulig kvalitet der det er verdt ventetiden.
* **Viktig:** `large-v3` KREVER `vad_filter=true` — uten hallusinerer modellen repeterende tekst.
* **Språk:** Sett `language=no` eksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk.

### 4.1 initial_prompt (navneliste)
`initial_prompt` primes Whisper med ordforråd som forbedrer gjenkjenning av egennavn. Effekten er tydelig:
* Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja"
* Med prompt: "Vegard Nøtnæs", "Sidelinja" (riktig)

Prompten bygges automatisk av Rust-worker fra en statisk navneliste + aktører i kunnskapsgrafen:
```
Sidelinja podcast med Vegard Nøtnæs, Trond Sørensen, Arne Eidshagen,
Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie
```

## 5. Per samlings-node konfigurasjon
Hver samlings-node (f.eks. Sidelinja) har sin egen podcast-konfigurasjon, lagret som JSONB-metadata på noden:

### 5.1 Mediefiler
Lydfiler lagres i CAS (content-addressable store) med edges til episode-noder. Caddy ruter trafikk basert på domene (fra samlings-nodens metadata) til riktig innhold.

### 5.2 Transkripsjoner (Git-repostruktur)
Det opprettes **ett Forgejo-repo per samlings-node** for SRT-filer, slik at historikk og redigering ikke blandes på tvers av podcaster.

#### Repo-oppretting
Repoet opprettes **on-demand** ved første transkripsjonsjobb for en samlings-node, via Forgejo API. Ikke alle samlings-noder trenger transkripsjonsrepo.

#### Filnavnkonvensjon
Flat struktur med prosesseringstidspunkt som filnavn:
```
20260315_143022.srt
20260401_091500.srt
```
* **Format:** `YYYYMMDD_HHMMSS.srt` — settes automatisk av Rust-worker ved prosessering
* **Sortering:** Kronologisk i enhver filvisning
* **Unikhet:** Tidsstempel garanterer unikhet uten suffiks-logikk
* **Ingen metadata i filnavn:** Episodenummer, tittel, slug og annen metadata lever i PostgreSQL, ikke i filnavnet. Filnavnet er en stabil identifikator som aldri endres.

#### Mediefiler matcher Git
Lydfilen i CAS bruker **samme navnekonvensjon** som SRT-filen: `20260315_143022.mp3` matcher `20260315_143022.srt`. Dette kobler mediefil og transkripsjon uten databaseoppslag.

#### Reprosessering (redigert lyd)
Når en lydfil redigeres og transkriberes på nytt, **beholdes det opprinnelige filnavnet**. Rust-worker overskriver SRT-filen i Git — historikken viser endringene via `git log`/`git diff`. Mediefilen i arkivet døpes om til å matche Git-filnavnet dersom den opprinnelig hadde et annet navn.

#### Forgejo-bruker
En dedikert servicebruker **"serverassistent"** opprettes i Forgejo med push-tilgang til transkripsjonsrepoer. Ingen admin-rettigheter.

#### Webhook-flyt
```
Forgejo push-webhook → SvelteKit POST /api/webhooks/forgejo
  → INSERT INTO job_queue (type: 'srt_parse', payload: {repo, commit, node_id})
  → Rust-worker plukker opp jobben og parser SRT → avledede formater i PG
```
SvelteKit validerer webhook-signatur og legger jobb i køen. Rust-worker forblir en ren kø-consumer uten eget HTTP-endepunkt.

#### SRT-editor
En enkel SRT-editor bygges i SvelteKit (Lag 3, sammen med Podcastfabrikken): segmenter som redigerbare tekstfelt med tidsstempler, "Lagre" committer tilbake til Git via Forgejo API. Forgejo web-UI fungerer som fallback for power users.

### 5.3 AI-prompts
* **Whisper `initial_prompt`:** Navnelister og kontekst lagres per samlings-node i metadata (`whisper_prompt`). Rust-worker bygger prompten fra statisk liste + aktører knyttet til samlings-noden via edges.
* **LLM system-prompts:** OpenRouter-prompts for metadata-uttrekk lagres i metadata (`llm_prompts`) slik at AI-en kjenner konteksten og vertene for akkurat den podcasten.

### 5.4 RSS-feed
SvelteKit genererer `/feed.xml` dynamisk basert på domenet forespørselen kommer fra (matcher samlings-nodens domene-metadata), eller node-slug som fallback.

### 5.5 Statistikk
Rust-workeren `stats_parse` knytter nedlastingstall fra Caddy-logger til riktig samlings-node basert på domene i loggen.

## 6. Instruks for Claude Code
* **Lydfiler:** Håndter filopplasting i SvelteKit strømmende (streaming) for filer >100MB for å unngå minne-lekkasjer.
* **Feilhåndtering:** Hvis OpenRouter timer ut eller Whisper feiler, må oppgaven flagges med status `error` i databasen slik at brukeren kan trigge jobben på nytt manuelt via UI.
* **Opprydding (Disk):** Når en fil oppdateres vellykket, skal den gamle/foreldede `.mp3`-filen enten slettes fra Hetzner-serveren automatisk, eller flyttes til en `/archive/`-mappe basert på en miljøvariabel.
* **Transkripsjoner:** Master-kopi alltid i Git. Aldri rediger avledede formater direkte i PG — de regenereres fra Git-kilden.
* **Tilhørighet:** Alle jobber, mediefiler og metadata knyttes til riktig samlings-node via edges. Hent config (prompts, domene) fra samlings-nodens JSONB-metadata.

================================================================
FILE: docs/concepts/redaksjonen.md
================================================================

# Konsept: Redaksjonen (Daglig redaksjonelt arbeid)
**Filsti:** `docs/concepts/redaksjonen.md`

## 1. Konsept
Redaksjonen er den daglige arbeidsflaten for Sidelinjas team. Her planlegges episoder, diskuteres temaer, samles research og skrives show notes. **Temaet** er hovedobjektet — ikke episoden.

## 2. Brukeropplevelse

### 2.1 Tema-bassenget
Alle pågående "Saker" vises i en oversikt. PostgreSQL er kilden til sannhet, SpacetimeDB holder aktive temaer i minnet for sanntidsoppdateringer.

### 2.2 Trådet Chat
Hver melding tilhører et Tema. Meldinger støtter tråder (svar-på-svar) og rike mentions med `#`-tags og `@`-mentions. Se `docs/features/chat.md` for teknisk spesifikasjon.

### 2.3 Kanban / Kjøreplan
Episoder fungerer som containere. Brukerne drar Temaer fra bassenget inn i en episodes kjøreplan med drag-and-drop. Se `docs/features/kanban.md` for teknisk spesifikasjon.

### 2.4 Show Notes
Et kollaborativt tekstfelt koblet til et Tema. Enkle "Operational Transformation"-aktige oppdateringer (eller felt-låsing) håndteres i SpacetimeDB-modulen. Synkes til PostgreSQL for persistens.

### 2.5 AI-behandling av tekst
Brukere limer inn uformatert tekst fra nettet i editoren, trykker AI-knappen (✨) og velger handling (rens, oppsummer, trekk ut fakta). Resultatet publiseres som en ny melding med foreslåtte graf-koblinger. Se `docs/proposals/editor.md` § "AI-behandling — universell knapp".

## 3. Komponenter

| Feature | Rolle i Redaksjonen |
|---|---|
| Chat | Trådet diskusjon per Tema (se `docs/features/chat.md`) |
| Kanban | Episodeplanlegging (se `docs/features/kanban.md`) |
| Editor (proposal) | Universell editor med AI-behandling av tekst (se `docs/proposals/editor.md`) |
| Whiteboard | Kan åpnes fra chat for visuell brainstorming (se `docs/features/whiteboard.md`) |

## 4. Instruks for Claude Code
* Bruk SvelteKit for Drag-and-Drop. Unngå tunge biblioteker hvis native HTML5 Drag and Drop er tilstrekkelig.
* SpacetimeDB er "State Manager". Frontend speiler SpacetimeDB sin tilstand — ikke bygg kompleks lokal state.
* All tilgang er styrt via node_access-matrisen. SpacetimeDB-tilkoblinger bærer brukerens identitet, og tilgang avgjøres av edges til samlings-noder.


================================================================
FILE: docs/concepts/studioet.md
================================================================

# Konsept: Studioet (Podcast-innspilling)
**Filsti:** `docs/concepts/studioet.md`

## 1. Konsept
Det virtuelle podcast-studioet er Sidelinjas innspillingsmiljø. LiveKit håndterer WebRTC for flerbruker lyd/video, mens AI-assistenten lytter med og dytter relevante faktoider til programlederne i sanntid.

## 2. Brukeropplevelse
1. Programlederne åpner studioet i SvelteKit (PWA) og kobler seg til et LiveKit-rom.
2. Høykvalitetslyd streames mellom deltakerne via WebRTC.
3. I bakgrunnen transkriberer Whisper lydstrømmen i chunks (~5 sek) via live transkripsjonspipelinen (se `docs/features/live_transkripsjon.md`).
4. AI-assistenten analyserer transkripsjonen for entiteter (NER) og slår opp i Kunnskapsgrafen. Relevante faktoider popper lydløst opp på skjermen (se `docs/features/live_ai.md`, studio-modus).
5. Programlederne kan trykke **Aha-markør** for å markere viktige øyeblikk. Tidsstempelet lagres i SpacetimeDB, koblet til episoden.
6. Etter innspilling skyves lydfilen inn i Podcastfabrikken for full transkripsjon og publisering (se `docs/concepts/podcastfabrikken.md`).

## 3. Komponenter

| Feature | Rolle i Studioet |
|---|---|
| LiveKit | WebRTC lyd/video mellom deltakere |
| Live transkripsjon | Whisper `small` for lav latens, ~1s forsinkelse (se `docs/features/live_transkripsjon.md`) |
| Live AI (studio-modus) | NER + faktoid-oppslag fra Kunnskapsgrafen (se `docs/features/live_ai.md`) |
| Aha-markør | Manuell markering av viktige øyeblikk, lagres i SpacetimeDB |

## 4. Avgrensning
- Studioet er for **innspilling**, ikke redigering. Klipping/postproduksjon skjer utenfor Sidelinja.
- Live-transkripsjonen her er **flyktig** (TTL 30 dager) — den endelige transkripsjonen lages via Podcastfabrikken med `medium` + `initial_prompt`.
- Aha-markøren deles med Møterommet (se `docs/concepts/møterommet.md`), men i studio-konteksten brukes den primært til klippepunkter.

## 5. Utviklingsfaser
1. Bygg SpacetimeDB-lytter i frontend + dummy faktoid-push for å verifisere UI.
2. Koble Whisper til et offline lydopptak, kjør NER/oppslag mot PostgreSQL.
3. Koble LiveKit-strømmen til Whisper for sanntid.


================================================================
FILE: docs/concepts/valgomaten.md
================================================================

# Konsept: Valgomaten (Crowdsourced & Datadrevet)
**Filsti:** `docs/concepts/valgomaten.md`

## 1. Konsept & Kjernefilosofi
Neste generasjons valgomat tar et oppgjør med den redaktørstyrte, endimensjonale modellen. Istedenfor forhåndsdefinerte akser styrer brukerne innholdet "bottom-up" gjennom et marked for akser. Valgomaten kombinerer friksjonsfri "Tinder-swiping" for folk flest, med et dyptgående redaksjonelt verktøy for nerdene, alt bygget på toppen av Sidelinjas eksisterende Kunnskapsgraf og sanntidsinfrastruktur.

### 1.1 Markedsmekanisme
Brukere kan fritt opprette nye politiske akser og spørsmål. Dimensjoner som engasjerer (får mange svar) "bobler opp" og blir standardakser, mens irrelevante forsvinner. Ingen begrensning på antall akser — systemet støtter komplekse skillelinjer i samfunnet.

### 1.2 Teoretisk fundament (Default-akser)
For å unngå "Blank Canvas"-syndromet startes plattformen med anerkjente rammeverk som fungerer som ankere:
* **John Haidts Moral Foundations Theory:** Omsorg, Rettferdighet, Lojalitet, Autoritet, Renhet.
* **Rokkans skillelinjer:** Sentrum/periferi, by/land, religiøs/sekulær.
* **Nanny State Index:** Formynderstat vs. personlig frihet og ansvar.
* **Intensjon vs. Resultat:** Støttes politikk på bakgrunn av teoretisk målsetting eller empirisk utfall?

## 2. Brukeropplevelse (Trakten)
Valgomaten er bygget som en to-trinns trakt for å maksimere viral spredning samtidig som dataintegriteten bevares.

### 2.1 Forsiden: Friksjonsfri & Anonym
* **Mekanikk:** Ingen registrering kreves for å starte. SvelteKit genererer en anonym UUID som lagres i `localStorage` og brukes mot SpacetimeDB.
* **UX:** Et rent kortstokk-grensesnitt (Tinder-stil swipe). Påstand på forsiden, brukeren velger Enig/Uenig.
* **Tre-trinns kalibrering (OKCupid-inspirert):**
  1. Hva mener du?
  2. Hva ønsker du at kandidaten skal mene?
  3. Hvor viktig er dette for deg?
* **Dealbreakers (Negative Veto):** Brukere kan sette absolutte grenser (f.eks. "Uansett hvor enige vi er om skatt, matcher jeg aldri med en som er for/mot vindkraft").
* **Sanntid:** SpacetimeDB beregner PCA (Prinsipalkomponentanalyse) og oppdaterer brukerens posisjon lynraskt i minnet for hvert swipe.

### 2.2 Baksiden: "Snu kortet" (Krever innlogging)
For avansert interaksjon må brukeren logge inn via Authentik (SSO). Den anonyme sesjonen flettes da automatisk med brukerprofilen. På baksiden av kortet finner man:
* **Folkets Redaksjonsmøte:** Mulighet til å se andres kommentarer til spørsmålet, foreslå endringer, og stemme opp/ned forbedringsforslag.
* **Podcast-snarveien:** Hvis spørsmålet er knyttet til et *Tema* i Kunnskapsgrafen, vises en "Play"-knapp. Caddy (`Accept-Ranges: bytes`) streamer tidsstemplede lydsegmenter fra tidligere Sidelinja-episoder der dette temaet ble diskutert.
* **Multi-akse vekting:** Innsikt i hvilke akser spørsmålet påvirker (f.eks. 80% Klima, 20% Sentrum/Periferi), med mulighet for å foreslå nye akse-koblinger.

## 3. Matching & Resultat

### 3.1 Individfokus
Matcher velgeren mot spesifikke lokalpolitikere og listekandidater, ikke bare mot det sentrale partiprogrammet.

### 3.2 PCA (Prinsipalkomponentanalyse)
Matematisk reduksjon av kompleksitet. Algoritmen koker ned brukerens svar på tvers av 50+ variabler til de 2-3 hovedfaktorene som faktisk styrer vedkommendes politiske kompass, og visualiserer disse spesifikt for brukeren.

## 4. Crowdsourcing & Dataintegritet

### 4.1 Uforanderlig Historikk (Versjonering)
Et spørsmål i PostgreSQL kan *aldri* endres (`UPDATE`) etter at folk har begynt å svare på det.
* Hvis et forbedringsforslag stemmes frem (eller godkjennes av redaksjonen), opprettes en ny versjon (f.eks. `Spørsmål 42 (v2)`).
* Brukere beholder sine resultater knyttet til den nøyaktige teksten de leste. Algoritmen vet at v1 og v2 tilhører samme politiske konsept.

### 4.2 Opprettelse av nye spørsmål og akser (Sandkassen)
* **Inkubator:** Innloggede brukere kan opprette nye spørsmål eller definere helt nye akser (ved å angi to motpoler). Disse havner i en "Sandkasse" og vises ikke på forsiden før de har fått nok organisk engasjement fra andre innloggede brukere.
* **Vekting via Graph Edges:** Koblingen mellom et spørsmål og en akse lagres som en relasjon i `graph_edges`. Når brukere "stemmer opp" at et spørsmål tilhører en spesifikk akse, øker feltet `confidence`. SpacetimeDB bruker denne `confidence`-scoren som multiplikator i match-algoritmen.

## 5. Visualisering & Sidelinja Explorer

### 5.1 Innhenting av referansedata
For å bygge nøyaktige velgerkart, introduseres et valgfritt spørsmål før resultatet vises: *"For å kalibrere landskapet: Hva stemte du ved forrige valg?"*. Dette knytter den anonyme eller innloggede sesjonen mot en parti-`aktør` i Kunnskapsgrafen.

### 5.2 Sidelinja Explorer (Offentlig Graf)
En egen SvelteKit-side der lekfolk og journalister kan analysere dataene.
* **Heatmaps:** Viser klynger ("skyer") av velgermasser basert på partipreferanse på to valgfrie akser.
* **Varder i landskapet:** Historiske figurer (Churchill, Stalin), nåværende politikere og Sidelinjas egne verter ligger inne som faste referansepunkter i grafen. AI-estimerte profiler gir pedagogisk (og underholdende) kontekst.

## 6. AI & Asynkron Prosessering
Av kostnads- og ytelseshensyn skjer all AI-bruk asynkront i backend via jobbkøen og `ai-gateway` (LiteLLM). Ingen live AI-kall i klienten.

* **AI-Kandidater (`valgomat_generate_profile`):** En bakgrunnsjobb analyserer partiprogrammer via `sidelinja/rutine` (Gemini) og genererer "syntetiske" referanseprofiler for listekandidater og historiske figurer. Kandidater kan senere logge inn (Authentik) og overstyre AI-ens svar manuelt.
* **Semantisk deduplisering (`valgomat_moderation`):** En asynkron jobb overvåker nye brukerskapte akser og spørsmål, slår sammen duplikater (f.eks. "Skattetrykk" og "Skattenivå"), og flagger emosjonelt ladede spørsmål for Sidelinja-redaksjonen i Redaksjonens chat.

## 7. Arkitektur & Ansvarsfordeling

| Komponent | Rolle / Ansvar |
|---|---|
| **SvelteKit (Klient)** | UX, Anonym UUID-håndtering i `localStorage`, kortstokk-swipe-grensesnitt, "lazy loading" av baksiden ved innlogging, visning av Heatmaps via Sidelinja Explorer. |
| **Authentik** | SSO for brukere, kandidater og redaksjon. Sammenfletting av anonym UUID med ekte bruker-ID. |
| **SpacetimeDB** | Sanntids match-kalkulering (Rust Reducers), swiping-logikk, PCA-beregning, og "Multiplayer"-rom ("Sofagruppa"). Holder kun aktive sesjoner i minnet. |
| **PostgreSQL** | Kunnskapsgrafen (`nodes`, `graph_edges`). Permanent lagring av spørsmål, akser, versjonshistorikk og aggregerte data for Sidelinja Explorer. |
| **Rust Worker (Sync)** | Synkroniserer batcher av svar fra SpacetimeDB over til PostgreSQL-lagringen via standard sync-mekanismen (se `synkronisering.md`). |
| **Rust Worker (AI)** | Kjører `valgomat_generate_profile` og `valgomat_moderation` via jobbkøen. |

## 8. Dataklassifisering (ref. docs/arkitektur.md 2.2)

| Data | Kategori | Detaljer |
|---|---|---|
| Spørsmål, akser, versjonshistorikk | Kritisk (PG) | Brukergenerert innhold, krever backup |
| Individuelle svar (aggregert) | Kritisk (PG) | Synket fra SpacetimeDB |
| Aktive sesjoner, live PCA-state | Flyktig (SpacetimeDB) | Tåler tap — bruker svarer på nytt |
| AI-genererte kandidatprofiler | Avledet (PG) | Kan regenereres fra partiprogrammer |

## 9. Skaleringsrisiko

### PCA i SpacetimeDB
PCA-beregning i SpacetimeDB er minnekrevende og udokumentert for store datasett. Ved tusenvis av samtidige brukere med 50+ akser kan minnebruken eksplodere. Tiltak:
- **Materialized Views i PostgreSQL** for Sidelinja Explorer — aldri kjør tunge aggregeringer on-the-fly. Oppdater views via nattlig jobb eller etter batch-sync fra SpacetimeDB.
- **Batch-sync med størrelsesbegrensning** — sync_outbox kan bli bottleneck ved høy svaraktivitet. Vurder dedikert sync-frekvens for valgomat-data.
- **PCA-fallback i PG** — hvis SpacetimeDB-minnebruk overstiger terskel, flytt PCA-beregning til PostgreSQL (via `pg_stat_statements` for kostnadsmåling) med lengre oppdateringsintervall.
- **Overvåk tidlig** — legg til valgomat-spesifikke metrikker i `/admin/observability` (antall aktive sesjoner, PCA-beregningstid, SpacetimeDB-minnebruk for valgomat-tabeller).

## 10. Instruks for Claude Code
1. **Datamodell:** Utvid enum `node_type` i PostgreSQL med `valgomat_question` og `valgomat_axis`. Bruk `graph_edges` med `relation_type = 'AFFECTS_AXIS'` og oppdater `confidence`-feltet for å håndtere crowdsourcet vekting av spørsmål opp mot ulike akser.
2. **SpacetimeDB Reducers:** Implementer innkommende events som `SubmitSwipe`, `SuggestAxis`, og match-algoritmen i Rust inne i SpacetimeDB. Pass på at reducere støtter anonym `session_id`.
3. **State Management:** SvelteKit skal ikke kreve innlogging for forsiden. Implementer Auth-guards slik at opprettelse av spørsmål, stemmegivning på andres forslag og visning av kommentarer gir `403 Forbidden` for uautoriserte og trigger Authentik-flyten.
4. **Explorer API:** Bygg aggregerte Materialized Views i PostgreSQL for Sidelinja Explorer for å unngå tung on-the-fly kalkulering av Heatmaps over millioner av rader.
5. **Jobbtyper:** Registrer `valgomat_generate_profile` og `valgomat_moderation` som jobbtyper i jobbkøen (se `jobbkø.md`). Bruk `sidelinja/rutine` som modellalias.
6. **Versjonering:** Spørsmål er append-only. Nye versjoner er nye rader med referanse til forgjengeren — aldri `UPDATE` på tekst som har mottatt svar.


================================================================
FILE: docs/features/brukerinnstillinger.md
================================================================

# Feature: Brukerinnstillinger
**Filsti:** `docs/features/brukerinnstillinger.md`

## 1. Konsept
Hver bruker har personlige innstillinger som styrer hvordan appen ser ut og oppfører seg. Innstillingene påvirker kun visning og opplevelse — aldri innholdet. Tilgjengelig via et innstillingspanel i appen.

## 2. Innstillinger

### 2.1 Utseende

| Innstilling | Default | Alternativer | Beskrivelse |
|---|---|---|---|
| Tema | System | Lys / Mørk / System | Følger OS-preferanse som default |
| Skriftstørrelse | 16px | 12–24px (slider) | Gjelder all tekst i appen |
| Linjehøyde | 1.6 | 1.4–2.0 (slider) | Avstand mellom linjer |
| Innholdsbredde | 70ch | 50ch–full (slider eller presets) | Maks bredde på tekstinnhold |
| Font | System default | Serif / Sans-serif / Monospace | Brødtekst-font i appen |
| Redusert bevegelse | System | Av / På / System | Respekterer `prefers-reduced-motion` |
| Kompakt modus | Av | Av / På | Mindre padding, tettere layout |

### 2.2 Editor

| Innstilling | Default | Alternativer | Beskrivelse |
|---|---|---|---|
| Standard visning | Rendered | Raw / Rendered | Foretrukket editor-modus |
| Stavekontroll | På | Av / På | Nettleserens innebygde stavekontroll |
| Auto-save intervall | 500ms | 500ms–5s | Debounce for auto-save |
| Vis tegnteller | Av | Av / På | Antall tegn/ord i bunn av editor |

### 2.3 Notifikasjoner (fremtidig)

| Innstilling | Default | Alternativer | Beskrivelse |
|---|---|---|---|
| Desktop-varsler | Av | Av / På | Push-notifikasjoner i nettleseren |
| Lyd | Av | Av / På | Lydvarsling ved nye meldinger |
| Varsle ved mention | På | Av / På | Varsle når noen `#`-mentioner noe du følger |

### 2.4 Tilgjengelighet

| Innstilling | Default | Alternativer | Beskrivelse |
|---|---|---|---|
| Høy kontrast | Av | Av / På | Sterkere fargekontraster |
| Fokus-synlighet | System | System / Forsterket | Tydeligere fokus-indikatorer |

## 3. Datamodell

Innstillingene lagres i en JSONB-kolonne på `users`-tabellen:

```sql
ALTER TABLE users ADD COLUMN settings JSONB NOT NULL DEFAULT '{}';
```

Eksempel:
```json
{
  "theme": "dark",
  "font_size": 18,
  "line_height": 1.7,
  "content_width": "80ch",
  "font_family": "serif",
  "reduced_motion": "system",
  "compact_mode": false,
  "editor_default_view": "raw",
  "spellcheck": true,
  "autosave_ms": 500,
  "show_char_count": true
}
```

**Hvorfor `users.settings` og ikke en egen tabell?**
- Innstillingene er per bruker, ikke per samlings-node
- JSONB er fleksibelt — nye innstillinger legges til uten migrering
- Ingen relasjoner, ingen spørringer mot enkeltfelt — bare hent hele objektet

**Samlings-node-spesifikke overrides** kan legges som edges med metadata mellom bruker og samlings-node ved behov.
Men dette er fase 2. Start med globale brukerinnstillinger.

## 4. Frontend

### 4.1 Innstillingspanel
Tilgjengelig via brukermenyen (avatar → "Innstillinger") eller hurtigtast.

```
┌─────────────────────────────────────┐
│ Innstillinger                    ✕  │
├─────────────────────────────────────┤
│                                     │
│ Utseende                            │
│ ┌─────────────────────────────────┐ │
│ │ Tema        [Lys] [Mørk] [Auto]│ │
│ │ Skrift      ──●────────── 18px │ │
│ │ Linjehøyde  ────●──────── 1.7  │ │
│ │ Bredde      ──────●────── 70ch │ │
│ │ Font        [Sans] [Serif] [Mono]│
│ └─────────────────────────────────┘ │
│                                     │
│ Editor                              │
│ ┌─────────────────────────────────┐ │
│ │ Standard    [Raw] [Rendered]    │ │
│ │ Stavekontroll        [●]       │ │
│ │ Tegnteller           [ ]       │ │
│ └─────────────────────────────────┘ │
│                                     │
│ Live forhåndsvisning:               │
│ ┌─────────────────────────────────┐ │
│ │ Dette er en prøvetekst som      │ │
│ │ viser hvordan innstillingene    │ │
│ │ påvirker visningen.             │ │
│ └─────────────────────────────────┘ │
│                                     │
│              [Tilbakestill default]  │
└─────────────────────────────────────┘
```

Endringer appliseres øyeblikkelig (live preview). Ingen "lagre"-knapp — auto-save med debounce.

### 4.2 CSS Custom Properties

Innstillingene appliseres via CSS custom properties på `:root`:

```css
:root {
  --user-font-size: 16px;
  --user-line-height: 1.6;
  --user-content-width: 70ch;
  --user-font-family: system-ui, sans-serif;
}
```

Alle komponenter bruker disse variablene i stedet for hardkodede verdier:

```css
.message-body {
  font-size: var(--user-font-size);
  line-height: var(--user-line-height);
  max-width: var(--user-content-width);
}
```

### 4.3 Svelte Store

```typescript
// $lib/stores/userSettings.ts
export const userSettings = writable<UserSettings>(defaults);

// Ved innlogging: hent fra API
// Ved endring: debounce → PATCH /api/user/settings
// CSS variables oppdateres reaktivt
```

## 5. API

| Metode | Sti | Beskrivelse |
|---|---|---|
| GET | `/api/user/settings` | Hent brukerens innstillinger |
| PATCH | `/api/user/settings` | Oppdater innstillinger (merge med eksisterende) |

PATCH gjør en shallow merge: `settings = settings || patch`. Kun oppgitte felt endres.

## 6. Viktige prinsipper

- **Innstillinger påvirker aldri innhold.** En bruker med 24px skrift og en bruker med 12px skrift ser identisk innhold. Publiserte artikler for eksterne lesere bruker typografi-stacken fra artikkel-publisering, ikke forfatterens personlige innstillinger.
- **Defaults er gode.** En bruker som aldri åpner innstillingspanelet skal ha en god opplevelse. Innstillinger er for tilpasning, ikke for at ting skal fungere.
- **Ingen overraskelser.** Endringer gjelder kun brukeren som gjør dem. Workspace-admin kan ikke overstyre personlige innstillinger.
- **Progressiv avsløring.** Start med de viktigste innstillingene (tema, skriftstørrelse). Legg til flere etter hvert som behov oppstår.

## 7. Instruks for Claude Code
- Innstillinger lagres i `users.settings` JSONB — hent hele objektet, merge ved oppdatering
- Bruk CSS custom properties for all visuell tilpasning — aldri hardkodede verdier i komponenter
- Innstillingspanelet er en modal/drawer, ikke en egen side
- Auto-save med debounce (500ms) — ingen "lagre"-knapp
- Ved nye innstillinger: legg til i defaults-objektet, ingen migrering nødvendig


================================================================
FILE: docs/features/canvas_primitiv.md
================================================================

# Feature: Canvas-primitiv — felles fritt-canvas underlag
**Filsti:** `docs/features/canvas_primitiv.md`

## 1. Konsept

Canvas-primitivet er den felles underliggende komponenten for alle friform-views i Sidelinja: whiteboard (tegning), storyboard (kort-canvas), og fremtidige canvas-baserte visninger. Det håndterer kamera (pan, zoom), viewport-styring, objekt-plassering og interaksjon — men vet ingenting om *hva* som rendres.

### 1.1 Hvorfor et felles primitiv?

Whiteboard og storyboard har identisk infrastruktur-behov:
- Uendelig canvas med pan og zoom
- Objekter med `(x, y)`-posisjon
- Drag-and-drop av objekter
- Viewport culling (ikke render det som er utenfor synsfeltet)
- Touch-støtte (pinch-zoom, to-finger-pan)
- Responsivt design (fungerer på mobil, tablet, desktop)

Forskjellen er *innholdet*: whiteboard rendrer streker/figurer, storyboard rendrer meldingsboks-kort. Primitivet abstraherer det felles, slik at begge views gjenbruker 100 % av canvas-logikken.

### 1.2 Arkitekturprinsipp

```
Canvas-primitiv (felles)
├── Kamera: pan, zoom, transform matrix
├── Viewport: culling, synlige objekter
├── Interaksjon: pointer events, touch, drag
├── Grid: valgfri snap, hjelpelinje
└── Render-delegering: slot/callback for innhold

Whiteboard (consumer)
├── Tegneverktøy: penn, linje, rektangel, tekst
├── Strøk-modell: SVG paths / canvas paths
└── SpacetimeDB: strøk-synkronisering

Storyboard (consumer)
├── Kort-rendering: <MessageBox> i kompakt modus
├── Status-modell: Klar / Tatt opp / Droppet / Arkivert
├── Portal-soner: overføringsmekanikk til andre blokker
└── SpacetimeDB: kort-posisjon + status-synkronisering
```

## 2. Kamera-modell

### 2.1 Transform

Kameraet representeres som en 2D affin transformasjon:

```typescript
interface Camera {
    x: number;      // pan offset X (world coords)
    y: number;      // pan offset Y (world coords)
    zoom: number;   // scale factor (1.0 = 100%)
}
```

Rendring via CSS `transform` på en wrapper-div:

```css
.canvas-world {
    transform: translate(calc(var(--cam-x) * 1px), calc(var(--cam-y) * 1px))
               scale(var(--cam-zoom));
    transform-origin: 0 0;
}
```

### 2.2 Zoom-begrensning

- Min zoom: `0.1` (10 % — fugleperspektiv, brukes av Pinboard Mode)
- Max zoom: `3.0` (300 % — detalj)
- Default: `1.0`
- Zoom pivoterer rundt musepeker/finger-midtpunkt

### 2.3 Pan

- **Desktop:** Hold mellomknapp eller mellomrom + dra. Alternativt: to-finger-drag på trackpad.
- **Touch:** To-finger-pan (én finger = dra objekter, to fingre = pan).
- **Edge-pan:** Når man drar et objekt nær kanten av viewport, scroller canvaset automatisk i den retningen.

## 3. Viewport Culling

Bare objekter som overlapper med det synlige viewport-rektangelet rendres i DOM. For storyboard med 50–200 kort er dette en optimalisering som holder DOM-et lett.

```typescript
function visibleObjects(objects: CanvasObject[], camera: Camera, viewportSize: { w: number, h: number }): CanvasObject[] {
    const worldRect = screenToWorld(camera, viewportSize);
    return objects.filter(obj => intersects(obj.bounds, worldRect));
}
```

En margin (f.eks. 200px i world-space) legges til for å unngå pop-in ved pan.

## 4. Objektmodell

Canvas-primitivet opererer på generiske objekter:

```typescript
interface CanvasObject {
    id: string;
    x: number;
    y: number;
    width: number;
    height: number;
    // Consumer-spesifikk data håndteres via generics/props
}
```

Consumer (whiteboard, storyboard) bestemmer *hva* som rendres for hvert objekt via en render-callback eller Svelte snippet:

```svelte
<Canvas objects={cards} let:object>
    <!-- Consumer bestemmer innholdet -->
    <StoryboardCard card={object} />
</Canvas>
```

## 5. Interaksjon

### 5.1 Pointer events

All interaksjon håndteres via pointer events (unified mouse + touch):

| Gest | Desktop | Touch | Handling |
|------|---------|-------|----------|
| Pan | Mellomknapp-drag / Space+drag | To-finger-drag | Flytt kamera |
| Zoom | Scroll wheel | Pinch | Zoom inn/ut |
| Velg | Klikk | Tap | Velg objekt |
| Flytt | Venstreklikk-drag på objekt | Én-finger-drag på objekt | Flytt objekt |
| Multi-select | Shift+klikk / lasso | Lang-trykk + drag | Velg flere |

### 5.2 Snap-to-grid (valgfri)

Når aktivert, snapper objekter til et rutenett ved drag-slipp:

```typescript
function snap(value: number, gridSize: number): number {
    return Math.round(value / gridSize) * gridSize;
}
```

Default: av. Kan toggles via hurtigtast eller toolbar.

### 5.3 Seleksjon

- Klikk på tom flate: deselect alle
- Klikk på objekt: velg det (deselect andre)
- Shift+klikk: toggle seleksjon
- Lasso: dra på tom flate uten Space = tegn seleksjonsboks

## 6. Responsivt design

Canvas-primitivet skal fungere på alle skjermstørrelser:

| Skjerm | Tilpasning |
|--------|-----------|
| Desktop (>1024px) | Full interaksjon, alle hurtigtaster |
| Tablet (768–1024px) | Touch-gester, toolbar i bunn |
| Mobil (<768px) | Forenklet toolbar, større treffområder for objekter, ingen lasso |

Touch-treffområder skal være minimum 44x44px (WCAG 2.5.5).

## 7. Fullskjerm-modus (BlockShell-feature)

Enhver blokk i `BlockShell` kan gå i fullskjerm. Dette er en generell feature, ikke spesifikk for canvas:

- **Toggle:** Dobbeltklikk på blokk-headeren, eller knapp i header
- **Implementering:** Blokken settes til `position: fixed; inset: 0; z-index: 50`
- **Escape:** Trykk Esc eller klikk "minimer"-knapp for å gå tilbake
- **URL-state:** Fullskjerm-tilstand lagres ikke i URL — det er en visuell modus, ikke en side

## 8. SpacetimeDB-integrasjon

Canvas-primitivet selv har ingen SpacetimeDB-kobling — det er consumer-ens ansvar. Men primitivet eksponerer events som consumeren kan koble til SpacetimeDB:

```typescript
interface CanvasEvents {
    onObjectMove: (id: string, x: number, y: number) => void;
    onObjectResize: (id: string, w: number, h: number) => void;
    onCameraChange: (camera: Camera) => void;
    onSelectionChange: (ids: string[]) => void;
}
```

Storyboard-consumeren bruker `onObjectMove` til å kalle en SpacetimeDB-reducer for å synkronisere posisjon til andre klienter.

## 9. Bygger på

- **SvelteKit:** Svelte 5 `$state`/`$derived` for reaktiv kamera- og objekt-state
- **CSS transforms:** Ingen Canvas2D eller WebGL — DOM-basert rendering for å beholde Svelte-komponent-rendering inne i objektene
- **Pointer Events API:** Unified input for mus og touch

## 10. Implementeringsstrategi

### Fase 1: Kjerne-primitiv
- `<Canvas>` Svelte-komponent med kamera (pan/zoom), viewport culling, og objekt-drag
- Touch-støtte (pinch-zoom, to-finger-pan)
- BlockShell fullskjerm-toggle

### Fase 2: Storyboard som første consumer
- `<StoryboardCard>` rendrer meldingsboks-kort på canvaset
- SpacetimeDB-synk for posisjon og status
- Portal-soner for overføring

### Fase 3: Whiteboard-migrering
- Migrere eksisterende whiteboard-spec til å bruke canvas-primitivet
- Tegneverktøy som overlay oppå primitivet

## 11. Instruks for Claude Code
- Canvas-primitivet er en ren Svelte-komponent uten backend-avhengigheter
- Bruk CSS transforms, ikke Canvas2D — innholdet inne i objekter er vanlige Svelte-komponenter
- All state styres via Svelte 5 `$state` og `$derived` — ingen external state management
- Pointer events, ikke mouse events — unified input
- Test med touch-emulering i DevTools for responsivitet
- Viewport culling er påkrevd fra dag 1 — ikke optimaliser bort


================================================================
FILE: docs/features/chat.md
================================================================

# Feature: Chat (Channels & Meldinger)
**Filsti:** `docs/features/chat.md`

## 1. Konsept
En universell, sanntids meldingskomponent bygget på SpacetimeDB. Chat er ikke bundet til én kontekst — den kan knyttes til enhver node i Kunnskapsgrafen via **channels**. Ulike konsepter bruker chat med ulik konfigurasjon, men all infrastruktur er delt.

## 2. Channels-modellen
En **channel** er en meldingsstrøm knyttet til en vilkårlig node (parent) i grafen. Channelen er selv en node (`node_type = 'channel'`), noe som betyr at den deltar i grafen og arver tilgangsstyring via `node_access`-matrisen.

```
┌─────────────┐     parent_id      ┌─────────────┐
│   Channel   │ ──────────────────► │ Vilkårlig   │
│   (node)    │                     │ node        │
└──────┬──────┘                     └─────────────┘
       │
       │  channel_id
       ▼
┌─────────────┐
│  Meldinger  │
│  (nodes)    │
└─────────────┘
```

En node kan ha **flere channels**. Eksempler:
- Et Tema har "Diskusjon" (default) + "Research-dump"
- En Episode har "Redaksjonelt" (intern kommentartråd)
- Et Møte har "Scratchpad" (flyktig, TTL)
- En Aktør har "Notater" (valgfri)

### 2.1 Channel-konfigurasjon
Hver channel har en `config` (JSONB) som styrer hvilke capabilities den støtter:

```json
{
  "threads": true,          // reply-to-tråder
  "mentions": true,         // #/@ parsing + automatiske graph_edges
  "attachments": true,      // filopplasting
  "research_clips": true,   // research_clip meldingstype (AI-prosessert)
  "ttl_days": null           // null = permanent, tall = auto-slett etter N dager
}
```

### 2.2 Standard channel-presets per konsept

| Konsept | Channel-navn | Config |
|---|---|---|
| **Redaksjonen** (Tema) | "Diskusjon" | `threads: true, mentions: true, attachments: true, research_clips: true, ttl_days: null` |
| **Redaksjonen** (Tema) | "Research" (valgfri) | `threads: false, mentions: true, attachments: true, research_clips: true, ttl_days: null` |
| **Møterommet** | "Scratchpad" | `threads: false, mentions: false, attachments: false, research_clips: false, ttl_days: 90` |
| **Studioet** | "Studio-chat" | `threads: false, mentions: false, attachments: false, research_clips: false, ttl_days: 30` |
| **Episode** | "Redaksjonelt" | `threads: true, mentions: true, attachments: true, research_clips: false, ttl_days: null` |

Presets er kun defaults — en admin for samlings-noden kan justere config per channel.

### 2.3 Automatisk opprettelse
Når en node opprettes som forventes å ha chat (Tema, Episode, Møte), oppretter systemet automatisk en default-channel. Dette skjer i SvelteKit server-side som en del av node-opprettelsestransaksjonen.

## 3. Meldinger

### 3.1 Datamodell (PostgreSQL)
Meldinger er noder i Kunnskapsgrafen (`node_type = 'melding'`):

```sql
messages (
    id           UUID PK → nodes(id),
    channel_id   UUID NOT NULL → channels(id),
    reply_to     UUID → messages(id),       -- tråder (hvis config.threads = true)
    author_id    TEXT NOT NULL → users,
    message_type message_type,              -- 'text', 'research_clip', 'factoid', 'system'
    body         TEXT NOT NULL,
    metadata     JSONB,                     -- ekstra data (research-klipp AI-resultat, etc.)
    edited_at    TIMESTAMPTZ,
    created_at   TIMESTAMPTZ
)
```

### 3.2 SpacetimeDB (sanntid)
SpacetimeDB holder aktive channels' meldinger i minnet som varm cache foran PG. Ved oppstart gjør worker warmup fra PG → SpacetimeDB (per-kanal konfigurasjon). Nye meldinger sendes via SpacetimeDB-reducers og kringkastes til alle tilkoblede klienter. Synkes til PostgreSQL med ~1 sek forsinkelse (se `docs/infra/synkronisering.md`).

## 4. Mentions & Autocomplete
Kun aktive når `config.mentions = true`.

* **Trigger-tegn:** `#` (Temaer/Aktører fra Kunnskapsgrafen), `@` (brukere/redaksjonsmedlemmer), `/` (kommandoer).
* **Filtrering:** Svelte-klienten filtrerer den lokale SpacetimeDB-cachen umiddelbart. Skriver man `#Ha...` vises en klikkbar liste ("Hans Petter Sjøli", "Høyre").
* **Grafkobling:** Ved `#`-mention opprettes automatisk `MENTIONS`-edges i `graph_edges` mellom meldingen og den nevnte noden.
* **Mobil-optimalisert:** Autocomplete-listen er tappbar og tilpasset mindre skjermer.

## 5. Tråder
Kun aktive når `config.threads = true`. Meldinger kan ha en `reply_to`-referanse. Frontend grupperer meldinger i tråder (rot + svar) med visuell skillelinje mellom hver tråd. Svar vises med innrykk og vertikal linje under rot-meldingen, uten ekstra skillelinje mellom rot og svar.

## 6. Vedlegg
Kun aktive når `config.attachments = true`. Meldinger kan ha vedlegg via `message_attachments` → `media_files`. Whiteboard-eksport kan knyttes som vedlegg.

## 7. Versjonshistorikk
Alle meldinger støtter redigering med full historikk via `message_revisions`. Original tekst bevares alltid. AI-behandlede meldinger har en revisjons-toggle i UI — brukeren kan veksle mellom AI-versjon og original tekst. AI-output rendres som Markdown via `marked`.

## 7.1 Meldingsvisning
Lange meldinger (mer enn 2 linjer) kollapses automatisk med en "Vis mer"-knapp. Ved ekspandering vises "Vis mindre" både over og under meldingen, slik at man slipper å scrolle for å kollapse igjen.

## 8. Tale-til-tekst (Voice-to-text)
Mobilvennlig diktering for situasjoner der tastatur er upraktisk. Brukeren trykker en mikrofon-knapp, snakker, og får teksten tilbake som en vanlig melding klar til redigering og sending.

### 8.1 Flyt
1. Bruker trykker og holder (eller toggler) mikrofon-knappen i chat-feltet.
2. Nettleseren fanger lyd via `MediaRecorder` API (WebM/Opus).
3. Lydklippet sendes til Whisper (`POST /v1/audio/transcriptions`, `response_format=text`, `language=no`) via SvelteKit server-side.
4. Transkripsjonen settes inn i meldingsfeltet — brukeren kan redigere før sending.
5. Ingen lagring av lydfilen — den kastes etter transkripsjon.

### 8.2 Avgrensning
* Dette er **ikke** en lydmelding-feature (à la WhatsApp). Lyden er et transportmiddel for tekst. Kun teksten lagres.
* Whisper-kallet er kort (<30 sek tale) og kan rutes direkte til Whisper-serveren uten jobbkø.
* Bruk `small`-modellen for lav latens. Navnenøyaktighet er mindre viktig for korte chatmeldinger.

## 9. TTL (automatisk opprydding)
Channels med `config.ttl_days` satt til et tall får sine meldinger automatisk slettet av en nattlig jobbkø-jobb. Brukes for flyktige kontekster (scratchpads, studio-chat).

## 10. Implementeringsstatus

### Ferdig (mars 2026)
- **ChatBlock.svelte:** Adapter-mønster via `createChat()` factory. Bruker `chat.edit()`, `chat.delete()`, `chat.react()` — ingen direkte PG API-kall.
- **SpacetimeDB-adapter (`spacetime.svelte.ts`):** Ren SpacetimeDB-adapter. All data fra SpacetimeDB (historikk via warmup + sanntid). Reaksjoner fra `message_reaction`-tabellen.
- **PG-adapter (`pg.svelte.ts`):** Polling hvert 3. sek. Readonly fallback når SpacetimeDB ikke er konfigurert.
- **Factory (`create.svelte.ts`):** Velger adapter basert på `VITE_SPACETIMEDB_URL`. SSR-safe med `browser`-guard.
- **Shared types (`types.ts`):** `ChatConnection` interface med `send`, `edit`, `delete`, `react`, `readonly`.
- **SpacetimeDB Rust-modul (`spacetimedb/src/lib.rs`):** `ChatMessage`, `MessageReaction`, `SyncOutbox`-tabeller. Reducers: `send_message`, `delete_message`, `edit_message`, `add_reaction`, `remove_reaction`, `load_messages`, `load_reactions`, `clear_channel`, `mark_synced`.
- **Worker warmup (`worker/src/warmup.rs`):** PG → SpacetimeDB ved oppstart. Per-kanal konfig (all/messages/days/none). Trådbasert henting.
- **Worker sync (`worker/src/sync.rs`):** SpacetimeDB → PG hvert sekund. Insert/delete/update meldinger + reaksjoner.
- **Admin-side (`/admin/channels`):** Per-kanal warmup-konfigurasjon.
- **Tråder:** Komplett trådvisning med datogruppering, autoscroll og visuell skillelinje mellom tråder.
- **Reaksjoner:** Via SpacetimeDB-reducers, synket til PG.
- **Meldingskollaps:** Lange meldinger begrenses til 2 linjer med "Vis mer"/"Vis mindre".
- **AI-behandling:** Meldinger kan AI-behandles (✨-knapp). Revisjons-toggle viser original vs. AI-versjon. Markdown-rendering for AI-output.
- **Konvertering:** Meldinger kan opprettes som kanban-kort eller kalenderhendelse (dialog sier "Opprett", ikke "Konverter" — meldingen beholdes i chatten).

### Gjenstår
- **Vedlegg, TTL** — avventer implementering.
- **Tilgangsfiltrering:** SpacetimeDB-laget må filtrere basert på `node_access`-matrisen.
- **Pin/konvertering:** Går fortsatt direkte til PG API (ikke via SpacetimeDB).

## 11. Instruks for Claude Code
* **Opprettelsesrekkefølge:** Opprett `nodes`-rad → `channels`-rad → (for meldinger) `nodes`-rad → `messages`-rad. Alt i én transaksjon.
* **Channel-opprettelse:** Når en Tema, Episode eller Møte opprettes, opprett alltid en default-channel i samme transaksjon.
* **Mentions-parsing:** Skjer i sync-workeren ved persistering til PG. Parser mention-UUIDs fra HTML body og oppretter `graph_edges`.
* **Config-respekt:** Frontend-komponenten må lese `channel.config` og slå av/på UI-elementer. `channels.config` inneholder også `warmup_mode`/`warmup_value` for SpacetimeDB-oppvarming.
* **PG er autoritativ** — SpacetimeDB er varm cache. Frontend snakker kun med SpacetimeDB.
* **Tilgang styres via `node_access`-matrisen.** Channels arver tilgang fra sin parent-node via edges.


================================================================
FILE: docs/features/kalender.md
================================================================

# Feature: Kalender
**Filsti:** `docs/features/kalender.md`

## 1. Konsept
Månedsbasert kalendervisning for redaksjonell planlegging. Hendelser er nodes i kunnskapsgrafen og kan kobles til episoder, temaer, aktører og kanban-kort. Komplementerer Kanban ("hva" vs "når").

## 2. Status
**PG-adapter ferdig og deployet (mars 2025).** Abonnement, ICS-eksport og SpacetimeDB-sync gjenstår.

### Implementert
- Migrering `0003_calendar.sql`: `calendars` + `calendar_events` (begge FK→nodes)
- Hendelser er nodes — tilgangsstyrt via `node_access`-matrise
- Heldagshendelser (`T12:00:00` for tidssone-trygghet) vs. tidshendelser med klokkeslett
- Fargekoder per hendelse (7 forhåndsdefinerte) + standard kalenderfarge
- REST API: GET med tidsvindu-filtrering, POST/PATCH/DELETE hendelser
- PG polling-adapter med 5 sek intervall
- CalendarBlock.svelte: månedsrutenett, navigering, opprett/rediger-modal, Escape-lukking
- `linked_node`-kolonne for fremtidig kobling til kanban-kort, episoder etc.

### Gjenstår — Fase 2
- Kobling til kanban-kort (vis deadline på kalender)
- Uke- og dagsvisning
- Gjentakende hendelser (RRULE)
- Dra-og-slipp for å flytte hendelser mellom datoer
- Flerdag-hendelser (vises over flere celler)
- Abonnementsmodell (kalender → kalender via graph_edges)
- Personlige vs. delte kalendere (via samlings-noder)
- ICS/CalDAV-eksport
- SpacetimeDB-modul + hybrid-adapter
- Varsler/påminnelser via jobbkøen

## 3. Datamodell (implementert)

```sql
CREATE TABLE calendars (
    id          UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    parent_id   UUID NOT NULL REFERENCES nodes(id),
    name        TEXT NOT NULL,
    color       TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE calendar_events (
    id            UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    calendar_id   UUID NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
    title         TEXT NOT NULL,
    description   TEXT,
    starts_at     TIMESTAMPTZ NOT NULL,
    ends_at       TIMESTAMPTZ,
    all_day       BOOLEAN NOT NULL DEFAULT false,
    color         TEXT,
    linked_node   UUID REFERENCES nodes(id) ON DELETE SET NULL,
    created_by    TEXT REFERENCES users(authentik_id),
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

Indekser: `(calendar_id)` og `(calendar_id, starts_at)` for effektiv tidsvindu-filtrering.

## 4. API-endepunkter

| Metode | Sti | Beskrivelse |
|---|---|---|
| GET | `/api/calendar/[calendarId]?from=...&to=...` | Hent kalender med hendelser i tidsvindu |
| POST | `/api/calendar/[calendarId]/events` | Opprett hendelse |
| PATCH | `/api/calendar/[calendarId]/events/[eventId]` | Oppdater hendelse |
| DELETE | `/api/calendar/[calendarId]/events/[eventId]` | Slett hendelse |

## 5. Brukes av

| Konsept | Bruk |
|---|---|
| Redaksjonen | Innspillingsdatoer, publiseringsplan, deadlines |
| Foreningen Liberalistene | Styremøter, arrangementer |
| Møterommet (fremtidig) | Møteplanlegging |

## 6. Tidssone-håndtering
- Heldagshendelser lagres som `T12:00:00` (middag) — unngår datoforskyvning ved UTC-konvertering
- Tidshendelser konverteres til ISO via `new Date().toISOString()` (lokal → UTC)
- Visning bruker `new Date()` som konverterer tilbake til lokal tid

## 7. Fremtidig: Abonnementsmodell
Kalendere kan abonnere på andre kalendere via `SUBSCRIBES_TO`-edge i grafen. Abonnenten ser hendelser read-only.

| Type | Eier | Synlighet |
|------|------|-----------|
| Samlings-node | Admin for samlings-noden | Alle med tilgang via `node_access` |
| Personlig | Bruker | Kun eier + eksplisitt deling |
| Offentlig | Samlings-node | Alle som abonnerer |

## 8. Fremtidig: ICS-eksport
Offentlige kalendere får en ICS-URL (`/cal/{calendar_id}.ics`) for Google Calendar, Apple Calendar etc.

## 9. Instruks for Claude Code
* Bruk `eventsForDate()` med lokal dato-konvertering, ikke UTC-substring
* Heldagshendelser: `T12:00:00`, aldri `T00:00:00` (tidssone-felle)
* Tilgang styres via `node_access`-matrisen
* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi


================================================================
FILE: docs/features/kanban.md
================================================================

# Feature: Kanban (Planlegging)
**Filsti:** `docs/features/kanban.md`

## 1. Konsept
Et drag-and-drop Kanban-brett for planlegging. Primært brukt til episodeplanlegging i Redaksjonen, men også mottaker av AI-genererte action points fra Møterommet.

## 2. Status
**PG-adapter ferdig og deployet (mars 2025).** SpacetimeDB-sync gjenstår.

### Implementert
- Migrering `0002_kanban.sql`: `kanban_boards`, `kanban_columns`, `kanban_cards`
- Kanban-kort er nodes i kunnskapsgrafen (tilgangsstyrt via `node_access`-matrise)
- REAL-posisjon for midpoint-innsetting (`(1.0 + 2.0) / 2 = 1.5`) — ingen re-nummerering
- REST API: GET brett, POST kolonne/kort, PATCH kort/flytt, DELETE kort
- PG polling-adapter (`pg.svelte.ts`) med 5 sek intervall og optimistisk UI
- Adapter-factory (`create.svelte.ts`) — klar for SpacetimeDB-hybrid
- KanbanBlock.svelte: drag & drop, redigeringsmodal (tittel/beskrivelse/slett), enkelt kort-input som legger til i første kolonne

### Gjenstår
- SpacetimeDB-modul + hybrid-adapter for sanntidsoppdatering
- Reposisjonering ved dra innad i kolonne (sortert rekkefølge)
- Tildeling (assignee) UI
- Fargekoder/labels på kort
- AI-integrasjon: møtereferent → nye kort

## 3. Datamodell

```
kanban_boards (id FK→nodes, parent_id FK→nodes, name)
kanban_columns (id, board_id FK→kanban_boards, name, color, position REAL)
kanban_cards (id FK→nodes, column_id FK→kanban_columns, title, description, assignee_id, position REAL, created_by, created_at)
```

Kort og brett er nodes — tilgang styres via `node_access`-matrisen.

## 4. API-endepunkter

| Metode | Sti | Beskrivelse |
|---|---|---|
| GET | `/api/kanban/[boardId]` | Hent brett med kolonner og kort |
| POST | `/api/kanban/[boardId]/columns` | Opprett kolonne |
| POST | `/api/kanban/[boardId]/cards` | Opprett kort (oppretter node + kort) |
| PATCH | `/api/kanban/[boardId]/cards/[cardId]` | Oppdater tittel/beskrivelse |
| PATCH | `/api/kanban/[boardId]/cards/[cardId]/move` | Flytt kort til kolonne/posisjon |
| DELETE | `/api/kanban/[boardId]/cards/[cardId]` | Slett kort (cascader fra node) |

## 5. Brukes av

| Konsept | Bruk |
|---|---|
| Redaksjonen | Episodeplanlegging — dra Temaer inn i Kjøreplanen |
| Møterommet | AI-referenten foreslår nye kort basert på action points |
| Foreningen Liberalistene | Styreoppgaver (Å gjøre / Pågår / Ferdig) |

## 6. Instruks for Claude Code
* Bruk native HTML5 Drag and Drop i SvelteKit, unngå tunge biblioteker.
* PG-adapter er autoritativ inntil SpacetimeDB-sync er på plass.
* Tilgang styres via `node_access`-matrisen.
* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi.


================================================================
FILE: docs/features/kunnskaps_bridge.md
================================================================

# Feature: Kunnskaps-Bridge (Cross-Context Discovery)
**Filsti:** `docs/features/kunnskaps_bridge.md`

> **NB:** Dette dokumentet er en skisse fra v1 og må oppdateres til node/edge-modellen ved implementering.

## 1. Konsept
En valgfri, opt-in feature som lar brukere oppdage semantisk beslektet kunnskap på tvers av samlings-noder de har tilgang til. Bryter ikke tilgangsstyringen — resultatet er en *peker* ("dette finnes i Podcast B"), ikke selve innholdet. Brukeren må ha tilgang til begge samlings-nodene via `node_access` for å se treffet.

## 2. Avgrensning: Hva dette IKKE er
- **Ikke datadeling.** Ingen data kopieres mellom samlings-noder. Bridge viser kun at en relevant node *finnes*.
- **Ikke automatisk.** Begge samlings-noder må ha Bridge eksplisitt aktivert av en admin.
- **Ikke synlig for gjester.** Kun for brukere med tilgang til begge samlings-nodene.
- **Ikke et søk i andres data.** Du ser bare treff i samlings-noder du allerede har tilgang til.

## 3. Teknisk arkitektur

### 3.1 Vector Embeddings (pgvector)
Krever `pgvector`-extension i PostgreSQL:

```sql
CREATE EXTENSION IF NOT EXISTS vector;

ALTER TABLE actors ADD COLUMN embedding vector(768);
ALTER TABLE topics ADD COLUMN embedding vector(768);
ALTER TABLE factoids ADD COLUMN embedding vector(768);

CREATE INDEX idx_actors_embedding ON actors USING ivfflat (embedding vector_cosine_ops);
CREATE INDEX idx_topics_embedding ON topics USING ivfflat (embedding vector_cosine_ops);
CREATE INDEX idx_factoids_embedding ON factoids USING ivfflat (embedding vector_cosine_ops);
```

### 3.2 Embedding-generering (`generate_embeddings`)
En jobbkø-jobb som genererer embeddings for nye/endrede noder:
1. Rust-worker plukker opp jobben fra jobbkøen.
2. Bygger en tekst-representasjon av noden (navn, body, tilknyttede faktoider).
3. Sender til AI Gateway (`sidelinja/rutine`) for embedding-generering.
4. Lagrer vektoren i pgvector-kolonnen.
5. Re-genereres ved vesentlige endringer av noden.

### 3.3 Cross-context søk
Når en bruker utforsker en node (f.eks. Tema "Skolepolitikk"):
1. SvelteKit server-side henter brukerens tilgjengelige samlings-noder fra `node_access`-matrisen.
2. Kjører et similarity-søk med `<=>` (cosine distance), filtrert mot brukerens tilgjengelige noder:
   ```sql
   SELECT na.node_id, e.name, e.embedding <=> $target_embedding AS distance
   FROM entities e
   JOIN nodes n ON e.id = n.id
   JOIN node_access na ON n.id = na.node_id
   WHERE na.user_id = $current_user
     AND n.id != $current_node_id
     AND e.embedding <=> $target_embedding < 0.3
   ORDER BY distance
   LIMIT 10;
   ```
3. Resultatet vises som en diskret "Finnes også i..."-seksjon i UI-et.

### 3.4 Samlings-node config
Bridge aktiveres per samlings-node i nodens metadata (JSONB):
```json
{
  "bridge_enabled": true,
  "bridge_discoverable": true
}
```
- `bridge_enabled` — samlings-noden kan søke i andre samlings-noder
- `bridge_discoverable` — andre samlings-noder kan finne noder under denne

Begge må være `true` for at en kobling skal vises.

## 4. Dataklassifisering

| Data | Kategori | Detaljer |
|---|---|---|
| Embedding-vektorer | Avledet (PG) | Kan regenereres fra nodeinnhold |

## 5. Instruks for Claude Code
* pgvector er en ny avhengighet — dokumenter i docker-compose og setup-guides.
* Cross-context søk bruker `node_access`-matrisen for tilgangsstyring. Isolér denne koden grundig — egen funksjon, aldri gjenbruk i andre kontekster.
* Embedding-dimensjon (768) bør matche modellen som brukes. Konfigurér som konstant, ikke hardkod overalt.
* Jobbtype `generate_embeddings` bruker `sidelinja/rutine` som modellalias.
* Bridge er **Lag 4+** — krever fylt kunnskapsgraf i minst to samlings-noder.


================================================================
FILE: docs/features/kunnskapsgraf_og_relasjoner.md
================================================================

# Feature Spec: Kunnskapsgraf og Relasjoner (Logseq-modell)
**Filsti:** `docs/features/kunnskapsgraf_og_relasjoner.md`

## 1. Konsept
Inspirert av verktøy som Logseq og Obsidian, bygger vi databasen som en toveis-lenket graf. Målet er å skape "serendipity" (lykketreff) i research-fasen ved å synliggjøre uventede forbindelser. Hvis Aktør A og Aktør B begge er nevnt i samme chat-tråd eller knyttet til samme Tema over tid, skal systemet kunne visualisere denne røde tråden for programlederne.

## 2. Arkitektur og Teknologivalg
Vi unngår tunge, dedikerte grafdatabaser (som Neo4j) for å holde infrastrukturen og ressursbruken (RAM) minimal.
* **Valgt teknologi:** Vanilla PostgreSQL.
* **Mekanisme:** En "Nodes and Edges" (Noder og Kanter) tabellstruktur kombinert med Recursive CTEs (Common Table Expressions) i SQL for å traversere grafen. Dette er mer enn raskt nok for redaksjonelle datamengder (100k+ noder).

## 3. Datastruktur

### 3.1 Supertabell: `nodes`
Alle entiteter i systemet arver sin UUID fra én sentral tabell. Dette gir ekte Foreign Key-integritet på `graph_edges` uten applikasjonslogikk-hacks.

```sql
CREATE TYPE node_type AS ENUM (
    'entitet',      -- person, organisasjon, sted, tema, konsept (erstatter 'aktør' og 'tema')
    'episode',      -- podcast-episode
    'segment',      -- tidsavgrenset del av episode
    'melding',      -- meldingsboks (chat, kanban-kort, kalenderhendelse, faktoide, notat)
    'channel',      -- gruppering av meldinger
    'kanban_board', -- strukturelt
    'calendar',     -- strukturelt
    'meeting'       -- LiveKit-møte
);

CREATE TABLE nodes (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    node_type    node_type NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

**Merk:** Gamle enum-verdier (`'aktør'`, `'tema'`, `'faktoide'`, `'note'`, `'kanban_card'`, `'calendar_event'`) kan ikke fjernes fra PostgreSQL ENUM, men skal aldri brukes i ny kode.

### 3.2 Detailtabeller
Hver nodetype har sin egen tabell med FK til `nodes`.

#### Entiteter (erstatter `actors` og `topics`)
Alt som kan nevnes med `#` i chat er en entitet — personer, organisasjoner, steder, temaer, konsepter. Én tabell, én autocomplete, én `#`-mekanisme.

```sql
CREATE TABLE entities (
    id        UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    name      TEXT NOT NULL,             -- Autoritativ skrivemåte
    type      TEXT NOT NULL,             -- 'person', 'organisasjon', 'sted', 'tema', 'konsept'
    aliases   TEXT[] DEFAULT '{}',       -- Forkortelser, kallenavn, vanlige feilstavinger
    avatar_url TEXT                      -- Portrett, flagg, logo, kommunevåpen
);

CREATE INDEX idx_entities_name ON entities(name);
CREATE INDEX idx_entities_aliases ON entities USING GIN(aliases);
```

Autocomplete søker i både `name` og `aliases`. `name` er den autoritative formen som vises i UI. Eksempler:
- `name: 'Jonas Gahr Støre'`, `aliases: {'JGS', 'Støre'}`, `type: 'person'`
- `name: 'Arbeiderpartiet'`, `aliases: {'AP', 'Ap', 'DNA'}`, `type: 'organisasjon'`
- `name: 'Lørenskog'`, `aliases: {'Lørenskog kommune'}`, `type: 'sted'`
- `name: 'Skolepolitikk'`, `aliases: {}`, `type: 'tema'`

#### Episoder og segmenter

```sql
CREATE TABLE episodes (
    id           UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    title        TEXT NOT NULL,
    published_at TIMESTAMPTZ,
    guid         TEXT UNIQUE NOT NULL  -- RSS <guid>, aldri endres
);

CREATE TABLE segments (
    id          UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    episode_id  UUID NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
    start_time  INTERVAL NOT NULL,
    end_time    INTERVAL NOT NULL,
    transcript  TEXT,          -- Segmentets transkripsjon
    CONSTRAINT valid_timerange CHECK (end_time > start_time)
);

CREATE INDEX idx_segments_episode ON segments(episode_id);
CREATE INDEX idx_segments_transcript_fts ON segments USING GIN (to_tsvector('norwegian', transcript));
```

### 3.3 Kantene: `graph_edges`
All kobling skjer i én sentral tabell med ekte FK-integritet:

```sql
CREATE TABLE graph_edges (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    source_id     UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    target_id     UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    relation_type TEXT NOT NULL,  -- 'MENTIONS', 'CONTRADICTS', 'WORKS_FOR', 'PART_OF', 'DISCUSSED_IN'
    context_id    UUID REFERENCES nodes(id) ON DELETE SET NULL,
    confidence    REAL CHECK (confidence BETWEEN 0.0 AND 1.0),
    created_by    TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
    origin        TEXT NOT NULL DEFAULT 'system',  -- 'system', 'user', 'ai'
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT no_self_reference CHECK (source_id != target_id),
    CONSTRAINT unique_edge UNIQUE (source_id, target_id, relation_type)
);

CREATE INDEX idx_edges_source ON graph_edges(source_id);
CREATE INDEX idx_edges_target ON graph_edges(target_id);
CREATE INDEX idx_edges_relation ON graph_edges(relation_type);
```

**Merk:** `UNIQUE(source_id, target_id, relation_type)` forhindrer duplikate relasjoner. Bruk `ON CONFLICT DO NOTHING` eller `ON CONFLICT DO UPDATE SET confidence = ...` ved upsert.

**Merk:** `node_type`-enumet kan utvides av feature-spesifikke migrasjoner (f.eks. `valgomat_question`, `valgomat_axis`). Se `docs/concepts/valgomaten.md` for eksempel.

## 4. Segmenter og Transkripsjoner

### 4.1 Segment som grafnode
Episoder deles i **segmenter** — tidsavgrensede deler med egen transkripsjon. Hvert segment er en node i grafen og kan kobles til Temaer, Aktører og Faktoider. Dette muliggjør presise oppslag som: "I Episode 42, fra 14:23 til 21:07, diskuterte dere Skolepolitikk og sa følgende..."

```
Episode 42 (node: episode)
  ├── Segment 00:00-14:22 (node: segment) ──DISCUSSED_IN──► Tema: Mediepolitikk
  ├── Segment 14:23-21:07 (node: segment) ──DISCUSSED_IN──► Tema: Skolepolitikk
  │                                        ──MENTIONS──────► Aktør: Støre
  └── Segment 21:08-45:00 (node: segment) ──DISCUSSED_IN──► Tema: Kommuneøkonomi
```

Kapitler i RSS-feeden genereres fra segmentene, men er et eget konsern (se `docs/concepts/podcastfabrikken.md`).

### 4.2 Transkripsjoner: Git som master, PG som søkeindeks
Transkripsjoner lever i **to steder** med klart eierskap:

| Sted | Rolle | Format |
|---|---|---|
| **Git (Forgejo)** | Kilde til sannhet. Redigerbar, sporbar, diffbar | Markdown med tidsstempler |
| **PostgreSQL** | Søkeindeks. Full-text search, koblet til grafen | Segmentert i `segments`-tabellen |

**Flyt:**
```
Whisper → Git (rå transkripsjon med tidsstempler)
            → Redaksjonen korrigerer manuelt ved behov
            → Push til Forgejo
            → Forgejo webhook trigger 'transcript_reimport'-jobb i jobbkøen
            → Rust-worker parser filen, splitter i segmenter
            → DELETE + INSERT i én PG-transaksjon (idempotent reimport)
            → Grafkoblinger bevares (segment-UUID deterministisk fra episode-UUID + tidsstempel)
```

**Deterministisk UUID for segmenter:** `UUID = uuid_v5(episode_uuid, start_time_ms)`. Dette sikrer at samme segment alltid får samme UUID, selv ved reimport. Grafkoblinger som peker på segmentet overlever dermed en full reimport.

## 5. Arbeidsflyt: Hvordan grafen vokser
Grafen bygger seg opp organisk gjennom daglig bruk av Sidelinja-suiten:
1. **Chat & Notater:** En bruker skriver: *"Apropos #Hans_Petter_Sjøli, hva var greia med #Arbeiderpartiet?"*
2. **Parsing (Svelte/Rust):** Systemet fanger opp de to `#`-taggene (som allerede har UUIDs i `Aktør`-tabellen).
3. **Edge Creation:** SvelteKit server-side oppretter automatisk to nye oppføringer i `graph_edges`-tabellen:
   * [Melding UUID] -> `MENTIONS` -> [Sjøli UUID]
   * [Melding UUID] -> `MENTIONS` -> [Arbeiderpartiet UUID]
4. **Indirekte relasjon:** Fordi begge aktørene nå deler samme `context_id` (meldingen), vet Kunnskapsgrafen at det finnes en tematisk kobling mellom Sjøli og Ap.
5. **Publisering:** Når en episode publiseres, kobles segmentene automatisk til relevante Temaer og Aktører basert på AI-analyse av transkripsjonen.

## 6. Instruks for Claude Code
* **Tilgangsstyring:** Tilgang til noder styres via `node_access`-matrisen (materialized view). Spørringer mot detailtabeller (entities, etc.) filtrerer via JOIN med `node_access`.
* **`nodes`-tabellen er obligatorisk.** Opprett alltid en rad i `nodes` før du inserter i en detailtabell. Bruk en hjelpefunksjon som gjør begge i én transaksjon.
* **`graph_edges`-tilgang.** Tilgang til edges avledes fra tilgang til kilde- og målnoder via `node_access`. `UNIQUE(source_id, target_id, relation_type)` hindrer duplikater — bruk `ON CONFLICT` ved upsert.
* **Graf-spørringer:** Bruk `WITH RECURSIVE` i PostgreSQL når du bygger endepunkter som skal hente ut "Linked Mentions" eller nettverket rundt en spesifikk Aktør opp til 2-3 ledd ut.
* **Fremtidssikring for UI:** Design JSON-responsen slik at den lett kan mates inn i graf-visualiseringsbiblioteker (som D3.js eller Vis.js) i Svelte-frontenden. Formatet bør være `{ "nodes": [...], "edges": [...] }`.
* **Transkripsjon-reimport:** Workeren må være idempotent. Bruk `uuid_v5(episode_uuid, start_time_ms)` for deterministiske segment-UUIDs. Slett og gjenopprett segmenter i én transaksjon, men **ikke** slett edges som peker til segmentene — de overlever fordi UUID-en er stabil. **Merk:** Hvis manuelle korrigeringer endrer segment-grenser (start_time), endres UUID-en. Løsning: automatisk flytt eksisterende edges til nærmeste nye segment basert på tidsintervall-overlapp.
* **Full-text search:** Bruk `to_tsvector('norwegian', transcript)` for norsk språkstøtte i søk.


================================================================
FILE: docs/features/live_ai.md
================================================================

# Feature: Live AI (Faktoid-oppslag & Referent)
**Filsti:** `docs/features/live_ai.md`

## 1. Konsept
AI-drevet analyse av sanntids transkripsjon. Opererer i to moduser med samme underliggende pipeline (Whisper → NER → handling), men ulik output.

## 2. Studio-modus: Faktoid-oppslag
Brukes i Studioet (se `docs/concepts/studioet.md`). En "virtuell co-host" som dytter relevante faktoider til programlederne i sanntid.

### 2.1 Dataflyt
1. Live transkripsjon (se `docs/features/live_transkripsjon.md`) leverer tekst-chunks.
2. Rust-tjenesten analyserer for egennavn (Named Entity Recognition).
3. Lynraskt oppslag i PostgreSQL: `SELECT * FROM factoids JOIN actors... WHERE actor.name = $1`.
4. Treff dytter `LiveFactoidEvent` inn i SpacetimeDB.
5. SvelteKit-studio viser faktoiden lydløst i en egen boks.

### 2.2 Lagring
Live-transkripsjonsloggen er flyktig (TTL 30 dager):

```sql
live_transcription_log (
    id SERIAL,
    session_id UUID,
    chunk_timestamp TIMESTAMPTZ,
    chunk_text TEXT,
    matched_entities TEXT[],
    pushed_factoids UUID[],
    created_at TIMESTAMPTZ DEFAULT now()
)
```

En nattlig jobbkø-jobb sletter rader eldre enn TTL.

## 3. Møte-modus: AI-Referent
Brukes i Møterommet (se `docs/concepts/møterommet.md`). Genererer referat og action points.

### 3.1 Dataflyt
1. **Under møtet:** Whisper transkriberer i chunks. Aha-markører fra deltakerne lagres med tidsstempler.
2. **Ved møteslutt:** En `meeting_summarize`-jobb opprettes i jobbkøen (se `docs/infra/jobbkø.md`).
3. Rust-worker sender transkripsjonen + Aha-markører til AI Gateway (`sidelinja/rutine`) med instruksjon om å generere:
   * Referat (strukturert Markdown)
   * Action points (foreslåtte Kanban-kort)
   * Identifiserte `#Temaer` og `@Aktører`
4. Referatet lagres som melding i relevant Tema-chat. Foreslåtte Kanban-kort vises for godkjenning.
5. `graph_edges` opprettes automatisk mellom møtesegmenter og identifiserte Temaer/Aktører.

### 3.2 Off-the-record
Når off-the-record er aktivt, stopper Whisper-strømmen og ingen data lagres. Visuell indikator i alle deltakeres grensesnitt. Transkripsjonen får et gap i tidslinja.

## 4. Kill Switch
Studio-modus har en **kill switch** — en synlig "Stopp AI"-knapp i studio-grensesnittet som umiddelbart:
1. Stopper NER-analyse av nye chunks
2. Skjuler faktoid-boksen
3. Logger tidspunkt og grunn (manuelt felt, valgfritt)

Nødvendig fordi AI-en kan dytte feil eller irrelevante faktoider under live innspilling. Programlederen må kunne slå den av uten å forlate studio-viewet.

Kill switch-status (`ai_enabled: bool`) lagres på LiveKit-rommet i SpacetimeDB og synkes til alle klienter i rommet.

## 5. Instruks for Claude Code
* Begge moduser deler samme Whisper-pipeline — ikke dupliser transkripsjonskode.
* Studio-modus krever lav latens — hold NER-oppslaget raskt (indekserte spørringer).
* Møte-modus er asynkron — prosessér via jobbkøen etter møteslutt.
* All AI-kode peker på `http://ai-gateway:4000/v1`, aldri direkte til leverandør.
* Kill switch skal alltid være tilgjengelig i studio-modus — default: AI aktivert.
* Tilgang styres via `node_access`-matrisen.


================================================================
FILE: docs/features/live_transkripsjon.md
================================================================

# Feature: Live Transkripsjon (Whisper-pipeline)
**Filsti:** `docs/features/live_transkripsjon.md`

## 1. Konsept
Den felles Whisper-pipelinen som brukes av flere konsepter for å transkribere lyd. Abstraherer bort konfigurasjonsforskjeller (modellvalg, latenskrav) bak et felles grensesnitt.

## 2. Moduser

| Kontekst | Modell | Latenskrav | initial_prompt | Output |
|---|---|---|---|---|
| **Studioet** (live) | `small` | <1s per chunk | Nei (hastighet prioritert) | Flyktig tekst → NER-pipeline |
| **Møterommet** (live) | `small` | <1s per chunk | Nei | Flyktig tekst → AI-referent |
| **Podcastfabrikken** (batch) | `medium` + prompt | Ingen (asynkront) | Ja (navneliste) | SRT → Git → PG |

## 3. Teknisk arkitektur
1. **Lydkilde:** LiveKit server-side hooks (live) eller filopplasting (batch).
2. **Whisper-server:** `fedirz/faster-whisper-server` (Docker, OpenAI-kompatibelt API). Endepunkt: `POST /v1/audio/transcriptions`.
3. **Chunking (live):** Rust-tjeneste mater lyd i ~5-sekunders chunks. `small`-modellen prosesserer ~5x raskere enn sanntid, noe som gir <1s forsinkelse per chunk.
4. **Output:** SRT (batch) eller ren tekst (live).

## 4. Whisper-konfigurasjon
* **Språk:** Sett `language=no` eksplisitt for norsk — unngå auto-detect som kan velge dansk/svensk.
* **`large-v3`** KREVER `vad_filter=true` — uten hallusinerer modellen repeterende tekst.
* **Benchmark og modellvalg:** Se `docs/concepts/podcastfabrikken.md` seksjon 4 for ytelsestall og anbefalinger.

### 4.1 initial_prompt (navneliste)
Brukes kun i batch-modus (Podcastfabrikken). Prompten bygges automatisk fra samlings-nodens metadata (`metadata.whisper_prompt`) + entiteter i kunnskapsgrafen.

Effekten er tydelig:
* Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja"
* Med prompt: "Vegard Nøtnæs", "Sidelinja" (riktig)

## 5. Lagring
* **Live-modus:** Transkripsjonen er flyktig (kategori 4, TTL 30 dager). Lagres i `live_transcription_log` for feilsøking.
* **Batch-modus:** SRT committes til Git (Forgejo). Avledede formater (ren tekst, segmenter, søkeindeks) i PostgreSQL.

## 6. Instruks for Claude Code
* Live transkripsjon blokkerer ALDRI web-requests — prosesseres i Rust-worker eller separat tjeneste.
* Batch-transkripsjon kjøres som `whisper_transcribe`-jobb i jobbkøen (se `docs/infra/jobbkø.md`).
* Config (prompts) hentes fra samlings-nodens metadata (JSONB).


================================================================
FILE: docs/features/lydmeldinger.md
================================================================

# Feature: Lydmeldinger & Diktering
**Filsti:** `docs/features/lydmeldinger.md`

## 1. Konsept
En mobil-first talefeature med tre moduser som dekker spekteret fra kjappe chatmeldinger til langt feltopptak. Grunnprinsippet er det samme: **du snakker inn i telefonen, og systemet gjør noe nyttig med det**. Forskjellen er hva som er output.

| Modus | Typisk lengde | Output | Master-format | Lagring |
|---|---|---|---|---|
| **Voice-to-text** | <30 sek | Rå transkripsjon i meldingsfeltet | Tekst | Ingen (lyden kastes) |
| **Lydmelding** | <10 min | Lydfil + transkripsjon for søk | Lyd | `media_file` + node i grafen |
| **Diktering** | 1–30 min | AI-ryddet tekst (strukturert notat) | Tekst | Node i grafen |

Voice-to-text er en del av chat-featuren (se `docs/features/chat.md`, seksjon 8). Denne specen dekker de to tyngre modusene.

## 2. Lydmelding-modus

### 2.1 Brukerflyt
1. Bruker åpner lydmelding-modus (via FAB-knapp, hurtigtast eller fra en channel).
2. Trykker record. Nettleseren fanger lyd via `MediaRecorder` API (WebM/Opus).
3. Kan valgfritt velge kontekst: et Tema, en Aktør, eller "Usortert".
4. Ved stopp lastes lydfilen opp til SvelteKit (streaming for store filer).
5. Filen lagres som `media_file` i mediamappen (`/srv/synops/media/voice/`).
6. En `whisper_transcribe`-jobb opprettes i jobbkøen for å gjøre opptaket søkbart.
7. Transkripsjonen lagres som metadata på noden — lydfilen forblir master.

### 2.2 Datamodell
Lydmeldingen er en node i Kunnskapsgrafen (`node_type = 'melding'`, `message_type = 'voice_memo'`):

```
nodes (node_type = 'melding')
  → messages (channel_id, message_type = 'voice_memo', body = transkripsjon, metadata = { duration, transcription_status })
  → message_attachments → media_files (lydfilen)
```

Lydmeldinger kan:
- Sendes i en channel (som vedlegg til en melding)
- Leve fristående i en personlig "Innboks"-channel
- Knyttes til Temaer/Aktører via `graph_edges` (manuelt eller AI-foreslått)

### 2.3 Triagering
Redaksjonen trenger en flate for å gå gjennom nye lydmeldinger:
- Lytt, tagg med Temaer/Aktører
- Marker for bruk i episode (kobles til Kanban/Kjøreplan)
- Forkast / arkiver
- Denne flaten er en filtrert visning av "Innboks"-channelen, ikke et eget system

### 2.4 Bruk i podcast
Lydmeldinger markert for bruk kan trekkes inn i Podcastfabrikkens pipeline. Lydfilen er allerede i media-mappen — den kan klippes inn i en episode direkte.

## 3. Dikteringsmodus

### 3.1 Brukerflyt
1. Bruker åpner dikteringsmodus (mobil-first, men fungerer på desktop).
2. Snakker fritt — kan være flere minutter. Visuell indikator viser at opptak pågår.
3. Ved stopp opprettes to jobber i jobbkøen (sekvensielt):
   - `whisper_transcribe` — rå transkripsjon (`medium` + `initial_prompt` for kvalitet)
   - `dictation_cleanup` — AI rydder i teksten
4. Brukeren ser resultatet: rå transkripsjon og AI-ryddet versjon side om side.
5. Kan redigere den ryddede versjonen før lagring.
6. Lagres som et notat (node i grafen). Lydfilen slettes etter vellykket transkripsjon.

### 3.2 AI-opprydding (`dictation_cleanup`)
AI Gateway (`sidelinja/rutine`) prosesserer transkripsjonen med instruksjon om å:
- Fjerne fyllord, gjentakelser og ufullstendige setninger
- Strukturere i avsnitt med overskrifter der det gir mening
- Bevare talerens mening og tone — ikke omskrive til "AI-språk"
- Foreslå `#Tema`- og `@Aktør`-tags basert på innholdet

### 3.3 Datamodell
Dikterte notater er meldinger i en channel:

```
nodes (node_type = 'melding')
  → messages (channel_id, message_type = 'text', body = ryddet tekst, metadata = { raw_transcript, source: 'dictation' })
```

Metadata bevarer rå-transkripsjonen slik at brukeren alltid kan se hva som faktisk ble sagt.

### 3.4 Målkanal
Brukeren velger hvor notatet havner:
- **Personlig notat-channel** — default, kun synlig for brukeren selv
- **Tema-channel** — delt med redaksjonen som et innspill
- **Direkte til en annen bruker** — som en asynkron "talemelding i tekstform"

## 4. Felles infrastruktur

### 4.1 Opptak (klient)
Begge moduser bruker samme opptakskomponent i SvelteKit:
- `MediaRecorder` API med WebM/Opus
- Visuell feedback (lydnivå-indikator, varighet)
- Modus-velger: Lydmelding / Diktering
- Kontekst-velger: Tema, Aktør, eller Usortert

### 4.2 Opplasting
SvelteKit håndterer filopplasting strømmende. For korte klipp (<2 min) kan opplasting starte umiddelbart etter stopp. For lengre opptak bør det vises en fremdriftsindikator.

### 4.3 Whisper
Begge moduser bruker live transkripsjonspipelinen (se `docs/features/live_transkripsjon.md`) i batch-modus via jobbkøen. Modellvalg:
- Lydmelding: `small` (rask, transkripsjonen er sekundær — lyden er master)
- Diktering: `medium` + `initial_prompt` (kvalitet prioritert — teksten er master)

### 4.4 Personlig "Innboks"-channel
Ved brukeropprettelse opprettes en privat channel per bruker for usorterte lydmeldinger og notater. Config:

```json
{
  "threads": false,
  "mentions": false,
  "attachments": true,
  "research_clips": false,
  "ttl_days": null
}
```

## 5. Dataklassifisering (ref. docs/arkitektur.md 2.2)

| Data | Kategori | Detaljer |
|---|---|---|
| Lydfiler (voice memos) | Kritisk (backup) | Unikt råmateriale — lyden *er* innholdet, kan brukes direkte i podcasten |
| Diktert tekst (ryddet) | Kritisk (PG) | Brukerens innhold |
| Rå transkripsjon | Avledet | Kan regenereres fra lydfil |
| Lydfil fra diktering | Flyktig (TTL) | Lyden er kun et *transportformat* for teksten — slettes etter at brukeren har godkjent den ryddede teksten. Spart diskplass, og unngår å lagre halvformulerte tanker som lyd. |

## 6. Jobbtyper

| Jobbtype | Modellalias | Beskrivelse |
|---|---|---|
| `whisper_transcribe` | — | Eksisterende jobbtype, gjenbrukes |
| `dictation_cleanup` | `sidelinja/rutine` | AI-opprydding av transkripsjon |

## 7. Instruks for Claude Code
* Opptakskomponenten skal være en gjenbrukbar Svelte-komponent med modus-prop (`voice_memo` / `dictation`).
* Lydfiler lagres i `media/voice/` — aldri i databasen.
* Diktering: slett lydfilen først *etter* at brukeren har godkjent den ryddede teksten.
* `voice_memo` er en ny `message_type` — utvid enum i migrasjonen.
* Personlig innboks-channel opprettes automatisk for nye brukere.
* Tilgang styres via `node_access`-matrisen.


================================================================
FILE: docs/features/meldingsboks.md
================================================================

# Feature: Meldingsboks — universell diskusjonsprimitiv
**Filsti:** `docs/features/meldingsboks.md`

## 1. Konsept
Meldingsboksen er den sentrale byggeklossen for alt ustrukturert innhold i Sidelinja. Én datamodell som erstatter separate tabeller for chat-meldinger, kanban-kort, kalenderhendelser, faktoider og notater. Samme objekt, samme diskusjon, vist i flere kontekster via view-config.

### 1.1 Hva meldingsboksen erstatter

| Tidligere modell | Egen tabell | Blir nå |
|---|---|---|
| Chat-melding | `messages` | Meldingsboks (node) |
| Kanban-kort | `kanban_cards` | Meldingsboks + `kanban_card_view` |
| Kalenderhendelse | `calendar_events` | Meldingsboks + `calendar_event_view` |
| Faktoide | `factoids` | Meldingsboks med `ABOUT`-edge |
| Notat | `notes` | Meldingsboks med tittel |

### 1.2 Hva som IKKE er meldingsbokser
Typed nodes med strukturelt unike skjemaer forblir egne detailtabeller:

| Type | Hvorfor egen tabell |
|---|---|
| Entitet | `name`, `type`, `aliases`, `avatar_url` — autocomplete, Whisper-prompt, autoritativ navngiving |
| Episode | `title`, `guid` (immutabel RSS-krav), `published_at` |
| Segment | `episode_id`, `start_time`/`end_time`, `transcript`, FTS-indeks |

**Entiteter** (erstatter `actors` + `topics`) er alt som kan nevnes med `#`: personer, organisasjoner, steder, temaer, konsepter. Se `docs/features/kunnskapsgraf_og_relasjoner.md` §3.2.

Typed nodes kan **kobles til meldingsbokser** via edges for diskusjon. En entitet har ikke innebygd diskusjon, men en meldingsboks kan knyttes til den med en `DISCUSSED_IN`-edge.

## 2. Alle meldinger er noder

Hver meldingsboks er en fullverdig node i kunnskapsgrafen (`node_type = 'melding'`). Ingen vektklasser, ingen promoteringslogikk. Opprettelse er alltid: `INSERT INTO nodes` + `INSERT INTO messages` i én transaksjon.

**Hvorfor:** De fleste meldinger i en aktiv redaksjon ender opp med å trenge graf-tilkobling uansett (mentions, svar, stemmer). Promoteringslogikk legger til kompleksitet uten reell gevinst. `nodes`-tabellen tåler volumet — TTL rydder opp i flyktige meldinger, og `node_type`-filter sikrer at spørringer aldri treffer hele tabellen.

**Konsekvens:** Ethvert svar er en rik entitet som kan kobles til kanban, kalender, graf — full fleksibilitet uten spesialtilfeller. Et svar på tredje nivå i en diskusjon kan bli en kalenderoppføring, og konteksten forsvinner ikke.

## 3. Datamodell

### 3.1 Messages (erstatter `messages`, `kanban_cards`, `calendar_events`, `factoids`, `notes`)

```sql
CREATE TABLE messages (
    id           UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    channel_id   UUID REFERENCES nodes(id) ON DELETE CASCADE,
    reply_to     UUID REFERENCES messages(id) ON DELETE SET NULL,
    author_id    TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
    message_type message_type NOT NULL DEFAULT 'text',
    title        TEXT,               -- Kanban-kort, notater, kalenderhendelser, faktoider
    body         TEXT NOT NULL,
    metadata     JSONB,              -- Ekstra data per message_type
    pinned       BOOLEAN NOT NULL DEFAULT false,
    visibility   TEXT NOT NULL DEFAULT 'shared',  -- 'shared' | 'private'
    edited_at    TIMESTAMPTZ,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_messages_channel ON messages(channel_id, created_at);
CREATE INDEX idx_messages_reply ON messages(reply_to) WHERE reply_to IS NOT NULL;

CREATE TRIGGER trg_messages_updated_at BEFORE UPDATE ON messages
    FOR EACH ROW EXECUTE FUNCTION set_updated_at();
```

**Forskjeller fra gammel `messages`-tabell:**
- `id` er FK til `nodes(id)` — **alle meldinger er noder** i kunnskapsgrafen
- Ingen `workspace_id` — tilgang styres via `node_access`-matrisen
- `channel_id` er nullable — notater og standalone-bokser trenger ikke en channel
- `title` er førsteklasses felt — brukes av kanban-kort, notater, kalenderhendelser
- `pinned` for manuell fritak fra TTL-sletting
- `visibility` styrer synlighet — `'shared'` (alle med tilgang via `node_access`) eller `'private'` (kun forfatter). Private meldingsbokser kan brukes som kladd for notater, kanban-kort og kalenderhendelser. Endre til `'shared'` for å dele.

### 3.1.1 Kontekst-navigering via `reply_to`

Når en meldingsboks har roller i flere kontekster (f.eks. et svar som også er en kalenderoppføring), gir `reply_to`-kjeden alltid vei tilbake til opprinnelig diskusjon:

```
📅 Kalenderen: "Planleggingsmøte for konferanse"
   ↩ Fra diskusjon i #Mediepolitikk → "Vi burde kanskje..."
```

UI-et følger `reply_to` → forelder → `channel_id` → parent node for å bygge brødsmulesti. Ingen ekstra data nødvendig — `reply_to` + view-config gir hele bildet i begge retninger:
- **Fra kalenderen:** "Hvor kom denne fra?" → følg `reply_to` oppover
- **Fra chatten:** "Hva ble dette?" → se at svaret har `calendar_event_view` → vis kalender-badge

### 3.2 Kanban view-config (erstatter `kanban_cards`)

```sql
CREATE TABLE kanban_card_view (
    message_id  UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
    column_id   UUID NOT NULL REFERENCES kanban_columns(id) ON DELETE CASCADE,
    position    REAL NOT NULL DEFAULT 0,
    color       TEXT,
    assignee_id TEXT REFERENCES users(authentik_id) ON DELETE SET NULL
);

CREATE INDEX idx_kanban_card_view_column ON kanban_card_view(column_id, position);
```

Et kanban-kort er en meldingsboks + en rad i `kanban_card_view`. Tittel og beskrivelse lever i `messages.title`/`messages.body`. Flytt mellom kolonner = `UPDATE kanban_card_view SET column_id = ...`.

`kanban_boards` og `kanban_columns` forblir uendret — de er strukturelle tabeller, ikke grafnoder (kolonner er intern organisering).

### 3.3 Kalender view-config (erstatter `calendar_events`)

```sql
CREATE TABLE calendar_event_view (
    message_id  UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
    calendar_id UUID NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
    starts_at   TIMESTAMPTZ NOT NULL,
    ends_at     TIMESTAMPTZ,
    all_day     BOOLEAN NOT NULL DEFAULT false,
    color       TEXT
);

CREATE INDEX idx_calendar_event_view_calendar ON calendar_event_view(calendar_id, starts_at);
```

`calendars`-tabellen forblir uendret.

### 3.4 Reaksjoner (erstatter `factoid_votes` og `message_votes`)

```sql
CREATE TABLE message_reactions (
    message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    user_id    TEXT NOT NULL REFERENCES users(authentik_id) ON DELETE CASCADE,
    reaction   TEXT NOT NULL,   -- 'upvote', 'downvote', '👍', '🔥', etc.
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (message_id, user_id, reaction)
);
```

Én tabell for alle interaksjoner — opp/ned-stemmer (`'upvote'`/`'downvote'`) og emoji-reaksjoner (`'👍'`, `'🔥'`) i samme modell. Ikke graf-edges — reaksjoner er høyfrekvente, lav-semantiske operasjoner.

Sortering etter stemmer: `SELECT COUNT(*) FILTER (WHERE reaction = 'upvote') - COUNT(*) FILTER (WHERE reaction = 'downvote') AS score`.

### 3.5 Message revisions (uendret)

```sql
CREATE TABLE message_revisions (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    body       TEXT NOT NULL,
    edited_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT revision_order UNIQUE (message_id, edited_at)
);
```

### 3.6 Nye relasjonstyper

Eksisterende relasjonstyper dekker behovene — ingen nye nødvendig:
- `DISCUSSED_IN` — kobler typed nodes til meldingsbokser (diskusjon på en aktør/tema)
- `ABOUT` — kobler faktoide-meldinger til aktører/temaer
- `MENTIONS` — chat-mentions via `#`-tags

### 3.7 node_type enum (opprydding)

Aktive verdier etter migrering:
- `'entitet'` — person, organisasjon, sted, tema, konsept (erstatter `'aktør'` og `'tema'`)
- `'episode'`, `'segment'` — typed nodes
- `'melding'` — meldingsboks (chat, kanban-kort, kalenderhendelse, faktoide, notat)
- `'channel'` — gruppering av meldinger
- `'kanban_board'`, `'calendar'`, `'meeting'` — strukturelle

Utfasede verdier (kan ikke fjernes fra PG ENUM, men skal aldri brukes i ny kode):
`'aktør'`, `'tema'`, `'faktoide'`, `'note'`, `'kanban_card'`, `'calendar_event'`

## 4. Multi-rolle via view-config

Konvertering = legg til view-config. Ingen data flyttes:

```
Meldingsboks (messages + nodes)
  ├── kanban_card_view  → vises på kanban-brettet
  ├── calendar_event_view → vises i kalenderen
  ├── edge: ABOUT → aktør/tema → vises som faktoide
  ├── edge: DISCUSSED_IN ← typed node → diskusjon på aktør/tema/episode
  ├── reply_to → parent melding → tråd-kontekst
  └── reply_to ← svar → diskusjonstråd følger med i alle kontekster
```

En meldingsboks kan ha **flere roller samtidig**: et kanban-kort som også er en kalenderhendelse, med en diskusjonstråd under seg. Svar på meldingsboksen følger med uansett kontekst — diskusjonen er alltid tilgjengelig.

## 5. Channels

Channels forblir som grupperingsmekanisme. En channel samler meldingsbokser under ett scope — typisk knyttet til en typed node (tema, episode, møte).

Channels **opprettes ved behov**, ikke automatisk for alle noder. Når en bruker starter en diskusjon på en aktør, opprettes en channel i samme transaksjon.

Channel-config (`threads`, `mentions`, `attachments`, `ttl_days`) arves fra kontekst (se `docs/features/chat.md` §2.2).

## 6. Slette-semantikk: "Fjern" vs "Slett"

En meldingsboks kan ha flere roller (kanban-kort, kalenderhendelse, diskusjonstråd). UI-et må ha et skarpt, eksplisitt skille:

| Handling | Hva skjer | Konsekvens |
|---|---|---|
| **Fjern fra brett/kalender** | `DELETE FROM kanban_card_view` / `calendar_event_view` | Meldingen lever videre i chatten og grafen. Kun visningen forsvinner. |
| **Slett innhold** | `DELETE FROM messages` → cascader til `nodes` | Alt borte: diskusjonstråd, view-configs, graf-edges. Irreversibelt. |

**UI-regler:**
- "Fjern fra brett" / "Fjern fra kalender" — standardhandling i kontekstmeny på kanban/kalender. Trygt.
- "Slett permanent" — bak en bekreftelsesdialog ("Denne meldingen har 3 svar og er koblet til 2 entiteter. Slett alt?"). Viser konsekvensene eksplisitt.
- En melding med aktive roller i andre views bør vise en advarsel: "Denne meldingen er også et kanban-kort i [brett]. Fjern fra brettet først, eller slett alt?"

## 7. Nesting og utskilling

Maks 3 nivåer visuelt (via `reply_to`-kjeding):
1. Boks (trådstart)
2. Svar på boks
3. Svar på svar

Ved nivå 3 tilbyr systemet: **"Skill ut som egen diskusjon?"**
- Svaret promoteres til boks (ny node)
- Originaltråden får en lenke-melding: "→ Diskusjonen fortsetter her"
- Den nye boksen lever sitt eget liv

## 8. Eierskap, kurasjon og prominens

### 8.1 Eierskap
Trådstarter og admin for samlings-noden deler eierskap over en diskusjonstråd. Eierskap gir tilgang til kurasjonsverktøy (se 7.2).

### 8.2 Kurasjon (TODO — UI-features, bygges inkrementelt)
Datamodellen trenger ikke endres for disse — alt håndteres via `messages.metadata` (JSONB):
- **Absorber svar** — `metadata.absorbed = true`. Svaret kollapses visuelt, ikke slettet.
- **Kollapser utdaterte svar** — `metadata.collapsed = true`. Alltid tilgjengelig med klikk.
- **Fest viktige svar** — `metadata.featured = true`. Vises prominent i lang tråd.

### 8.3 Prominens (avledet, ikke lagret)
Hvor viktig en meldingsboks er, beregnes fra eksisterende data — aldri lagret som en score:
- Antall svar (`COUNT` på `reply_to`)
- Stemmer (`SUM` fra `message_votes`)
- Antall roller (finnes i `kanban_card_view`, `calendar_event_view`?)
- Graf-koblinger (`COUNT` fra `graph_edges`)
- Alder på siste aktivitet (`MAX(created_at)` fra svar)

Beregnes ved visning eller caches i materialized view ved behov. Algoritmen kan justeres uten migrasjoner eller skjemaendringer.

## 9. TTL og livsløp

### 9.1 To-trinns fading
1. **Skjult fra visning** — meldingen forsvinner fra default UI etter TTL (arvet fra channel eller samlings-node). `messages.metadata.hidden_at` settes.
2. **Slettet** — etter tilleggsperiode (dobbel TTL) fjernes raden permanent.

### 9.2 Alder som dynamisk faktor
Tid bidrar til utfasing — eldre meldinger uten aktivitet eller koblinger fader naturlig. Prominens-scoren (§8.3) synker med alder, og TTL-jobben bruker den til å avgjøre hva som skjules og slettes.

### 9.3 Fritak-regler
En melding slettes **ikke** hvis:
- Den har **graf-edge(s)** (`ABOUT`, `MENTIONS`, `DISCUSSED_IN`, etc.) — koblet til noe varig i kunnskapsgrafen. Dette er det som gjør faktoider immune: en `ABOUT`-edge til en aktør/tema betyr at informasjonen har verdi utover konteksten den ble skrevet i.
- Den har `kanban_card_view`-rad i en aktiv kolonne
- Den har `calendar_event_view`-rad med fremtidig tidspunkt
- Den har aktive svar (siste svar innenfor TTL)
- `pinned = true`

**Prinsippet:** Grafen bestemmer hva som er varig. Ingen spesialhåndtering for faktoider — enhver melding med en graf-kobling overlever. Meldinger uten koblinger fader med tid.

Lever boksen, lever alt under den — svar beholdes uansett alder.

### 9.4 Konfigurerbarhet
```
Samlings-node default TTL: 30 dager (samlings-node metadata.default_ttl_days)
  └── Channel kan overstyre: config.ttl_days
        └── Individuelle meldinger frittes via reglene over
```

## 10. `<MessageBox>` Svelte-komponent

Én komponent som rendrer en meldingsboks i alle kontekster:
- **Kompakt modus** — kanban: tittel + "3 svar" + fargekode
- **Kalender-modus** — tittel + tidspunkt + fargekode
- **Utvidet modus** — full diskusjon med innrykk (maks 3 nivåer)
- Leser kontekst og tilpasser capabilities (stemmer, mentions, vedlegg)
- Lazy-loader tråd ved expand (ytelse)

## 11. Konsekvenser for eksisterende kode

### 11.1 Tabeller som fjernes
- `kanban_cards` → erstattes av `kanban_card_view`
- `calendar_events` → erstattes av `calendar_event_view`
- `factoids` + `factoid_votes` → erstattes av `messages` + `message_reactions`
- `message_votes` → erstattes av `message_reactions`
- `notes` → erstattes av `messages` med tittel

### 11.2 Tabeller som forblir uendret
- `kanban_boards`, `kanban_columns` — strukturelle
- `calendars` — strukturell
- `channels` — gruppering
- `message_revisions` — revisjonshistorikk
- `message_attachments`, `media_files` — vedlegg

### 11.3 API-endringer
Eksisterende API-ruter (`/api/kanban/`, `/api/calendar/`, `/api/notes/`) refaktoreres til å bruke den nye datamodellen. Grensesnittet mot frontend kan holdes stabilt — endringene er i datalaget.

## 12. Migrering (0005_meldingsboks.sql)

Migrasjonen konverterer eksisterende data:

1. **Endre `messages`-tabellen:** Legg til `title`, `pinned`. `id → nodes(id)` beholdes (alle meldinger er allerede noder).
2. **Migrer kanban-kort:** For hver `kanban_cards`-rad: opprett `messages`-rad (med `title`, `body` fra description, gjenbruk eksisterende node-id) + `kanban_card_view`-rad.
3. **Migrer kalenderhendelser:** For hver `calendar_events`-rad: opprett `messages`-rad + `calendar_event_view`-rad.
4. **Migrer faktoider:** For hver `factoids`-rad: opprett `messages`-rad (med `body`, `message_type = 'factoid'`). Flytt `factoid_votes` til `message_votes`. Opprett `ABOUT`-edges i `graph_edges`.
5. **Migrer notater:** For hver `notes`-rad: opprett `messages`-rad (med `title`, `body` fra content).
6. **Drop gamle tabeller:** `kanban_cards`, `calendar_events`, `factoids`, `factoid_votes`, `notes`.

**Merk:** Migrasjonen bør ha en tilhørende down-migrering som gjenskaper de gamle tabellene og flytter data tilbake.

## 13. Instruks for Claude Code
- **Opprettelse av meldingsboks:** Alltid INSERT i `nodes` (type 'melding') + INSERT i `messages` med samme id. Alt i én transaksjon.
- **Kanban-kort:** INSERT i `nodes` + `messages` (med tittel) + `kanban_card_view`. Én transaksjon.
- **Kalenderhendelse:** INSERT i `nodes` + `messages` (med tittel) + `calendar_event_view`. Én transaksjon.
- **Faktoide:** INSERT i `nodes` + `messages` (`message_type = 'factoid'`) + `graph_edges` med `ABOUT`-relasjon til aktør/tema. Én transaksjon.
- **Notat:** INSERT i `nodes` + `messages` (med tittel + body). `channel_id` peker på en personlig eller delt channel, eller er NULL.
- **Konvertering mellom roller:** Legg til view-config-rad (kanban/kalender). Aldri kopier data.
- **Kontekst-navigering:** Bruk `reply_to`-kjeden for å bygge brødsmulesti tilbake til opprinnelig diskusjonskontekst.
- **TTL:** Implementér som nattlig jobbkø-jobb (`message_ttl_cleanup`). Sjekk fritak-regler før sletting. Ved sletting: slett fra `messages` → cascade til `nodes` via FK.
- **Tilgang:** Styres via `node_access`-matrisen. Visibility håndteres i applikasjonskode (SvelteKit): `WHERE visibility = 'shared' OR author_id = $current_user`.
- **Visibility:** Default `'shared'`. Sett `'private'` for personlige kladder. Endre til `'shared'` for å dele — ingen kopiering nødvendig.


================================================================
FILE: docs/features/notater.md
================================================================

# Feature: Notater (Scratchpad)
**Filsti:** `docs/features/notater.md`

## 1. Konsept
Et enkelt notatverktøy med automatisk lagring. Brukes som scratchpad i ulike kontekster — show notes, møtenotater, research-notater. Notater er nodes i kunnskapsgrafen og kan kobles til andre noder.

## 2. Status
**PG-adapter ferdig og deployet (mars 2025).** Rich text og SpacetimeDB-sync gjenstår.

### Implementert
- Migrering `0004_notes.sql`: `notes`-tabell (FK→nodes)
- Notater er nodes — tilgangsstyrt via `node_access`-matrise
- Auto-save med 500ms debounce (visuell feedback: "Lagrer..."/"Lagret")
- REST API: GET og PATCH (tittel + innhold)
- PG polling-adapter med 10 sek intervall (tregere enn chat/kanban — notater endres sjeldnere)
- NotesBlock.svelte: tittel-input + fritekst-textarea med auto-save
- Polling pauses mens brukeren skriver (unngår overskriving av egne endringer)

### Gjenstår — Fase 2
- Markdown-editor (rich text med forhåndsvisning)
- Versjonering / undo-historikk
- Kobling til andre noder (temaer, episoder, aktører)
- Flerbruker-redigering (conflict resolution)
- SpacetimeDB-modul + hybrid-adapter
- Eksport (Markdown, PDF)

## 3. Datamodell (implementert)

```sql
CREATE TABLE notes (
    id          UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    parent_id   UUID NOT NULL REFERENCES nodes(id),
    title       TEXT NOT NULL DEFAULT '',
    content     TEXT NOT NULL DEFAULT '',
    created_by  TEXT REFERENCES users(authentik_id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

`updated_at` oppdateres automatisk ved PATCH og brukes til "Lagret [tidspunkt]"-visning.

## 4. API-endepunkter

| Metode | Sti | Beskrivelse |
|---|---|---|
| GET | `/api/notes/[noteId]` | Hent notat |
| PATCH | `/api/notes/[noteId]` | Oppdater tittel og/eller innhold |

## 5. Brukes av

| Konsept | Bruk |
|---|---|
| Redaksjonen (Sidelinja) | Show notes for episoder |
| Foreningen Liberalistene | Møtenotater |
| Møterommet (fremtidig) | Scratchpad under møter |

## 6. Auto-save-mønster
- Bruker skriver → 500ms debounce → PATCH til server
- Under lagring vises "Lagrer..." (gul)
- Etter vellykket lagring vises "Lagret [dato]" (grå)
- Polling (10 sek) henter siste versjon, men hopper over overskriving mens `saving`-flagget er satt

## 7. Instruks for Claude Code
* Tilgang til notater styres via `node_access`-matrisen
* Auto-save bruker debounce — ikke send PATCH ved hvert tastetrykk
* `updated_at` brukes til UI-feedback, ikke til conflict resolution (ennå)
* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi


================================================================
FILE: docs/features/podcast_statistikk.md
================================================================

# Feature Spec: Podcast-Statistikk
**Filsti:** `docs/features/podcast_statistikk.md`

## 1. Konsept
IAB-kompatibel lytterstatistikk bygget fra bunnen av. Vi fanger all rådata via Caddy, og bruker asynkron batch-prosessering for å bygge grafer og tall uten å belaste webserveren eller databasen med sanntids-skriving.

## 2. Arkitektur & Dataflyt
1. **Rådata (Caddy):** Caddy konfigureres til å skrive access-logs for stien `/media/podcast/*.mp3` til en formatert JSON-fil (f.eks. `/srv/synops/logs/caddy/podcast_access.log`).
2. **Logrotate:** Standard Linux logrotate arkiverer loggene nattlig.
3. **Rust Batch Processor (Jobbkø):** Statistikkparseren kjøres som en `stats_parse`-jobb i den felles jobbkøen (se `docs/infra/jobbkø.md`), med `scheduled_for` satt 1 time frem for periodisk kjøring. Workeren re-enqueuer seg selv ved fullføring.
   * **Steg A (Filtrering):** Leser JSON-loggen. Fjerner treff fra kjente bots ved å krysjekke `User-Agent` mot OPAWG (Open Podcast Analytics Working Group) sine åpne bot-lister.
   * **Steg B (Deduplisering):** Slår sammen byte-range forespørsler. Hvis samme IP og User-Agent har lastet ned deler av samme fil innenfor et 24-timers vindu, telles det som KUN én (1) nedlasting.
   * **Steg C (Geografi/Klient):** Mapper User-Agent til Podcast-klient (Spotify, Apple) basert på OPAWG-regler.
4. **Lagring (PostgreSQL):** Rust-programmet skriver det aggregerte resultatet inn i PostgreSQL (`episode_stats` tabell med felter for `tenant_id`, `date`, `episode_id`, `client_name`, `unique_downloads`). Tenant-tilhørighet utledes fra filstien i loggen (`/media/{tenant_slug}/...`).

## 3. Personvern og GDPR

Caddy access-logger inneholder IP-adresser og User-Agent — dette er personopplysninger under GDPR.

**Tiltak:**
* **IP-anonymisering:** Rust-workeren hasher IP-adresser (SHA-256 med daglig roterende salt) *før* de lagres i `episode_stats`. Rå IP-er lagres aldri i PostgreSQL.
* **Loggretensjon:** Caddy-logger roteres og slettes etter 90 dager (kategori 4, flyktig). Etter at statistikk-workeren har prosessert en loggfil, inneholder PG kun aggregerte tall — ingen identifiserbar data.
* **Ingen sporing på tvers:** Vi bruker ikke cookies, fingerprinting eller tredjepartstracking. Deduplisering basert på IP + User-Agent i 24-timers vindu er IAB-standard og minimumsdata.
* **Dokumentasjon:** Podcastens RSS-feed bør lenke til en personvernerklæring som forklarer at anonymisert nedlastingsstatistikk samles inn.

## 4. Instruks for Claude Code
* Bruk Rust-biblioteket `serde_json` for rask parsing av Caddy-loggene.
* Dette programmet må skrives robust med tanke på at filer kan være låst av Caddy. Det bør tåle å avbrytes, og må holde styr på hvilken linje i loggfilen det prosesserte sist (f.eks. via en liten cursor-fil).
* Rålogger skal ALDRI lagres i PostgreSQL.
* Statistikk er tenant-scopet. `episode_stats` merkes med `tenant_id`. Admin-visningen filtrerer per tenant.

================================================================
FILE: docs/features/prompt_lab.md
================================================================

# Feature: Prompt-Laboratorium
**Filsti:** `docs/features/prompt_lab.md`

> **NB:** Dette dokumentet er en skisse fra v1 og må oppdateres til node/edge-modellen ved implementering.

## 1. Konsept
Et internt kvalitetssikringsverktøy der redaksjonen kan teste, sammenligne og godkjenne LLM-prompts mot faktiske data fra egen samlings-node — før de ruller ut i produksjon. Integrert med AI Gateway (LiteLLM) og Promptfoo-testsettene.

## 2. Problemet det løser
- Modellkvalitet på norsk varierer kraftig mellom leverandører og versjoner.
- Leverandører oppdaterer modeller uten varsel — kvaliteten kan degraderes over natten.
- Redaksjonen må kunne verifisere at en prompt fungerer *med deres data* (transkripsjoner, artikler, aktørnavn) før den settes i produksjon.
- I dag krever prompt-testing kommandolinje og Promptfoo — det bør være tilgjengelig i nettleseren.

## 3. Brukeropplevelse

### 3.1 Playground (Ad-hoc testing)
1. Bruker velger en **jobbtype** (research_clip, whisper_postprocess, metadata_extract, dictation_cleanup, etc.).
2. Systemet laster inn gjeldende system-prompt fra samlings-nodens metadata.
3. Bruker kan redigere prompten i et tekstfelt.
4. Velger **testdata**: enten paste inn tekst manuelt, eller velg fra faktiske transkripsjoner/artikler brukeren har tilgang til.
5. Velger **modeller** å teste mot (f.eks. `sidelinja/rutine` + `sidelinja/resonering`).
6. Kjører testen — ser resultatene side-om-side.
7. Kan iterere: endre prompten, kjør igjen, sammenlign.

### 3.2 Batch-evaluering (Promptfoo-integrasjon)
1. Bruker velger et lagret **testsett** (fra `tests/prompts/` i Git).
2. Kjører testsettene mot valgte modeller via AI Gateway.
3. Ser en matrise: testcase × modell × resultat, med pass/fail-markering.
4. Kan sammenligne mot tidligere kjøringer for å oppdage regresjoner.

### 3.3 Deploy
Når en prompt er verifisert:
1. Bruker klikker "Deploy".
2. Prompten skrives til samlings-nodens metadata (`metadata.llm_prompts[jobbtype]`).
3. Alle fremtidige jobber av den typen bruker den nye prompten.
4. Tidligere prompt lagres i en `prompt_history`-logg for rollback.

## 4. Teknisk arkitektur

### 4.1 Datamodell

```sql
prompt_test_runs (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    context_node  UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,  -- samlings-node
    job_type      TEXT NOT NULL,
    system_prompt TEXT NOT NULL,
    model_alias   TEXT NOT NULL,
    input_text    TEXT NOT NULL,
    output_text   TEXT,
    tokens_used   INTEGER,
    latency_ms    INTEGER,
    created_by    TEXT NOT NULL REFERENCES users(authentik_id),
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_prompt_runs_context ON prompt_test_runs(context_node, job_type, created_at DESC);
```

`prompt_test_runs` er **ikke** en node i grafen — det er en intern verktøytabell for utviklere/redaktører.

### 4.2 Prompt-historikk

```sql
prompt_history (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    context_node  UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,  -- samlings-node
    job_type      TEXT NOT NULL,
    system_prompt TEXT NOT NULL,
    deployed_by   TEXT NOT NULL REFERENCES users(authentik_id),
    deployed_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

Muliggjør rollback: "forrige prompt for research_clip fungerte bedre, rull tilbake."

### 4.3 Kjøring
- **Ad-hoc tester:** SvelteKit server-side sender request direkte til AI Gateway (`http://ai-gateway:4000/v1`) med brukerens prompt og testdata. Resultatet returneres synkront.
- **Batch-evaluering:** Oppretter en `prompt_eval`-jobb i jobbkøen. Rust-worker kjører testsettene mot AI Gateway og lagrer resultatene.

## 5. Dataklassifisering

| Data | Kategori | Detaljer |
|---|---|---|
| Test-kjøringer | Flyktig (TTL 90 dager) | For analyse, ryddes automatisk |
| Prompt-historikk | Kritisk (PG) | Muliggjør rollback |
| Testsett (Promptfoo) | Gjenskapbar (Git) | `tests/prompts/` — versjonskontrollert |

## 6. Jobbtyper

| Jobbtype | Modellalias | Beskrivelse |
|---|---|---|
| `prompt_eval` | (varierer) | Batch-evaluering av testsett mot valgte modeller |

## 7. Instruks for Claude Code
* Prompt Lab er et admin-verktøy — krev admin-tilgang til samlings-noden.
* Ad-hoc tester kjøres synkront (SvelteKit → AI Gateway → respons). Ikke bruk jobbkø for enkelt-tester.
* Batch-evaluering kjøres via jobbkø for å unngå timeouts.
* Vis alltid token-bruk og latens — dette er et kostnadsbevisst verktøy.
* Testdata (transkripsjoner, artikler) lastes via SvelteKit server-side fra PG. Gjesten-UX vises aldri her.
* `prompt_test_runs` og `prompt_history` er knyttet til en samlings-node og trenger ikke RLS — de er kun tilgjengelige for admins via applikasjonslogikk.
* Tilgang styres via `node_access`-matrisen.


================================================================
FILE: docs/features/universell_overfoering.md
================================================================

# Feature: Universell overføring — flytt objekter mellom blokker
**Filsti:** `docs/features/universell_overfoering.md`

## 1. Konsept

Universell overføring er mekanikken som lar brukere flytte meldingsboks-objekter mellom vilkårlige blokker: fra storyboard til chat, fra kanban til kalender, fra chat til storyboard. Enhver blokk kan *sende* og *motta* objekter. Meldingsboksen er alltid det underliggende objektet — overføringen endrer kun *view-config*, ikke selve meldingen.

### 1.1 Grunnprinsipp

En meldingsboks (node i kunnskapsgrafen) kan ha flere samtidige roller via view-configs (se `meldingsboks.md` §4). Universell overføring gjør dette til en førsteklasses brukerinteraksjon:

```
Bruker drar kort fra Storyboard → slipper på Chat-blokken
  → Meldingen får en ny plassering i chatten (placement-record)
  → Meldingen beholder sin posisjon på storyboardet
  → Diskusjonstråden er synlig begge steder (samme objekt)
```

Alternativt kan brukeren *flytte* (fjerne fra kilde, legge til i mål) i stedet for å *kopiere* (beholde begge). Kontekstmeny gir valget.

## 2. Plasseringsrelasjon (Placement)

Når en melding vises i en kontekst (chat, kanban, storyboard, kalender), trenger vi metadata om *hvordan* den vises der. Dette er **plasseringsrelasjonen** — en edge i grafen mellom meldingen og konteksten, med metadata.

### 2.1 Datamodell

```sql
CREATE TABLE message_placements (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    message_id   UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    context_type TEXT NOT NULL,  -- 'chat', 'kanban', 'storyboard', 'calendar', 'notes'
    context_id   UUID NOT NULL,  -- channel_id, board_id, episode_id, calendar_id, note_id
    entered_at   TIMESTAMPTZ NOT NULL DEFAULT now(),  -- når objektet ankom denne konteksten
    position     JSONB,          -- kontekst-spesifikk posisjon (se §2.2)
    UNIQUE (message_id, context_type, context_id)
);

CREATE INDEX idx_placements_context ON message_placements(context_type, context_id, entered_at);
CREATE INDEX idx_placements_message ON message_placements(message_id);
```

### 2.2 Posisjonsdata per kontekst

`position`-feltet er JSONB og inneholder kontekst-spesifikk plassering:

| Kontekst | position-innhold | Eksempel |
|----------|-----------------|----------|
| Chat | `null` (sorteres etter `entered_at`) | `null` |
| Kanban | `{ "column_id": "...", "position": 1.5 }` | Kolonne + rekkefølge |
| Storyboard | `{ "x": 340, "y": 120 }` | Fritt canvas-posisjon |
| Kalender | `{ "date": "2026-03-20", "all_day": true }` | Dato + tidspunkt |
| Notes | `{ "position": 3 }` | Rekkefølge i notatet |

### 2.3 Forhold til eksisterende view-configs

`message_placements` erstatter *ikke* de eksisterende view-config-tabellene (`kanban_card_view`, `calendar_event_view`) umiddelbart. Strategien er:

1. **Fase 1:** `message_placements` brukes for nye kontekster (storyboard, notes) og for overføringsmekanikken
2. **Fase 2:** Eksisterende view-configs migreres gradvis til `message_placements` (kanban-posisjon, kalender-dato)
3. **Fase 3:** `kanban_card_view` og `calendar_event_view` kan fjernes når all logikk bruker placements

### 2.4 `entered_at` vs `created_at`

- `messages.created_at` = når meldingen ble skrevet
- `message_placements.entered_at` = når meldingen ankom *denne konteksten*

En melding opprettet mandag i chatten som dras til storyboardet onsdag har `created_at = mandag` og storyboard-plassering med `entered_at = onsdag`. I chatten sorteres den etter sin chat-plasserings `entered_at` — som er mandag (opprinnelig plassering). I storyboardet vises den på canvas-posisjonen, uavhengig av tid.

## 3. Sende-mekanikk (source)

### 3.1 Drag-and-drop

Brukeren drar et objekt ut av en blokk. Når objektet forlater blokk-grensen:

1. Objektet blir en "ghost" (semi-transparent drag-representasjon)
2. Andre blokker som kan motta objektet highlighter sin mottakssone
3. Slipp på en mottakssone → overføring

### 3.2 Kontekstmeny: "Send til..."

For situasjoner der drag-and-drop er upraktisk (mobil, lang avstand):

```
Høyreklikk kort → "Send til..." →
  ├── 📋 Kanban: Episodeplanlegging
  ├── 💬 Chat: #studio-diskusjon
  ├── 📅 Kalender: Redaksjonskalender
  └── 🎬 Storyboard: Episode 47
```

Listen viser alle blokker brukeren har tilgang til som kan motta meldinger.

### 3.3 Flytt vs. kopier

- **Kopier** (default): Meldingen får en ny plassering i mål-konteksten, beholder plasseringen i kilde-konteksten
- **Flytt** (hold Shift ved drag, eller velg i kontekstmeny): Plasseringen i kilde-konteksten fjernes, ny plassering opprettes i mål

## 4. Mottak-mekanikk (target)

Hver blokk-type definerer en **mottaker** som bestemmer hva som skjer når et objekt ankommer:

### 4.1 Mottaker per blokk-type

| Blokk | Default-plassering | Visuell feedback |
|-------|-------------------|-----------------|
| **Chat** | Ny melding i bunnen, `entered_at = now()` | Kort blinker inn i chatflyten |
| **Kanban** | Første kolonne (eller "Innboks"), posisjon øverst | Kort glir inn i kolonnen |
| **Storyboard** | Senter av viewport (eller slipp-posisjon) | Kort fader inn |
| **Kalender** | Dagens dato, heldagshendelse | Dato highlighter |
| **Notes** | Ny blokk i bunnen av notatet | Tekst fades inn |

### 4.2 Mottakssone-rendering

Hver blokk rendrer en visuell drop-target når en drag er aktiv:

- **Hele blokken** lyser opp med en subtil border/glow
- **Spesifikke soner** (f.eks. en kolonne i kanban, en dato i kalender) highlighter ved hover
- **Avviste drops** (feil type objekt) viser en dempet tilstand

### 4.3 Mottaker-interface

```typescript
interface BlockReceiver {
    /** Kan denne blokken motta dette objektet? */
    canReceive(message: Message): boolean;

    /** Opprett plassering for mottatt objekt */
    receive(message: Message, dropPosition?: { x: number, y: number }): Placement;

    /** Visuell feedback for aktiv drag */
    renderDropZone(): void;
}
```

Alle blokk-typer implementerer dette interfacet.

## 5. SpacetimeDB-integrasjon

Plasseringsdata for sanntidskontekster (storyboard, kanban) eies av SpacetimeDB:

```rust
#[table(name = message_placement, public)]
pub struct MessagePlacement {
    #[primary_key]
    pub id: String,
    pub message_id: String,
    pub context_type: String,
    pub context_id: String,
    pub entered_at: Timestamp,
    pub position_json: String,  // JSON-serialisert posisjon
}

#[reducer]
pub fn place_message(ctx: &ReducerContext, placement: MessagePlacement) { ... }

#[reducer]
pub fn remove_placement(ctx: &ReducerContext, message_id: String, context_type: String, context_id: String) { ... }

#[reducer]
pub fn move_on_canvas(ctx: &ReducerContext, placement_id: String, new_position_json: String) { ... }
```

Sync-workeren persisterer til PG `message_placements`-tabellen.

## 6. Responsivt design

- **Desktop:** Drag-and-drop mellom blokker fungerer naturlig
- **Tablet:** Drag-and-drop fungerer med touch, men "Send til..."-meny er primær
- **Mobil:** Kun "Send til..."-meny (blokker er stacked i én kolonne, drag mellom dem er upraktisk)

## 7. Bygger på

- **Meldingsboks** (`meldingsboks.md`): Alle overførte objekter er meldingsbokser
- **Kunnskapsgraf** (`kunnskapsgraf_og_relasjoner.md`): Plasseringer er relasjoner i grafen
- **BlockShell** / PageGrid: Blokk-rammen som rendrer mottakssoner
- **SpacetimeDB** (`synkronisering.md`): Sanntidssynk av plasseringer

## 8. Konsekvenser for eksisterende kode

### 8.1 BlockShell utvidelse

`BlockShell` trenger:
- `onDragEnter`/`onDragLeave`/`onDrop` handlers for visuell feedback
- Prop for `receiver: BlockReceiver` fra innholdsblokken
- Fullskjerm-toggle (knapp i header + Esc for å lukke)

### 8.2 Ny plasserings-tabell

`message_placements` er ny. Eksisterende `kanban_card_view` og `calendar_event_view` lever parallelt inntil migrering.

### 8.3 SpacetimeDB-modul

Ny tabell `message_placement` med reducers for place/remove/move.

## 9. Instruks for Claude Code
- Overføring oppretter aldri en kopi av meldingen — kun en ny plassering (view-config)
- `entered_at` er alltid `now()` ved overføring, aldri kopiert fra kilden
- En melding uten noen plasseringer er "løs" — den eksisterer i grafen men vises ikke noe sted. UI skal advare om dette ved siste fjerning
- Drag-and-drop bruker HTML5 Drag and Drop API for blokk-til-blokk, og pointer events for intra-canvas (storyboard/whiteboard)
- Hold overføringslogikken i en sentral `transferService` — ikke spread ut i hver blokk-type
- Mottaker-interfacet er obligatorisk for alle blokk-typer


================================================================
FILE: docs/features/visuell_graf.md
================================================================

# Feature: Visuell Kunnskapsgraf (Graph View)
**Filsti:** `docs/features/visuell_graf.md`

## 1. Konsept
En interaktiv graf-visning i SvelteKit som gjør Kunnskapsgrafen visuelt navigerbar og redigerbar. Brukes i Kunnskapsgrafen-konseptet (se `docs/concepts/kunnskapsgrafen.md`).

## 2. Funksjonalitet
* **Visualisering:** Viser noder (Temaer, Aktører, Faktoider, Segmenter) og relasjoner som et interaktivt nettverkskart. Bygges med D3.js eller Vis.js i Svelte-frontenden.
* **Visuell redigering:** Redaksjonen kan dra streker mellom noder for å opprette nye relasjoner i `graph_edges`-tabellen. Velg relasjonstype (`PART_OF`, `CONTRADICTS`, `MENTIONS`, etc.) via en kontekstmeny.
* **Hierarkier uten mapper:** `PART_OF`-relasjoner muliggjør fleksible prosjekthierarkier uten stive mappestrukturer. Et Tema kan være `PART_OF` et annet Tema, en Aktør kan være `PART_OF` en organisasjon, osv.
* **Filtrering:** Brukeren kan filtrere grafen etter nodetype, relasjonstype, tidsperiode eller fritekst.

## 3. Datakilde
* **SvelteKit server-side:** Henter grafdata via Recursive CTE-spørringer mot PostgreSQL og returnerer `{ "nodes": [...], "edges": [...] }` til klienten.
* **SpacetimeDB:** Brukes ikke for graf-visualisering — dette er historiske data som lever i PostgreSQL.

## 4. Instruks for Claude Code
* Design JSON-responsen slik at den lett kan mates inn i graf-visualiseringsbiblioteker (D3.js, Vis.js).
* Begrens traversering til 2-3 ledd ut fra startnode for å unngå eksplosjoner i store grafer.
* Tilgangsstyrt: noder filtreres via `node_access`-matrisen.


================================================================
FILE: docs/features/whiteboard.md
================================================================

# Feature Spec: Whiteboard (Frihåndstavle)
**Filsti:** `docs/features/whiteboard.md`

## 1. Konsept
Et delt, sanntids tegnebrett for frihåndsskisser, diagrammer og visuell brainstorming. Whiteboardet er en selvstendig komponent som kan brukes i flere kontekster: i møterom, i chat-tråder knyttet til et Tema, eller alene som personlig skisseblokk.

## 2. Brukskontekster

| Kontekst | Deltakere | Lagring |
|---|---|---|
| **Møterom** | Alle møtedeltakere i sanntid | Eksporteres som bilde ved møteslutt, knyttes til møtereferatet |
| **Tema-chat** | Alle med tilgang til temaet | Lagres som vedlegg til en melding, synlig i chat-historikken |
| **Personlig** | Kun brukeren selv | Privat skisse, kan deles til et Tema senere |

## 3. Sanntidssynkronisering
* **SpacetimeDB** synkroniserer strøk (penseltype, farge, koordinater) mellom alle deltakere i sanntid.
* Hvert whiteboard har en unik ID og er en node i grafen.
* Tilgangskontroll følger konteksten: møterom-deltakere, tema-medlemmer, eller kun eieren for personlige tavler.

## 4. Funksjonalitet
* **Tegneverktøy:** Frihåndstegning, rette linjer, rektangler, ellipser, tekst, piler.
* **Interaksjon:** Fargevelger, strektykkelse, viskelær, angre/gjør om.
* **Implementering:** HTML Canvas eller SVG i SvelteKit. Vurder et lett bibliotek (f.eks. tldraw, Excalidraw) hvis frihåndskvaliteten krever det — men foretrekk egenutviklet for å holde avhengigheter nede.

## 5. Lagring og Eksport
* **Sanntidsdata (SpacetimeDB):** Strøk-historikk holdes i minnet så lenge tavlen er aktiv.
* **Eksport (PostgreSQL + filsystem):** Når tavlen "lukkes" eller deles, rendres den til PNG eller SVG og lagres som en `media_file`. Referansen knyttes til konteksten (melding, møte) via `message_attachments` eller tilsvarende.
* **Dataklassifisering:** Strøk-data i SpacetimeDB er flyktig (kategori 4). Eksporterte bilder er avledet (kategori 3) — kan gjenskapes fra strøk-data så lenge tavlen er aktiv, men etter lukking er bildet den permanente kopien.

## 6. Instruks for Claude Code
* Whiteboard-komponenten skal være en gjenbrukbar Svelte-komponent som kan mountes i møterom, chat og som frittstående side.
* SpacetimeDB-tabellen for strøk bør være enkel: `whiteboard_id`, `stroke_data` (JSON), `user_id`, `timestamp`.
* Ikke bygg et fullverdig tegneprogram — start med frihåndstegning, viskelær og farger. Utvid ved behov.
* Tilgang til whiteboards styres via `node_access`-matrisen.


================================================================
FILE: docs/infra/ai_gateway.md
================================================================

# Infrastruktur: AI Gateway (LiteLLM)
**Filsti:** `docs/infra/ai_gateway.md`

## 1. Konsept
Synops bruker en sentralisert AI Gateway (LiteLLM) som eneste kontaktpunkt for alle AI-kall i systemet. All kode — Rust-workers, SvelteKit server-side — snakker med `http://ai-gateway:4000/v1`. Aldri direkte til leverandør-APIer.

Fordeler:
* **BYOK (Bring Your Own Key):** Direkte API-nøkler til Anthropic, Google, xAI — ingen markup
* **OpenRouter som fallback:** Tilgang til alle modeller vi ikke har direkte nøkler til, og sikkerhetsventil ved nedetid
* **Kostnadskontroll:** Rutineoppgaver rutes til gratisnivå (Gemini), dyre modeller kun når det trengs
* **Sentralisert logging:** Token-bruk per funksjon (Podcastfabrikken, Editor AI-behandling, Live-assistent) på ett sted
* **Redundans:** Automatisk failover mellom leverandører — redaksjonen merker ikke nedetid

## 2. Leverandører og bruksmønster

| Leverandør | Nøkkeltype | Primært bruksområde |
|---|---|---|
| Google Gemini | BYOK (gratisnivå) | Rutineoppgaver: transkripsjonsvasking, research-oppsummering, metadata-uttrekk |
| Anthropic (Claude) | BYOK | Oppgaver som krever høy resonneringsevne: live-assistent faktoid-vurdering, kompleks analyse |
| xAI (Grok) | BYOK | Alternativ for analyse, sanntidssøk (når tilgjengelig) |
| OpenRouter | BYOK | Fallback for alle modeller, sikkerhetsventil ved leverandør-nedetid |

**Merk:** Kvaliteten på norsk tekst varierer mellom modeller. Test alltid med norsk innhold før en modell tildeles en produksjonsoppgave.

## 3. Modellruting

### 3.1 Arkitekturprinsipp: PG eier config, LiteLLM er stateløs

PostgreSQL er single source of truth for all modellkonfigurasjon. LiteLLM er en stateløs proxy som får generert `config.yaml` fra PG-data. Dette gir:

* **Ingen avhengighet til LiteLLM sitt admin API** — de endrer API mellom versjoner
* **All konfig i samme backup/migrasjon** som resten av systemet
* **Enkel bytte** — hvis LiteLLM erstattes, er all konfig intakt i PG
* **Admin-UI i SvelteKit** — gjenbruker eksisterende `/admin/`-mønster

### 3.2 Datamodell

```sql
-- Globale modellaliaser (server-nivå)
CREATE TABLE ai_model_aliases (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    alias         TEXT NOT NULL,           -- 'sidelinja/rutine', 'sidelinja/resonering'
    description   TEXT,                    -- 'Billig, høyt volum'
    is_active     BOOLEAN NOT NULL DEFAULT true,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(alias)
);

-- Leverandør-modeller med prioritert fallback per alias
CREATE TABLE ai_model_providers (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    alias_id      UUID NOT NULL REFERENCES ai_model_aliases(id) ON DELETE CASCADE,
    provider      TEXT NOT NULL,           -- 'gemini', 'openrouter', 'anthropic'
    model         TEXT NOT NULL,           -- 'gemini/gemini-2.5-flash', 'openrouter/anthropic/claude-sonnet-4'
    api_key_env   TEXT NOT NULL,           -- 'GEMINI_API_KEY', 'OPENROUTER_API_KEY'
    priority      SMALLINT NOT NULL,       -- lavere = prøves først
    is_active     BOOLEAN NOT NULL DEFAULT true,
    UNIQUE(alias_id, model)
);

-- Jobbtype → modellalias mapping
CREATE TABLE ai_job_routing (
    job_type      TEXT PRIMARY KEY,        -- 'ai_text_process', 'whisper_postprocess', etc.
    alias         TEXT NOT NULL,           -- 'sidelinja/rutine'
    description   TEXT
);
```

### 3.3 Config-generering

SvelteKit-serveren genererer `config.yaml` fra PG ved oppstart og ved endringer i admin-panelet:

1. Les aktive aliaser og deres providers (sortert etter priority)
2. Skriv `config.yaml` til volum delt med LiteLLM-containeren
3. Restart LiteLLM (`docker restart ai-gateway`) eller send `SIGHUP`

Generert config inkluderer alltid `router_settings` og `general_settings` fra faste verdier — kun `model_list` er dynamisk.

### 3.4 Jobbkø-styrt modellvalg

Jobbkøen bruker `ai_job_routing` for å bestemme modellalias per jobbtype:

| Jobbtype | Standard alias | Begrunnelse |
|---|---|---|
| `ai_text_process` (✨-behandling) | `sidelinja/rutine` | Tekstvasking, høyt volum |
| `whisper_postprocess` | `sidelinja/rutine` | Transkripsjonsvasking, høyt volum |
| `research_clip` | `sidelinja/rutine` | Research-oppsummering, høyt volum |
| `live_factoid_eval` | `sidelinja/resonering` | Krever presis vurdering under tidspress |

Modellalias lagres som felt på jobben i PG — kan overstyres manuelt per jobb ved behov.

### 3.5 Admin-panel (`/admin/ai`)

Admin-panelet lar administrator:
* Se og redigere modellaliaser og deres fallback-liste (drag-and-drop prioritering)
* Aktivere/deaktivere individuelle leverandør-modeller
* Endre jobbtype → alias mapping
* Se live-status: hvilke leverandører som svarer, responstider
* Trigge config-regenerering og LiteLLM-restart

## 4. Docker-oppsett

```yaml
# docker-compose.dev.yml / docker-compose.yml
ai-gateway:
  image: ghcr.io/berriai/litellm:main-stable
  restart: unless-stopped
  command: --config /etc/litellm/config.yaml
  environment:
    LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
    GEMINI_API_KEY: ${GEMINI_API_KEY}
    ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
    XAI_API_KEY: ${XAI_API_KEY}
    OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
  volumes:
    - ./config/litellm/config.yaml:/etc/litellm/config.yaml:ro
  ports:
    - "127.0.0.1:4000:4000"  # kun localhost (dev), ingen port i prod
  networks:
    - sidelinja-dev  # eller sidelinja-net i prod
```

## 5. Prompt-kvalitetssikring (Promptfoo)

Alle LLM-prompts i Sidelinja testes systematisk med [Promptfoo](https://promptfoo.dev) før de brukes i produksjon. Dette er spesielt viktig fordi vi jobber med norsk tekst, der modellkvaliteten varierer kraftig mellom leverandører.

### 5.1 Hva vi tester
Hver jobbtype som bruker LLM har et tilhørende testsett:

| Jobbtype | Testsett | Eksempler på assertions |
|---|---|---|
| `whisper_postprocess` | Norske transkripsjoner med kjente feil | Egennavn korrigert, setningsflyt bevart |
| `openrouter_analyze` | Episoder med kjent metadata | Riktig tittel, kapitler matcher innhold |
| `research_clip` | Nyhetsartikler med kjente aktører/fakta | Aktører identifisert, faktoider korrekte |
| `live_factoid_eval` | Transkripsjons-chunks med kjente entiteter | Riktig entity-match, lav falsk-positiv-rate |

### 5.2 Hva vi sammenligner
Promptfoo kjøres mot alle kandidatmodeller via AI Gateway:

```yaml
# promptfoo-config.yaml
providers:
  - id: "openai:chat:sidelinja/rutine"
    config:
      apiBaseUrl: "http://localhost:4000/v1"
      apiKey: "${LITELLM_MASTER_KEY}"
  - id: "openai:chat:sidelinja/resonering"
    config:
      apiBaseUrl: "http://localhost:4000/v1"
      apiKey: "${LITELLM_MASTER_KEY}"
```

Dette lar oss svare på:
* Klarer Gemini (gratis) denne oppgaven like bra som Claude (betalt)?
* Fungerer prompten på norsk, eller trenger vi en annen formulering?
* Har en modelloppgradering hos leverandøren degradert kvaliteten?

### 5.3 Når vi kjører tester
* **Ved ny prompt:** Før den tas i bruk i produksjon
* **Ved modellbytte:** Før en leverandør/modell settes som primær for en jobbtype
* **Periodisk (CI):** Månedlig cron-jobb i Forgejo Actions kjører `promptfoo eval` mot alle testsett. Resultater postes som issue ved regresjoner. Leverandører oppdaterer modeller uten varsel — automatisk regresjonssjekk fanger dette opp.
* **Ved kvalitetsklager:** Når redaksjonen rapporterer dårlig output

### 5.4 Lagring av testsett
Testsett og promptfoo-config versjonskontrolleres i Git under `tests/prompts/`. Testdata er norske eksempler fra faktiske episoder og artikler.

```
tests/prompts/
├── promptfooconfig.yaml
├── whisper_postprocess/
│   ├── prompt.txt
│   └── dataset.json
├── metadata_extract/
│   ├── prompt.txt
│   └── dataset.json
└── research_clip/
    ├── prompt.txt
    └── dataset.json
```

## 6. Tokenregnskap og kostnadskontroll

### 6.1 Token-logging per samlings-node

Rust-workeren logger tokenforbruk etter hvert AI-kall. Dataen lagres i PG:

```sql
CREATE TABLE ai_usage_log (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    collection_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    job_id          UUID REFERENCES job_queue(id) ON DELETE SET NULL,
    model_alias     TEXT NOT NULL,          -- 'sidelinja/rutine'
    model_actual    TEXT,                   -- 'gemini/gemini-2.5-flash' (fra LiteLLM-respons)
    prompt_tokens   INT NOT NULL,
    completion_tokens INT NOT NULL,
    total_tokens    INT NOT NULL,
    estimated_cost  NUMERIC(10, 6),         -- USD, beregnet fra kjente priser
    job_type        TEXT,                   -- 'ai_text_process', etc.
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_ai_usage_collection_month ON ai_usage_log (collection_node_id, created_at);
```

**Flyten:**
1. Rust-worker sender AI-kall via gateway, får tilbake `usage` i responsen
2. Worker skriver rad til `ai_usage_log` med collection_node_id, tokens og modellinfo
3. Estimert kostnad beregnes fra en enkel prisliste i config (oppdateres manuelt)

### 6.2 Visning -- to nivåer

**Admin (`/admin/ai`):**
Aggregert oversikt over alle samlings-noder. Tabell med totaler per samlings-node/modell/periode. Identifiserer kostnadsdrivere.

**Samlings-node (sidebar-widget):**
Enkel tekst-indikator i sidebar: `12.4k tokens denne uken`. Klikk åpner detaljert visning med fordeling per jobbtype og modell. Ingen speedometer -- det krever et definert budsjett for å gi mening, og det er overkill for MVP.

### 6.3 Budsjett per samlings-node (fase 2)

Når token-logging er på plass, kan budsjett-tak legges til:

- Budsjett lagres som JSONB-metadata på samlings-noden: `{ "ai_budget": { "monthly_limit_usd": 50 } }`
- Rust-worker sjekker aggregert forbruk før AI-kall
- Ved budsjett nær: fall tilbake til `sidelinja/rutine` (billigste)
- Ved budsjett nådd: sett jobb i `paused` med varsel i samlings-nodens chat

### 6.4 Per-episode maks-kostnad
Podcastfabrikken-jobber (whisper + metadata + oppsummering) kan estimere totalkostnad basert på lydlengde. Jobben avbrytes med varsel hvis estimert kostnad overstiger `max_cost_per_episode` (default: $5).

## 7. Dataklassifisering (ref. docs/arkitektur.md 2.2)

| Data | Kategori | Detaljer |
|---|---|---|
| LiteLLM config.yaml | Gjenskapbar (Git) | Versjonskontrollert |
| API-nøkler | Kritisk (.env) | Aldri i Git |
| Token-bruk-logger | Flyktig (TTL 90 dager) | For kostnadsoversikt, ryddes automatisk |
| Promptfoo testsett | Gjenskapbar (Git) | `tests/prompts/` — versjonskontrollert |
| Promptfoo testresultater | Flyktig (lokal) | Kjøres on-demand, ikke lagret permanent |

## 8. Kildevern-modus (proposal)
For sensitive redaksjonelle diskusjoner kan en lokal LLM-leverandør (Ollama/vLLM) registreres som `sidelinja/lokal` i config. Channels/møter med `kildevern: true` ruter all AI-prosessering til denne modellen — data forlater aldri serveren. Se `docs/proposals/kildevern_modus.md`.

## 9. Instruks for Claude Code
* All AI-kode skal peke på `http://ai-gateway:4000/v1` — aldri direkte til leverandør
* Bruk modellaliaser (`sidelinja/rutine`, `sidelinja/resonering`) — aldri hardkod leverandør-spesifikke modellnavn i applikasjonskode
* API-nøkler i `.env`, aldri i config-filer eller kode
* Test alltid med norsk innhold før en ny modell/leverandør tas i bruk for en produksjonsoppgave
* Kjør `promptfoo eval` før du endrer prompts eller bytter modell for en jobbtype
* Nye jobbtyper som bruker LLM skal ha et tilhørende testsett i `tests/prompts/` før de merges


================================================================
FILE: docs/infra/api_grensesnitt.md
================================================================

# Infrastruktur: API-grensesnitt og Tjenesteansvar
**Filsti:** `docs/infra/api_grensesnitt.md`

## 1. Konsept
Definerer hvordan SvelteKit-frontenden kommuniserer med backend-tjenestene. Prinsippet er: **SvelteKit er web-serveren, Rust er workeren.** Ingen separat Rust HTTP API.

## 2. Kommunikasjonskart

```
┌─────────────────────────────────────────────────────────────┐
│  Brukerens nettleser (SvelteKit klient)                     │
└──────────┬──────────────────────┬───────────────────────────┘
           │                      │
           │ WebSocket            │ HTTP (forms, fetch)
           ▼                      ▼
┌──────────────────┐   ┌─────────────────────────────────────┐
│  SpacetimeDB     │   │  SvelteKit Server                   │
│                  │   │  (load functions, form actions,      │
│  - Chat          │   │   API routes)                        │
│  - Kanban        │   │                                      │
│  - Live events   │   │  Ansvar:                             │
│  - Autocomplete  │   │  - Les/skriv PostgreSQL direkte      │
│  - Studio-       │   │  - Opprett jobber i job_queue        │
│    markører      │   │  - Filopplasting (streaming)         │
│                  │   │  - RSS-generering                    │
│                  │   │  - Kunnskapsgraf-spørringer          │
└──────────────────┘   └──────────────┬───────────────────────┘
                                      │
                                      │ SQL
                                      ▼
                        ┌──────────────────────────┐
                        │  PostgreSQL               │
                        │                           │
                        │  - Kunnskapsgraf          │
                        │  - Episodemetadata        │
                        │  - Statistikk             │
                        │  - Jobbkø (job_queue)     │
                        │  - Brukerdata             │
                        └──────────────┬────────────┘
                                       │
                                       │ Poll (SELECT FOR UPDATE)
                                       ▼
                        ┌──────────────────────────┐
                        │  Rust Workers             │
                        │                           │
                        │  - whisper_transcribe     │
                        │  - openrouter_analyze     │
                        │  - research_clip          │
                        │  - stats_parse            │
                        │  - sync_to_pg (SpaceDB→PG)│
                        └──────────────────────────┘
```

## 3. Ansvarsfordeling

| Komponent | Rolle | Snakker med |
|---|---|---|
| **SvelteKit (klient)** | UI, brukerinteraksjon | SpacetimeDB (WS), SvelteKit server (HTTP) |
| **SvelteKit (server)** | Web-API, PG-tilgang, jobb-trigger | PostgreSQL (SQL) |
| **SpacetimeDB** | Sanntids state, push til klienter | Klienter (WS), sync-worker (intern) |
| **Rust Workers** | Tunge bakgrunnsjobber, synk | PostgreSQL (SQL), SpacetimeDB, OpenRouter, faster-whisper |

## 4. Viktige avklaringer
- **Rust er ikke en API-server.** Rust kjører kun som workers/prosessorer som poller jobbkøen
- **SvelteKit server-side er trygt.** Load functions og form actions kjører på serveren og kan snakke direkte med PG uten sikkerhetsproblemer
- **Filopplasting** håndteres av SvelteKit (streaming for store filer), som lagrer filen på disk og oppretter en jobb i køen
- **SpacetimeDB nås aldri via SvelteKit server** — kun direkte fra klienten via WebSocket

## 5. Instruks for Claude Code
- Ikke opprett et separat Rust HTTP API/webserver-prosjekt
- Bruk SvelteKit `+server.ts` (API routes) eller `+page.server.ts` (form actions/load) for all HTTP-kommunikasjon
- Rust-kode skal struktureres som worker-binærer som konsumerer fra `job_queue`
- For PG-tilgang i SvelteKit, bruk et bibliotek som `postgres.js` eller `drizzle-orm`


================================================================
FILE: docs/infra/jobbkø.md
================================================================

# Infrastruktur: Jobbkø (PostgreSQL-basert)
**Filsti:** `docs/infra/jobbkø.md`

## 1. Konsept
Et felles, sentralisert køsystem for alle asynkrone bakgrunnsjobber i Sidelinja. Bygget som en enkel tabell i PostgreSQL med Rust-workers som konsumerer jobber. Ingen ekstern message broker — PostgreSQL er køen.

## 2. Hvorfor PostgreSQL?
- Allerede i stacken, ingen ny infrastruktur å drifte
- Transaksjonell garanti: jobben og resultatet kan committes sammen med dataendringer
- Lavt volum (titalls jobber/time) gjør polling neglisjerbart
- Enkel feilsøking via SQL (`SELECT * FROM job_queue WHERE status = 'error'`)
- `SELECT ... FOR UPDATE SKIP LOCKED` gir trygg concurrent polling uten låsekonflikt

## 3. Datastruktur

```sql
CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'error', 'retry');

CREATE TABLE job_queue (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    collection_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,  -- Samlings-node jobben tilhører
    job_type      TEXT NOT NULL,          -- 'whisper_transcribe', 'openrouter_analyze', 'stats_parse', 'research_clip'
    payload       JSONB NOT NULL,         -- Inputdata (filsti, tekst, tema_id, etc.)
    status        job_status NOT NULL DEFAULT 'pending',
    priority      SMALLINT NOT NULL DEFAULT 0,  -- Høyere = viktigere
    result        JSONB,                  -- Resultatet ved fullført jobb
    error_msg     TEXT,                   -- Feilmelding ved error
    attempts      SMALLINT NOT NULL DEFAULT 0,
    max_attempts  SMALLINT NOT NULL DEFAULT 3,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    started_at    TIMESTAMPTZ,
    completed_at  TIMESTAMPTZ,
    scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now()  -- For utsatte jobber / retry med backoff
);

CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for ASC)
    WHERE status IN ('pending', 'retry');
```

## 4. Worker-arkitektur (Rust)

### 4.1 Designprinsipp: Orkestrator, ikke prosesseringsmotor
Workeren gjør lite tung prosessering selv. Den er en **orkestrator** som koordinerer eksterne tjenester:

| Jobbtype | Hva workeren gjør | Tung logikk i workeren? |
|---|---|---|
| `whisper_transcribe` | HTTP-kall til faster-whisper-server, commit SRT til Forgejo | Nei — venter på svar |
| `openrouter_analyze` | HTTP-kall til AI Gateway | Nei — venter på svar |
| `srt_parse` | Parser SRT-tekst, skriver avledede formater til PG | Lett strengparsing |
| `stats_parse` | Parser Caddy-loggfiler, skriver til PG | Lett I/O |
| `research_clip` | HTTP-kall til AI Gateway | Nei — venter på svar |
| `generate_embeddings` | HTTP-kall til AI Gateway | Nei — venter på svar |

Ny jobbtype = ny handler-funksjon (bygg request, håndter respons, feilhåndtering). Tynt glue-code. Rekompilering er triviell og inkrementell.

### 4.2 Én worker, prioritetsstyrt
Én enkelt worker-prosess håndterer **alle jobbtyper**. Prioritering skjer via `priority`-kolonnen i køen — SQL-spørringen plukker alltid viktigste jobb først. Ingen behov for separate prosesser per jobbtype.

```
┌──────────────────────────────────────────────────┐
│  Rust Worker (sidelinja-worker)                  │
│                                                  │
│  Konfigurasjon:                                  │
│    --max-concurrent 3  (samtidige jobber)        │
│    --poll-interval 1s                            │
│                                                  │
│  Loop (per ledig slot):                          │
│    1. SELECT ... FOR UPDATE SKIP LOCKED          │
│       WHERE status IN ('pending','retry')        │
│       AND scheduled_for <= now()                 │
│       ORDER BY priority DESC, scheduled_for      │
│       LIMIT 1                                    │
│                                                  │
│    2. UPDATE status = 'running'                  │
│    3. Dispatch til handler basert på job_type    │
│    4a. OK:    UPDATE status = 'completed'        │
│    4b. Feil:  attempts += 1                      │
│               Hvis attempts < max_attempts:      │
│                 status = 'retry'                 │
│                 scheduled_for = now()            │
│                   + backoff(attempts)            │
│               Ellers: status = 'error'           │
│                                                  │
└──────────────────────────────────────────────────┘
```

### 4.3 Prioritetsmodell

| Prioritet | Kategori | Eksempler |
|---|---|---|
| 10 | Brukerrettet / sanntid | `dictation_cleanup`, `research_clip` |
| 5 | Normal | `whisper_transcribe`, `openrouter_analyze`, `srt_parse` |
| 1 | Bakgrunn | `stats_parse`, `generate_embeddings`, `prompt_eval` |

Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på kontekst. En manuelt trigget transkripsjon kan få høyere prioritet enn en automatisk nattjobb.

### 4.4 Ressursstyring
* **Concurrency:** `--max-concurrent` begrenser antall samtidige jobber. Default 3 — passer for 8 vCPU der noen slots er Whisper (CPU-tung) og resten er HTTP-kall (ventetid).
* **Resource Governor (Whisper):** Når et LiveKit-rom er aktivt, reduserer workeren Whisper-tråder (`--threads 2` i HTTP-kall til faster-whisper) for å beskytte lydkvaliteten. Sjekkes via LiveKit room-status før Whisper-kall.
* **Skalering senere:** To nivåer:
    1. **Worker-splitting:** Workeren splittes til to binærer fra samme crate (`worker-heavy`, `worker-light`) via CLI-argument (`--types whisper_transcribe,openrouter_analyze`). Ingen kodeendring nødvendig — kun deploy-konfigurasjon.
    2. **Compute-separasjon:** Flytt Rust-worker + faster-whisper til en separat Hetzner-node (evt. ARM/Ampere for pris/ytelse). LiveKit er ekstremt sensitivt for CPU-stotring — ved samtidig WebRTC og Whisper på samme maskin risikerer vi audio glitches uansett cgroups. Worker-noden poller jobbkøen i PostgreSQL over internt nettverk — arkitekturen støtter dette uten kodeendring.

**Backoff-strategi:** Eksponentiell: `30s × 2^(attempts-1)` (30s, 60s, 120s).

## 5. Jobbtyper

| `job_type` | Konsument | Beskrivelse |
|---|---|---|
| `whisper_transcribe` | Podcastfabrikken | Transkriber MP3 via faster-whisper |
| `openrouter_analyze` | Podcastfabrikken | Metadata-uttrekk fra transkripsjon |
| `ai_text_process` | Editor (AI-knapp) | Rens, oppsummer, trekk ut fakta, skriv om (se `docs/proposals/editor.md`) |
| `stats_parse` | Podcast-Statistikk | Batch-prosesser Caddy-logger |
| `meeting_summarize` | Møterommet | Generer møtereferat og action points fra transkripsjon |
| `valgomat_generate_profile` | Valgomat | Generer syntetiske kandidatprofiler fra partiprogrammer |
| `valgomat_moderation` | Valgomat | Semantisk deduplisering og nøytralitetsvask av brukerspørsmål |
| `dictation_cleanup` | Lydmeldinger | AI-opprydding av diktert transkripsjon til strukturert notat |
| `generate_embeddings` | Kunnskaps-Bridge | Generer vector embeddings for noder (pgvector) |
| `prompt_eval` | Prompt-Laboratorium | Batch-evaluering av testsett mot valgte modeller |
| `url_ingest` | Web Clipper (proposal) | Hent URL, oppsummer via AI, opprett research-klipp med graf-koblinger |
| `generate_waveform` | Waveforms (proposal) | Generer audio-peaks fra lydfil for visuell bølgeform |

## 6. Tilgangsisolasjon
Alle jobber merkes med `collection_node_id`. Rust-workers kjører som superuser (bypasser RLS) og sikrer isolasjon i applikasjonskode:
* Worker leser `collection_node_id` fra jobben og bruker det til å lagre resultater tilbake i riktig samlings-node
* Per samlings-node config (AI-prompts, navnelister) hentes fra samlings-nodens JSONB-metadata
* Feilede jobber vises kun for brukere med tilgang til samlings-noden (via node_access) i admin-visningen

## 7. Observabilitet
- Jobber med `status = 'error'` skal være synlige i admin-visningen (SvelteKit `/admin/jobs`)
- Valgfritt: SpacetimeDB-event ved statusendring slik at UI kan vise fremdrift i sanntid (f.eks. "Transkriberer... 2/3 forsøk")

## 8. Instruks for Claude Code
- Én binær: `sidelinja-worker`. Én Rust-crate med polling-loop + handler-dispatch
- Hver jobbtype implementeres som en handler-funksjon som registreres i en `HashMap<String, Handler>`
- Bruk `tokio` med semaphore for concurrency-kontroll (`--max-concurrent`)
- Aldri lagre lydfiler i `payload` — bruk filstier
- Opprett alltid jobber med riktig `collection_node_id` — hent fra konteksten (innlogget bruker, webhook, etc.)
- Ved `stats_parse`: denne erstatter den frittstående cronjobben beskrevet i podcast_statistikk.md — bruk jobbkøen med `scheduled_for` for periodisk kjøring
- Splitt til flere binærer kun hvis det blir eksplisitt bedt om — start med én


================================================================
FILE: docs/infra/synkronisering.md
================================================================

# Synkronisering: SpacetimeDB ↔ PostgreSQL

## Konsept

SpacetimeDB holder hele grafen (alle noder og edges) i minne.
PostgreSQL er persistent backup og arkiv. Skriving går til begge.
Lesing går fra SpacetimeDB.

```
GUI (SvelteKit)
  │ skriv                    │ les (sanntid)
  ▼                          ▼
Maskinrommet (Rust)          SpacetimeDB ──→ GUI
  │                            ▲
  ├──→ PostgreSQL (persistent) │
  └──→ SpacetimeDB ───────────┘
```

## Hvorfor hele grafen i SpacetimeDB

Node- og edge-skjemaet er minimalt: åtte kolonner på nodes, åtte
på edges. For en liten brukerbase er hele grafen triviell å holde
i minne. Dette forenkler alt:

- Ingen sync-logikk for å bestemme hva som er "aktivt"
- Ingen henting fra PG når noen åpner noe gammelt
- Frontend har alltid alt tilgjengelig via WebSocket
- Én lesekilde — ingen tvetydighet

Når dette *blir* problematisk (hundretusenvis av noder, minnepress),
innføres eviction basert på aksessmønstre. Men det er en
optimaliseringsbeslutning, ikke en arkitekturbeslutning. Modellen
endres ikke.

## Skrivestien

Maskinrommet validerer, så skriver i to steg:

1. **Validering** — tilgangssjekk, unique-constraints, forretningsregler
2. **SpacetimeDB først** — frontend oppdateres umiddelbart (~10μs)
3. **PG asynkront** — persistent backup i bakgrunnen

Frontend er allerede oppdatert mens PG-skrivingen skjer. Brukeren
merker ingenting.

Hvis PG-skrivingen feiler: maskinrommet logger og prøver igjen.
SpacetimeDB har dataen — den er bare ikke persistert ennå. Ingen
datatap så lenge SpacetimeDB kjører.

## Lesestien

Frontend leser **kun fra SpacetimeDB** via WebSocket-subscriptions.
Ingen PG-kall fra frontend for data som vises i sanntid.

Unntak: tunge spørringer som ikke passer sanntidslaget — statistikk,
fulltekstsøk, pgvector-søk, AGE-traverseringer. Disse går
GUI → Maskinrommet → PG.

## Oppvarming (PG → SpacetimeDB)

Ved oppstart av SpacetimeDB laster maskinrommet hele grafen fra PG:

1. Alle noder
2. Alle edges
3. `node_access`-matrisen

Rekkefølge: noder først (edges refererer til noder).
CAS-noder lastes uten binærinnhold (bare metadata).

## Feilhåndtering

| Scenario | Konsekvens | Håndtering |
|----------|-----------|------------|
| SpacetimeDB krasjer | Sanntid forsvinner, upersistert data kan tapes | Restart + oppvarming fra PG. Tap begrenset til skrivinger som ikke rakk PG. |
| PG nede | Persistering stopper | SpacetimeDB serverer lesing og mottar skrivinger. PG-backlog tas igjen ved recovery. |
| Maskinrommet krasjer | Ingen skriving | Frontend ser siste state. Restart plukker opp. |

## SpacetimeDB som utbyttbar

SpacetimeDB er en sanntidscache, ikke en avhengighet. Hvis den
fjernes fra stacken:

- **Sanntid:** PG `LISTEN/NOTIFY` → SvelteKit SSE
- **Skriving:** Maskinrommet → PG direkte
- **Lesing:** Maskinrommet → PG → GUI

Denne fallbacken trenger ikke implementeres, men arkitekturen
skal aldri gjøre den umulig.

## Tilgangsmatrise i SpacetimeDB

`node_access`-matrisen lastes inn i SpacetimeDB ved oppvarming.
Når maskinrommet oppdaterer matrisen i PG (ved edge-endring),
oppdaterer den også SpacetimeDB. SpacetimeDB-modulen bruker
matrisen for å filtrere subscriptions — klienter ser kun noder
de har tilgang til.

## Konflikthåndtering

SpacetimeDB er single-threaded per modul — reducer-funksjoner
serialiseres automatisk. Ingen klassiske race conditions.

Samtidig redigering (to brukere endrer samme node): maskinrommet
serialiserer via SpacetimeDB. Last-write-wins. SpacetimeDB
kringkaster resultatet til alle klienter. PG oppdateres asynkront
med det endelige resultatet.


================================================================
FILE: docs/setup/lokal.md
================================================================

# Oppsett: Lokalt Utviklingsmiljø (WSL2)
**Filsti:** `docs/setup/lokal.md`

Det lokale miljøet er et **utviklingsmiljø for kode**. Frontend (SvelteKit) kjøres lokalt med HMR, Rust bygges lokalt. Alle tjenester (PG, SpacetimeDB, AI Gateway, etc.) kjører på produksjonsserveren — ingen lokal Docker-replika.

## Hva som gjøres hvor

| Aktivitet | Hvor | Hvorfor |
|---|---|---|
| Skrive/teste kode (Rust, SvelteKit, TypeScript) | Lokalt | Rask iterasjon, HMR |
| PG-skjema og migrasjoner | Mot server-PG | Én sannhetskilde |
| Whisper/AI-eksperimentering | Via AI Gateway på server | Felles tjeneste |
| Docker-compose endringer | Direkte på server | Serveren er dev-miljø |
| Caddy/Authentik/Forgejo config | Direkte på server | Avhenger av domener, sertifikater, SSO |

## 0. Forutsetninger
- Windows 11 med WSL2 (Ubuntu 24.04 LTS)
- Node.js 20+ (via nvm i WSL2)
- Rust toolchain (via rustup i WSL2)
- SSH-nøkkel konfigurert mot serveren og Forgejo (`~/.ssh/id_ed25519`)

## 1. Installer verktøy i WSL2

```bash
# Node.js via nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
nvm use 20

# Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
```

## 2. Klon prosjektet

```bash
cd ~
git clone ssh://git@git.sidelinja.org:222/vegard/synops.git
cd synops
```

## 3. Miljøvariabler (.env.local)

```bash
cp .env.example .env.local
# Fyll inn API-nøkler (Gemini, xAI, etc.)
```

`.env.local` inneholder kun lokale variabler (API-nøkler for dev, Authentik-innstillinger). Tjenestekonfigurasjon lever på serveren.

## 4. Utviklingsflyt

```bash
# SvelteKit med HMR
cd web && npm run dev

# Rust maskinrom
cd rust && cargo run
```

## 5. Deploy

```bash
# 1. Commit og push
git push forgejo main

# 2. Deploy til prod (krever eksplisitt godkjenning)
ssh vegard@157.180.81.26 "cd /srv/synops && git pull && docker compose up -d --build"
```

## 6. Forskjeller fra produksjon (bevisste)

| Aspekt | Lokalt | Produksjon |
|---|---|---|
| SvelteKit | `npm run dev` (HMR) | Docker container |
| Rust | `cargo run` | Docker container |
| Database/tjenester | Kobler til server | Kjører lokalt i Docker |
| HTTPS | Ikke nødvendig | Let's Encrypt via Caddy |
| Auth | Authentik bypass eller dev-token | Full Authentik OIDC |


================================================================
FILE: docs/setup/migration_safety.md
================================================================

# Migration Safety Checklist

> **Merk:** Denne sjekklisten er skrevet for v1-arkitekturen der RLS var
> basert på `workspace_id`-kolonner og `SET app.current_workspace_id`.
> Workspace-modellen er erstattet av en node-basert tilgangsmatrise (se
> `docs/retninger/bruker_ikke_workspace.md`). Sjekklisten må skrives om for
> det nye mønsteret: `node_access`-matrise, edge-basert tilgang, og
> RLS-policies som opererer på bruker→node-edges i stedet for workspace-scope.
>
> Seksjonene under er bevart som referanse for v1-mønsteret.

Sjekkliste for alle som kjører PostgreSQL-migrasjoner — lokalt eller i prod.

## Før migrering

- [ ] Les migrasjonfilen og forstå hva den gjør
- [ ] Har migrasjonen en tilhørende **down-migrering**? (påkrevd for skjema-endringer)
- [ ] Tar migrasjonen backup-hensyn? (dropper den kolonner/tabeller med data?)

## Etter migrering

### RLS-verifisering (KRITISK)
Etter *enhver* migrering som oppretter eller endrer tabeller med `workspace_id`:

```sql
-- 1. Verifiser at RLS er aktivert på alle workspace-tabeller
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
  AND tablename IN ('nodes', 'graph_edges', 'messages', 'channels',
                     'media_files', 'job_queue', 'message_attachments')
ORDER BY tablename;
-- Forventet: rowsecurity = true for alle

-- 2. Verifiser at policies eksisterer
SELECT tablename, policyname, cmd, qual
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename;
-- Forventet: workspace_isolation_* policy for hver tabell

-- 3. Test isolasjon: sett workspace A, forsøk å lese workspace B
SET app.current_workspace_id = '<workspace_a_uuid>';
SELECT count(*) FROM nodes WHERE workspace_id = '<workspace_b_uuid>';
-- Forventet: 0 rader (RLS blokkerer)

-- 4. Verifiser at superuser IKKE blokkeres (Rust workers trenger dette)
RESET app.current_workspace_id;
SET ROLE postgres;
SELECT count(*) FROM nodes;
-- Forventet: alle rader synlige
```

### Indeksverifisering
```sql
-- Sjekk at viktige indekser finnes
SELECT indexname, tablename
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename IN ('nodes', 'graph_edges', 'messages')
ORDER BY tablename;
```

### Constraint-verifisering
```sql
-- Sjekk at foreign keys er intakte
SELECT tc.table_name, tc.constraint_name, tc.constraint_type
FROM information_schema.table_constraints tc
WHERE tc.table_schema = 'public'
  AND tc.constraint_type IN ('FOREIGN KEY', 'CHECK', 'UNIQUE')
ORDER BY tc.table_name;
```

## RLS Leak Hunter (CI-test)

`SET app.current_workspace_id` er en skjult single point of failure — en glemt SET i en ny feature, en feil i connection-pool, eller en ny tjeneste som kobler til PG uten middleware kan føre til cross-workspace datalekkasje. Denne testen fanger det opp.

### Automatisk CI-test (to-workspace leak detection)
Kjøres i migrasjonstester og som egen CI-steg:

```sql
-- Opprett to test-workspaces
INSERT INTO workspaces (id, name, slug) VALUES
  ('aaaaaaaa-0000-0000-0000-000000000001', 'Workspace A', 'ws-a'),
  ('aaaaaaaa-0000-0000-0000-000000000002', 'Workspace B', 'ws-b');

-- Seed testdata i begge
INSERT INTO nodes (id, node_type, workspace_id) VALUES
  ('bbbbbbbb-0000-0000-0000-000000000001', 'tema', 'aaaaaaaa-0000-0000-0000-000000000001'),
  ('bbbbbbbb-0000-0000-0000-000000000002', 'tema', 'aaaaaaaa-0000-0000-0000-000000000002');

-- TEST 1: Sett workspace A, forsøk å lese workspace B
SET app.current_workspace_id = 'aaaaaaaa-0000-0000-0000-000000000001';
DO $$
BEGIN
  IF (SELECT count(*) FROM nodes WHERE workspace_id = 'aaaaaaaa-0000-0000-0000-000000000002') > 0 THEN
    RAISE EXCEPTION 'RLS LEAK: Workspace A kan lese Workspace B sine noder!';
  END IF;
END $$;

-- TEST 2: Uten SET (tom current_setting) skal returnere 0 rader
RESET app.current_workspace_id;
DO $$
BEGIN
  -- For vanlig bruker (ikke superuser) bør dette returnere 0
  IF (SELECT count(*) FROM nodes) > 0 AND current_setting('is_superuser') = 'off' THEN
    RAISE EXCEPTION 'RLS LEAK: Uautentisert tilkobling kan lese data!';
  END IF;
END $$;
```

### Audit-trigger (produksjon)
Valgfri trigger som logger mistenkelige queries i prod:

```sql
-- Tabell for RLS-audit
CREATE TABLE IF NOT EXISTS rls_audit_log (
    id BIGSERIAL PRIMARY KEY,
    table_name TEXT NOT NULL,
    operation TEXT NOT NULL,
    current_workspace TEXT,
    session_user TEXT NOT NULL,
    query_timestamp TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Funksjon som logger når current_workspace_id ikke er satt
CREATE OR REPLACE FUNCTION audit_rls_context() RETURNS TRIGGER AS $$
BEGIN
  IF current_setting('app.current_workspace_id', true) IS NULL
     OR current_setting('app.current_workspace_id', true) = '' THEN
    IF current_setting('is_superuser') = 'off' THEN
      INSERT INTO rls_audit_log (table_name, operation, current_workspace, session_user)
      VALUES (TG_TABLE_NAME, TG_OP, current_setting('app.current_workspace_id', true), session_user);
    END IF;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
```

**Kjør leak hunter mot ALLE tabeller med workspace_id — ikke bare de som er listet over.** Nye tabeller legges til i listen automatisk via introspeksjon:

```sql
-- Finn alle tabeller med workspace_id-kolonne (bør alle ha RLS)
SELECT t.tablename
FROM pg_tables t
JOIN information_schema.columns c ON c.table_name = t.tablename
WHERE c.column_name = 'workspace_id'
  AND t.schemaname = 'public'
  AND NOT EXISTS (
    SELECT 1 FROM pg_policies p WHERE p.tablename = t.tablename
  );
-- Forventet: 0 rader. Enhver rad her = tabell med workspace_id UTEN RLS-policy.
```

## Automatisering
Disse sjekkene kjøres automatisk i migrasjonstestene (se `docs/arkitektur.md` §10.2). Manuell kjøring er kun nødvendig ved prod-migrasjoner til automatiserte tester er på plass. **RLS Leak Hunter bør prioriteres som første CI-steg — den beskytter mot den mest alvorlige feilkategorien (cross-workspace datalekkasje).**


================================================================
FILE: docs/setup/produksjon.md
================================================================

# Oppsett: Produksjonsserver (Hetzner VPS)
**Filsti:** `docs/setup/produksjon.md`

Denne oppskriften tar en fersk Ubuntu VPS fra null til en komplett Synops-installasjon. Hvert steg er sekvensielt — ikke hopp over noe.

## 0. Forutsetninger
- Hetzner VPS med Ubuntu 24.04 LTS (8 vCPU, 16 GB RAM minimum)
- DNS A-records som peker til VPS-ens IP:
  - `sidelinja.org` + `*.sidelinja.org`
  - `vegard.info` + `*.vegard.info`
- SSH-tilgang med nøkkelpar (passordautentisering deaktiveres i steg 1)

## 1. Grunnsikring av VPS

```bash
# Oppdater systemet
apt update && apt upgrade -y

# Opprett tjenestebruker (ikke kjør alt som root)
adduser sidelinja
usermod -aG sudo sidelinja

# Kopier SSH-nøkkel til ny bruker
mkdir -p /home/sidelinja/.ssh
cp ~/.ssh/authorized_keys /home/sidelinja/.ssh/
chown -R sidelinja:sidelinja /home/sidelinja/.ssh

# Deaktiver passordautentisering og root-login
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart sshd

# Brannmur: kun SSH, HTTP, HTTPS
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```

**Logg ut og logg inn som `sidelinja` fra nå av.**

## 2. Installer Docker

```bash
# Docker Engine (offisiell repo)
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

# Kjør Docker uten sudo
sudo usermod -aG docker sidelinja
newgrp docker
```

## 3. Opprett mappestruktur

```bash
sudo mkdir -p /srv/synops/{config,data,media,logs}
sudo mkdir -p /srv/synops/config/{caddy,authentik}
sudo mkdir -p /srv/synops/data/{postgres,spacetimedb,forgejo,authentik}
sudo mkdir -p /srv/synops/media/podcast
sudo mkdir -p /srv/synops/logs/caddy
sudo chown -R sidelinja:sidelinja /srv/synops
```

Resultat:
```
/srv/synops/
├── docker-compose.yml
├── .env
├── config/
│   ├── caddy/Caddyfile
│   └── authentik/
├── data/
│   ├── postgres/
│   ├── spacetimedb/
│   ├── forgejo/
│   └── authentik/
├── media/
│   └── podcast/
└── logs/
    └── caddy/
```

## 4. Miljøvariabler (.env)

```bash
cat > /srv/synops/.env << 'EOF'
# === Domener ===
DOMAIN_SIDELINJA=sidelinja.org
DOMAIN_VEGARD=vegard.info
DOMAIN_AUTH=auth.sidelinja.org
COMPOSE_PROJECT_NAME=sidelinja

# === PostgreSQL ===
POSTGRES_USER=sidelinja
POSTGRES_PASSWORD=<generer med: openssl rand -hex 32>
POSTGRES_DB=sidelinja

# === Authentik ===
AUTHENTIK_SECRET_KEY=<generer med: openssl rand -hex 64>
AUTHENTIK_POSTGRESQL_PASSWORD=<generer med: openssl rand -hex 32>
# Authentik bruker sin egen database i samme PostgreSQL-instans
AUTHENTIK_POSTGRESQL_HOST=postgres
AUTHENTIK_POSTGRESQL_USER=authentik
AUTHENTIK_POSTGRESQL_NAME=authentik

# === Forgejo ===
FORGEJO_DB_PASSWD=<generer med: openssl rand -hex 32>

# === LiveKit ===
LIVEKIT_API_KEY=<generer>
LIVEKIT_API_SECRET=<generer med: openssl rand -hex 32>

# === OpenRouter ===
OPENROUTER_API_KEY=<fra openrouter.ai>

# === Intern ===
# Ingen porter eksponeres utenom 80/443. Alt rutes internt via Docker-nettverket.
EOF

chmod 600 /srv/synops/.env
```

## 5. Tjeneste-installasjon (rekkefølge)

Tjenestene startes i rekkefølge fordi noen avhenger av andre. Alle defineres i `docker-compose.yml`, men vi verifiserer hvert lag før vi går videre.

### Lag A: Fundament (ingen avhengigheter mellom seg)
1. **Docker-nettverk:** Opprett internt nettverk `sidelinja-net`
2. **PostgreSQL:** Start, opprett databaser for Authentik og Forgejo, verifiser (`pg_isready`)
3. **Caddy:** Start med Caddyfile for alle domener, verifiser at HTTPS fungerer
4. **Authentik:** Start, gjennomfør initial setup via `https://auth.sidelinja.org`
5. **Forgejo:** Start med Authentik som OAuth2-provider, opprett organisasjon og repo

### Lag B: Sanntid (krever nettverk)
6. **SpacetimeDB:** Start, verifiser tilkobling
7. **LiveKit:** Start, verifiser at WebRTC fungerer

### Lag C: Applikasjon (krever alt over)
8. **SvelteKit:** Bygg og start container, verifiser at frontenden laster
9. **Rust Workers:** Bygg og start container(e), verifiser at jobbkøen polles

## 6. docker-compose.yml (skjelett)

```yaml
# Fullstendig docker-compose.yml bygges ut når tjenestene implementeres.
# Denne seksjonen dokumenterer strukturen og viktige regler.

# REGLER:
# - Ingen "ports:" mot host UTENOM Caddy (80, 443)
# - Alle tjenester på samme interne nettverk (sidelinja-net)
# - Volumer bruker bind mounts til /srv/synops/
# - .env-filen lastes automatisk av Docker Compose
# - RESSURSGRENSER: Worker-containere (Whisper) MÅ ha deploy.resources.limits
#   for å forhindre at de sultefôrer LiveKit og PostgreSQL.
#   Eksempel: workers: deploy: resources: limits: cpus: '4' memory: 8G

networks:
  sidelinja-net:
    driver: bridge

services:
  caddy:        # Eneste tjeneste med eksponerte porter (80, 443)
  postgres:     # data:/srv/synops/data/postgres
  authentik:    # SSO for alle domener, på auth.sidelinja.org
  forgejo:      # data:/srv/synops/data/forgejo, på git.sidelinja.org
  spacetimedb:  # data:/srv/synops/data/spacetimedb
  livekit:      # Intern port, proxyet via Caddy
  sveltekit:    # Intern port, proxyet via Caddy
  workers:      # Rust job workers, ingen porter
```

## 7. Caddy (Caddyfile grunnstruktur)

```caddyfile
# === SSO (felles for alle domener) ===
auth.sidelinja.org {
    reverse_proxy authentik:9000
}

# === Sidelinja (hovedapplikasjon) ===
sidelinja.org {
    # SvelteKit (frontend + API)
    reverse_proxy sveltekit:3000

    # LiveKit (WebSocket upgrade)
    handle_path /livekit/* {
        reverse_proxy livekit:7880
    }

    # SpacetimeDB (WebSocket)
    handle_path /spacetime/* {
        reverse_proxy spacetimedb:3000
    }

    # Podcast media (statiske filer med byte-range support)
    handle_path /media/* {
        root * /srv/synops/media
        file_server
    }

    # Podcast access log (kun media-forespørsler)
    log {
        output file /srv/synops/logs/caddy/podcast_access.log
        format json
    }
}

# === Forgejo (Git) ===
git.sidelinja.org {
    reverse_proxy forgejo:3000
}

# === Vegard.info ===
vegard.info {
    # Konfigureres når innhold er klart
    respond "Under construction" 200
}
```

## 8. PostgreSQL: Initielle databaser

Ved første oppstart må det opprettes separate databaser og brukere for Authentik og Forgejo:

```sql
-- Kjøres mot PostgreSQL etter første start
-- (eller via init-script montert til /docker-entrypoint-initdb.d/)

CREATE USER authentik WITH PASSWORD '<AUTHENTIK_POSTGRESQL_PASSWORD>';
CREATE DATABASE authentik OWNER authentik;

CREATE USER forgejo WITH PASSWORD '<FORGEJO_DB_PASSWD>';
CREATE DATABASE forgejo OWNER forgejo;
```

## 9. Authentik: Initial konfigurasjon

Etter oppstart, gå til `https://auth.sidelinja.org/if/flow/initial-setup/`:
1. Opprett admin-konto
2. Opprett OAuth2/OpenID Connect-provider for Forgejo
3. Opprett OAuth2/OpenID Connect-provider for SvelteKit (senere)
4. Konfigurer brukergrupper etter behov (redaksjon, admin)

## 10. Forgejo: Koble til Authentik

Forgejo konfigureres med Authentik som OAuth2-kilde:
- Authentication Source: OAuth2
- Provider: OpenID Connect
- Discovery URL: `https://auth.sidelinja.org/application/o/<slug>/.well-known/openid-configuration`
- Etter oppsett: opprett organisasjon `sidelinja`, opprett repo `sidelinja`

## 11. Backup-strategi

Se `docs/arkitektur.md` seksjon 2.2 for full dataklassifisering. Kun kategori 1 (kritisk) og Forgejo-data backupes.

### 11.1 PostgreSQL (daglig dump, 03:00)
```bash
# pg_dump er konsistent selv under last — ingen nedetid
docker compose exec -T postgres pg_dump -U sidelinja -Fc sidelinja \
  > /srv/synops/backup/pg/sidelinja_$(date +%Y%m%d).dump

# Behold 30 dager, slett eldre
find /srv/synops/backup/pg/ -name "*.dump" -mtime +30 -delete
```

### 11.1b PostgreSQL WAL-arkivering (kontinuerlig, PITR)
Daglig dump gir opptil 24 timers datatap. WAL-arkivering muliggjør Point-In-Time Recovery til minuttet.

```bash
# Installer pgBackRest (i PostgreSQL Docker-containeren eller som sidecar)
# Alternativt: WAL-G for enklere S3-oppsett

# postgresql.conf (legg til i Docker-volumet eller via environment)
archive_mode = on
archive_command = 'pgbackrest --stanza=sidelinja archive-push %p'
wal_level = replica

# pgbackrest.conf
[sidelinja]
pg1-path=/var/lib/postgresql/data

[global]
repo1-type=s3
repo1-s3-bucket=sidelinja-backup
repo1-s3-endpoint=fsn1.your-objectstorage.com
repo1-s3-region=fsn1
repo1-path=/pgbackrest
repo1-retention-full=4
repo1-retention-diff=14

# Ukentlig full backup (søndag kl. 02:00)
# 0 2 * * 0 sidelinja pgbackrest --stanza=sidelinja --type=full backup
# Daglig differensiell (man-lør kl. 02:00)
# 0 2 * * 1-6 sidelinja pgbackrest --stanza=sidelinja --type=diff backup

# Recovery-eksempel (gjenopprett til spesifikt tidspunkt):
# pgbackrest --stanza=sidelinja --target="2026-03-15 13:59:00" \
#   --target-action=promote restore
```

**Merk:** WAL-arkivering erstatter IKKE daglig pg_dump — dumpen er en enkel, portabel backup som fungerer uavhengig av pgBackRest. WAL-arkivering er et tillegg for finkornet recovery.

### 11.2 Media-filer (daglig, 03:30)
```bash
# Inkrementell med rsync til lokal backup-disk eller ekstern lagring
rsync -a --delete /srv/synops/media/ /srv/synops/backup/media/
```

### 11.3 Forgejo-data (daglig, 04:00)
```bash
# Forgejo-repos kan gjenskapes, men det er tidkrevende.
# Sikkerhetsnett-backup av hele data-mappen:
rsync -a --delete /srv/synops/data/forgejo/ /srv/synops/backup/forgejo/
```

### 11.4 Hemmeligheter (.env)
```bash
# Manuell kopi ved endring — ALDRI i Git
cp /srv/synops/.env /srv/synops/backup/env_$(date +%Y%m%d)
chmod 600 /srv/synops/backup/env_*
```

### 11.5 Off-site backup (rclone → Hetzner Object Storage)

Lokal backup beskytter kun mot logiske feil. Ved fysisk nodefeil tapes alt. Kategori 1-data pushes daglig til Hetzner Object Storage via `rclone`.

```bash
# Installer og konfigurer rclone
curl https://rclone.org/install.sh | sudo bash
rclone config
# Opprett remote "hetzner-s3" med Hetzner Object Storage credentials
# (S3-kompatibelt, endpoint: fsn1.your-objectstorage.com eller nbg1)

# /srv/synops/scripts/backup-offsite.sh
#!/bin/bash
set -euo pipefail
BUCKET="s3:hetzner-s3/sidelinja-backup"

# PG-dump (siste lokale dump)
LATEST_DUMP=$(ls -t /srv/synops/backup/pg/*.dump 2>/dev/null | head -1)
if [ -n "$LATEST_DUMP" ]; then
  rclone copy "$LATEST_DUMP" "$BUCKET/pg/"
fi

# Media (inkrementell sync)
rclone sync /srv/synops/media/ "$BUCKET/media/" --transfers 4

# Behold 90 dager PG-dumper off-site
rclone delete "$BUCKET/pg/" --min-age 90d

echo "$(date): Off-site backup ferdig" >> /srv/synops/logs/backup-offsite.log
```

### 11.6 Cron-oppsett
```bash
# /etc/cron.d/sidelinja-backup
0  3 * * * sidelinja /srv/synops/scripts/backup-pg.sh
30 3 * * * sidelinja /srv/synops/scripts/backup-media.sh
0  4 * * * sidelinja /srv/synops/scripts/backup-forgejo.sh
30 4 * * * sidelinja /srv/synops/scripts/backup-offsite.sh
```

### 11.7 Hva som IKKE backupes (bevisst)
- **Redis** — cache, regenereres automatisk
- **Caddy-data** — sertifikater regenereres av Let's Encrypt
- **Avledede data i PG** (ren tekst, segmenter, søkeindeks) — regenereres fra Git
- **Logger** — rulleres med logrotate, arkiveres separat ved behov
- **Whisper-modeller** — re-download fra HuggingFace
- **SpacetimeDB** — sanntidsdata synkes til PG, in-memory state er flyktig

### 11.8 Restore-prosedyre
```bash
# 1. PostgreSQL
docker compose exec -T postgres pg_restore -U sidelinja -d sidelinja --clean \
  < /srv/synops/backup/pg/sidelinja_YYYYMMDD.dump

# 2. Media
rsync -a /srv/synops/backup/media/ /srv/synops/media/

# 3. Forgejo
docker compose down forgejo
rsync -a /srv/synops/backup/forgejo/ /srv/synops/data/forgejo/
docker compose up -d forgejo

# 4. Avledede data: trigges automatisk ved webhook eller manuelt
# Rust-worker reimporterer alle SRT-filer fra Git til PG
```

## 12. Deploy-workflow (etter initial setup)
Etter at serveren er satt opp, er dette den daglige deploy-flyten:

```bash
# Fra lokal maskin (WSL2):
git push forgejo main

# SSH inn til server:
ssh sidelinja@<server-ip>
cd /srv/synops
git pull
docker compose build --no-cache <tjeneste>
docker compose up -d <tjeneste>
```

## 13. Verifisering etter oppsett

### Lag A (minimum fungerende server)
- [ ] `https://auth.sidelinja.org` viser Authentik login
- [ ] `https://git.sidelinja.org` viser Forgejo, innlogging via Authentik fungerer
- [ ] PostgreSQL: `docker compose exec postgres pg_isready` returnerer OK
- [ ] SSH-push fra lokal WSL2 til Forgejo fungerer

### Lag B-C
- [x] `https://sidelinja.org` laster SvelteKit-appen (deployet 2025-03-15)
- [x] `https://sidelinja.org/api/health` returnerer 200
- [x] Authentik OIDC-innlogging fungerer fra nettleser (verifisert 2025-03-15)
- [x] Chat: meldinger sendes og vises med riktig brukernavn (verifisert 2025-03-15)
- [ ] `https://vegard.info` svarer
- [ ] SpacetimeDB: WebSocket-tilkobling fra nettleser fungerer
- [ ] LiveKit: Test-rom med video/lyd fungerer
- [ ] Media: `curl -I https://sidelinja.org/media/podcast/test.mp3` returnerer `Accept-Ranges: bytes`


================================================================
FILE: docs/erfaringer/README.md
================================================================

# Erfaringer — Ting vi lærte av å feile

Denne mappen samler **praktiske lærdommer** fra implementering — ikke hva vi valgte, men hva vi lærte som ikke er åpenbart fra koden eller arkitekturdokumentene.

Formålet er å treffe raskere blink med neste komponent. Hver fil dekker én teknologi eller ett mønster og inneholder konkrete feller, anti-patterns og løsninger vi landet på.

## Innhold

| Fil | Tema |
|---|---|
| `svelte5_reaktivitet.md` | Svelte 5 $state, SSR, reaktivitet gjennom funksjoner |
| `spacetimedb_integrasjon.md` | SDK-konvensjoner, TypeScript-bindings, BigInt, tilkobling |
| `adapter_moenster.md` | Adapter/factory for PG↔SpacetimeDB, hybrid-tilnærming |
| `authentik_oidc.md` | Authentik sub-claim format, @auth/sveltekit JWT-quirks |

## Retningslinjer

- **Kort og konkret.** Maks 1–2 sider per fil. Fellen først, forklaring etter.
- **Bare ting som ikke er åpenbare.** Ikke dokumenter at `npm install` installerer pakker.
- **Oppdater fremfor å legge til.** Hvis en erfaring utdypes, oppdater eksisterende fil.
- **Kodereferanser.** Vis til filer der mønsteret er implementert, så man kan lese koden.


================================================================
FILE: docs/erfaringer/adapter_moenster.md
================================================================

# Erfaring: Adapter-mønster for chat (PG ↔ SpacetimeDB)

## 1. Mønsteret

Et felles interface (`ChatConnection`) med to implementasjoner:
- **SpacetimeDB-adapter** (primær) — all data fra SpacetimeDB, worker håndterer warmup + sync
- **PG-adapter** (fallback) — polling hvert 3 sek, brukes kun når SpacetimeDB ikke er konfigurert

Factory-funksjon velger adapter basert på miljøvariabel (`VITE_SPACETIMEDB_URL`).

```
ChatBlock.svelte  →  createChat()  →  SpacetimeDB-adapter (primær)
                                    →  PG-adapter (fallback, readonly)
```

**Fordeler:**
- Kan teste PG-adapter isolert uten Docker/SpacetimeDB
- Fallback er trivielt — fjern env-variabelen
- Komponenten vet ingenting om hvilken adapter som brukes
- ChatConnection-interface har `edit()`, `delete()`, `react()` — ingen direkte PG API-kall fra komponenten

**Referanse:** `web/src/lib/chat/` — hele mappen er organisert etter dette mønsteret.

## 2. Historisk anti-pattern: Lazy wrapper som byttet adapter

Vi prøvde først en "lazy wrapper" som startet med PG-adapter og byttet til SpacetimeDB-adapter når tilkoblingen var klar. Problemet:

- En plain `let activeConnection` i wrapperen er **ikke reaktiv** i Svelte 5
- Når wrapperen byttet adapter, forsvant meldingene — ny adapter startet med tom liste
- Svelte-komponentene så aldri byttet fordi proxy-referansen ikke oppdaterte seg

**Lærdom:** Ikke bytt adapter runtime. Velg én ved oppstart.

## 3. Historisk anti-pattern: Hybrid-adapter (PG + SpacetimeDB samtidig)

Den andre iterasjonen brukte en hybrid-adapter som hentet historikk fra PG via REST og lyttet på SpacetimeDB for nye meldinger. Dette skapte:

- Kompleks dedup-logikk (`deletedIds` Set, merge av PG- og ST-meldinger)
- Race conditions mellom PG-polling og SpacetimeDB-callbacks
- BigInt-konverteringer og workarounds i frontend

**Løsningen:** SpacetimeDB som cache foran PG. Worker gjør warmup (PG → ST) ved oppstart, frontend snakker kun med SpacetimeDB. Ingen merge-logikk nødvendig.

## 4. Nåværende arkitektur

SpacetimeDB er en varm cache foran PostgreSQL:
- **Worker warmup:** Ved oppstart lastes meldinger + reaksjoner fra PG → SpacetimeDB per kanal
- **Frontend → SpacetimeDB:** Subscription gir alle meldinger (historikk fra warmup + nye)
- **SpacetimeDB → PG:** Sync-worker poller `sync_outbox` hvert sekund
- **PG er autoritativ** — ved SpacetimeDB-restart oppvarmes fra PG

**Fordeler over hybrid:**
- Ingen dedup, merge eller deletedIds
- Frontend-koden er dramatisk enklere
- Konsistent datamodell — alt kommer fra én kilde
- Reaksjoner håndteres via SpacetimeDB-tabeller, ikke PG API

## 5. Historisk anti-pattern: "PG-lekkasje" i SpacetimeDB-adapteren

Gjentatt feil (mars 2026, minst 3 iterasjoner): Når en ny feature trenger data som SpacetimeDB-modulen ikke har (metadata, edited_at, revisjoner), er det fristende å legge til en `enrichFromPg()`-funksjon som henter fra PG direkte. Dette bryter hele poenget med caching-laget.

**Symptomer:**
- SpacetimeDB-adapteren har `fetch('/api/messages/...')` kall
- Worker skriver til PG først, SpacetimeDB er "best-effort"
- Etter AI-vask vises ikke metadata/revisjoner fordi de bare finnes i PG
- Race conditions mellom SpacetimeDB-oppdateringer og PG-fetch

**Hvorfor det skjer:** Det er raskere å lage en PG API-rute enn å utvide SpacetimeDB-modulen (Rust compile, publish, regenerer bindings). Men det skaper teknisk gjeld som akkumulerer og undergraver hele arkitekturen.

**Riktig løsning når SpacetimeDB mangler et felt:**
1. Legg til feltet i SpacetimeDB Rust-modul (`spacetimedb/src/lib.rs`)
2. Utvid warmup til å laste feltet fra PG
3. Utvid sync til å persistere feltet til PG
4. Worker skriver til SpacetimeDB via reducer
5. Frontend leser kun fra SpacetimeDB

**Aldri:** Legg til `fetch('/api/.../metadata')` i SpacetimeDB-adapteren.

## 6. Anbefaling for neste komponent

Når Kanban eller Whiteboard skal bygges med SpacetimeDB:

1. **Start med PG-adapter.** Få hele flyten til å fungere med REST/polling først.
2. **Lag SpacetimeDB-adapter med warmup.** Worker laster data fra PG ved oppstart.
3. **Bruk samme factory-mønster.** Felles interface, env-variabel for valg.
4. **Legg til warmup-config** i `channels.config` (eller tilsvarende config-felt).
5. **Test begge adaptere uavhengig** før du integrerer i UI-komponenten.
6. **Sjekk at alle felter frontend trenger finnes i SpacetimeDB-modulen** før du implementerer adapteren. Utvid modulen først hvis nødvendig.


================================================================
FILE: docs/erfaringer/authentik_oidc.md
================================================================

# Erfaring: Authentik OIDC-integrasjon

## 1. `profile.sub` er IKKE Authentik sin PostgreSQL-UUID

Authentik sin OIDC `sub`-claim er en **SHA256-hash**, ikke UUID-kolonnen fra `authentik_core_user`. Eksempel:

| Felt | Verdi |
|---|---|
| Authentik DB `uuid` | `0ac94e00-015b-4e78-9f32-269fa6ce3f44` |
| OIDC `sub` claim | `6af61f43c6647a237cbb381ee7788376a9bc20299c2c06281d9954d763e854f0` |

Bruk **alltid** `sub`-verdien fra OIDC som nøkkel i `users.authentik_id`. For å finne den riktige verdien for en bruker: logg inn og les `profile.sub` fra callback, eller sjekk JWT-tokenet.

## 2. `@auth/sveltekit` sin `user.id` er IKKE `profile.sub`

`@auth/sveltekit` genererer sin egen interne UUID for `user.id` i JWT. Denne overlever ikke mellom sesjoner og matcher ingenting i vår database.

For å bruke Authentik `sub` som bruker-ID:

```typescript
callbacks: {
    jwt({ token, user, profile }) {
        if (user) token.id = user.id;
        if (profile?.sub) token.authentik_sub = profile.sub;
        return token;
    },
    session({ session, token }) {
        if (session.user) {
            // Bruk Authentik sub, IKKE token.id
            session.user.id = (token.authentik_sub ?? token.id) as string;
        }
        return session;
    }
}
```

`profile` er kun tilgjengelig i JWT-callbacken ved innlogging (ikke ved token-refresh), derfor må `authentik_sub` lagres i tokenet.

**Referanse:** `web/src/lib/server/auth.ts`

## 3. Redirect-URI i Authentik

`@auth/sveltekit` bruker callback-URL `https://<domain>/auth/callback/<provider-id>`. For oss: `https://sidelinja.org/auth/callback/authentik`.

Denne MÅ være registrert som redirect-URI i Authentik sin OAuth2-provider. Verifiser via:

```sql
SELECT _redirect_uris FROM authentik_providers_oauth2_oauth2provider
WHERE client_id = '<din client_id>';
```

**Tips:** Legg til en regex-variant for lokal utvikling: `http://localhost:\d+/auth/callback/authentik` med `matching_mode: "regex"`.


================================================================
FILE: docs/erfaringer/spacetimedb_integrasjon.md
================================================================

# Erfaring: SpacetimeDB-integrasjon

## 1. Genererte bindings — navnekonvensjoner

SpacetimeDB sin `spacetime generate --lang typescript` produserer bindings med inkonsistente konvensjoner. Sjekk **alltid** de genererte filene i stedet for å gjette.

| Hva | Konvensjon | Eksempel |
|---|---|---|
| Tabell-accessor på `conn.db` | snake_case | `conn.db.chat_message` |
| Reducer-accessor på `conn.reducers` | camelCase | `conn.reducers.sendMessage()` |
| Felt-navn i tabellrader | camelCase | `row.channelId`, `row.authorName` |
| Reducer-parametere | Enkelt objekt | `sendMessage({ id, channelId, body, ... })` |

**Felle:** Man forventer at tabell-accessorer er camelCase (`chatMessage`), men de er snake_case.

## 2. `DbConnection.builder()` — API-detaljer (SDK v2)

```typescript
DbConnection.builder()
  .withUri(spacetimeUrl)
  .withDatabaseName(moduleName)   // IKKE withModuleName
  .withToken(token)
  .onConnect((connection) => {    // første param er DbConnection, ikke EventContext
    connection.subscriptionBuilder()
      .subscribe([`SELECT * FROM chat_message WHERE channel_id = '${id}'`]);
  })
  .build();
```

**Feller:**
- `withModuleName()` finnes ikke — bruk `withDatabaseName()`
- `onConnect`-callback mottar `DbConnection`, ikke `EventContext`
- `onConnectError`-callback har signatur `(ctx, errMessage)` der `errMessage` er en string

## 3. Timestamps — bruk SDK-metoder, ikke interne felter

SpacetimeDB `Timestamp`-objektet har en intern property `__timestamp_micros_since_unix_epoch__` (BigInt). **Ikke bruk den direkte** — bruk SDK-metodene:

```typescript
// FEIL — microsSinceEpoch finnes ikke, __timestamp_micros_since_unix_epoch__ er internt
const micros = row.createdAt?.microsSinceEpoch;

// RIKTIG — bruk SDK-metoder
const iso = row.createdAt?.toISOString();       // "2026-03-15T23:57:11.677139Z"
const date = row.createdAt?.toDate();            // Date-objekt
const ms = row.createdAt?.toDate()?.getTime();   // millisekunder for sortering
```

### Timestamp-parsing i Rust-modul (warmup)

PG returnerer timestamps som `"2026-03-15 23:57:11.677139+00"`. chrono parser IKKE `+00` — krever `+00:00`:

```rust
// FEIL — chrono gir ParseError(TooShort) på "+00"
let dt = s.parse::<chrono::DateTime<chrono::FixedOffset>>();

// RIKTIG — normaliser PG-offset først
let normalized = if s.ends_with("+00") { format!("{}:00", s) } else { s.to_string() };
let dt = chrono::DateTime::parse_from_str(&normalized, "%Y-%m-%d %H:%M:%S%.f%:z");
```

Uten korrekt parsing faller `load_messages` tilbake til `ctx.timestamp` (nåtidspunkt), og alle meldinger får samme klokkeslett.

**Referanse:** `spacetimedb/src/lib.rs` — `parse_timestamp()`, `web/src/lib/chat/spacetime.svelte.ts` — `spacetimeRowToMessage()`.

## 4. Rust-modul — borrow checker med SpacetimeDB-makroer

SpacetimeDB-makroer genererer kode som tar eierskap over struct-felter. Bruk verdier **før** du flytter dem inn i structs:

```rust
// FEIL — channel_id er moved inn i ChatMessage, kan ikke bruke i log!() etterpå
let msg = ChatMessage { channel_id, body, ... };
log::info!("Melding i kanal {}", channel_id);  // borrow after move

// RIKTIG — bruk verdien før struct-opprettelse
let log_msg = format!("Melding i kanal {}", channel_id);
let msg = ChatMessage { channel_id, body, ... };
log::info!("{}", log_msg);
```

## 5. Publisering og lokal testing

```bash
# Publiser modul mot lokal SpacetimeDB (må kjøre i Docker først)
cd spacetimedb
spacetime publish sidelinja-realtime --server local

# Generer TypeScript-bindings
spacetime generate --lang typescript --out-dir ../web/src/lib/chat/module_bindings \
  --module-path .
```

**Merk:** SpacetimeDB-modulen publiseres manuelt med `spacetime publish` mot server-instansen.

## 6. Arkitekturendring: SpacetimeDB som cache foran PG (mars 2026)

Tidligere: Hybrid-adapter der frontend merget data fra PG (historikk) og SpacetimeDB (sanntid) med dedup, deletedIds og BigInt-workarounds.

Ny modell:
- **PG autoritativ** — all persistent data i PostgreSQL
- **SpacetimeDB = varm cache** — worker gjør warmup (PG → ST) ved oppstart
- **Frontend snakker KUN med ST** — ingen PG API-kall fra chat-adapteren
- **Worker håndterer toveissynk** — ST → PG for nye/redigerte/slettede meldinger og reaksjoner

### Warmup-flyt
1. Worker starter → `warmup::run()` leser kanaler med config fra PG
2. Per kanal: sjekker `channels.config.warmup_mode` (all/messages/days/none)
3. Kaller `clear_channel` reducer (unngår duplikater ved restart)
4. **Trådbasert henting:** Finner kvalifiserende tråder, henter alle meldinger i disse (komplett med svar)
   - `messages`-modus: de N nyeste trådene (sortert etter siste aktivitet)
   - `days`-modus: alle tråder med minst én melding i tidsvinduet
   - Et svar som kvalifiserer tar med hele tråden (inkludert eldre trådstarter)
5. Kaller `load_messages` reducer med JSON-array
6. Laster også reaksjoner via `load_reactions` reducer

### Per-kanal konfigurasjon
- Lagres i `channels.config` JSONB: `warmup_mode` + `warmup_value`
- Admin-UI: `/admin/channels` — tabell med inline-redigering
- Default: `"all"` (last alt). Andre: `"messages"` (siste N tråder), `"days"` (siste N dager), `"none"` (inaktiv)

### Sync-flyt (ST → PG)
- SyncOutbox-events prosesseres hver 1. sekund
- Støtter: `messages/insert`, `messages/delete`, `messages/update`, `messages/ai_update`, `message_reactions/insert`, `message_reactions/delete`
- `ai_update`-action: oppdaterer body + metadata + edited_at i PG, inserter revisjon

## 7. Subscription-begrensninger

**SpacetimeDB-subscriptions støtter IKKE JOINs.** En subscription-query som `SELECT mr.* FROM message_reaction mr JOIN chat_message cm ON cm.id = mr.message_id WHERE ...` feiler stille — `onApplied` kalles aldri, og ingen data vises.

Bruk kun enkle `SELECT * FROM tabell WHERE ...`-queries i `.subscribe([...])`. Filtrer heller klient-side etter at data er lastet.

Eksempel:
```typescript
// FEIL — feiler stille, ingen data
.subscribe([
  `SELECT * FROM chat_message WHERE channel_id = '${id}'`,
  `SELECT mr.* FROM message_reaction mr JOIN chat_message cm ON cm.id = mr.message_id WHERE cm.channel_id = '${id}'`
]);

// RIKTIG — last alle reaksjoner, filtrer i koden
.subscribe([
  `SELECT * FROM chat_message WHERE channel_id = '${id}'`,
  `SELECT * FROM message_reaction`
]);
```

### Fallback
PG-polling adapter (`pg.svelte.ts`) brukes kun når SpacetimeDB ikke er konfigurert. Markeres som `readonly: true`.

## 8. Reducer-parameternavn — unngå underscore-prefix

> *Kodeeksemplene i denne seksjonen er fra v1 og bruker `workspace_id`-parametere.
> Workspace-modellen er erstattet av noder og edges (se `docs/retninger/bruker_ikke_workspace.md`),
> men lærdommen om underscore-prefix gjelder generelt for alle SpacetimeDB-reducere.*

SpacetimeDB eksponerer Rust-parameternavn direkte i HTTP JSON API-et. Underscore-prefix (`_workspace_id`) blir til `_workspace_id` i JSON, ikke `workspace_id`:

```rust
// FEIL — HTTP-kall med {"workspace_id": "..."} feiler med 400
pub fn set_ai_processing(ctx: &ReducerContext, id: String, _workspace_id: String) { ... }

// RIKTIG — bruk vanlig navn, suppress warning med let _ = &var;
pub fn set_ai_processing(ctx: &ReducerContext, id: String, workspace_id: String) -> Result<(), String> {
    let _ = &workspace_id;
    // ...
}
```

## 9. Schema-migrering ved nye kolonner

Å legge til kolonner på eksisterende SpacetimeDB-tabeller krever `--delete-data` ved publish. Dette sletter all data og krever warmup på nytt:

```bash
# Feiler uten --delete-data:
# "Adding a column metadata to table chat_message requires a default value annotation"
echo "y" | spacetime publish sidelinja-realtime --server local --delete-data
```

## 10. AI-worker-flyt via SpacetimeDB

Worker som gjør AI-behandling av meldinger:
1. Leser meldingens body fra PG (OK — PG er persistent lager)
2. Kaller `set_ai_processing` reducer → frontend ser pulsering umiddelbart
3. Kaller AI Gateway med prompt
4. Kaller `ai_update_message` reducer → SpacetimeDB oppdaterer body/metadata/edited_at atomisk, lagrer revisjon, legger outbox-entry
5. Sync-worker persisterer til PG via `ai_update` action
6. Ved feil: `clear_ai_processing` reducer rydder flagget


================================================================
FILE: docs/erfaringer/svelte5_reaktivitet.md
================================================================

# Erfaring: Svelte 5 Reaktivitet

## 1. `$state` i `.svelte.ts` krever getters

Svelte 5 sin `$state` lager reaktive proxyer. Når en funksjon returnerer et objekt med `$state`-verdier, **mister man reaktiviteten** hvis man returnerer verdien direkte:

```typescript
// FEIL — mister reaktivitet, verdien fryses ved retur
function createThing() {
  let count = $state(0);
  return { count };  // snapshot, ikke reaktiv
}

// RIKTIG — getter bevarer proxy-tilgang
function createThing() {
  let count = $state(0);
  return {
    get count() { return count; }
  };
}
```

**Referanse:** `web/src/lib/chat/pg.svelte.ts` — alle returnerte verdier bruker getters.

## 2. SSR kjører alt utenfor `onMount`

SvelteKit server-renderer kjører komponent-script ved SSR. Alt som bruker browser-APIer (WebSocket, `fetch` til relative URLer, `setInterval`, `sessionStorage`) **krasjer på serveren** hvis det ikke er beskyttet.

```typescript
// FEIL — krasjer ved SSR
let chat = createChat(channelId);  // kjøres på server

// RIKTIG — kun i browser
import { onMount } from 'svelte';
let chat = $state<ChatConnection | null>(null);
onMount(() => {
  chat = createChat(channelId);
  return () => chat?.destroy();
});
```

Alternativt kan factory-funksjonen selv sjekke:
```typescript
import { browser } from '$app/environment';
if (browser) { /* ... */ }
```

**Referanse:** `web/src/lib/chat/create.svelte.ts` — `browser`-guard i factory, `web/src/lib/blocks/ChatBlock.svelte` — `onMount` for opprettelse.

## 3. `$derived` og `$effect` med null-initialisert state

Når en `$state`-variabel starter som `null` (fordi den settes i `onMount`), må `$derived` og `$effect` håndtere null-tilfellet:

```typescript
let chat = $state<ChatConnection | null>(null);
let messages = $derived(chat?.messages ?? []);  // fallback til tom liste

$effect(() => {
  const count = messages.length;  // trygt, alltid array
  if (count > prevCount) scrollToBottom();
  prevCount = count;
});
```

**Referanse:** `web/src/lib/blocks/ChatBlock.svelte` — `$derived` med optional chaining.

## 4. Polling: full-fetch slår inkrementell akkumulering

Vi prøvde først å bruke en `latestTimestamp`-cursor for å hente kun nye meldinger og appende dem. Dette ga duplikater — telleren vokste mens man tastet (polling i bakgrunnen).

**Løsning:** Enkel `refresh()` som alltid henter full liste og erstatter `messages` i sin helhet. For et lite volum meldinger er dette enklere og tryggere enn inkrementell logikk.

**Referanse:** `web/src/lib/chat/pg.svelte.ts` — `refresh()` gjør full fetch, ingen akkumulering.


================================================================
FILE: docs/proposals/README.md
================================================================

# Forslag (Proposals)

Halvtenkte idéer, kreative innfall og ting vi vil utforske når vi får tid. Ikke spesifisert, ikke forpliktet — bare parkert.

## Pipeline

```
retninger/    →    påvirker alt (arkitektoniske teser)
proposals/    →    features/ eller concepts/
(idé)              (spesifisert, klar for implementering)
```

Når en idé modnes nok til å bli implementert, skrives en full spec i `docs/features/` eller `docs/concepts/` og forslaget slettes herfra. Idéer som er for store og fundamentale for proposals — arkitektoniske teser om prosjektets retning — hører hjemme i `docs/retninger/`.

## Oversikt

| Forslag | Innsats | Wow-faktor | Bygger på |
|---|---|---|---|
| [Auto-Clipper](auto_clipper.md) | Middels | Høy | Live transkripsjon, jobbkø, Caddy byte-range |
| [Graph Health Monitor](graph_health_monitor.md) | Lav | Middels | Kunnskapsgraf, pgvector, jobbkø |
| [Serendipity Roulette](serendipity_roulette.md) | Lav | Høy | Kunnskapsgraf, Live AI |
| [Podcast Time Machine](podcast_time_machine.md) | Lav | Høy | Segmenter, Caddy byte-range, Live AI |
| [Meme Generator](meme_generator.md) | Lav | Høy | Whiteboard, transkripsjon, AI Gateway |
| [Valgomat Roast](valgomat_roast.md) | Lav | Middels | Valgomat, kunnskapsgraf |
| [Live Audience Q&A](live_audience_qa.md) | Middels | Høy | Valgomat, LiveKit, SpacetimeDB |
| [Guest Prep Simulator](guest_prep_simulator.md) | Middels | Høy | Kunnskapsgraf, AI Gateway |
| [Debate Club](debate_club.md) | Middels | Middels | Kunnskapsgraf, AI Gateway, jobbkø |
| [Ghost Host TTS](ghost_host_tts.md) | Stor | Høy | LiveKit, AI Gateway, ny TTS-infra |
| [Tekst-primitiv](tekst_primitiv.md) | Lav–Middels | Middels–Høy | Meldingsboks, view-configs |
| [Editor](editor.md) | Middels–Stor | Høy | Tekst-primitiv, Tiptap/ProseMirror, KaTeX |
| [Artikkel-publisering](artikkel_publisering.md) | Middels–Stor | Høy | Tekst-primitiv, kunnskapsgraf, Caddy, jobbkø |
| [Sosial publisering](social_posting.md) | Lav–Middels | Høy | Chat, jobbkø, workspace settings |
| [Komponerbare sider](komponerbare_sider.md) | Lav (Fase 1) | Middels–Høy | Workspace-modell, SvelteKit, alle feature-komponenter |
| [Contradiction Detector](contradiction_detector.md) | Middels | Høy | Live AI, kunnskapsgraf, pgvector, segmenter |
| [Auto-Highlight Reel](auto_highlight_reel.md) | Middels | Høy | Podcastfabrikken, jobbkø, AI Gateway, Caddy byte-range |
| [Audience Voice Memo](audience_voice_memo.md) | Lav | Høy | Den Asynkrone Gjesten, Live transkripsjon, Live AI |
| [Avisvisning](avisvisning.md) | Lav–Middels | Høy | Meldingsboks, kunnskapsgraf, prominens-score |
| [Personlig workspace](personlig_workspace.md) | Lav–Middels | Middels–Høy | Workspace-modell, meldingsboks, tekst-primitiv |
| [Kildevern-modus](kildevern_modus.md) | Lav–Middels | Høy | AI Gateway, Ollama/vLLM, Møterommet |
| [Podcasting 2.0](podcasting_2_0.md) | Lav | Høy | Podcastfabrikken, kunnskapsgraf, RSS |
| [Web Clipper](web_clipper.md) | Lav–Middels | Høy | Jobbkø, AI Gateway, meldingsboks, kunnskapsgraf |
| [Visuelle Waveforms](waveforms.md) | Lav–Middels | Høy | Podcastfabrikken, jobbkø, editor |
| **Innspilling & Storyboard** | | | |
| [Storyboard](storyboard.md) | Middels–Stor | Høy | Canvas-primitiv, meldingsboks, universell overføring, Studioet, Podcastfabrikken |
| [Card Chaining](card_chaining.md) | Lav | Middels | Kunnskapsgraf, Storyboard, AI Gateway |
| [Ghost Cards](ghost_cards.md) | Lav–Middels | Høy | Storyboard, meldingsboks, kunnskapsgraf |
| [Pinboard Mode](pinboard_mode.md) | Lav | Høy | Storyboard, kanban |
| [Flow Meter](flow_meter.md) | Lav | Middels | Storyboard |
| [Emotion Tags](emotion_tags.md) | Lav | Middels | Meldingsboks, kanban, storyboard |
| **Samarbeid** | | | |
| [Collaborative Cursors](collaborative_cursors.md) | Lav | Middels | SpacetimeDB, Svelte |
| [Card Heat Map](card_heat_map.md) | Lav | Middels | Meldingsboks, kanban/storyboard |

**Forfremmet til feature:** [Meldingsboks](../features/meldingsboks.md) — universell diskusjonsprimitiv (erstatter separate modeller for chat, kanban-kort, kalenderhendelser, faktoider, notater).

**Lavthengende frukter** (lav innsats, høy wow): Serendipity Roulette, Podcast Time Machine, Meme Generator, Audience Voice Memo, Pinboard Mode, Ghost Cards.

## Format
Forslagsfiler er lette — ingen streng mal. Minimum:
- Hva er idéen?
- Hvorfor er den interessant?
- Hva bygger den på? (eksisterende features/infra)
- **Innsats** (Lav / Middels / Stor) og **Wow-faktor** (Lav / Middels / Høy)
- Åpne spørsmål


================================================================
FILE: docs/proposals/artikkel_publisering.md
================================================================

# Forslag: Artikkel-publisering og publikasjonsmodell

## Idé
Utvide Sidelinja til en publiseringsplattform der individuelle skribenter og redaksjonelle team kan skrive, samarbeide på, og publisere tekster. Inspirert av Substack (individuell publisering), men med en kollaborativ og kuratorisk dimensjon: en tekst eies av noen, samarbeides med noen, og publiseres av én eller flere.

## Designfilosofi

### Vakre tekster
Sidelinja-artikler skal føles som noe mellom en Substack-essay og en akademisk publikasjon. Ingen sidebar-rot, ingen widget-helvete. Bare tekst, typografi og innhold.

**Prinsipper:**
- **Typografi først.** Seriffont for brødtekst (Georgia, Literata), god linjehøyde (1.6–1.8), komfortabel lesbredde (60–75 tegn). Overskrifter i sans-serif for kontrast.
- **Luft.** Generøse marginer. Innholdet puster.
- **Mørk/lys.** Respekterer `prefers-color-scheme`. Begge moduser like gjennomtenkte.
- **Matematikk er førsteklasses.** KaTeX for LaTeX-notasjon, server-side-rendret.
- **Ingen visuell støy.** Metadata diskret plassert. Fokus er teksten.

**Inspirasjon:** Gwern.net (typografi + fotnoter), Distill.pub (interaktive figurer), Stratechery (ren leseopplevelse), Edward Tufte (informasjonstetthet uten rot).

### Teksten som primitiv
En artikkel er ikke en egen ting — den er en melding med `article_view` (se `tekst_primitiv.md`). Samme meldingsboks-filosofi: én primitiv, flere formål. Det som gjør dette til noe mer enn "bare en editor" er *publikasjonsmodellen*.

## Hvorfor er dette interessant?

### Publiseringslandskapet har et hull
De fleste plattformer tvinger deg til å velge:
- **Individuell blogg** (WordPress, Substack) — du skriver alene, du publiserer alene
- **Redaksjonelt fellesprosjekt** (nettavis, magasin) — alt er felles, individet forsvinner

Virkeligheten er mer nyansert:
- Individuelle skribenter skriver sitt eget
- Samarbeidende team gjør sin kollaborative greie
- Alle publiserer sitt eget (personlig feed/blogg)
- En redaktør/publikasjon kan *kuratere* — plukke opp individuelle tekster til en felles utgivelse
- Lesere abonnerer fast på noen skribenter, velger selektivt fra andre

Sidelinja kan modellere alt dette fordi primitiven er riktig: **en tekst eies av en forfatter, kan ha medforfattere, og kan publiseres i flere kontekster**.

### Naturlig forlengelse av eksisterende features
- Redaksjonen har chat, AI-behandling og kunnskapsgraf. Artikler er neste steg — fra intern diskusjon til publisert innhold.
- Grafkobling: en artikkel arver automatisk koblinger til temaer, aktører og episoder via `#`-mentions.
- SEO: podcast-innhold er vanskelig å søkemotor-indeksere. Artikler gir tekstlig innhold som rangerer.
- Show notes 2.0: kan bli fullverdige artikler med egne URL-er.

## Hva bygger den på?
- **Tekst-primitiv** (proposal) — `article_view`, WYSIWYG-editor, lagringsformat
- **Kunnskapsgraf** — artikkel er en melding (node) med edges til temaer/aktører
- **Personlig workspace** (proposal) — personlig publisering
- **Jobbkø** — AI-assistert skriving, faktasjekk, oppsummering
- **Caddy** — servering av publiserte artikler

## Skisse

### Publikasjonsmodellen

#### Publikasjon som node
En publikasjon er en ny `node_type` — en kuratert samling tekster med egen identitet:

```sql
ALTER TYPE node_type ADD VALUE 'publikasjon';

CREATE TABLE publications (
    id           UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
    name         TEXT NOT NULL,
    slug         TEXT NOT NULL UNIQUE,      -- URL-prefiks
    description  TEXT,
    avatar_url   TEXT,
    owner_id     TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

Typer publikasjoner:
- **Personlig feed** — implisitt, én per bruker, knyttet til personlig workspace
- **Redaksjonell publikasjon** — opprettet manuelt, flere kuratorer
- **Tematisk feed** — automatisk generert fra graf-spørringer ("alt tagget med #Skolepolitikk")

#### `PUBLISHED_IN`-edge
En tekst publiseres i en publikasjon via en graf-edge:

```sql
-- Ny relation_type: 'PUBLISHED_IN'
-- source_id = melding (artikkel)
-- target_id = publikasjon
-- origin = 'user' (forfatter publiserer selv) eller 'curator' (redaktør plukker opp)
-- context_id = NULL (eller referanse til kurateringsforespørsel)
```

En tekst kan ha flere `PUBLISHED_IN`-edges — publisert i forfatterens personlige feed *og* i redaksjonens magasin. Ingen kopiering — bare flere edges til samme node.

#### Samarbeid: medforfattere
En tekst har én `author_id` (eier) i `messages`. Medforfattere modelleres som:

```sql
-- Ny relation_type: 'CONTRIBUTED_BY'
-- source_id = melding (artikkel)
-- target_id = entitet (person) eller bruker-node
-- confidence = NULL
-- origin = 'user'
```

Medforfattere kan redigere teksten (tilgangskontroll i appkode). Kreditering vises i publisert artikkel.

### URL-struktur
```
sidelinja.org/@vegard/                      → Vegards personlige feed
sidelinja.org/@vegard/skolepolitikk-2026    → Enkeltartikkel (personlig)
sidelinja.org/pub/sidelinja-magasinet/      → Redaksjonell publikasjon
sidelinja.org/pub/sidelinja-magasinet/artikkel-slug  → Artikkel i publikasjon
sidelinja.org/feed/@vegard.xml              → Personlig Atom-feed
sidelinja.org/feed/pub/sidelinja-magasinet.xml  → Publikasjons-feed
```

### Kurateringsflyt
```
Forfatter skriver tekst i sitt workspace
  → Publiserer i personlig feed (@vegard/slug)
  → Redaktør ser teksten (følger forfatteren, eller finner via graf)
  → Redaktør "kuraterer" teksten til sin publikasjon
     → Ny PUBLISHED_IN-edge med origin: 'curator'
     → Teksten dukker opp i publikasjonens feed
     → Forfatter varsles, kan godkjenne/avslå
```

### Abonnementer
Lesere kan følge:
- En forfatter (personlig feed)
- En publikasjon (redaksjonell feed)
- Et tema (automatisk feed fra graf)

**Ekstern distribusjon:** Atom/RSS-feeds for alt. Ingen e-postavhengighet (anti-Substack). Lesere bruker sin egen feed-reader.

**Intern distribusjon (fremtidig):** Notifikasjoner i appen. Men RSS er minimum viable — funker fra dag 1 uten notifikasjonssystem.

### Innholdsformat og rendering
Se `tekst_primitiv.md` for editor og lagringsformat. Tillegg for publiserte artikler:

- **KaTeX server-side rendering** — `body_html` i `article_view` inneholder ferdig-rendret HTML med KaTeX. Ingen JavaScript for lesere.
- **Sidemerknad-fotnoter** (Tufte-stil) — vises i margen på brede skjermer (>1200px), popup på mobil.
- **Podcast-embeds** — `{{segment:uuid}}` rendrer innebygd lydspiller med transkripsjon.
- **OG-tags** — automatisk generert Open Graph-metadata per artikkel.

### Typografi-stack (CSS)
```
Brødtekst:    Literata / Georgia / serif      (1.6 linjehøyde, 60–75ch bredde)
Overskrifter: Inter / system-ui / sans-serif  (stramt, tydelig hierarki)
Kode:         JetBrains Mono / monospace      (med ligaturer)
Matematikk:   KaTeX default                   (skalerer med brødtekst)
```

## Åpne spørsmål

### Publikasjonsmodell
- Bør en forfatter godkjenne at teksten kurateres av en publikasjon, eller er det implisitt tillatt?
- Kan en publikasjon ha eksklusivitet (teksten publiseres *bare* der)?
- Hvem eier kommentarfeltet på en kuratert tekst — forfatteren eller publikasjonen?

### Samarbeid
- Er `CONTRIBUTED_BY`-edge nok for medforfatterskap, eller trengs en egen `article_collaborators`-tabell med rolleinfo (forfatter, redaktør, korrekturleser)?
- Sanntids samredigering (Yjs) fra dag 1, eller auto-save + manuell koordinering?

### Kommentarer: to kanaler, én node
En publisert artikkel har to separate diskusjonsrom:

1. **Intern kontekst** — den opprinnelige `reply_to`-kjeden og workspace-channelen artikkelen tilhører. Usynlig utenfra. En artikkel som startet som et svar i #Mediepolitikk beholder hele den interne tråden — men den lekker aldri ut.

2. **Offentlig kommentarkanal** — en separat channel med `visibility: 'public'`, knyttet til artikkelen via `article_view.comment_channel_id`. Workspace-medlemmer ser begge kanaler. Publikum ser bare den offentlige.

Åpne spørsmål rundt offentlige kommentarer:
- Hvem kan kommentere? Anonymt, autentisert (Authentik), kun inviterte?
- Moderasjon: forfatter, workspace-admin, eller begge?
- Enkleste start: ingen offentlige kommentarer (read-only for publikum). Arkitekturen tillater det via nullable `comment_channel_id`, men det bygges først når behovet er reelt.

### Tematiske feeds
- Automatisk feed basert på graf-spørringer ("alle artikler med MENTIONS-edge til #Skolepolitikk") — er dette en publikasjon, eller et separat konsept?
- Trolig: en publikasjon med `type: 'auto'` og en lagret graf-spørring. Men det er et steg videre.

### Versjonering
- Bør publiserte artikler ha synlig versjonering (à la Wikipedia)? `message_revisions` gir historikk allerede, men det er et spørsmål om det eksponeres til lesere.

### Bilder og media
- Content-addressable via `media_files` (eksisterende), eller CDN?
- Bildeoptimalisering (responsive `srcset`) for publiserte artikler?

## Innsats: Middels-Stor
Datamodellen (publikasjoner, `PUBLISHED_IN`-edges) er overkommelig. Typografi og leseopplevelse krever CSS-arbeid. Kurateringsflyten er det mest komplekse — men kan bygges inkrementelt.

## Wow-faktor: Høy
Kombinasjonen av vakker typografi, individuelle forfattere, kollaborativt forfatterskap og kuratoriske publikasjoner — integrert med kunnskapsgraf og podcast-embeds — er noe som ikke finnes andre steder. En publiseringsplattform der en politisk analyse kan ha formler, lydklipp fra intervjuer, og koblinger til kunnskapsgrafen.

## Relasjon til andre proposals
- **Tekst-primitiv** — fundament: editor, `article_view`, lagringsformat
- **Personlig workspace** — kontekst: der individet skriver og publiserer fra
- Denne proposalen handler om *hva som skjer etter at teksten er skrevet* — publisering, kurasjon, distribusjon, leseopplevelse


================================================================
FILE: docs/proposals/audience_voice_memo.md
================================================================

# Forslag: Audience Voice Memo (Live publikums-innspill)
**Innsats:** Lav | **Wow-faktor:** Høy

## Idé
Under live-innspilling vises en QR-kode (eller kort-URL) som publikum kan skanne. Den åpner en minimal nettside (gjenbruker Den Asynkrone Gjestens tech) der de kan sende voice memos. Memoene dukker opp i studio-chatten som `voice_memo`-meldinger, transkriberes live, og AI matcher innholdet til kunnskapsgrafen:

*"Lytter 'Kari fra Bergen' spør om vindkraft — du har 3 faktoider om dette fra Episode 12 og 17."*

## Hvorfor
- Gjør live-innspilling interaktiv uten at publikum trenger app eller konto
- Gjenbruker nesten alt fra Den Asynkrone Gjesten (guest_tokens, lydopplasting, Whisper)
- Kombinert med Live AI gir det programlederen kontekst på publikums-spørsmål i sanntid
- Viralt: "Send oss en voice memo LIVE mens vi spiller inn"

## Bygger på
- **Den Asynkrone Gjesten** (guest_tokens, `/guest/[token]`-rute, lydopplasting)
- **Live transkripsjon** (Whisper transkriberer voice memos via jobbkø)
- **Live AI** (matcher transkriberte memos mot kunnskapsgraf)
- **SpacetimeDB / PG-polling** (memos dukker opp i studio-chat i sanntid)

## Forskjell fra Den Asynkrone Gjesten
- **Asynkron gjest:** Én person, navngitt, forberedte spørsmål, tidsbegrenset
- **Audience Voice Memo:** Mange anonyme/pseudonyme lyttere, fritt innhold, kun aktivt under innspilling

## Teknisk skisse
1. Redaksjonen oppretter en "Live Q&A-sesjon" (spesiell guest_token med `type: 'audience'`)
2. QR-kode genereres med kort-URL → `/live/[token]`
3. Publikum åpner, skriver inn kallenavn, tar opp voice memo (maks 30 sek)
4. Voice memo lastes opp, Whisper transkriberer, AI matcher mot graf
5. Studio-chatten viser: "[Kari fra Bergen]: <transkribert tekst>" + AI-kontekst

## Dataklassifisering
- Audience voice memos: Flyktig (TTL 7 dager) — kun relevant rundt innspilling
- Transkripsjoner av memos: Flyktig (TTL 7 dager)
- Kuraterte memos (valgt ut av redaksjonen): Kritisk (flyttes til workspace media/)

## Åpne spørsmål
- Moderering: skal alle memos dukke opp automatisk, eller må en produsent godkjenne først?
- Skalering: hva om 100+ lyttere sender memos samtidig? Whisper-kø kan bli overbelastet
- Kan dette kombineres med Live Audience Q&A-forslaget (stemmegiving på spørsmål)?
- Personvern: skal lytterne akseptere at memoet kan brukes i podcasten?


================================================================
FILE: docs/proposals/auto_clipper.md
================================================================

# Forslag: Auto-Clipper (Sosiale medier-klipp)
**Innsats:** Middels | **Wow-faktor:** Høy

## Idé
Under studio-innspilling (eller møterom) kjører en bakgrunnsprosess som lytter til Whisper-chunks + Aha-markører. Når AI-en oppdager en "punchline", en sterk mening eller et morsomt øyeblikk, genereres automatisk 15–45 sekunders lydklipp + thumbnail-tekst. Klippene havner i en "Clip Inbox"-channel med ett-klikk eksport.

## Hvorfor
- Podcast-klipp er gull for vekst, men ingen vil klippe manuelt etter innspilling
- Utnytter allerede eksisterende live-transkripsjon + faktoid-oppslag
- Gir umiddelbar ROI: redaksjonen får 5–10 klipp per episode "gratis"

## Bygger på
- Live transkripsjon + Live AI (studio-modus)
- Jobbkø (`clip_extract`-jobb)
- Caddy byte-range (klippene streames direkte fra original MP3 uten å duplisere filer)
- Kunnskapsgraf (klipp kobles automatisk til `#Tema` og `@Aktør`)

## Dataklassifisering
- Lydklipp: Avledet (kategori 3) — kan regenereres fra original MP3 + tidsstempler
- Klipp-metadata (tidsstempler, score, thumbnail-tekst): Kritisk (PG)

## Åpne spørsmål
- Scoring-modell: hva gjør et øyeblikk "klippverdig"? Humor, nyhet, kontrovers, emosjonell intensitet?
- Skal klippene genereres som faktiske MP3-filer umiddelbart, eller lagre kun tidsstempler og generere on-demand ved eksport?
- Eksportmål: TikTok/Instagram/YouTube Shorts — trenger vi video med waveform/teksting, eller holder lyd?
- Kan dette kombineres med Podcast Time Machine for "throwback-klipp"?


================================================================
FILE: docs/proposals/auto_highlight_reel.md
================================================================

# Forslag: Auto-Highlight Reel (Post-innspilling)
**Innsats:** Middels | **Wow-faktor:** Høy

## Idé
Etter innspilling analyserer Podcastfabrikken transkripsjonen for humor, emosjonelle topper, sterke meninger og "punchlines". AI genererer automatisk 5-10 klipp (15-45 sek) med:
- Tidsstempler (start/slutt) i originalt opptak
- Foreslått teksting (fra transkripsjon, formatert for sosiale medier)
- Auto-generert thumbnail-tekst (det sterkeste sitatet)
- Foreslått hashtags basert på kunnskapsgraf-tags

Klippene havner i en "Highlights"-channel i workspace-chatten for review, med ett-klikk godkjenning og auto-posting via sosial publisering.

## Hvorfor
- Podcast-klipp er den viktigste vekstmotoren, men manuell klipping er tidkrevende
- Bygger på eksisterende Whisper-transkripsjon + jobbkø + AI Gateway
- Kombinert med sosial publisering-forslaget gir dette en komplett "innspilling → distribusjon"-pipeline
- Differensiator: ingen annen podcast-plattform gjør dette automatisk med kvalitetskontroll

## Bygger på
- **Podcastfabrikken** (Whisper SRT + AI-metadata — allerede spesifisert)
- **Auto-Clipper** (eksisterende forslag — dette er post-innspilling-versjonen)
- **Jobbkø** (`highlight_extract`-jobb, kjøres etter `whisper_postprocess`)
- **AI Gateway** (`sidelinja/resonering` for klipp-vurdering)
- **Caddy byte-range** (klipp serveres som range-requests mot original MP3)
- **Sosial publisering** (eksisterende forslag — ett-klikk posting)

## Forskjell fra Auto-Clipper
Auto-Clipper kjører *live* under innspilling og fanger øyeblikk i sanntid. Auto-Highlight Reel kjører *etter* innspilling og har tilgang til hele transkripsjonen — kan dermed finne narrative buer og tematiske høydepunkter som bare er synlige i kontekst.

## Dataklassifisering
- Klipp-metadata (tidsstempler, teksting, score): Kritisk (PG)
- Klipp-lydfiler: Avledet (kategori 3) — genereres on-demand fra original MP3 + tidsstempler
- Highlight-forslag (før godkjenning): Flyktig (TTL 30 dager)

## Åpne spørsmål
- Scoring: hva gjør et øyeblikk "klippverdig"? Humor, nyhet, kontrovers, emosjon?
- Videostøtte: trenger vi waveform-video med teksting for TikTok/Shorts, eller holder lyd + bilde?
- Skal AI-en foreslå rekkefølge/gruppering av klipp til en "highlight reel" (2-3 min sammenklipp)?
- Kan den lære av hvilke klipp redaksjonen godkjenner over tid (feedback loop)?


================================================================
FILE: docs/proposals/avisvisning.md
================================================================

# Forslag: Avisvisning

## Idé
En read-only blokk som rendrer workspace-aktivitet som en avis — sortert etter prominens (klikk, svar, reaksjoner, graf-koblinger, alder). Ingen redigering, ingen forvaltning, bare et annet blikk inn på det som allerede finnes. Med et søkefelt som lar deg filtrere på en entitet, slik at du kan få "Jonas Gahr Støre-avisen" eller "Skolepolitikk-avisen" — alt vi har om akkurat det temaet/den personen, rangert etter viktighet.

## Hvorfor er dette interessant?

### Et nytt perspektiv uten ny data
All data finnes allerede: meldinger, artikler, graf-koblinger, reaksjoner, svar-tråder, kalender-hendelser. Avisvisningen er bare en spørring + et layout. Ingen ny datamodell, ingen ny input — bare en ny måte å lese på.

### Entitet-filter som superkraft
Kunnskapsgrafen kobler alt til entiteter via `MENTIONS`-edges. Et filter på én entitet gir deg øyeblikkelig alt workspace vet om den — som en personlig nyhetsfeed:

- `#Jonas Gahr Støre` → alle meldinger, artikler, faktoider, episodesegmenter som nevner ham
- `#Skolepolitikk` → alt om temaet, på tvers av channels og tidsperioder
- `#Episode 42` → alt knyttet til den episoden: research, diskusjon, show notes, publiserte artikler
- Ingen filter → hele workspacets aktivitet, rangert etter prominens

### Arkiv
Innhold som overlever TTL-opprydding (har graf-koblinger, er pinned, har mange svar) er per definisjon det viktige. Avisvisningen har to moduser:

**Dagsaktuelt** (default): Rangert med alders-boost — nytt innhold scorer høyere. Viser det som skjer *nå*.

**Arkiv**: Samme spørring uten alders-boost. Alt som noensinne har hatt tyngde for denne entiteten, kronologisk eller rangert etter total prominens. En historisk oversikt: "Hva har vi sagt og skrevet om skolepolitikk gjennom tidene?"

## Hva bygger den på?
- **Meldingsboks** — alt innhold er meldinger med view-configs
- **Prominens-score** (meldingsboks §7.3) — beregnes fra svar, stemmer, roller, graf-koblinger, alder
- **Kunnskapsgraf** — `MENTIONS`-edges gir entitets-filtrering
- **Komponerbare sider** — avisvisningen er en blokk som kan plasseres på en side

## Skisse

### UI

**Header med søk:**
```
┌─────────────────────────────────────────────────┐
│ 📰 Avis        [# Søk entitet...          ▼]   │
│                  Skolepolitikk                   │
│                  [Dagsaktuelt] [Arkiv]           │
├─────────────────────────────────────────────────┤
```

Søkefeltet er `#`-autocomplete mot entities-tabellen — samme mekanisme som i editoren og chatten. Velg en entitet, avisen filtrerer seg.

**Avis-layout (dagsaktuelt):**
```
┌──────────────────────┬──────────────────────────┐
│                      │ Artikkel: Ny rapport fra  │
│  TOPPSAK             │ Utdanningsforbundet       │
│  (høyest prominens)  │ 12 reaksjoner · 2t siden  │
│                      ├──────────────────────────┤
│  Tittel, ingress,    │ Chat: Interessant tråd i  │
│  forfatter, bilde    │ #Mediepolitikk            │
│  23 svar · 45 min    │ 8 svar · 5t siden         │
│                      ├──────────────────────────┤
│                      │ 📅 I morgen: Intervju med │
│                      │ Kunnskapsministeren        │
├──────┬───────┬───────┴──────┬───────────────────┤
│Fakto-│Segment│ Kanban-kort  │ Notat: Research   │
│ide   │Ep. 42 │ "Skriv intro"│ om PISA-tall      │
│⬆12   │14:23  │ → In Progress│ Oppdatert i går   │
└──────┴───────┴──────────────┴───────────────────┘
```

Størrelsen på hver "sak" bestemmes av prominens-scoren. Toppsaken er størst. Lavere score = mindre kort. Ren CSS grid med dynamisk `grid-row: span N` basert på score-kvantiler.

**Arkiv-layout:**
```
┌─────────────────────────────────────────────────┐
│ 📰 Arkiv: Skolepolitikk       [Dagsaktuelt] [Arkiv]│
├─────────────────────────────────────────────────┤
│ 2026                                            │
│ ├── Mars: "Ny PISA-rapport" (artikkel, 45 ⬆)   │
│ ├── Mars: Diskusjon om budsjettkutt (23 svar)   │
│ ├── Feb: Episode 42 segment om skolepolitikk    │
│ 2025                                            │
│ ├── Nov: "Lærermangel i distriktene" (artikkel) │
│ ├── Sep: Faktoide: Antall lærerstudenter 2024   │
│ └── ...                                         │
└─────────────────────────────────────────────────┘
```

Arkivet er en kronologisk liste gruppert etter tidsperiode. Enklere layout, tyngre på metadata (type, score, alder). Fungerer som et oppslagsverk.

### Rangering: Alder først, prominens som tiebreaker

Dette er en **avis**, ikke et oppslagsverk. Nytt innhold skal alltid dominere. En fersk melding med 2 svar slår en gammel med 50. Prominens avgjør bare *innenfor omtrent samme tidsperiode* — hvilken av dagens saker er toppsaken.

**Modell: Tidsvinduer med intern prominens-rangering**

Innholdet deles i tidsvinduer. Innenfor hvert vindu rangeres det etter prominens. Vinduer vises i rekkefølge — nyeste først.

```
Siste 24 timer     → rangert etter prominens (dette er "forsiden")
1–3 dager siden    → rangert etter prominens (side 2 / bla-ned)
3–7 dager siden    → rangert etter prominens (eldre nyheter)
7–30 dager siden   → bare det mest prominente overlever (highlights)
```

Brukeren blar nedover for å gå bakover i tid, men innenfor hver tidsperiode ser de det viktigste først.

**SQL-tilnærming:**

```sql
WITH scored AS (
    SELECT
        m.id,
        m.title,
        m.body,
        m.created_at,
        -- Prominens (tiebreaker innenfor tidsvindu)
        (SELECT COUNT(*) FROM messages r WHERE r.reply_to = m.id) * 2
      + (SELECT COUNT(*) FILTER (WHERE reaction = 'upvote')
              - COUNT(*) FILTER (WHERE reaction = 'downvote')
         FROM message_reactions mr WHERE mr.message_id = m.id) * 3
      + (SELECT COUNT(*) FROM graph_edges ge WHERE ge.source_id = m.id) * 5
      + (CASE WHEN EXISTS (SELECT 1 FROM article_view a WHERE a.message_id = m.id) THEN 10 ELSE 0 END)
        AS prominence,
        -- Tidsvindu-bucket
        CASE
            WHEN m.created_at > now() - interval '1 day'  THEN 1
            WHEN m.created_at > now() - interval '3 days' THEN 2
            WHEN m.created_at > now() - interval '7 days' THEN 3
            ELSE 4
        END AS time_bucket
    FROM messages m
    JOIN nodes n ON n.id = m.id
    WHERE n.workspace_id = $workspace_id
      AND ($entity_id IS NULL OR EXISTS (
          SELECT 1 FROM graph_edges ge
          WHERE ge.source_id = m.id
            AND ge.target_id = $entity_id
            AND ge.relation_type = 'MENTIONS'
      ))
      -- Minimumsterskel: ikke vis støy
      AND (
          m.pinned = true
          OR m.title IS NOT NULL
          OR EXISTS (SELECT 1 FROM messages r WHERE r.reply_to = m.id)
          OR EXISTS (SELECT 1 FROM graph_edges ge WHERE ge.source_id = m.id)
          OR EXISTS (SELECT 1 FROM message_reactions mr WHERE mr.message_id = m.id)
      )
)
SELECT *
FROM scored
ORDER BY time_bucket ASC, prominence DESC
LIMIT 30;
```

**Hvorfor tidsvinduer og ikke en multiplikativ formel?**

En `score * recency_decay`-formel er vanskelig å balansere universelt. Med høy decay dominerer alltid det nyeste — prominens betyr ingenting. Med lav decay kryper gamle tungvektere opp og skyver bort nyhetene. Tidsvinduer unngår dette: nytt er alltid først, men innenfor "i dag" får prominens avgjøre hva som er toppsaken.

**Minimumsterskel:** Ikke alt hører hjemme i avisen. En enkel "hei" uten svar, reaksjoner eller graf-koblinger er støy. Spørringen filtrerer bort meldinger som ikke har *noen* form for tyngde (tittel, svar, reaksjoner, edges, pinned). Dette er avisen — ikke firehosen.

**Tuning over tid:** Vektene (2, 3, 5, 10) og tidsvindu-grensene (1d, 3d, 7d) er konfigurerbare uten migrasjoner. Kan lagres i `workspaces.settings` og justeres per workspace etter behov. Men vi starter med én universell default og justerer basert på faktisk bruk.

**Arkiv-modus:** Ingen tidsvinduer. Sortert etter `created_at DESC` (kronologisk) med prominens som sekundær sortering. Alt som har overlevd TTL-opprydding vises — gruppert etter måned/år.

### Kort-typer

Ulike meldingstyper rendres med ulike kort i avisen:

| Kilde | Kort-layout |
|---|---|
| Melding med mange svar | Tittel/ingress + svar-tall + siste aktivitet |
| Artikkel (article_view) | Tittel + excerpt + forfatter + bilde |
| Kanban-kort | Tittel + kolonne + assignee |
| Kalenderhendelse | Tittel + dato/tid + "om 2 dager" |
| Faktoide (ABOUT-edge) | Innhold + stemme-score + tilknyttet entitet |
| Episodesegment | Episode-tittel + tidsrom + transkripsjon-utdrag |
| Notat med tittel | Tittel + oppdatert-dato |

Alle er lenker — klikk fører deg til meldingen i sin naturlige kontekst (chatten, kanban-brettet, kalenderen, etc.).

### Blokk i komponerbare sider

Avisvisningen er en blokk-komponent (`<NewsView>`) som kan plasseres på en komponerbar side:

```jsonc
{
  "type": "news",
  "config": {
    "entity_id": null,        // null = hele workspacet, UUID = filtrert
    "mode": "current",        // "current" eller "archive"
    "limit": 20
  },
  "span": 2                   // tar gjerne full bredde
}
```

Kan også stå alene som en dedikert side i workspacet — "Avisen" i navigasjonen.

## Åpne spørsmål

### Flere entiteter
- Filtrere på flere entiteter samtidig? "Alt om #Skolepolitikk OG #Støre" (AND) vs "Alt om #Skolepolitikk ELLER #Støre" (OR)?
- Trolig: start med én entitet. Kombiner-logikk er et steg videre.

### Caching
- Prominens-scoren er en tung spørring med subqueries. For en aktiv workspace med mange meldinger bør den caches.
- Materialized view som refreshes periodisk (hvert 5. minutt)? Eller beregnes on-demand med cache-TTL?
- For arkiv-modus er caching enklere — dataen endres sjelden.

### Personalisering
- Ser alle samme avis, eller kan den vektes mot brukerens interesser?
- Start: alle ser det samme. Personalisering er et stort steg (og potensielt en filterboble-felle).

### Layout-algoritme
- Hvor mange "størrelser" av kort? Tre nivåer (stor/medium/liten) er trolig nok.
- Hvordan fordeles plass? Kvantiler: topp-10% er store, neste 30% medium, resten små?
- Responsivt: på mobil stacker alt vertikalt, største sak øverst.

### Oppdatering
- Sanntid (nytt innhold dukker opp)? Eller "pull to refresh" / periodisk?
- Trolig: periodisk (hvert minutt) + manuell refresh-knapp. Sanntid gir en "flimrende" opplevelse i et avis-layout.

## Innsats: Lav–Middels
Spørringen er rett frem (prominens-faktorer finnes allerede). Layout er CSS grid. Kort-komponentene gjenbruker eksisterende `<MessageBox>`-varianter. Den tyngste delen er å tuning av rangering og layout-algoritme for at det skal *føles* som en avis.

## Wow-faktor: Høy
"Skriv #Støre i søkefeltet og få en hel avis med alt vi vet om ham" er en øyeblikkelig aha-opplevelse. Kombinasjonen av graf-filtrering og avis-layout gjør kunnskapsgrafen *synlig* på en måte som graf-visualisering aldri klarer for folk flest.

## Relasjon til andre proposals
- **Komponerbare sider** — avisvisningen er en blokk
- **Tekst-primitiv / Meldingsboks** — all data som vises er meldinger med view-configs
- **Kunnskapsgraf** — entitets-filtrering er en graf-spørring
- **Artikkel-publisering** — publiserte artikler får prominente kort i avisen


================================================================
FILE: docs/proposals/card_chaining.md
================================================================

# Card Chaining — Automatisk kobling av relaterte kort

## Idé

Når to kort plasseres ved siden av hverandre (i kanban, storyboard eller kalender), opprettes automatisk en graf-edge mellom dem. Systemet kan også foreslå overganger: "Og apropos drømmer..."

## Hvorfor interessant?

Podcast-segmenter henger ofte sammen tematisk, men koblingen er implisitt. Card chaining gjør den eksplisitt — uten manuelt arbeid. Gir bedre flyt under innspilling og bedre metadata for kunnskapsgrafen.

## Fungerer slik
1. Dra kort A ved siden av kort B i storyboard
2. System oppretter `graph_edge` med `relation_type: 'sequence'` og `origin: 'proximity'`
3. Valgfritt: AI foreslår overgangssetning basert på begge kortenes innhold
4. Ved eksport/arkivering: sekvensen bevares som episode-struktur

## Bygger på
- Kunnskapsgraf (graph_edges)
- Storyboard (proximity detection)
- AI Gateway (overgangsforslag)

## Innsats
Lav — graph_edges finnes, bare UI for proximity + auto-edge.

## Wow-faktor
Middels — subtilt, men forbedrer metadata-kvaliteten dramatisk over tid.


================================================================
FILE: docs/proposals/card_heat_map.md
================================================================

# Card Heat Map — Visuell indikator for engasjement

## Idé

Kort på storyboard/kanban gløder basert på hvor mye oppmerksomhet de har fått: hover-tid, antall redigeringer, diskusjonstråd-lengde, og tid brukt i innspilling.

## Hvorfor interessant?

Hjelper med å se hva teamet faktisk er engasjert i — uten å lese alt. Under innspilling: "det kortet gløder mest, kanskje vi bør ta det først."

## Fungerer slik
1. Klient tracker hover-tid per kort (lokal state)
2. Server aggregerer: antall edits, tråd-lengde, reaksjoner
3. Kombinert score → CSS-variabel (`--heat: 0.0–1.0`) → glow-effekt
4. Valgfritt: "Hot topics"-filter som sorterer kort etter heat

## Bygger på
- Meldingsboks (reaksjoner, tråd-lengde)
- Kanban/Storyboard (visuell rendering)

## Innsats
Lav — ren frontend-logikk med enkel server-aggregering.

## Wow-faktor
Middels — subtilt men nyttig for redaksjonell prioritering.


================================================================
FILE: docs/proposals/collaborative_cursors.md
================================================================

# Collaborative Cursors — Sanntids-pekere for flerbrukermiljø

## Idé

Alle brukere som er på samme side ser hverandres musepekere som fargede prikker med navn. Fungerer på storyboard, kanban, whiteboard og kalender.

## Hvorfor interessant?

Gir "jamming together"-følelse under innspilling og planlegging. Produsent og host ser hverandre jobbe i sanntid uten å snakke om det.

## Fungerer slik
1. Klient sender `{ user_id, x, y, page }` til SpacetimeDB ved musebevegelse (throttlet til ~10 Hz)
2. Andre klienter renderer fargede SVG-sirkler med brukernavn
3. Prikken fader ut etter 5 sekunder uten bevegelse
4. Valgfritt: kort "trail" som viser bevegelsesretning

## Bygger på
- SpacetimeDB (pub/sub for posisjoner)
- Svelte ($state store for cursor-map)

## Innsats
Lav — under 50 linjer Svelte + en SpacetimeDB-reducer.

## Wow-faktor
Middels — visuelt tiltalende, men ikke kritisk funksjonalitet.

## Åpne spørsmål
- Bør pekere vises i chat-visning også, eller bare canvas-baserte views?
- Throttling-strategi: SpacetimeDB-reducer eller klient-side debounce?


================================================================
FILE: docs/proposals/contradiction_detector.md
================================================================

# Forslag: Contradiction Detector (Live i Studioet)
**Innsats:** Middels | **Wow-faktor:** Høy

## Idé
Under live-innspilling matcher Live AI nye utsagn mot eksisterende `CONTRADICTS`-edges og gamle segmenter i kunnskapsgrafen. Når en selvmotsigelse oppdages, popper det opp et diskret varsel i studio-UI:

*"Du sa akkurat «vi må kutte støtte til vindkraft» — men i Episode 17 (segment 3, 14:22) sa du «vindkraft er fremtiden». Vil du adressere det?"*

Programlederen kan:
1. Ignorere (ingen handling)
2. Markere for oppfølging (Aha-markør)
3. Spille inn et 12-sekunders "correction clip" på stedet

## Hvorfor
- Den ultimate "live co-host"-funksjonen — AI som faktisk gjør programlederen bedre
- Bygger direkte på eksisterende infrastruktur (Live AI + segmenter + kunnskapsgraf)
- Øker troverdigheten til podcasten (selvkorreksjon er sterkere enn å bli tatt i feil)
- Viralt potensial: "Denne podcasten har en AI som fanger selvmotsigelser i sanntid"

## Bygger på
- **Live transkripsjon** (Whisper-chunks i sanntid)
- **Live AI** (eksisterende faktoid-oppslag-pipeline)
- **Kunnskapsgraf** (segmenter med NER-tags, `CONTRADICTS`-edges)
- **pgvector** (semantisk matching for "lignende men motstridende" utsagn)
- **Caddy byte-range** (for å hente originalt lydklipp fra gammel episode)

## Teknisk skisse
1. Whisper-chunk → NER-uttrekk (aktører, temaer, påstander)
2. Søk i kunnskapsgrafen: finnes det segmenter med samme aktør/tema men motstridende innhold?
3. pgvector cosine similarity for semantisk matching + LLM-vurdering via `sidelinja/resonering`
4. Resultat med confidence score > terskel → push til studio-UI via SpacetimeDB

## Dataklassifisering
- Contradiction-alerts: Flyktig (TTL 24t) — kun relevant under/etter innspilling
- Godkjente contradictions → nye `CONTRADICTS`-edges i kunnskapsgrafen (kritisk)

## Åpne spørsmål
- Terskel for confidence: for lav = støy under innspilling, for høy = misser reelle motstridelser
- Skal den kun matche mot egne episoder, eller også mot eksterne faktoider?
- Kan dette kombineres med Ghost Host for å "lese opp" motstridelsen?
- Latens-krav: må fungere innen 10-15 sek etter utsagnet for å være nyttig live


================================================================
FILE: docs/proposals/debate_club.md
================================================================

# Forslag: Debate Club (Simulerte debatter)

## Idé
Velg to aktører + et tema. Systemet genererer en simulert debatt der AI-en spiller begge roller basert på faktiske faktoider og sitater fra kunnskapsgrafen.

Kan brukes i studioet som research-verktøy: *"Hva ville Støre og Solberg sagt om dette i dag?"* — og eksporteres som lydklipp til episoden via TTS.

## Hvorfor
- Kreativt bruk av kunnskapsgrafen som går utover oppslag
- Research-verktøy: hjelper programlederne å forberede motargumenter
- Underholdningsverdi: genuint morsomt når det treffer (og når det går galt)
- Kan brukes i valgomaten som "hør hva partiene mener" basert på AI-profiler

## Bygger på
- Kunnskapsgrafen (faktoider, aktør-profiler, `CONTRADICTS`-edges)
- AI Gateway (`sidelinja/resonering` for nyansert debatt)
- Jobbkø (generering tar tid)
- Valgfritt: TTS for lydklipp (se `ghost_host_tts.md`)

## Åpne spørsmål
- Etikk: OK å legge ord i munnen på ekte politikere? Trenger tydelig "AI-generert"-merking
- Skal debatten baseres kun på faktoider vi har, eller kan AI-en fylle inn basert på offentlig kjent posisjon?
- Node-type `debate` i grafen, eller bare en melding med spesiell `message_type`?
- Kobling til valgomaten: kan debatter genereres fra AI-kandidatprofiler?


================================================================
FILE: docs/proposals/editor.md
================================================================

# Forslag: Universell editor

## Idé
Én editor-komponent som brukes overalt i Sidelinja — chat, notater, artikler, kanban-kort, show notes. Editoren autodetekterer format (plaintext, markdown, LaTeX) og rendrer riktig uten at brukeren velger modus. Avansert funksjonalitet er alltid tilgjengelig, aldri påtvunget.

## Kjerneprinsipp: Brukeren bare skriver

Editoren forstår hva brukeren skriver og rendrer det riktig — live, uten konfigurasjon:

```
Bruker skriver              Autodetektert           Rendres som
──────────────              ─────────────           ───────────
hei                         plaintext               tekst
**viktig**                  markdown                bold
$E = mc^2$                  LaTeX inline            formel
$$\int_0^1 f(x)dx$$        LaTeX blokk             sentrert formel
```python\n...              kodeblokk               syntax highlight
# Overskrift               markdown heading         H1
#Skolepolitikk              mention                 graf-edge + lenke
{{segment:uuid}}            podcast-embed           lydspiller
bilde dratt inn             media                   inline bilde med bildetekst
https://youtu.be/xyz        YouTube-embed           innebygd videospiller
https://example.com         lenke                   rik forhåndsvisning (OG-kort)
```

En bruker som kan markdown bruker markdown. En bruker som kan LaTeX bruker LaTeX. En bruker som bare skriver vanlig tekst får vanlig tekst. Ingen modusvelger, ingen forhåndsvalg.

## Hvorfor er dette et eget prosjekt?

Editoren er det mest komplekse enkeltkomponenten i Sidelinja:
- Autodeteksjon av formater (markdown, LaTeX, mentions, embeds)
- Progressiv toolbar (fra usynlig til fullverdig)
- Live rendering av alt innhold
- `#`-mention med autocomplete og graf-integrasjon
- Podcast-embeds, bilder, vedlegg
- Versjonering og auto-save
- Format-kontekstsensitivitet (emoji i chat vs sirlig typografi i artikler)
- Fremtidig: collaborative editing (Yjs)
- Mobilopplevelse

Alt dette fortjener sin egen spec, sin egen utviklingssyklus, og sin egen iterasjon — uavhengig av tekst-primitiven (arkitektur) og artikkel-publisering (distribusjon).

## Hva bygger den på?
- **Tekst-primitiv** — filosofien om at enhver melding kan vokse
- **Meldingsboks** — datamodellen editoren skriver til (`messages.body`)
- **Kunnskapsgraf** — `#`-mentions oppretter graf-edges
- **Message revisions** — editoren trigge lagring av revisjoner

## Skisse

### Teknologivalg: Tiptap (ProseMirror)

Tiptap er det naturlige valget for SvelteKit:
- ProseMirror-basert, modular, godt vedlikeholdt
- Headless — full kontroll over UI og toolbar
- Lagrer som JSON (strukturert, transformerbart)
- Utvidbar med custom nodes og marks
- Svelte-kompatibelt (`@tiptap/core` headless + egen Svelte-wrapper)
- Collaborative editing via Yjs-plugin (fase 2)

### Progressiv toolbar

Editoren er **én komponent** (`<Editor>`) med ulik toolbar-konfigurasjon basert på kontekst:

**Kompakt** (default i chat, kanban-kort, quick reply):
```
[tekst-input .......................... ↑] [Send]
```
Ingen synlig toolbar. `#`-mentions og inline-formatering (bold, italic, lenker) via keyboard shortcuts. Enter = send. `↑`-knappen utvider til full modus.

**Utvidet** (notater, lengre tekster, "↑" fra kompakt):
```
[Tittel                                            ]
[B I S ~ | H1 H2 H3 | • — ✓ | 🔗 📎 # | ↓ ]
[                                                  ]
[ editor-innhold med live-rendering                ]
[                                                  ]
```
Full toolbar. Enter = ny linje. Auto-save. `↓` kollapser tilbake.

**Publisering** (artikler med `article_view`):
```
[Tittel                                            ]
[B I S ~ | H1 H2 H3 | • — ✓ | 🔗 📎 # | ƒ 🎙️ |📝]
[                                                  ]
[ editor-innhold med sidemerknad-støtte            ]
[                                                  ]
[Slug: ________] [Status: Utkast ▼] [Forhåndsvis] [Publiser]
```
Alt fra utvidet + LaTeX-toolbar, podcast-embeds, fotnoter, publiseringskontroller. `📝`-knappen åpner sidemerknad-panel.

**Modusene er ikke låst.** Brukeren kan alltid utvide eller kollapse. Toolbar-nivå er en UI-preferanse per kontekst, ikke en datadistinksjon.

### Raw / Rendered — brukeren bestemmer

Editoren har to visninger, alltid tilgjengelig via en enkel toggle (Ctrl+/ eller knapp i toolbar):

**Raw:** Viser kildekoden. Markdown som markdown, LaTeX som LaTeX, mentions som `#Skolepolitikk`. En monospace-editor der du ser nøyaktig hva som er lagret. Ingen magi, ingen overraskelser. Syntax highlighting for lesbarhet, men ingen transformasjon av det du skriver.

```
# Min analyse av skolepolitikken

Gini-koeffisienten $G = \frac{1}{2n^2\bar{x}}$ viser at...

**Viktig:** Se også #Utdanningsforbundet sin rapport.

{{segment:550e8400-e29b-41d4-a716-446655440000}}
```

**Rendered:** WYSIWYG-visning. Overskrifter er store, bold er bold, LaTeX er rendret som formler, mentions er klikkbare lenker, podcast-embeds er lydspillere. Du kan skrive direkte i denne visningen — toolbar-knapper og formatering fungerer som i et vanlig tekstbehandlingsverktøy.

```
┌──────────────────────────────────────────────────┐
│ Min analyse av skolepolitikken          [Raw/Rendered]
│                                                  │
│ Gini-koeffisienten G = ½n²x̄ viser at...         │
│                                                  │
│ Viktig: Se også Utdanningsforbundet sin rapport. │
│                                                  │
│ ▶ [Episode 42, 14:23–21:07] ░░░░░░░░            │
└──────────────────────────────────────────────────┘
```

**Prinsippet:** Rendered er standard for de fleste. Raw er alltid ett tastetrykk unna for de som vil ha kontroll. Begge visningene redigerer samme data — switch er øyeblikkelig og tapsfri.

**Teknisk:** Raw-modus er en CodeMirror-instans (eller enkel textarea med syntax highlighting) som opererer på serialisert markdown/LaTeX. Rendered-modus er Tiptap WYSIWYG. Begge skriver til samme Tiptap JSON — raw via en markdown→JSON parser, rendered direkte. Switch mellom dem re-serialiserer.

**Viktig detalj:** I rendered-modus forsvinner ikke kildekoden. Klikker du på en rendret formel, ser du LaTeX-kilden inline (som Obsidian gjør med markdown). Klikker du utenfor, rendres den igjen. Rendered-modus er altså ikke et "preview" — det er en visuell editor der kildekoden er tilgjengelig ved klikk.

### Autodeteksjon

Editoren forstår hva brukeren skriver — i begge moduser:

**Markdown:** `**bold**`, `# Heading`, `- item`, `> quote`, `` `code` ``. I raw: syntax highlighted. I rendered: rendret som formatering.

**LaTeX:** `$...$` inline, `$$...$$` blokk. I raw: syntax highlighted. I rendered: rendret med KaTeX.

**Mentions:** `#` trigger autocomplete i begge moduser. Ved valg opprettes en mention-node som rendres som lenke (rendered) eller `#Navn` (raw), og oppretter `MENTIONS`-edge ved lagring.

**Podcast-embeds:** `{{segment:uuid}}`. I raw: syntax highlighted. I rendered: mini-lydspiller.

**Kodeblokker:** Triple backtick med språk-hint. Syntax highlighting i begge moduser.

### Media, lenker og embeds

Editoren håndterer bilder, lenker og eksterne embeds som førsteklasses innhold — ikke som vedlegg på siden, men inline i teksten.

**Bilder:**
- Drag-and-drop, paste fra utklippstavle, eller opplastingsknapp i toolbar
- Lastes opp til `media_files` (eksisterende tabell) via API
- Inline i teksten med valgfri bildetekst og alt-tekst
- Resize ved å dra i hjørnet (rendered-modus)
- I raw: `![alt-tekst](media:uuid)` eller standard markdown bildesyntaks
- Responsiv `srcset` genereres ved opplasting (jobbkø) for publiserte artikler

**Lenker:**
- Paste en URL → autodetekteres som lenke
- Rik forhåndsvisning (Open Graph): tittel, beskrivelse, miniatyrbilde — hentes asynkront ved paste, caches
- Brukeren kan velge mellom rik forhåndsvisning (kort) og enkel tekstlenke
- I raw: standard markdown `[tekst](url)` eller bare URL

**Externe embeds:**
- YouTube, Vimeo, Twitter/X, o.l. — paste en URL, editoren gjenkjenner domenet og rendrer innebygd spiller/visning
- Basert på oEmbed-protokollen der tilgjengelig, ellers sandboxed iframe
- I raw: bare URL-en på egen linje. I rendered: innebygd player med riktig aspekt-ratio
- Whitelist-basert: kun kjente domener får embed-behandling. Ukjente URL-er vises som rik lenke-forhåndsvisning

**Filvedlegg:**
- PDF, dokumenter, andre filer — lastes opp til `media_files`, vises som nedlastbar lenke med filtype-ikon og størrelse

**Teknisk:**
- Alle opplastinger går via `POST /api/media` → lagres content-addressable i `media_files`
- Referanser i Tiptap JSON er `media:uuid` — aldri direkte fil-URL-er. Gjør det mulig å flytte lagring (lokal → CDN) uten å endre innhold
- Bildeoptimalisering (resize, WebP-konvertering) som jobbkø-oppgave ved opplasting
- oEmbed/OG-metadata caches i en enkel tabell for å unngå gjentatte oppslag

### AI-behandling — universell knapp

Editoren har en AI-knapp (✨) som behandler innholdet i boksen. Originalteksten bevares alltid som revisjon (`message_revisions`), og AI-resultatet tar over som nytt innhold — klart for videre redigering av brukeren.

Det som opprinnelig var tenkt som en separat "AI Research-Klipper"-modal er nå bare én av handlingene her: paste inn hva som helst → trykk ✨ → AI-en behandler det.

**Flyten:**
```
Bruker limer inn rotete Ctrl+A-tekst fra en nettavis
  → Trykker ✨ (standard: "Fiks tekst")
  → Originalen lagres som revisjon (alltid tilgjengelig)
  → AI-resultatet erstatter innholdet i editoren
  → Brukeren redigerer videre, legger til tittel, justerer
  → AI-en foreslår #-mentions basert på innholdet → graf-edges opprettes
  → Meldingen lever videre: kan få prominens i avisen, bli et kanban-kort, publiseres
```

**Alternativt:** Brukeren kan velge at resultatet publiseres som *ny melding* (svar på originalen) i stedet for å erstatte innholdet. Nyttig for "trekk ut fakta" der originalen og resultatet er to ulike ting.

### Standard-prompt: "Fiks tekst" (✨)

Standardhandlingen — den brukeren får ved å trykke ✨ uten å åpne menyen — er en generisk "magi"-prompt:

```
Fiks denne teksten. Output på norsk.
- Fiks skrivefeil og grammatikk
- Start med en kort oppsummering av det viktigste (2–3 setninger)
- Fjern metainformasjon, navigasjon, annonser og annen støy fra innlimt webinnhold
- Dersom det er tydelig hva kilden er, oppgi den etter innledende oppsummering
- Behold saklig innhold og fakta intakt
- Bruk markdown-formatering der det gir bedre lesbarhet
```

Denne prompten kan justeres per workspace i Prompt Lab (se `docs/features/prompt_lab.md`). Men standarden skal være god nok til at brukeren bare kan lime inn og trykke ✨ uten å tenke.

### Handlingsmeny (lang-trykk eller ▼ ved siden av ✨)

For mer spesifikke behov åpnes en meny:

| Handling | Hva AI-en gjør |
|---|---|
| ✨ Fiks tekst (standard) | Rens, oppsummer, fiks feil, identifiser kilde |
| Trekk ut fakta | Identifiserer påstander, tall, sitater — som separate faktoider (ny melding) |
| Skriv om for publisering | Omskriver til artikkelformat med tittel, ingress, struktur |
| Oversett | Oversetter til valgt språk |
| Custom | Brukerens egne prompts fra Prompt Lab |

### Revisjon og sporbarhet

Når ✨ brukes:
1. Nåværende innhold lagres i `message_revisions` (originalen er alltid tilgjengelig)
2. AI-resultatet erstatter `messages.body`
3. `messages.metadata` oppdateres med `{ ai_processed: true, ai_action: 'fix_text', ai_prompt_id: '...' }`
4. Brukeren ser resultatet i editoren og kan redigere videre, angre (gå tilbake til revisjon), eller kjøre ✨ igjen

Revisjonshistorikken viser tydelig hva som var original og hva som er AI-behandlet. Brukeren kan alltid gå tilbake.

### Teknisk
- `POST /api/ai/process` med `{ message_id, action, prompt_id? }`
- Oppretter jobbkø-oppgave (`ai_text_process`)
- Rust-worker sender til AI Gateway (`http://ai-gateway:4000/v1`)
- Ved "erstatt innhold": lagre revisjon + oppdater `messages.body`
- Ved "ny melding": opprett ny melding med `reply_to = original_message_id`
- AI-foreslåtte `#`-mentions vises for bruker-godkjenning før edges opprettes

### Kontekst-bevisst (Agentic RAG)
AI-en kjenner konteksten meldingen lever i. Hvis den er i en channel knyttet til #Skolepolitikk, brukes det som hint for å identifisere relevante entiteter og fakta. Workspace-kontekst + graf-nabolag gir bedre resultater enn en kontekstløs prompt.

**RAG-berikelse via kunnskapsgrafen:** Når brukeren skriver et utkast og nevner `#Skolepolitikk`, gjør SvelteKit et usynlig vektorsøk (pgvector) i bakgrunnen mot kunnskapsgrafen *før* prompten sendes til AI Gateway. System-prompten oppdateres dynamisk:

```
Sidelinja har tidligere etablert disse faktaene om Skolepolitikk:
- [Faktoide 1 fra grafen]
- [Faktoide 2 fra grafen]
- [Relatert segment fra Episode 42]
Ta hensyn til dette i behandlingen.
```

Resultatet: AI-en ikke bare retter skrivefeil, men fyller inn kontekst spesifikk for redaksjonens kunnskapsbase. Krever pgvector-migrasjon (0006) og `generate_embeddings`-jobbtype.

### Format-kontekst

Ikke alt passer overalt. En emoji-rik chatmelding og en sirlig publisert artikkel har ulike estetiske forventninger:

**Chat-kontekst:** Alt tillatt. Emojis, GIFs, korte meldinger, uformelt.

**Publiserings-kontekst:** Editoren tilbyr et "publiseringsfilter" — en valgfri siste-sjekk som flagger potensielle stilbrudd (emojis i overskrifter, manglende alt-tekst på bilder, etc.). Aldri blokkerende — bare forslag.

Publiseringskonteksten tilbyr en "forhåndsvisning som leser" der teksten rendres i den ferdige typografi-stacken (Literata, marginer, sidemerknad-fotnoter) slik at forfatteren ser hvordan det blir. Dette er en tredje visning — read-only, ren leseopplevelse — tilgjengelig via [Forhåndsvis]-knappen i publiserings-modus.

### Brukerinnstillinger

Skriftstørrelse, linjehøyde, font, tema og andre visuelle preferanser styres per bruker. Se `docs/features/brukerinnstillinger.md` for full spec — inkludert datamodell (`users.settings` JSONB), CSS custom properties, innstillingspanel og editor-spesifikke preferanser (standard Raw/Rendered, stavekontroll, tegnteller).

### Lagringsformat

**Tiptap JSON** som universelt format:

```json
{
  "type": "doc",
  "content": [
    { "type": "paragraph", "content": [
      { "type": "text", "text": "Gini-koeffisienten: " },
      { "type": "math_inline", "attrs": { "latex": "G = \\frac{1}{2n^2\\bar{x}}" } }
    ]},
    { "type": "mention", "attrs": { "id": "uuid", "label": "Skolepolitikk" } }
  ]
}
```

En enkel "hei" og en 3000-ords artikkel med LaTeX bruker samme format.

**Body-strategi:**
```
messages.body        → Tiptap JSON (universelt kildeformat)
messages.metadata    → { body_html: '...', body_format: 'tiptap' }
```

**Bakoverkompatibilitet:** Eksisterende ren-tekst-meldinger (der `body` ikke er gyldig JSON) tolkes som plaintext. Editoren wrapper dem i Tiptap-paragraph ved redigering. Ingen migrering — bare fallback i lesekoden.

**Pre-rendret HTML:** `body_html` beregnes ved lagring. Brukes for:
- Rask visning i feeds og lister (ingen klient-parsing)
- Publiserte artikler (KaTeX ferdig-rendret, ingen JS for lesere)
- RSS/Atom-feeds
- Søkeindeksering

### Auto-save og versjonering

**Auto-save:** 500ms debounce etter siste tastetrykk (identisk med dagens notat-mønster). Visuell feedback: "Lagrer..." → "Lagret [tidspunkt]".

**Versjonshistorikk:** `message_revisions` lagrer `body` ved hver lagring (eller ved signifikant endring — delta-basert for å unngå å lagre hvert tastetrykk). Brukeren kan bla gjennom tidligere versjoner og tilbakestille.

**Fremtidig:** Navngitte snapshots ("Kladd 1", "Sendt til review", etc.) via `metadata` på revisjonen. Ikke dag 1.

### Keyboard shortcuts

Konsistente overalt:
```
Ctrl+B          → bold
Ctrl+I          → italic
Ctrl+K          → lenke
Ctrl+Shift+M    → math (LaTeX-blokk)
#               → mention-autocomplete
Tab/Shift+Tab   → innrykk i lister
```

**Enter-oppførsel:**
- Kompakt modus: Enter = send. Shift+Enter = linjeskift.
- Utvidet/publisering: Enter = ny linje. Ctrl+Enter = eksplisitt lagre (auto-save gjør det uansett).

**Toggle:**
- Ctrl+/ → switch mellom Raw og Rendered.

### Lazy loading av extensions

Kompakt modus laster bare:
- Plaintext
- Mentions (`#`-autocomplete)
- Inline formatting (bold, italic, lenke)

Ved Expand lastes:
- Overskrifter, lister, blokk-quotes
- Bilder, vedlegg
- Kodeblokker med syntax highlighting

Ved publiserings-modus lastes:
- KaTeX (LaTeX-rendering)
- Podcast-embeds
- Sidemerknad-fotnoter

God for bundle-størrelse og oppstartstid.

## Åpne spørsmål

### Tekniske
- **Tiptap JSON vs Markdown som kildeformat?** JSON er editor-vennlig. Markdown er portabelt. Anbefaling: JSON som primær, Markdown-import/-eksport som transformasjon.
- **Ytelse:** Tiptap JSON for millioner av chatmeldinger? ~60 bytes overhead per melding. Trolig neglisjerbart, men verdt å måle.
- **KaTeX i editoren:** Live-rendering av LaTeX krever KaTeX lastet i editoren. ~300KB gzipped. Akseptabelt for utvidet/publisering, for mye for kompakt? Lazy load løser det.
- **Collaborative editing:** Tiptap + Yjs er veletablert. Ikke dag 1. Auto-save + `message_revisions` + optimistic locking (`updated_at`) er nok initialt.

### UX
- **Overgangen kompakt → utvidet:** Hvordan føles det? Smooth animasjon? Teksten forblir, toolbar glir inn? Eller instant switch?
- **Autodeteksjon av LaTeX i kompakt modus:** Rendres `$E=mc^2$` i en kort chatmelding? Ja — rendring er universell, toolbar er kontekstbetinget.
- **Mobile:** Toolbar på liten skjerm? Trolig: floating toolbar som dukker opp ved tekstseleksjon (Medium-stil) i stedet for fast toolbar.
- **Paste fra eksterne kilder:** Paste av HTML (fra nettside), Markdown (fra Obsidian), ren tekst? Tiptap håndterer HTML-paste. Markdown-paste krever custom paste handler.

### Format-kontekst
- **Emoji-filtrering i publisering:** For rigid? Brukeren bør ha full frihet. Kanskje bare en visuell advarsel i forhåndsvisning, aldri blokkering.
- **Ulike typografi-profiler?** En personlig blogg kan ha annen estetikk enn et magasin. Tema per publikasjon (se artikkel-publisering)?

## Innsats: Middels–Stor
Tiptap-integrasjon er rett frem. Autodeteksjon, progressiv toolbar, mentions, LaTeX, podcast-embeds, auto-save, versjonering, mobilopplevelse — summen er betydelig. Bør bygges inkrementelt:

1. **Fase 1:** Tiptap med plaintext + mentions + markdown formatting. Kompakt og utvidet modus. Auto-save.
2. **Fase 2:** LaTeX (KaTeX), kodeblokker, bilder/vedlegg. Publiserings-modus.
3. **Fase 3:** Podcast-embeds, sidemerknad-fotnoter, collaborative editing (Yjs).

## Wow-faktor: Høy
En editor der du bare skriver — markdown rendres automatisk, LaTeX rendres automatisk, mentions oppretter graf-koblinger, og alt kan vokse til en publisert artikkel — er en opplevelse de fleste verktøy ikke tilbyr. Det nærmeste er Notion, men uten graf-integrasjon og uten podcast-embeds.

## Relasjon til andre proposals og features
- **Tekst-primitiv** — filosofien editoren realiserer
- **Artikkel-publisering** — publiseringslaget som bruker editoren
- **Personlig workspace** — konteksten der editoren brukes daglig
- **Meldingsboks** (feature) — datamodellen editoren skriver til
- **Komponerbare sider** — maximize gir editoren plass til å gå fra chatboks til fullskjerm skriveverksted
- **Chat** (feature) — kompakt modus erstatter dagens chat-input
- **Notater** (feature) — utvidet modus erstatter dagens textarea


================================================================
FILE: docs/proposals/emotion_tags.md
================================================================

# Emotion Tags — Hurtigkategorisering av segmenter

## Idé

Kort i storyboard/kanban kan tagges med stemnings-ikoner — morsom, seriøs, kontroversiell, personlig. Taggen farger kortets ramme og fungerer som filter i etterarbeid.

## Hvorfor interessant?

Under redigering er det nyttig å filtrere på stemning: "vis alle kontroversielle segmenter" eller "vi trenger en morsom bit mellom to tunge tema." Raskere enn å lese alle kortene.

## Fungerer slik
1. Predefinerte tags med ikoner og farger (konfigurerbart per workspace)
2. Klikk-basert tagging — ett klikk per tag, toggle on/off
3. Visuelt: farget border + ikon-badge på kortet
4. Filter: "vis kun 🔥-kort" i storyboard og kanban
5. Lagres som `message.metadata.emotion_tags: string[]`

## Passer inn i eksisterende
- **Meldingsboks**: tags i metadata-feltet, ingen ny tabell
- **Reaksjoner**: kan gjenbruke reaksjons-mekanismen (emoji = emotion tag)

## Innsats
Lav — ren frontend + metadata-felt.

## Wow-faktor
Middels — nyttig for store episoder med mange segmenter.


================================================================
FILE: docs/proposals/flow_meter.md
================================================================

# Flow Meter — Visuell episodeprogresjon

## Idé

En tynn progresjonslinje langs toppen av storyboardet som fylles etter hvert som kort dras til "Tatt opp". Grønn = solid episode, gul = trenger mer, rød = for kort. Basert på antall segmenter og total opptakstid.

## Hvorfor interessant?

Under innspilling er det lett å miste oversikten over om man har nok materiale. Flow meter gir et intuitivt "mage-sjekk" uten å telle manuelt.

## Fungerer slik
1. Konfigurerbar målvarighet per workspace (f.eks. 45 min)
2. Summerer varighet for alle "Tatt opp"-kort
3. Fargeovergang: rød (0–30%) → gul (30–70%) → grønn (70–100%)
4. Valgfritt: pulserer sakte når man nærmer seg mål

## Bygger på
- Storyboard (episode-sekvens med tidsstempler)

## Innsats
Lav — én beregning + CSS gradient.

## Wow-faktor
Middels — liten ting, men fjerner mental overhead.


================================================================
FILE: docs/proposals/ghost_cards.md
================================================================

# Ghost Cards — Visuelle spor fra tidligere episoder

## Idé

Når en episode er ferdig og arkivert, etterlater kort som ble "Tatt opp" svake, semi-transparente spøkelseskort på storyboardet. De fungerer som påminnelser: "vi snakket om dette forrige uke — har vi oppfølging?"

## Hvorfor interessant?

Podcast-temaer henger sammen over tid. Ghost cards gir visuell kontinuitet mellom episoder uten manuell sporing. Perfekt for serier og løpende historier.

## Fungerer slik
1. Ved arkivering: kort som var "Tatt opp" får `ghost_episode_id` i metadata
2. Ved neste episode: ghost cards vises med `opacity: 0.3` og episode-nummer
3. Klikk på ghost → se original diskusjonstråd og tidsstempel
4. Dra ghost → promoter til nytt aktivt kort for oppfølging (beholder graf-edge til originalen)
5. Konfigurerbart: vis ghosts fra siste N episoder (default: 3)

## Bygger på
- Storyboard (visuell rendering)
- Meldingsboks (message med view-config)
- Kunnskapsgraf (edge mellom original og oppfølger)

## Innsats
Lav–Middels — mest metadata-design og UI for ghost-rendering.

## Wow-faktor
Høy — gir podcasten "hukommelse" som oppleves magisk for brukeren.


================================================================
FILE: docs/proposals/ghost_host_tts.md
================================================================

# Forslag: Ghost Host (AI Text-to-Speech i Studio)

## Idé
Under innspilling kan programlederne trykke "Ghost Host"-knappen. AI-en genererer en kort kommentar (10-15 sek) basert på kunnskapsgrafen og tidligere episoder, og spiller den av med syntetisk stemme direkte i LiveKit-rommet.

*"Vegard, du sa akkurat 'det er jo helt bananas', men i episode 17 sa du det samme om vindkraft — skal vi sette inn et klipp?"*

## Hvorfor
- Tar live AI-assistenten fra passiv (tekst-popup) til aktiv (snakker med i rommet)
- Kan gi ikoniske podcast-øyeblikk
- Unik feature som ingen andre podcast-plattformer har

## Bygger på
- Live AI-assistent (faktoid-oppslag, NER)
- Kunnskapsgrafen (faktoider, segmenter)
- LiveKit (lydstrøm)
- AI Gateway (tekst-generering)

## Ny avhengighet
- **Text-to-Speech (TTS)** — dette krever ny infrastruktur:
  - Ekstern: ElevenLabs API (kan rutes via LiteLLM?)
  - Lokal: Piper TTS, Coqui TTS, eller Tortoise-TTS (Docker-container)
  - Vurdering: Lokal TTS passer bedre med self-hosted-filosofien, men kvaliteten er vesentlig lavere enn ElevenLabs

## Åpne spørsmål
- Stemme: nøytral syntetisk stemme, eller voice clone av en vert? (etiske implikasjoner)
- Latens: kan vi generere tekst + TTS + injisere i LiveKit under 3 sekunder?
- Godkjenning: bør det spilles av direkte, eller vises som "Ghost Host vil si noe" med play-knapp?
- Kill switch: hva om den sier noe feil live? Trenger en "avbryt"-knapp


================================================================
FILE: docs/proposals/graph_health_monitor.md
================================================================

# Forslag: Knowledge Graph Health Monitor
**Innsats:** Lav | **Wow-faktor:** Middels

## Idé
En admin-side i SvelteKit som viser "Graf-helse": tetthet, isolerte noder, svake relasjoner, ubalanserte temaer. AI-en foreslår ukentlig nye edges:

*"Du har 47 faktoider om Støre og 32 om Ap — skal vi koble dem med WORKS_FOR?"*
*"Dette segmentet nevner vindkraft 9 ganger, men ingen #Tema-knute finnes."*

## Hvorfor
- Kunnskapsgrafen vokser organisk, men kan bli rotete uten vedlikehold
- Gir redaksjonen en "huskeliste" av ting de bør koble manuelt eller godkjenne
- Synliggjør verdien av grafen — man ser den bli smartere over tid
- Forhindrer "orphan nodes" som aldri dukker opp i oppslag

## Bygger på
- Kunnskapsgrafen (nodes, graph_edges — rekursive CTEs for å finne isolerte subgrafer)
- pgvector (allerede planlagt i Kunnskaps-Bridge — brukes for å finne semantisk like noder som mangler eksplisitt kobling)
- `generate_embeddings`-jobb (eksisterende jobbtype)
- Jobbkø (`graph_suggest_edges` — ny jobbtype, scheduled ukentlig)
- AI Gateway (`sidelinja/resonering` for naturlig språk-forslag)

## Helsemetrikker
| Metrikk | SQL-skisse | Handlingsforslag |
|---|---|---|
| Isolerte noder | `nodes LEFT JOIN graph_edges ... WHERE edge IS NULL` | "Koble til tema eller slett" |
| Temaer uten faktoider | `themes LEFT JOIN factoids ... HAVING count = 0` | "Tomt tema — berik eller arkiver" |
| Aktører uten relasjoner | Samme mønster | "Ny aktør — trenger kontekst" |
| Semantisk like noder | pgvector cosine distance < 0.15 | "Mulig duplikat — slå sammen?" |
| Manglende edges | AI-analyse av node-par med høy co-occurrence i segmenter | "Koble Støre → Ap?" |

## Dataklassifisering
- Edge-forslag: Flyktig (TTL 30 dager) — godkjente forslag blir ekte `graph_edges`
- Helsemetrikker: Avledet (beregnes on-demand fra grafen)

## Åpne spørsmål
- Skal forslagene dukke opp som "innboks-kort" i Redaksjonen, eller kun på en dedikert admin-side?
- Terskel for "semantisk lik": hvor lav cosine distance = mulig duplikat?
- Bør monitoren kjøre per workspace eller globalt (cross-workspace via Bridge)?


================================================================
FILE: docs/proposals/guest_prep_simulator.md
================================================================

# Forslag: Guest Prep Simulator
**Innsats:** Middels | **Wow-faktor:** Høy

## Idé
Før man sender async-gjest-lenke eller inviterer noen til studio, kan redaksjonen trykke "Simuler gjest". Systemet genererer en 3–5 min "prøve-debatt" der AI-en spiller gjesten basert på alt som finnes i kunnskapsgrafen om vedkommende (faktoider, tidligere episoder, sitater).

*"La meg forberede deg — her er hva 'AI-Erna' svarer når du spør om vindkraft..."*

## Hvorfor
- Reduserer "overraskelser" live — programlederne er bedre forberedt
- Gir bedre spørsmål og motargumenter på forhånd
- Morsomt å høre AI-versjonen av gjesten svare på dagens tema
- Naturlig utvidelse av Debate Club-idéen, men som forberedelsesverktøy

## Bygger på
- Kunnskapsgraf + `graph_edges` (`CONTRADICTS`, `MENTIONS`)
- AI Gateway (`sidelinja/resonering` — krever nyansert rollespill)
- Debate Club-forslaget (lignende genereringslogikk)
- Den Asynkrone Gjesten (konseptuell kobling — simuler *før* reell invitasjon)

## Dataklassifisering
- Generert script (Markdown): Avledet (kategori 3)
- TTS-lydfil (valgfritt): Flyktig (TTL 7 dager)

## Åpne spørsmål
- Trenger vi TTS for å gjøre det "levende", eller holder tekst-basert simulering?
- Etikk: tydelig "AI-simulert"-merking, spesielt hvis TTS brukes
- Skal simuleringen baseres kun på faktoider vi har, eller kan AI-en fylle inn basert på offentlig kjent posisjon?
- Kobling til Debate Club: er dette bare en spesialisert variant av samme feature?


================================================================
FILE: docs/proposals/kildevern_modus.md
================================================================

# Forslag: Kildevern-modus (100% lokal LLM)

## Idé
Når Møterommet eller en channel brukes til sensitive, upubliserte redaksjonelle diskusjoner, bryter det med kildevernet å sende transkripsjoner til Claude/Gemini — selv via LiteLLM. En toggle for "kildevern-modus" ruter all AI-prosessering til en lokal modell. Data forlater aldri serveren.

## Hvorfor er dette interessant?
- Presseetikk og kildevern er ikke-forhandlbart for seriøse redaksjoner
- Kan være et differensierende salgspunkt for plattformen
- LiteLLM støtter allerede Ollama/vLLM som leverandør — arkitekturen er klar

## Hva bygger den på?
- **AI Gateway** — Ollama/vLLM som ny leverandør i `config.yaml`
- **Møterommet** — kildevern-toggle på channel/rom-nivå
- **Jobbkø** — ruting basert på `kildevern`-flagg

## Gjennomføring
1. Sett opp Ollama eller vLLM som egen Docker-container med en lett, lokal modell (f.eks. Llama-3-8B eller Gemma-2-9B)
2. Registrer som `sidelinja/lokal` i LiteLLM config
3. Channels/møter får en toggle: `kildevern: true` (lagres i channel-config eller `workspaces.settings`)
4. Når flagget er satt, ruter AI Gateway til `sidelinja/lokal` i stedet for eksterne modeller
5. UI viser tydelig "Kildevern aktiv — all AI-prosessering skjer lokalt" med visuell indikator

## Ressurskrav
- Lokal 8B-modell krever ~6 GB VRAM (GPU) eller ~8 GB RAM (CPU, saktere)
- På nåværende server (16 GB RAM) er dette mulig men trangt — compute-separasjon (se `docs/infra/jobbkø.md` §4.4) gjør det mer komfortabelt
- Kvaliteten på norsk tekst med 8B-modeller er merkbart lavere enn Claude/Gemini — akseptabelt for oppsummering, ikke for kompleks analyse

## Åpne spørsmål
- Hvor granulært skal kildevern-toggle være? Per channel, per melding, per workspace?
- Trenger vi et visuelt "sikkerhetsnivå" (grønt/rødt skjold) i UI?
- Bør kildevern-modus også blokkere ekstern embedding-generering (pgvector)?

## Innsats: Lav–Middels
## Wow-faktor: Høy


================================================================
FILE: docs/proposals/komponerbare_sider.md
================================================================

# Komponerbare sider (Dashboard-komposisjon)

## Idé
Brukere ser ferdige sider (Redaksjonen, Studioet, etc.), men admin kan komponere egne sider fra tilgjengelige byggeklosser — chat, kanban, statistikk, graf-visning, whiteboard, osv.

## Hvorfor interessant?
Ulike redaksjoner jobber ulikt. Noen vil ha chat + kanban side-om-side, andre vil ha statistikk + research. I stedet for å hardkode alle kombinasjoner, gir vi admin verktøy til å sette opp sider tilpasset teamets arbeidsflyt. Brukerne ser resultatet som ferdige sider i navigasjonen.

## Modell

### Fase 1: Forhåndsdefinerte layouts (implementeres først)
- Admin velger fra en katalog av **blokker** (chat, kanban, statistikk, etc.)
- Plasserer dem i et **grid-layout** med forhåndsdefinerte maler (1-kolonne, 2-kolonne, 2+1, etc.)
- Lagres som JSONB i `workspaces.settings` (nøkkel `pages`)
- Brukere ser sidene i navigasjonen — ingen komposisjon, bare bruk
- Mobil: blokkene stacker vertikalt (grid kollapser)

```jsonc
// Eksempel: admin-definert side
{
  "slug": "oversikt",
  "title": "Redaksjonsoversikt",
  "layout": "two-column",        // Forhåndsdefinert mal
  "blocks": [
    { "type": "chat", "channel": "general", "span": 1 },
    { "type": "kanban", "board": "episoder", "span": 1 },
    { "type": "stats", "view": "lyttere-7d", "span": 2 }
  ]
}
```

### Fase 2: Konfigurerbare dashboards (senere, ved behov)
- Brukere kan lage egne personlige dashboards
- Drag-and-drop for å endre rekkefølge og størrelse
- Lagres per bruker i `workspace_members.dashboard_config` JSONB (PG, ikke fil)

### Fase 3: Full fri tiling (trolig unødvendig)
- VS Code / Bloomberg-stil fritt plassering med splitters
- Ekstremt høy kompleksitet, lav marginalverdi vs. Fase 2
- Unngå med mindre det er et demonstrert behov

## Blokker som resizable containere

Hver blokk på en side er et selvstendig, resizable panel. Brukeren kan:

### Resize
Dra i kanten mellom blokker for å justere proporsjoner. Midlertidig (session) eller persistent (lagres i brukerens `dashboard_config`).

```
Standard layout:          Etter resize:
┌──────┬──────┐           ┌───┬─────────┐
│ Chat │Kanban│           │   │         │
│      │      │    →      │   │ Kanban  │
│      │      │           │   │         │
└──────┴──────┘           └───┴─────────┘
```

### Maximize
Dobbelklikk på tittellinjen, eller en maximize-knapp — blokken utvider seg til hele tilgjengelig skjermflate. Alle andre blokker kollapses. Trykk Escape eller minimize for å gå tilbake.

```
Maksimert chat:
┌──────────────────────────┐
│ Chat — #Mediepolitikk  ✕ │
│                          │
│  [full editor,           │
│   full tråd-oversikt,    │
│   full funksjonalitet]   │
│                          │
│                          │
└──────────────────────────┘
```

Dette er spesielt verdifullt for:
- **Editoren** — en chatmelding som vokser til en artikkel trenger plutselig hele skjermen. Maximize gir deg et reelt skriveverksted i stedet for en bitteliten boks.
- **Whiteboard** — trenger plass for å være nyttig
- **Graf-visualisering** — uleselig i en liten boks
- **Mobil** — der skjermplassen er begrenset er maximize den naturlige interaksjonen. Blokkene stacker vertikalt, og du tapper en blokk for å utvide den til fullskjerm.

### Mobil-opplevelse
På mobil er default-visningen en stacked liste med kollapsede blokker:

```
┌──────────────────┐
│ ▼ Chat (3 nye)   │
│ ▼ Kanban         │
│ ▼ Statistikk    │
└──────────────────┘
```

Tapp en blokk → den ekspanderer til fullskjerm. Swipe ned eller tilbake-knapp → tilbake til oversikten. Editoren i fullskjerm på mobil = et reelt skriveverksted, ikke en liten inputboks nederst.

### Teknisk implementering
- CSS Grid med `grid-template-columns`/`grid-template-rows` som justeres via drag
- Resize via pointer events på grid-gapene (eller et lett bibliotek som `svelte-splitpanes`)
- Maximize = CSS `position: fixed` + z-index overlay, med smooth transition
- Blokk-størrelse lagres i `dashboard_config` JSONB: `{ "blockId": { "span": 1.5 } }` eller lignende
- Midlertidig resize (ikke lagret) = Svelte `$state`, forsvinner ved refresh

## Arkitektur-krav
Hver feature-komponent MÅ bygges som en **selvstendig Svelte-komponent** som:
- Tar imot `workspaceId` (og evt. config-props som `channelId`, `boardId`)
- Håndterer sin egen datahenting og tilstand
- Respekterer container-størrelse (responsiv innenfor sin blokk, `container queries` i CSS)
- Eksponerer en `blockMeta`-descriptor (tittel, min-bredde, min-høyde, ikon) for katalogen
- Har en `maximized`-prop som tilpasser layout (f.eks. editoren viser full toolbar i maximized)

Dette koster ingenting å gjøre fra start og gir full fleksibilitet senere.

## Bygger på
- Workspace-modell, SvelteKit layout
- Alle feature-komponenter (chat, kanban, whiteboard, statistikk, etc.)
- Editor (proposal) — maximize gir editoren plass til å være et reelt skriveverksted

## Innsats
- Fase 1: **Lav** (grid-layout + JSON-config, ingen drag-and-drop)
- Fase 1.5: **Lav** (maximize per blokk — relativt enkelt med CSS overlay)
- Fase 2: **Middels** (resize, svelte-splitpanes, bruker-lagring)
- Fase 3: **Stor** (custom tiling engine — trolig unødvendig)

## Wow-faktor
Middels–Høy. Gir en "dette er MITT verktøy"-følelse. Maximize alene er en stor forbedring — spesielt på mobil der det transformerer opplevelsen fra "begrenset app" til "fullverdig arbeidsflate".

## Åpne spørsmål
- Skal sider være workspace-globale (alle ser samme oppsett) eller per-bruker?
  → Fase 1: workspace-globale via `workspaces.settings` JSONB. Fase 2: personlige overrides i `workspace_members.dashboard_config` JSONB. Alt i PG — ingen filer per bruker.
- Midlertidig vs persistent resize: bør default være at resize forsvinner ved refresh (session), eller lagres automatisk?
  → Trolig: auto-lagre med debounce. Brukeren forventer at ting "huskes". En "tilbakestill layout"-knapp for å gå tilbake til admin-default.
- Bør det finnes et "standard-oppsett" per workspace-type (podcast, nyhetsredaksjon)?
  → Ja, som templates admin kan velge som utgangspunkt.
- Keyboard shortcut for maximize? F11 er konvensjon for fullskjerm, men kolliderer med nettleser. Kanskje Ctrl+Shift+F eller dobbelklikk.

## Relasjon til andre proposals
- **Editor** — maximize gir editoren rom til å gå fra chatinput til fullverdig skriveverksted
- **Personlig workspace** — personlige dashboards (fase 2) henger tett sammen med personlig workspace
- **Tekst-primitiv** — en melding som vokser trenger plass; maximize er mekanismen som gir den det


================================================================
FILE: docs/proposals/live_audience_qa.md
================================================================

# Forslag: Live Audience Q&A (Studio-integrasjon)
**Innsats:** Middels | **Wow-faktor:** Høy

## Idé
Under live-innspilling åpner programlederne en "Spør oss"-QR-kode. Tilskuere (eller lyttere på nett) kan sende spørsmål anonymt via en mini-flate. Spørsmålene dukker opp i studio-chatten sortert etter popularitet (real-time voting). AI-en kan foreslå "Svar på dette med faktoid X" i sanntid.

*"En lytter spør: 'Hvorfor sa dere det motsatte i episode 17?' — og AI-en har allerede funnet klippet."*

## Hvorfor
- Gjør innspilling interaktiv uten å miste kontroll
- Gir gullmateriale til episoden ("En lytter spurte akkurat...")
- Kobler publikum direkte inn i studioet — bygger community
- Kan gjenbruke valgomat-konseptets anonyme deltakelse

## Bygger på
- Valgomat (anonym UUID + SpacetimeDB for sanntids-stemming)
- LiveKit + studio-chat (spørsmål vises i studio-UI)
- Kunnskapsgraf (spørsmål kobles automatisk til `#Tema` via NER)
- Live AI (kan foreslå relevante faktoider som svar)

## Datamodell (skisse)
```
audience_sessions (
    id UUID,
    workspace_id UUID,
    livekit_room_id TEXT,
    created_at TIMESTAMPTZ,
    closed_at TIMESTAMPTZ
)

audience_questions (
    id UUID,
    session_id UUID,
    anonymous_id UUID,  -- ingen brukerregistrering
    body TEXT,
    votes INT DEFAULT 0,
    status TEXT DEFAULT 'pending',  -- pending / shown / dismissed
    created_at TIMESTAMPTZ
)
```

## Dataklassifisering
- Spørsmål brukt i episode: Kritisk (PG) — del av episodhistorikken
- Spørsmål ikke brukt: Flyktig (TTL per sesjon, slettes ved lukking)
- Anonym ID: Flyktig — ingen kobling til ekte bruker

## Åpne spørsmål
- Moderering: bør spørsmål gjennom en "godkjenn"-queue før de vises i studio?
- Spam: rate limiting per anonymous_id? Captcha? Eller holder det med manuell moderering?
- Kan dette utvides til live-polling ("Er dere enige med Erna?") med sanntids-resultater?
- Kobling til valgomat: bør QA-flaten være en utvidelse av valgomat-UI, eller helt separat?
- Personvern: lagre IP? Kun for rate limiting, aldri i PG?


================================================================
FILE: docs/proposals/meme_generator.md
================================================================

# Forslag: Møteroms-Meme Generator

## Idé
Når whiteboard lukkes i møterommet, kjører en `meme_gen`-jobb som tar siste 30 sek transkripsjon + whiteboard-PNG og genererer 3 meme-forslag (Impact-font, norske tekster). Lagres som vedlegg i scratchpad-channelen.

## Hvorfor
- Redaksjonen elsker dette allerede i Slack — her blir det innebygd
- Viser at plattformen har personlighet
- Utnytter whiteboard + transkripsjon + AI som allerede finnes
- Lav innsats, høy moro

## Bygger på
- Whiteboard (eksportert PNG)
- Live transkripsjon (siste 30 sek)
- AI Gateway (tekst-generering for meme-tekster)
- Jobbkø (`meme_gen`-jobb)
- Chat/channels (vedlegg i scratchpad)

## Åpne spørsmål
- Trenger vi bildegenerering (DALL-E/Stable Diffusion), eller holder det med tekst-overlay på whiteboard-bildet?
- Impact-font rendering server-side: Rust image-lib, eller ImageMagick i Docker?
- Opt-in per møte, eller alltid-på?


================================================================
FILE: docs/proposals/personlig_workspace.md
================================================================

# Forslag: Personlig workspace

> **Superseded:** Dette forslaget er erstattet av retningen
> "noder er sentrum" (se `docs/retninger/bruker_ikke_workspace.md`).
> Det finnes ingen workspaces. Privat rom oppstår naturlig:
> noder uten edges til andre brukere er ditt private rom. Personlig
> kanban, kalender og notater er bare noder du eier uten delte edges.
> Dokumentet er bevart som historisk referanse.

## Idé
Hver bruker får et personlig workspace som fungerer som en individuell produktivitetssuite. Alle verktøyene et delt workspace har — kanban, kalender, notater, graf-koblinger — men privat og selvorganisert. I tillegg: en personlig publiseringskanal ("blogg") der tekster kan deles med omverdenen.

## Hvorfor er dette interessant?

### Individuell produktivitet
Redaksjonsmedlemmer trenger et sted å jobbe uforstyrret:
- Personlige oppgavelister (kanban)
- Egen kalender (deadlines, påminnelser)
- Kladder og research-notater
- Graf-koblinger til temaer og aktører de følger

`visibility = 'private'` på meldingsbokser innenfor delte workspaces dekker noe av dette, men gir ikke en *egen arbeidsflate*. Et personlig workspace gir:
- Eget kanban-brett for personlige oppgaver (ikke synlig for andre)
- Egen kalender (kan overlappes med delt kalender i UI)
- Egne notater uten støy fra fellesrommet
- Egne graf-koblinger og research

### Personlig publisering
Med tekst-primitiven (se `tekst_primitiv.md`) og publiseringsmodellen (se `artikkel_publisering.md`) kan personlig workspace også være utgangspunkt for en personlig blogg/feed:
- Skriv en tekst i personlig workspace
- Publiser den → tilgjengelig på en personlig URL (`sidelinja.org/@vegard/...`)
- Teksten kan også plukkes opp av en felles publikasjon (se artikkel-publisering)

## Hva bygger den på?
- **Workspace-modellen** (RLS, workspace_members) — et personlig workspace er bare et vanlig workspace med én member
- **Meldingsboks** — alt er allerede workspace-scopet
- **Tekst-primitiv** (proposal) — gir notater en skikkelig editor
- **Artikkel-publisering** (proposal) — gir publiseringskanalen

## Skisse

### Verktøy i personlig workspace

| Verktøy | Hva det er | Bygger på |
|---|---|---|
| Oppgaver | Personlig kanban-brett | `kanban_card_view` |
| Kalender | Personlig kalender | `calendar_event_view` |
| Notater/kladder | Meldinger med rich text editor | Tekst-primitiv |
| Research | Editor AI-knapp + graf-koblinger | Kunnskapsgraf, AI gateway |
| Personlig feed | Publiserte tekster med egen URL | Artikkel-publisering |

Alle disse er eksisterende features brukt i en personlig kontekst. Ingen ny funksjonalitet — bare et eget workspace å bruke dem i.

### Opprettelse
Automatisk ved brukerregistrering. Workspacet er implisitt — det dukker opp i workspace-switcheren med et visuelt skille (ikon, farge, eller plassering).

Slug: `personal-{authentik_id}` (intern), visningsnavn: brukerens display_name.

### Workspace-switcher
```
┌─────────────────────┐
│ 👤 Mitt workspace    │  ← alltid øverst, visuelt adskilt
├─────────────────────┤
│ 📻 Sidelinja         │
│ 🏛️ Foreningen        │
│ ...                  │
└─────────────────────┘
```

### Flytt mellom workspaces
Tre strategier, rangert etter pragmatisme:

1. **Del, ikke flytt** (enklest) — endre `visibility` fra `'private'` til `'workspace'`. Krever at meldingen allerede bor i mål-workspacet. Fungerer for "jobbe privat i fellesrommet", men ikke for å flytte fra personlig workspace til et delt.

2. **Kopier, ikke flytt** (anbefalt) — opprett ny node i mål-workspace, behold original i personlig. Lenke mellom dem med `COPIED_FROM`-edge. Enkelt, trygt, ingen referanseproblemer.

3. **Flytt atomisk** — endre `workspace_id` på node + alle avhengigheter i én transaksjon. Komplekst: `graph_edges`, `reply_to`-kjeder, `kanban_card_view`-referanser til kolonner i kilde-workspace. Ikke verdt kompleksiteten initialt.

**Anbefaling:** Start med (2). "Kopier til fellesrom" er en tydelig handling. Originalen forblir i personlig workspace som referanse.

### Personlig publisering (avhenger av artikkel-publisering)
Hvert personlig workspace har en implisitt publikasjon (feed). Når en tekst publiseres fra personlig workspace:
- Den får en `article_view` med slug og status
- Den blir tilgjengelig på `sidelinja.org/@brukernavn/slug`
- Den dukker opp i brukerens personlige Atom-feed
- En redaktør i en felles publikasjon kan kuratere den derfra (se `artikkel_publisering.md`)

## Åpne spørsmål

### Grense mot delte workspaces
- Kan et personlig workspace ha flere medlemmer (f.eks. invitere en kollega til å se kanban-brettet)? Eller er det strengt personlig?
- Pragmatisk: start strengt personlig (1 member). Utvid later hvis behov oppstår.

### Kvoter og vekst
- Eget lagringsbudsjett per personlig workspace?
- TTL-policy: samme som delte workspaces, eller mer liberal (personlig innhold slettes ikke automatisk)?
- Trolig: ingen TTL på personlig workspace som default. Brukeren styrer selv.

### Dashboard / startside
- Bør personlig workspace ha et dashboard? F.eks.:
  - Siste notater
  - Kommende kalenderhendelser
  - Kanban-kort med deadline
  - Siste aktivitet i delte workspaces brukeren er med i
- Eller er det overkill — bare vis verktøyene?

### Alternativ: "Visibility er nok"
Det kan fortsatt hende at `visibility = 'private'` i delte workspaces dekker 80% av behovet. Et personlig workspace er da mest relevant for:
- Innhold som ikke hører til noe delt workspace
- Personlig publisering
- Et "hjem" i appen

Verdt å evaluere etter at visibility og tekst-primitiven er på plass.

## Innsats: Lav (opprettelse) / Middels (med publisering og dashboard)
Workspace-opprettelse ved registrering er trivielt. Publiseringslaget avhenger av tekst-primitiv og artikkel-publisering. Dashboard er eget arbeid.

## Wow-faktor: Middels-Høy
Alene er det "et privat workspace". Med publisering blir det en personlig plattform — Substack-aktig, men integrert i redaksjonsverktøyet.

## Relasjon til andre proposals
- **Tekst-primitiv** — gir notater og kladder en skikkelig editor
- **Artikkel-publisering** — gir publiseringsmodellen (publikasjoner, kuratorer, feeds)
- Personlig workspace er *konteksten* der tekst-primitiven og publisering møtes for individet


================================================================
FILE: docs/proposals/pinboard_mode.md
================================================================

# Pinboard Mode — Fugleperspektiv over episode-arc

## Idé

Hurtigtast (f.eks. `Ctrl+0`) zoomer ut storyboardet til fugleperspektiv. Kort krymper, titler blir store. Du ser hele episodens arc som en visuell flyt: intro → morsom bit → dypt spørsmål → outro. Dra kort som Lego-klosser for å endre rekkefølge.

## Hvorfor interessant?

Under innspilling er man "for nær" — man ser enkelt-kort, ikke helheten. Pinboard mode gir 3-sekunders overblikk: "vi har tre morsomme segmenter på rad, vi trenger noe tungt i midten."

## Fungerer slik
1. Toggle via hurtigtast eller knapp
2. CSS transform: `scale(0.4)` + økt font-size på titler
3. Kort viser kun: tittel, status-farge, varighet (hvis tatt opp)
4. Drag-and-drop endrer rekkefølge i episode-sekvensen
5. Klikk på kort = zoom tilbake til normalvisning på det kortet

## Bygger på
- Storyboard (episode-sekvens)
- Kanban (drag-and-drop)

## Innsats
Lav — ren CSS/UI-jobb.

## Wow-faktor
Høy — visuelt imponerende og genuint nyttig under innspilling.


================================================================
FILE: docs/proposals/podcast_time_machine.md
================================================================

# Forslag: Podcast Time Machine

## Idé
Mens programlederne snakker om et tema, vises en "Play past clip"-knapp i studio-UI-et. Trykker du den, streamer systemet et 20-sekunders segment fra en gammel episode der samme aktør/tema ble nevnt.

Morsom variant: "Time Machine Roast" — viser bare de mest selvmotsigende eller pinlige sitatene fra arkivet.

## Hvorfor
- Naturlig utvidelse av segment-søk som allerede finnes
- Gjør podcast-arkivet til en aktiv ressurs under innspilling
- Kan gi gullmomenter: "Du sa jo det stikk motsatte i episode 17!"
- Caddy serverer allerede media med `Accept-Ranges: bytes` — byte-range streaming fungerer

## Bygger på
- Segmenter med tidsstempler i PG (allerede indeksert med full-text search)
- `DISCUSSED_IN` og `MENTIONS` edges i kunnskapsgrafen
- Live AI-assistent (NER matcher aktører/temaer → oppslag)
- Caddy media-servering (byte-range for å streame bare segmentet)

## Åpne spørsmål
- Trenger vi en egen "clip cache" eller kan vi streame direkte fra MP3 med start/end byte offset?
- Bør AI-en velge det mest *relevante* klippet, eller det mest *underholdende*?
- Kan dette kombineres med Serendipity Roulette til et "throwback"-modus?


================================================================
FILE: docs/proposals/podcasting_2_0.md
================================================================

# Forslag: Podcasting 2.0 — strukturert RSS

## Idé
Sidelinja har allerede strukturert data for transkripsjoner (segmenter), kapittelinndeling og personer (aktører i grafen). Mate dette direkte inn i RSS-feeden via Podcasting 2.0-standarden — zero ekstra arbeid for redaksjonen, maks wow i lytterappen.

## Hvorfor er dette interessant?
- Apper som Apple Podcasts og Pocket Casts viser automatisk live-synkronisert teksting
- Lytteren kan klikke på gjestens navn for profilbilde (fra `entities.avatar_url`)
- Kapitlene genereres allerede fra segmenter — bare å eksponere dem i riktig format
- Nesten null implementeringskostnad — dataen finnes, bare RSS-generatoren mangler tags

## Hva bygger den på?
- **Podcastfabrikken** — episoder, segmenter, transkripsjoner
- **Kunnskapsgraf** — aktører med `avatar_url`, relasjoner til segmenter
- **RSS-feed** — SvelteKit-generert (se `docs/arkitektur.md` §6)

## Podcasting 2.0 tags

| Tag | Sidelinja-kilde | Resultat i lytterapp |
|---|---|---|
| `<podcast:transcript>` | SRT fra Git (eller VTT-konvertert) | Live tekstssynkronisert teksting |
| `<podcast:person>` | `entities` med `type = 'person'` + `avatar_url` | Gjeste-/vertsprofiler med bilde |
| `<podcast:chapters>` | Segmenter (tidsstemplet) | Klikkbare kapitler |
| `<podcast:soundbite>` | Aha-markører (hvis implementert) | Utvalgte høydepunkter |

## Gjennomføring
1. Utvid SvelteKit RSS-generatoren med Podcasting 2.0 namespace: `xmlns:podcast="https://podcastindex.org/namespace/1.0"`
2. Per episode: generer `<podcast:transcript>` med URL til SRT/VTT-fil
3. Per episode: generer `<podcast:person>` for aktører koblet til episoden via `DISCUSSED_IN`/`MENTIONS`-edges
4. Per episode: generer `<podcast:chapters>` fra segmenter

## Åpne spørsmål
- VTT vs SRT for transkripsjoner? VTT er standarden for web, men SRT er vår master. Konvertering er triviell.
- Hvor mange apper støtter dette faktisk i dag? Nok til at det er verdt det.

## Innsats: Lav
## Wow-faktor: Høy


================================================================
FILE: docs/proposals/serendipity_roulette.md
================================================================

# Forslag: Serendipity Roulette

## Idé
Med jevne mellomrom (konfigurerbart, f.eks. hvert 8. minutt) under innspilling i Studioet, trekker systemet en tilfeldig `DISCUSSED_IN`-edge fra kunnskapsgrafen — 2-3 ledd unna dagens tema. AI-en presenterer en morsom eller relevant faktoide som en "overraskelse" i studio-UI-et.

*"Visste dere at Jonas Gahr Støre i 2011 søkte jobb i AP… samtidig som han var i en episode der dere kalte ham 'den evige kronprinsen'?"*

## Hvorfor
- Gir ekte "live co-host"-følelse — systemet bidrar aktivt til samtalen
- Utnytter kunnskapsgrafen på en måte som belønner at den er godt fylt
- Kan gi genuint gode podcast-øyeblikk som ellers ikke ville oppstått
- Kan skrus av med én toggle

## Bygger på
- Kunnskapsgrafen (graph_edges, recursive CTE for 2-3 ledd)
- Live AI-assistent (SpacetimeDB push til studio-UI)
- Eksisterende segmenter + faktoider

## Åpne spørsmål
- Trenger vi tekst-til-tale (TTS) for at AI-en "leser høyt", eller holder en tekst-popup?
- Hvordan unngå gjentakelser? Trenger vi en "already shown"-tabell per sesjon?
- Bør den være smart nok til å vente på en naturlig pause i samtalen?


================================================================
FILE: docs/proposals/social_posting.md
================================================================

# Forslag: Sosial publisering fra chat

## Idé
Post til X (og senere Bluesky/Mastodon) direkte fra Redaksjonens chat. Noen sier noe morsomt eller skarpt — én kommando, og det er ute i verden. Flere teammedlemmer deler én konto uten å dele passord.

## Hvorfor er det interessant?
- **Senker terskelen.** En podcast-konto på X dør ofte fordi kun én person har tilgang og gidder. Med chat-integrasjon kan hvem som helst foreslå en post, og flyten er én handling — ikke "åpne X, logg inn på riktig konto, skriv, post".
- **Spontanitet.** De beste sosiale medie-postene er de som kommer i øyeblikket. Å fange dem rett fra chatten der samtalen skjer bevarer energien.
- **Kontroll uten friksjon.** Konfigurerbar godkjenningsflyt per workspace — fra "alle poster fritt" til "admin godkjenner alt".
- **Bygger på det som finnes.** Chat, jobbkø, workspace-settings — ingen ny infrastruktur.

## Hva bygger den på?
- **Chat** — `/x`-kommando som trigger publisering
- **Jobbkø** — `social_post`-jobbtype med retry ved rate limit
- **Workspace settings** — API-nøkler og godkjenningsflyt per workspace
- **Admin-panel** — Workspace-innstillinger for plattformer og tilgangsstyring

## Flyt

### Uten godkjenning (workspace-innstilling: `approval: none`)
```
Vegard i chat: "Fantastisk intervju med @støre i dag, han innrømmet at han liker brunost på vaffel"
Marte svarer:  /x
              ┌──────────────────────────────────────┐
              │ Post til X:                          │
              │                                      │
              │ Fantastisk intervju med @støre i     │
              │ dag, han innrømmet at han liker      │
              │ brunost på vaffel 🧇                 │
              │                                      │
              │ 156/280 tegn                         │
              │                                      │
              │ [Rediger]  [Avbryt]  [Post nå]       │
              └──────────────────────────────────────┘
→ Jobb opprettes → Rust-worker poster til X
→ Systemmelding i chat: "Marte postet til X: https://x.com/sidelinja/status/..."
```

### Med godkjenning (workspace-innstilling: `approval: required`)
```
Marte:  /x
→ Jobb opprettes med status 'pending_approval'
→ Systemmelding: "Marte foreslår X-post: «...» — reager med ✅ for å godkjenne"
→ Admin reagerer med ✅
→ Jobb endres til 'pending', worker poster
→ Systemmelding oppdateres: "Postet av Marte, godkjent av Lars: https://x.com/..."
```

### Med godkjenning fra hvem som helst med riktig rolle (workspace-innstilling: `approval: role`)
Samme flyt, men `approval_role` i settings styrer hvem som kan godkjenne (default: `admin`+`owner`).

## Workspace-innstillinger

Lagres i `workspaces.settings` under nøkkelen `social`:

```json
{
  "social": {
    "platforms": {
      "x": {
        "enabled": true,
        "api_key": "...",
        "api_secret": "...",
        "access_token": "...",
        "access_token_secret": "..."
      }
    },
    "approval": "none",
    "approval_role": "admin",
    "default_hashtags": ["#sidelinja"],
    "max_daily_posts": 20
  }
}
```

**`approval`-verdier:**
| Verdi | Oppførsel |
|---|---|
| `none` | Alle workspace-medlemmer kan poste direkte |
| `required` | Alle poster krever godkjenning |
| `role` | Poster fra brukere med lavere rolle enn `approval_role` krever godkjenning |

## Datamodell

Ingen ny tabell — bruker jobbkøen med `job_type = 'social_post'`:

```json
{
  "job_type": "social_post",
  "payload": {
    "platform": "x",
    "text": "Fantastisk intervju med @støre...",
    "source_message_id": "uuid",
    "suggested_by": "authentik_id",
    "approved_by": null,
    "thread_ids": []
  }
}
```

For godkjenningsflyt brukes en ekstra jobbstatus. Jobber med `approval: required` opprettes med `status = 'pending_approval'` (ny enum-verdi) og endres til `pending` ved godkjenning.

## Admin-panel (workspace-innstillinger)

Denne featuren forutsetter et minimalt admin-panel i SvelteKit (`/workspace/settings`). Første iterasjon trenger kun:

- **Sosiale kontoer:** Koble til X (OAuth-flyt), se tilkoblet konto, fjern
- **Godkjenningsflyt:** Velg mellom `none` / `required` / `role`
- **Historikk:** Liste over poster sendt fra dette workspacet (fra jobbkø-data)

Admin-panelet vil vokse med andre workspace-innstillinger over tid (Whisper-config, AI-prompts, channel-defaults, etc.), men sosial publisering er et godt første bruksområde.

## Plattform-abstraksjon

Chat-kommandoen og jobbkøen vet ikke om X spesifikt. Arkitekturen er:

```
Chat (/x, /bsky, /social)
    │
    ▼
Jobbkø: social_post { platform: "x", text: "..." }
    │
    ▼
Rust worker: matcher platform → riktig API-klient
    ├── X (OAuth 1.0a, v2 API)
    ├── Bluesky (AT Protocol) [fremtidig]
    └── Mastodon (OAuth 2.0, REST) [fremtidig]
```

Å legge til en ny plattform er å implementere én ny handler i Rust-workeren + legge til credentials i workspace-settings. Ingen endring i chat-koden.

## X API: Tekniske detaljer
- **API:** X API v2, `POST /2/tweets`
- **Auth:** OAuth 1.0a (User Context) — krever app + bruker-tokens
- **Rate limit:** 200 poster/15 min per bruker (mer enn nok)
- **Tråd-støtte:** `reply.in_reply_to_tweet_id` for tråder
- **Pris:** Free tier tillater 1500 poster/mnd. Basic ($100/mnd) gir 3000 poster + lesing. Free er nok for start.

## Innsats
**Lav–Middels** — X API-integrasjonen er enkel. Chat-kommandoen er en ny handler. Det meste av arbeidet er UI for preview/redigering-popupen og admin-panelet for innstillinger.

## Wow-faktor
**Høy** — Gjør podcast-kontoen levende uten at noen må "huske å poste". Demokratiserer kontoen uten å dele passord.

## Åpne spørsmål
- Skal `/x` kunne brukes på en hel tråd (flere meldinger → X-tråd)?
- Bilder/media i poster — fra chat-vedlegg eller kun tekst i v1?
- Skal poster inkludere en automatisk lenke tilbake til episoden/temaet fra grafen?
- Bør det finnes en `/draft`-kommando som legger posten i en kø uten å sende (planlagt posting)?
- Varsling i chat når en foreslått post venter på godkjenning for lenge?


================================================================
FILE: docs/proposals/storyboard.md
================================================================

# Storyboard — Fritt canvas for innspillingsplanlegging og live-produksjon

## 1. Konsept

Storyboardet er et fritt canvas der meldingsboks-kort plasseres, flyttes og grupperes visuelt. Det brukes **før, under og etter innspilling** — fra idémyldring til ferdig episodestruktur. Ikke et erstatningsverktøy for kanban (som er for langsiktig planlegging), men et taktilt arbeidsflate for å *se* en episode ta form.

Storyboardet er en consumer av Canvas-primitivet (`docs/features/canvas_primitiv.md`) og deltar i universell overføring (`docs/features/universell_overfoering.md`).

## 2. Kjernemodell

### 2.1 Kort = Meldingsboks med storyboard-plassering

Alle kort på storyboardet er vanlige `messages`-noder. Plasseringen på canvaset styres av `message_placements`-tabellen:

```
message_placements:
  message_id  → messages.id
  context_type = 'storyboard'
  context_id  → episode_id (eller standalone storyboard-id)
  entered_at  = når kortet ble lagt på boardet
  position    = { "x": 340, "y": 120, "status": "klar" }
```

Kortet kan samtidig leve i en chat, på et kanban-brett, eller i en kalender. Endringer i chatten (nye svar, redigeringer) propagerer til storyboard-visningen fordi det er *samme melding*.

### 2.2 Episode-objekt

Et storyboard er knyttet til en episode (node av type `episode`) eller eksisterer som frittstående canvas (for idémyldring uten episode-tilknytning).

Episode-noden eier:
- **Sekvensliste:** En ordnet liste over kort som er "Tatt opp", med tidsstempel for når de ble markert. Lagres som `episode_sequence` i SpacetimeDB.
- **Målvarighet:** Konfigurerbar per workspace (f.eks. 45 min) — brukes av Flow Meter.

### 2.3 Statuser

Kort har status (lagret i `position`-JSONB på plasseringen):

| Status | Visuelt | Betydning |
|--------|---------|-----------|
| **Klar** | Solid, hvit/lys border | Planlagt, venter på tur |
| **Tatt opp** | Grønn border, tidsstempel-badge | Snakket om under innspilling |
| **Droppet** | Dimmet (opacity 0.4), rød stripe | Ikke brukt denne gang |
| **Arkivert** | Skjult (filter) | Ferdig behandlet |

Status-endring skjer via:
- Drag til en status-sone (valgfri — konfigurer per workspace)
- Hurtigtast: `R` = Tatt opp, `D` = Droppet (når kort er valgt)
- Kontekstmeny
- Flytende toolbar ved seleksjon

### 2.4 Flere storyboards

Et workspace kan ha mange storyboards — ett per episode, pluss frittstående for idémyldring. Hver er en `StoryboardBlock` med `props.episodeId` (eller `props.boardId` for frittstående).

Tre episoder under planlegging = tre storyboard-blokker, enten:
- På samme side (splittet grid-layout)
- På ulike sider i workspace-navigasjonen

## 3. Fritt canvas

Storyboardet bruker Canvas-primitivet for all canvas-interaksjon:
- **Pan/zoom:** Se `canvas_primitiv.md` §2
- **Objekt-plassering:** Kort har `(x, y)` i world-space, ingen kolonner eller rader
- **Snap-to-grid:** Av som default, toggle med `G`
- **Ingen akse-begrensning:** Brukeren plasserer kort fritt. Ingen antatt retning eller tidslinje

### 3.1 Kort-rendering

Hvert kort rendres som en `<StoryboardCard>` inne i canvas-primitivets objekt-slot:

```
┌─────────────────────────┐
│ 🔵 Tittel               │  ← Status-farge som border
│                         │
│ Kort sammendrag av body │  ← Avkortet tekst
│                         │
│ 💬 3  ⏱ 04:32          │  ← Svar-count + varighet (hvis tatt opp)
└─────────────────────────┘
```

Kort-størrelse er fast bredde (variabel ved zoom), høyde tilpasser seg innholdet opp til en max.

### 3.2 Kort-interaksjon

- **Klikk:** Velg kort, vis flytende toolbar
- **Dobbeltklikk:** Åpne meldingsboksen i utvidet modus (full diskusjonstråd)
- **Drag:** Flytt kort på canvaset
- **Høyreklikk:** Kontekstmeny (status, send til, fjern, slett)

## 4. Overføring mellom blokker

Storyboardet deltar fullt i universell overføring (`universell_overfoering.md`):

### 4.1 Som sender

Dra et kort ut av storyboard-blokken → ghost følger musepekeren → slipp på annen blokk:
- **→ Chat:** Melding ankommer som ny chatmelding med `entered_at = now()`
- **→ Kanban:** Melding blir kort i første kolonne
- **→ Kalender:** Melding blir hendelse på dagens dato
- **→ Annet storyboard:** Melding får ny canvas-posisjon i mål-boardet

### 4.2 Som mottaker

Storyboardet kan motta fra alle blokk-typer:
- **Default-plassering:** Senter av viewport
- **Drag-plassering:** Der brukeren slipper objektet på canvaset
- **Status:** Nye kort ankommer som "Klar"

### 4.3 Inter-storyboard overføring

For å flytte kort mellom episoder (f.eks. "dette passer bedre i episode 48"):
- Dra kortet til en annen storyboard-blokk
- Eller bruk "Send til..." → velg annet storyboard
- Plasseringen i kilde-boardet fjernes, ny plassering opprettes i mål-boardet

## 5. Kort som oppstår under innspilling

Under live innspilling dukker nye idéer opp. Flere veier inn:

| Metode | Flyt |
|--------|------|
| **Hurtigtast (`N`)** | Popup for tittel + body → kort plasseres nær senter |
| **Fra chat** | Skriv melding i studio-chat → "Send til Storyboard" |
| **AI-forslag** | Live AI foreslår kort basert på transkripsjon (se §8) |

Alle veier oppretter en meldingsboks + plassering med status "Klar".

## 6. Kobling til LiveKit / Studioet

### 6.1 Tidsstempel ved "Tatt opp"

Når et kort settes til "Tatt opp" under en aktiv innspilling:

1. **Klient spør LiveKit** om nåværende innspillings-tidspunkt (offset fra oppstart)
2. Tidspunktet lagres i plasserings-metadata: `position.recorded_at_offset = 1823` (sekunder)
3. Episode-sekvensen oppdateres med kortet i riktig posisjon

Etter innspilling, når Whisper har prosessert lyden, kan `recorded_at_offset` matches mot Whisper-segmenter for å koble kort til eksakte transkripsjonsavsnitt.

### 6.2 Episode-sekvens

```rust
#[table(name = episode_sequence_entry, public)]
pub struct EpisodeSequenceEntry {
    #[primary_key]
    pub id: String,
    pub episode_id: String,
    pub message_id: String,
    pub sequence_position: f32,      // REAL for midpoint-innsetting
    pub recorded_at_offset: Option<i64>,  // sekunder fra innspillingsstart
    pub workspace_id: String,
}
```

Sekvensen er den ordnede listen over "Tatt opp"-kort og blir grunnlaget for episode-strukturen i Podcastfabrikken.

## 7. Etter innspilling

### 7.1 Episode-oppsummering

Automatisk generert visning etter innspilling:

```
Episode 47 — Oppsummering
━━━━━━━━━━━━━━━━━━━━━━━━
1. [00:00] Intro — Kommunevalg 2027          💬 2 svar
2. [04:32] Listekandidatene i Oslo            💬 5 svar
3. [12:15] Valgomaten — første resultater     💬 1 svar
4. [18:40] Debatten om bompenger              💬 3 svar
━━━━━━━━━━━━━━━━━━━━━━━━
Totalt: 23:10 av 45:00 mål (51%)
Droppet: 3 kort (bevart for neste episode)
```

### 7.2 Arkivering

Ett-klikks arkivering:
1. Alle "Tatt opp"-kort settes til "Arkivert"
2. "Droppet"-kort beholder status (synlige for neste episode som Ghost Cards)
3. "Klar"-kort beholder status (ubrukte idéer)
4. Episode-sekvensen fryses (immutable etter arkivering)

## 8. AI-integrasjon (fremtidig)

Under innspilling kan Live AI foreslå nye kort:

1. Whisper transkriberer i sanntid
2. AI analyserer transkripsjon: "det ble nevnt et nytt tema som ikke er på boardet"
3. AI oppretter et foreslått kort (status: "Foreslått", visuelt distinkt — stiplet border)
4. Bruker aksepterer → status endres til "Klar"

Dette bygger på Live AI (`docs/features/live_ai.md`) og er ikke del av MVP.

## 9. SpacetimeDB-modell

### 9.1 Tabeller

```rust
// Kort-posisjon og status (via universell overføring)
// Bruker message_placement-tabellen — se universell_overfoering.md

// Episode-sekvens (ordnet liste over "Tatt opp"-kort)
#[table(name = episode_sequence_entry, public)]
pub struct EpisodeSequenceEntry {
    #[primary_key]
    pub id: String,
    pub episode_id: String,
    pub message_id: String,
    pub sequence_position: f32,
    pub recorded_at_offset: Option<i64>,
    pub workspace_id: String,
}
```

### 9.2 Reducers

```rust
#[reducer]
pub fn set_card_status(ctx: &ReducerContext, placement_id: String, status: String) { ... }

#[reducer]
pub fn record_card(ctx: &ReducerContext, episode_id: String, message_id: String, offset: Option<i64>) {
    // Sett status til "Tatt opp" + legg til i episode-sekvens
}

#[reducer]
pub fn reorder_sequence(ctx: &ReducerContext, episode_id: String, entries: Vec<(String, f32)>) { ... }

#[reducer]
pub fn archive_episode(ctx: &ReducerContext, episode_id: String) { ... }
```

## 10. Responsivt design

| Skjerm | Tilpasning |
|--------|-----------|
| Desktop | Full canvas med zoom/pan, hurtigtaster, drag-and-drop |
| Tablet | Touch-gester, flytende toolbar i bunn, "Send til"-meny |
| Mobil | Listevisning av kort (gruppert etter status), "Send til"-meny, ingen canvas |

Mobil får en **alternativ listevisning** fordi fritt canvas på liten skjerm er upraktisk. Listen grupperer kort etter status og lar brukeren endre status via swipe.

## 11. Bygger på

| Feature / Infra | Rolle |
|-----------------|-------|
| Canvas-primitiv | Fritt canvas med zoom/pan/drag |
| Meldingsboks | Kort = meldinger med view-config |
| Universell overføring | Portal-mekanikk mellom blokker |
| Kunnskapsgraf | Kort er noder, relasjoner mellom kort |
| SpacetimeDB | Sanntidssynk av posisjon og status |
| Studioet / LiveKit | Tidsstempel ved "Tatt opp" under innspilling |
| Podcastfabrikken | Episode-sekvens → redigeringsgrunnlag |

## 12. Innsats
Middels–Stor — canvas-primitivet er det tyngste løftet, men gjenbrukes av whiteboard. Selve storyboard-logikken er middels (status, sekvens, arkivering).

## 13. Wow-faktor
Høy — dette er "limen" mellom redaksjonelt arbeid og faktisk innspilling. Visuelt imponerende og genuint nyttig.

## 14. Implementeringsfaser

### Fase 1: Fundament
1. Canvas-primitiv (`<Canvas>`) med pan, zoom, drag, viewport culling
2. BlockShell fullskjerm-toggle
3. `message_placements`-tabell (PG + SpacetimeDB)
4. `StoryboardBlock` registrert i block registry

### Fase 2: Kjerne-storyboard
5. `<StoryboardCard>` med status-visning og hurtigtaster
6. Episode-sekvens med SpacetimeDB-synk
7. Universell overføring: "Send til..." kontekstmeny
8. Drag-and-drop mellom blokker

### Fase 3: Innspillingsintegrasjon
9. LiveKit-kobling for tidsstempler ved "Tatt opp"
10. Episode-oppsummering og arkivering
11. Kort opprettet under innspilling (hurtigtast + fra chat)

### Fase 4: Polish og utvidelser
12. Ghost Cards (forrige episodes kort)
13. Pinboard Mode (zoom-ut til fugleperspektiv)
14. Flow Meter (visuell progresjon)
15. Mobil listevisning
16. AI-foreslåtte kort under innspilling

## 15. Åpne spørsmål (parkert)
- Skal frittstående storyboards (uten episode) ha en egen node-type, eller er de bare episoder uten publiseringsdato?
- Bør canvaset ha et bakgrunnsmønster (dots/grid) for orientering, eller blank?
- Hvor mange kort tåler canvaset før ytelsen lider? Sannsynlig grense: 200–500 DOM-noder med viewport culling.

## 16. Instruks for Claude Code
- Storyboard er en blokk-type (`StoryboardBlock.svelte`) i block registry
- Kort-posisjon eies av SpacetimeDB via `message_placements` — aldri direkte PG fra frontend
- Episode-sekvens er en egen SpacetimeDB-tabell, ikke metadata på episode-noden
- Bruk Canvas-primitivet for all canvas-logikk — ikke reimplementer pan/zoom
- Mobil-fallback (listevisning) er en egen komponent, ikke en "responsiv" versjon av canvaset
- Status-endring skal alltid gå via SpacetimeDB reducer, aldri direkte state-mutasjon


================================================================
FILE: docs/proposals/tekst_primitiv.md
================================================================

# Forslag: Tekst-primitiv

## Idé
Det finnes ingen forskjell mellom en chatmelding og en artikkel — bare ulike stadier av samme ting. Enhver tekst starter som det enkleste ("hei") og kan vokse til hva som helst: få en tittel, bli rik-formatert, dras inn i en kalender, publiseres på web. Alt er samme node, samme primitiv. Brukeren bestemmer aldri "type" på forhånd — de bare skriver, og utvider når det føles naturlig.

## Kjerneprinsipp: Enhver melding kan vokse

```
"hei"                          → ren tekst
"hei, her er mine tanker..."   → brukeren utvider → toolbar dukker opp
   + tittel                    → den har nå et navn
   + overskrifter, lister      → strukturert innhold
   + bilder, LaTeX, embeds     → rikt innhold
   + kanban_card_view          → oppgave
   + calendar_event_view       → kalenderhendelse
   + article_view              → publiserbar artikkel
```

Ingen "oppgradering", ingen "konvertering", ingen modusskifte i data. Noden er den samme hele veien. View-configs legges til og fjernes fritt — de er additive, aldri destruktive.

### Hva dette betyr i praksis
- En chatmelding i #Mediepolitikk kan få en tittel → den er nå et notat
- Det notatet kan dras til kanban → det er nå også en oppgave
- Samme notat kan få `article_view` → det er nå publiserbart
- Alt uten å kopiere, flytte, eller miste kontekst (reply_to-kjeden, graf-edges, reaksjoner)

Dette er meldingsboks-filosofien tatt til sin logiske konklusjon: ikke bare "én primitiv, flere views", men "én primitiv som vokser organisk med brukerens intensjon".

## Hvorfor er dette interessant?

### Rekkefølgeproblemet
Brukeren vet sjelden på forhånd hva en tekst skal bli. Man skriver, tenker, og innser: "dette bør bli noe mer". Ved å la enhver melding vokse forsvinner problemet — veien fra tanke til publisering er bare å legge til, aldri å starte på nytt.

### Konsistens
- Én primitiv i hele systemet (meldingsboksen)
- `#`-mentions fungerer overalt — chat, notater, artikler — samme mekanisme
- Graf-koblinger er universelle — en mention i chat og en mention i en publisert artikkel er identisk
- Versjonshistorikk (`message_revisions`) følger med uansett hvor teksten ender opp

## Hva bygger den på?
- **Meldingsboks** — enhver tekst er en melding (node i grafen)
- **Kunnskapsgraf** — `#`-mentions oppretter graf-edges uansett kontekst
- **Message revisions** — revisjonshistorikk allerede på plass
- **View-configs** — kanban_card_view, calendar_event_view, article_view er bare pekere

## Skisse

### Visibility-trappen
En melding kan bevege seg gjennom synlighetsnivåer uten å endre identitet:

```
'private'    → kun forfatter (kladd, personlig notat)
'workspace'  → alle i workspacet (delt internt)
'link'       → hvem som helst med URL-en (Google Docs-modell, ingen indeksering)
'public'     → publisert (indeksert, i feeds, full SEO)
```

Hvert steg er reversibelt. En publisert artikkel kan trekkes tilbake til `'workspace'`. En privat kladd kan deles med én lenke uten å bli offentlig.

### Delbare URL-er
Enhver melding med `visibility: 'link'` eller `'public'` får en URL:
```
sidelinja.org/delt/<uuid>                → enkel deling (lesbar med lenke, ingen SEO)
sidelinja.org/@vegard/<slug>             → publisert artikkel (SEO, feed, OG-tags)
sidelinja.org/pub/<publikasjon>/<slug>   → kuratert artikkel (se artikkel-publisering)
```

### `article_view` — publiseringslaget
Når en tekst skal publiseres, legges en view-config til:

```sql
CREATE TABLE article_view (
    message_id   UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
    slug         TEXT NOT NULL,
    status       TEXT NOT NULL DEFAULT 'draft',  -- 'draft', 'review', 'published', 'archived'
    excerpt      TEXT,
    body_html    TEXT,                           -- Pre-rendret HTML (KaTeX + editor → HTML)
    published_at TIMESTAMPTZ,
    CONSTRAINT unique_slug_per_workspace UNIQUE (message_id)
);
```

### Offentlig vs intern kontekst
En publisert artikkel har to ansikter:

**Inne i workspacet:** Artikkelen er en melding i en tråd. Den har `reply_to` til meldingen som startet diskusjonen, interne svar, graf-koblinger. Full kontekst.

**Ute på web:** Artikkelen er en frittstående tekst. Den interne konteksten er usynlig. Offentlige kommentarer lever i en separat kanal (`visibility: 'public'`) som workspace-medlemmer kan se, men som ikke blander seg med den interne diskusjonen.

```
article_node (melding i workspace)
├── reply_to → intern forelder (usynlig utenfra)
├── channel: #Mediepolitikk (intern diskusjon)
└── public_comment_channel (offentlige kommentarer, separat)
```

## Åpne spørsmål

### Versjoner og kladder
- Er det nok med `message_revisions` (lineær historikk), eller trengs navngitte versjoner / snapshots?
- Bør ulike versjoner kunne ha ulik visibility? ("kladd 1 er privat, kladd 2 er delt med lenke, publisert versjon er offentlig")
- Eller er det enklere: én tekst, én visibility, revisjonshistorikk under panseret?

### Grensen melding ↔ artikkel
- Teknisk: ingen grense. En melding med `article_view` er en artikkel.
- UX-messig: når tilbyr systemet "vil du publisere dette?" Manuelt via meny? Automatisk forslag når teksten når en viss lengde/kompleksitet?

### Offentlige kommentarer
- Hvem kan kommentere? Anonymt, autentisert, inviterte?
- Enkleste start: ingen offentlige kommentarer. Artikler er read-only for publikum. Diskusjon skjer internt eller via eksterne kanaler.

## Innsats: Lav (prinsipp) / Middels (article_view + visibility)
Prinsippet krever ingen ny kode — meldingsboksen støtter det allerede. `article_view`-tabell og visibility-utvidelse er overkommelig. Den tunge delen er editoren (se `editor.md`).

## Wow-faktor: Middels–Høy
Filosofien — at enhver tekst kan bli hva som helst — er enableren for personlig workspace, artikkel-publisering, samarbeid og kurasjon.

## Relasjon til andre proposals
- **Editor** — det tekniske skriveverktøyet som gjør alt dette mulig i UI
- **Personlig workspace** — konteksten der brukeren kladder og publiserer
- **Artikkel-publisering** — publiseringsmodellen (publikasjoner, kuratorer, feeds) bygger på tekst-primitiven
- **Meldingsboks** (feature) — tekst-primitiven er den naturlige utviklingen: "én primitiv som vokser med brukerens intensjon"


================================================================
FILE: docs/proposals/valgomat_roast.md
================================================================

# Forslag: Valgomat Roast Mode

## Idé
Når en bruker matcher dårlig mot en politiker i valgomaten, får de en ekstra fane: "Sidelinja Roast". Systemet trekker morsomme sitater fra kunnskapsgrafen der politikeren har motsagt seg selv, eller der brukeren er overraskende enig med noen de ikke forventet.

*"Du er 98% uenig med Støre… men her er 3 ganger han har sagt akkurat det samme som deg."*

## Hvorfor
- Gjør valgomaten delbar og viral — folk poster roasts på sosiale medier
- Utnytter `CONTRADICTS`-edges og faktoider som allerede finnes i grafen
- Pedagogisk: viser at politikk er mer nyansert enn venstre/høyre

## Bygger på
- Valgomaten (match-algoritme, brukerprofil)
- Kunnskapsgrafen (faktoider, `CONTRADICTS`-edges, AI-kandidatprofiler)
- AI Gateway (generering av "roast"-tekst)

## Åpne spørsmål
- Tone: morsom og spiss, eller saklig og overraskende? Begge?
- Bør roasts modereres av redaksjonen før de vises?
- Juss: kan vi "roaste" ekte politikere basert på AI-generert innhold?


================================================================
FILE: docs/proposals/waveforms.md
================================================================

# Forslag: Visuelle Waveforms som UI-primitiv

## Idé
Podcast handler om lyd, men i Sidelinja-editoren er lyd foreløpig kun tekst eller en usynlig boks. Generer komprimerte audio-peaks fra lydfiler og rendrer dem som visuelle bølgeformer (SoundCloud-stil) overalt der lyd refereres — i editoren, i segment-embeds, i Podcastfabrikken.

## Hvorfor er dette interessant?
- Gjør manuell redigering og verifisering av transkripsjoner mye mer intuitivt
- Tidsstempler og Aha-markører kan lyse opp oppå bølgene
- Visuell representasjon av lyd er forventet i 2026 — ren tekst føles utdatert
- `{{segment:uuid}}` i editoren kan rendres som en interaktiv bølgeform i stedet for en flat lydspiller

## Hva bygger den på?
- **Podcastfabrikken** — lydfilene og segmentene
- **Jobbkø** — ny jobbtype `generate_waveform`
- **Editor** — `{{segment:uuid}}`-embeds rendrer waveform i stedet for enkel player
- **Studioet** — Aha-markører vises oppå bølgeformen

## Gjennomføring

### 1. Generere peaks
Utvid `whisper_transcribe`-jobben (eller opprett separat `generate_waveform`-jobb) til å generere en komprimert array med audio-peaks:

```
Verktøy: audiowaveform (BBC, C++, rask) eller ffmpeg
Input: MP3/WAV
Output: JSON-array med peaks (f.eks. 800 datapunkter per minutt)
Lagring: media_files.metadata (JSONB) eller separat fil
```

### 2. Svelte-komponent: `<Waveform>`
- Rendrer peaks som SVG eller Canvas
- Klikkbar: hopp til tidspunkt i lyden
- Overlay: Aha-markører, segment-grenser, talerskifter
- Responsiv: tilpasser seg containerbredde
- Brukes i: editor-embeds, Podcastfabrikken, segment-visning

### 3. Editor-integrasjon
`{{segment:uuid}}` i rendered-modus viser:
```
┌──────────────────────────────────────────┐
│ ▶ Episode 42, 14:23–21:07               │
│ ░▓▓░▓▓▓░░▓▓░▓░░▓▓▓▓░░▓░░▓▓░▓▓▓░░▓▓░▓  │
│     ^Aha          ^Støre nevnt           │
└──────────────────────────────────────────┘
```

## Åpne spørsmål
- Peaks per minutt: 800 er nok for de fleste visninger. Trenger vi flere nivåer (zoom)?
- Farger: Mono-farge eller fargekode per taler (krever diarisering)?
- Lagring: Inline i JSONB eller som separat `.json`-fil i media/?

## Innsats: Lav–Middels
## Wow-faktor: Høy


================================================================
FILE: docs/proposals/web_clipper.md
================================================================

# Forslag: Web Clipper / "Send til Sidelinja"

## Idé
Redaksjonell research skjer oftest utenfor Sidelinja — når man leser VG, Aftenposten, eller PDF-er. En minimal "Send til Sidelinja"-mekanisme lar brukeren sende en URL til sin personlige innboks, der AI-en oppsummerer, trekker ut aktører og lager et ferdig research-klipp.

## Hvorfor er dette interessant?
- Fjerner friksjonen mellom "leste noe interessant" og "la det inn i systemet"
- Utnytter eksisterende infrastruktur: jobbkø, AI Gateway, kunnskapsgraf
- Research-klipp lander som meldingsbokser — kan bli kanban-kort, faktoider, artikkel-grunnlag

## Hva bygger den på?
- **Jobbkø** — ny jobbtype `url_ingest`
- **AI Gateway** — oppsummering og entity-uttrekk
- **Meldingsboks** — resultatet er en vanlig melding med `#`-mentions og graf-edges
- **Kunnskapsgraf** — kobler research til eksisterende entiteter
- **Personlig workspace** — innboks for ubehandlet research

## Gjennomføring

### Innsendingsmetoder (velg én eller flere)
1. **Share Target (PWA):** SvelteKit PWA registrerer seg som share target på mobil. Brukeren trykker "Del" i nettleseren → Sidelinja mottar URL-en. Krever kun en `share_target`-entry i `manifest.json` + et API-endepunkt.
2. **Chrome-utvidelse:** Minimal popup med "Send til Sidelinja" + workspace-velger. POST til `/api/clip`.
3. **Bookmarklet:** JavaScript-bookmarklet som sender `window.location.href` til API-et. Zero install.

### Pipeline
```
URL mottatt via /api/clip
  → Opprett melding i personlig innboks (umiddelbart synlig for bruker)
  → Legg jobb i køen: url_ingest (prioritet 5)
  → Rust-worker:
    1. Hent HTML (eller bruk readability-parser for ren tekst)
    2. Send til AI Gateway (sidelinja/rutine): "Oppsummer, identifiser aktører"
    3. Oppdater meldingen med oppsummering, kilde-URL, foreslåtte #-mentions
    4. Brukeren godkjenner mentions → graf-edges opprettes
```

## Åpne spørsmål
- Paywall-innhold? Brukeren ser det i nettleseren, men workeren kan ikke hente det. Løsning: Send full tekst fra klienten i stedet for bare URL?
- Batching? "Legg til 5 artikler i kø" eller én-og-én?
- Automatisk duplikat-deteksjon? Sjekk om URL-en allerede er klippet.

## Innsats: Lav–Middels
## Wow-faktor: Høy


