Arkitekturbeslutninger: noder er sentrum, edges definerer alt

Grunnleggende arkitekturbeslutninger tatt og dokumentert:

- Alt er noder (brukere, team, innhold, mediefiler, samlings-noder)
- Edges definerer hva en node er (freeform typer, metadata i JSONB)
- Materialisert tilgangsmatrise (node_access) erstatter workspace-RLS
- Visibility (hidden/discoverable/readable/open) på noder
- Aliaser via usynlige system-edges
- Maskinrommet eier all skriving (SpacetimeDB først, PG asynk)
- SpacetimeDB holder hele grafen, PG er persistent backup
- Node- og edge-skjema spesifisert (docs/primitiver/)

Fjernet workspace-konseptet fra hele dokumentasjonen (~40 filer).
Fem retninger besluttet, én åpen (rom, ikke forum).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 10:29:54 +01:00
parent 0a467066ba
commit 00bf5d27ce
44 changed files with 1663 additions and 1548 deletions

View file

@ -1,4 +1,4 @@
# === Sidelinja v2 — Lokalt utviklingsmiljø ===
# === Synops — Lokalt utviklingsmiljø ===
# Kopier til .env.local og fyll inn verdier:
# cp .env.example .env.local
#

View file

@ -12,15 +12,11 @@ plattformkode og infrastruktur er skilt fra tenant-data og -innhold.
- **Standard arbeidsmodus:** Start i planleggingsmodus. Lag en grundig plan,
få godkjenning, deretter implementer. Jobbene er ment å kunne kjøre
lenge autonomt uten input underveis.
- **Testmiljø:** `./dev.sh` er den kanoniske måten å starte utviklingsmiljøet.
Når nye steg eller oppsett-quirks oppdages, oppdater alltid `dev.sh`.
Før Vegard tester i browser: kjør `./dev.sh`, verifiser med
`cargo check`/`svelte-check`/`curl`, og meld tilbake at det er klart.
- **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).
- **Utvikling mot server.** Ingen lokale databaser eller tjenester.
Frontend utvikles lokalt mot server-API. Rust bygges lokalt,
deployes til server for integrasjonstest.
- **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.
@ -36,10 +32,12 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:
- `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`Brukeren er sentrum, samlings-noder med RLS-siloer
- `bruker_ikke_workspace.md`Noder er sentrum: brukere, team, innhold er noder. Tilgangsmatrise fra edges.
- `datalaget.md` — PG(+AGE) som graf og arkiv, SpacetimeDB som sanntidslag
- `docs/primitiver/` — Spesifikasjoner for kjerneprimitivene:
- (kommer: input, mottak, kommunikasjonsnode, node, edge)
- `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`
@ -50,7 +48,7 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:
- `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 (RLS-verifisering)
- `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
@ -63,7 +61,7 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:
## 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. Tjenestebruker `sidelinja` eier filer/Docker.
- Begge har sudo + docker-tilgang på serveren.
## Stack
- **Orkestrator/Backend:** Rust (maskinrommet)
@ -88,10 +86,9 @@ CLAUDE.md er eneste startdokument. Alt annet ligger under `docs/`:
- `synops.no` — Plattformdomene (reservert, ikke i bruk ennå)
## Git
- **Forgejo-org:** `sidelinja`
- **Repos:**
- `synops` — plattformkode og arkitektur
- `sidelinja` — podcastinnhold: `ssh://git@git.sidelinja.org:222/sidelinja/sidelinja.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)
@ -121,8 +118,8 @@ Tjenester: PG+AGE, SpacetimeDB, CAS, Whisper, LiteLLM, LiveKit ...
(samler folk). Alt annet er visninger og edges.
3. **Maskinrommet orkestrerer alt.** Fang, prosesser, lever. Edge-drevet
ressursallokering. Tjenester under er utbyttbare.
4. **Brukeren er sentrum.** Ingen workspace-velger. Du ser dine edges.
Samlings-noder gir felles kontekst med RLS-siloer under.
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 arkivet, SpacetimeDB er nåtid.** Ingen eierskapskonflikt.

View file

@ -1,158 +1,172 @@
# Arkitektur — Sidelinja v2
## Visjon
Sidelinja er en plattform for redaksjonelt arbeid og podcast-produksjon.
Ikke en webapp med features — en plattform med primitiver som kan bli
hva som helst.
Alt er noder og edges i en graf. Input fanger, mottak presenterer,
kommunikasjonsnoder samler folk. Maskinrommet orkestrerer tekniske
tjenester under. Frontend er et tynt lag som viser grafen.
## Lagmodell
```
┌─────────────────────────────────────┐
│ GUI (SvelteKit) │
│ Visninger: spørringer mot grafen │
└────────┬──────────────────┬─────────┘
│ skriv │ les (sanntid)
│ │ direkte WebSocket
┌────────▼────────┐ ┌─────▼─────────┐
│ Maskinrommet │ │ SpacetimeDB │
│ (Rust) │ │ Aktive noder │
│ Orkestrering │ └───────────────┘
└──┬─────┬─────┬──┘
│ │ │
▼ ▼ ▼
┌─────┐┌─────┐┌─────┐┌─────────────┐
│ PG ││STDB ││ CAS ││ Whisper, │
│ ││(skr)││ ││ LiteLLM, │
│ ││ ││ ││ LiveKit ... │
└─────┘└─────┘└─────┘└─────────────┘
```
### Skrivestien
GUI → Maskinrommet (Rust) → tjenester
All orkestrering, edge-logikk, validering og ressursallokering
går gjennom Rust. Maskinrommet leser edges og bestemmer hvilke
tjenester som trigges.
### Lesestien (sanntid)
SpacetimeDB → GUI (direkte WebSocket)
STDB klient-SDK gir ~10μs-oppdateringer med lokal cache.
En bevisst optimering — ikke et hull i lagmodellen.
### Lesestien (tradisjonell)
GUI → Maskinrommet (Rust) → PG
Søk, historikk, statistikk, arkiv. Cypher via AGE for
graftraversering når CTEs ikke holder.
## Datamodell
### Noder
Én tabell. Alt innhold er noder: meldinger, oppgaver, notater,
faktoider, mediefiler, kommunikasjonsrom, samlings-noder, brukere.
Felles skjema: id, innhold, created_at, author, content_hash (→ CAS).
Modalitetsspesifikk metadata i JSONB.
### Edges
Én tabell. Alle relasjoner er edges: tilhørighet, tilgang, type,
synlighet, rolle, status.
Hva en node "er" bestemmes utelukkende av 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 + edge til topic = faktoid
- Node uten edges = løs tanke
### Visninger
Visninger er spørringer mot grafen med ulike 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
## Tre primitiver
### 1. Input
Én multimodal overflate som fanger alt: tekst, lyd, bilde, AI,
URL. To versjoner — sanntid (STDB-lag) og flat (PG-lag).
Output er alltid en node.
### 2. Mottak
Én personlig flate som presenterer alt tilpasset deg. Format
bestemmes av mottaker (tekst/lyd). Filtrering via dine edges.
Sanntid (stream) eller asynkront (digest).
### 3. Kommunikasjon
En node som samler folk med tilgangsregler. Samme type fra
én-til-én-samtale til livesending. Forskjellen er edges:
eier, input-tilgang, mottak-tilgang, rolle.
## Maskinrommet
Rust-tjeneste med tre operasjoner: **fang**, **prosesser**, **lever**.
Edge-drevet ressursorkestrering: maskinrommet leser edges og
bestemmer hvilke tjenester som spinnes opp. Dagboknotat = bare
transkriber. Livesending = transkriber + LiveKit + AI + mediaprosessering.
Forvalter også CAS (binærlagring) med intelligent pruning basert
på modalitet, edges og aksessmønstre.
## Sikkerhet
### RLS-siloer
Samlings-noder (det som var workspaces) er harde sikkerhetsgenser
i PG med RLS. `workspace_id = current_setting(...)` — instant,
vanntett. Edge-basert tilgang er UX *innenfor* siloen.
### Privat er default
Input uten mottaker-edge er automatisk privat. Ingen ser det.
Deling er å legge til edges.
## Datalag
### PostgreSQL
Arkiv og graf. Alle noder og edges. Fulltekstsøk, pgvector
(semantisk søk), JSONB. Apache AGE for Cypher når CTEs ikke holder.
### SpacetimeDB
Sanntidslag. Aktive noder og edges speilet fra PG. Frontend
abonnerer via WebSocket. Ingen eierskapskonflikt — STDB er en
live-visning av en delmengde av PG-grafen.
### CAS (Content-Addressable Store)
Binærdata (lyd, bilde, video) lagret med hash. TTL basert på
modalitet, edges og aksesslog. Tekst lever evig, lyd 30 dager,
video 14 dager — forlenges ved aksess. Generert innhold (TTS,
thumbnails) er en cache som regenereres on-demand.
## Teknologivalg
| Rolle | Teknologi | Begrunnelse |
|-------|-----------|-------------|
| Orkestrator | Rust | Ytelse, typesikkerhet, allerede i stacken |
| Frontend | SvelteKit | PWA, SSR, allerede i stacken |
| Database | PostgreSQL | Økosystem (pgvector, fulltekstsøk, AGE), stabilitet |
| Sanntid | SpacetimeDB | In-memory, WebSocket-subscriptions, ~10μs |
| 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.
# 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 leser grafen fra SpacetimeDB.
## Lagmodell
```
┌─────────────────────────────────────┐
│ GUI (SvelteKit) │
│ Visninger: spørringer mot STDB │
└────────┬──────────────────┬─────────┘
│ intensjoner │ les (sanntid)
│ │ direkte WebSocket
┌────────▼────────┐ ┌──────▼──────────┐
│ Maskinrommet │ │ SpacetimeDB │
│ (Rust) │ │ Hele grafen │
│ Eier alle │ │ (noder+edges) │
│ skrivinger │ └─────────────────┘
└──┬─────┬─────┬──┘
│ │ │
▼ ▼ ▼
┌─────┐┌─────┐┌─────┐┌─────────────┐
│ PG ││STDB ││ CAS ││ Whisper, │
│(bak)││(skr)││ ││ LiteLLM, │
│ ││ ││ ││ LiveKit ... │
└─────┘└─────┘└─────┘└─────────────┘
```
### Skrivestien
GUI → intensjon → Maskinrommet (Rust) → SpacetimeDB (instant) → PG (async)
Frontend sender intensjoner (ikke data). Maskinrommet validerer,
skriver til SpacetimeDB først for umiddelbar oppdatering, deretter
persisterer til PG asynkront. Maskinrommet leser edges og bestemmer
hvilke tjenester som trigges.
### Lesestien (sanntid)
SpacetimeDB → GUI (direkte WebSocket)
SpacetimeDB holder hele grafen — alle noder og edges. Frontend
abonnerer via WebSocket med edge-filtre. Visninger er spørringer
mot STDB, ikke forhåndsdefinerte API-endepunkter.
### Lesestien (tunge spørringer)
GUI → Maskinrommet (Rust) → PG
Søk, statistikk, semantisk søk (pgvector), graftraversering
(AGE/Cypher). For operasjoner der STDB ikke er egnet.
## 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 SpacetimeDB 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.
### SpacetimeDB
Holder hele grafen — alle noder og edges. Frontend abonnerer via
WebSocket. Maskinrommet skriver hit først for umiddelbar oppdatering,
PG synkroniseres asynkront.
### 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 STDB |
| Database | PostgreSQL | Persistent backup, pgvector, fulltekstsøk, AGE |
| Sanntid | SpacetimeDB | Hele grafen, WebSocket-subscriptions, ~10μs |
| 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.

View file

@ -43,8 +43,7 @@ Mange interessante gjester har ikke tid til å stille i studio. Den Asynkrone Gj
```sql
guest_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
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
@ -58,9 +57,9 @@ guest_tokens (
### 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 workspace-tilhørighet.
- 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 channels, workspaces eller funksjoner.
- Ingen tilgang til andre noder eller funksjoner.
- Tokenet kan revokeres manuelt av redaksjonen.
### 4.2b Sikkerhetsdybde (mot token-lekkasje og misbruk)
@ -81,7 +80,7 @@ clamav:
image: clamav/clamav:latest
restart: unless-stopped
volumes:
- /srv/sidelinja/media:/scan:ro
- /srv/synops/media:/scan:ro
networks:
- sidelinja-net
```
@ -101,7 +100,7 @@ Gjest åpner URL med token
→ SvelteKit validerer token
→ Viser spørsmål + opptaksknapp
→ Gjest tar opp svar
→ SvelteKit streamer lydfil til media/{workspace_slug}/voice/
→ SvelteKit streamer lydfil til CAS (content-addressable store)
→ Oppretter message (voice_memo) i channelen
→ Oppretter whisper_transcribe-jobb i jobbkøen
→ Inkrementerer recordings_count
@ -118,8 +117,8 @@ Gjest åpner URL med token
## 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, ingen workspace-switcher).
* 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.
* Alt er workspace-scopet. Token bærer workspace_id eksplisitt.
* Tilgang er styrt via node_access. Token bærer node_id eksplisitt.

View file

@ -49,4 +49,4 @@ Grafen vokser organisk via `#`-mentions, men dette skaper uunngåelig fragmenter
**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, workspace-isolasjon) er dokumentert i `docs/features/kunnskapsgraf_og_relasjoner.md`.
Den tekniske datamodellen (nodes-supertabell, graph_edges, detailtabeller, deterministiske UUIDs, tilgangsstyring via node_access) er dokumentert i `docs/features/kunnskapsgraf_og_relasjoner.md`.

View file

@ -29,6 +29,23 @@ Hver publisert episode får en side med:
* 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:
@ -69,17 +86,17 @@ Sidelinja podcast med Vegard Nøtnæs, Trond Sørensen, Arne Eidshagen,
Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie
```
## 5. Workspace-spesifikk konfigurasjon
Hver workspace har sin egen podcast-konfigurasjon, lagret i `workspaces.settings` (JSONB):
## 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 undermapper per workspace: `/srv/sidelinja/media/{workspace_slug}/`. Caddy ruter trafikk basert på domene (fra `workspaces.domain`) til riktig undermappe.
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 workspace** for SRT-filer, slik at historikk og redigering ikke blandes på tvers av podcaster.
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 workspace, via Forgejo API. Ikke alle workspaces trenger transkripsjonsrepo.
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:
@ -93,7 +110,7 @@ Flat struktur med prosesseringstidspunkt som filnavn:
* **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 `/srv/sidelinja/media/{workspace_slug}/` bruker **samme navnekonvensjon** som SRT-filen: `20260315_143022.mp3` matcher `20260315_143022.srt`. Dette kobler mediefil og transkripsjon uten databaseoppslag.
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.
@ -104,7 +121,7 @@ En dedikert servicebruker **"serverassistent"** opprettes i Forgejo med push-til
#### Webhook-flyt
```
Forgejo push-webhook → SvelteKit POST /api/webhooks/forgejo
→ INSERT INTO job_queue (type: 'srt_parse', payload: {repo, commit, workspace_id})
→ 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.
@ -113,18 +130,18 @@ SvelteKit validerer webhook-signatur og legger jobb i køen. Rust-worker forblir
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 workspace i `settings.whisper_prompt`. Rust-worker bygger prompten fra statisk liste + aktører i workspace-ets kunnskapsgraf.
* **LLM system-prompts:** OpenRouter-prompts for metadata-uttrekk lagres i `settings.llm_prompts` slik at AI-en kjenner konteksten og vertene for akkurat den podcasten.
* **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 `workspaces.domain`), eller workspace-slug som fallback.
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 `workspace_id` basert på filsti i loggen.
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.
* **Workspace:** Alle jobber, mediefiler og metadata opprettes med riktig `workspace_id`. Hent workspace-config (prompts, domene) fra `workspaces.settings`.
* **Tilhørighet:** Alle jobber, mediefiler og metadata knyttes til riktig samlings-node via edges. Hent config (prompts, domene) fra samlings-nodens JSONB-metadata.

View file

@ -33,4 +33,4 @@ Brukere limer inn uformatert tekst fra nettet i editoren, trykker AI-knappen (
## 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.
* Alle data er workspace-scopet. SpacetimeDB-tilkoblinger bærer `workspace_id`.
* All tilgang er styrt via node_access-matrisen. SpacetimeDB-tilkoblinger bærer brukerens identitet, og tilgang avgjøres av edges til samlings-noder.

View file

@ -90,7 +90,7 @@ spacetime generate --lang typescript --out-dir ../web/src/lib/chat/module_bindin
--module-path .
```
**Merk:** Bruk `./dev.sh` for å starte hele stacken automatisk (inkl. SpacetimeDB publish + binding-generering). `./dev.sh --clean` starter blankt.
**Merk:** SpacetimeDB-modulen publiseres manuelt med `spacetime publish` mot server-instansen.
## 6. Arkitekturendring: SpacetimeDB som cache foran PG (mars 2026)
@ -149,6 +149,10 @@ PG-polling adapter (`pg.svelte.ts`) brukes kun når SpacetimeDB ikke er konfigur
## 8. Reducer-parameternavn — unngå underscore-prefix
> *Kodeeksemplene i denne seksjonen er fra v1 og bruker `workspace_id`-parametere.
> I v2 er workspace-modellen 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

View file

@ -68,14 +68,11 @@ Eksempel:
```
**Hvorfor `users.settings` og ikke en egen tabell?**
- Innstillingene er per bruker, ikke per workspace
- 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
**Workspace-spesifikke overrides** kan legges i `workspace_members` ved behov:
```sql
ALTER TABLE workspace_members ADD COLUMN settings JSONB NOT NULL DEFAULT '{}';
```
**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

View file

@ -5,7 +5,7 @@
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 workspace-isolasjon.
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 ┌─────────────┐
@ -50,7 +50,7 @@ Hver channel har en `config` (JSONB) som styrer hvilke capabilities den støtter
| **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 workspace-admin kan justere config per channel.
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.
@ -135,13 +135,13 @@ Channels med `config.ttl_days` satt til et tall får sine meldinger automatisk s
### Gjenstår
- **Vedlegg, TTL** — avventer implementering.
- **Workspace-partisjonering:** SpacetimeDB har `workspace_id` men bruker ikke token ennå.
- **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 med riktig `workspace_id`.
* **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.
* **Alt er workspace-scopet.** Channels arver workspace via `nodes.workspace_id`.
* **Tilgang styres via `node_access`-matrisen.** Channels arver tilgang fra sin parent-node via edges.

View file

@ -9,7 +9,7 @@ Månedsbasert kalendervisning for redaksjonell planlegging. Hendelser er nodes i
### Implementert
- Migrering `0003_calendar.sql`: `calendars` + `calendar_events` (begge FK→nodes)
- Hendelser er nodes — arver workspace-isolasjon automatisk
- 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
@ -24,7 +24,7 @@ Månedsbasert kalendervisning for redaksjonell planlegging. Hendelser er nodes i
- Dra-og-slipp for å flytte hendelser mellom datoer
- Flerdag-hendelser (vises over flere celler)
- Abonnementsmodell (kalender → kalender via graph_edges)
- Personlige vs. workspace-kalendere
- Personlige vs. delte kalendere (via samlings-noder)
- ICS/CalDAV-eksport
- SpacetimeDB-modul + hybrid-adapter
- Varsler/påminnelser via jobbkøen
@ -84,15 +84,15 @@ Kalendere kan abonnere på andre kalendere via `SUBSCRIBES_TO`-edge i grafen. Ab
| Type | Eier | Synlighet |
|------|------|-----------|
| Workspace | Workspace (admin) | Alle medlemmer |
| Samlings-node | Admin for samlings-noden | Alle med tilgang via `node_access` |
| Personlig | Bruker | Kun eier + eksplisitt deling |
| Offentlig | Workspace | Alle som abonnerer |
| Offentlig | Samlings-node | Alle som abonnerer |
## 8. Fremtidig: ICS-eksport
Offentlige kalendere får en ICS-URL (`/cal/{workspace_slug}/{calendar_id}.ics`) for Google Calendar, Apple Calendar etc.
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)
* Alt er workspace-scopet via node-modellen
* Tilgang styres via `node_access`-matrisen
* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi

View file

@ -9,7 +9,7 @@ Et drag-and-drop Kanban-brett for planlegging. Primært brukt til episodeplanleg
### Implementert
- Migrering `0002_kanban.sql`: `kanban_boards`, `kanban_columns`, `kanban_cards`
- Kanban-kort er nodes i kunnskapsgrafen (arver workspace-isolasjon)
- 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
@ -31,7 +31,7 @@ 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 — all workspace-isolasjon arves automatisk via `nodes.workspace_id`.
Kort og brett er nodes — tilgang styres via `node_access`-matrisen.
## 4. API-endepunkter
@ -55,5 +55,5 @@ Kort og brett er nodes — all workspace-isolasjon arves automatisk via `nodes.w
## 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.
* Alt er workspace-scopet via node-modellen.
* Tilgang styres via `node_access`-matrisen.
* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi.

View file

@ -1,14 +1,16 @@
# Feature: Kunnskaps-Bridge (Cross-Workspace Discovery)
# 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 workspaces de har tilgang til. Bryter ikke workspace-isolasjonen — resultatet er en *peker* ("dette finnes i Podcast B"), ikke selve innholdet. Brukeren må ha tilgang til begge workspaces for å se treffet.
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 workspaces. Bridge viser kun at en relevant node *finnes*.
- **Ikke automatisk.** Begge workspaces må ha Bridge eksplisitt aktivert av en admin.
- **Ikke synlig for gjester.** Kun for workspace-medlemmer med tilgang til begge sider.
- **Ikke et søk i andres data.** Du ser bare treff i workspaces du allerede er medlem av.
- **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
@ -35,32 +37,33 @@ En jobbkø-jobb som genererer embeddings for nye/endrede noder:
4. Lagrer vektoren i pgvector-kolonnen.
5. Re-genereres ved vesentlige endringer av noden.
### 3.3 Cross-workspace søk
### 3.3 Cross-context søk
Når en bruker utforsker en node (f.eks. Tema "Skolepolitikk"):
1. SvelteKit server-side henter brukerens tilgjengelige workspaces fra `workspace_members`.
2. Kjører et similarity-søk med `<=>` (cosine distance) **som superuser** (bypasser RLS) — men filtrerer eksplisitt mot brukerens workspace-liste:
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 n.workspace_id, t.name, t.embedding <=> $target_embedding AS distance
FROM topics t
JOIN nodes n ON t.id = n.id
WHERE n.workspace_id = ANY($user_workspace_ids)
AND n.workspace_id != $current_workspace_id
AND t.embedding <=> $target_embedding < 0.3
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 Workspace-config
Bridge aktiveres per workspace i `workspaces.settings`:
### 3.4 Samlings-node config
Bridge aktiveres per samlings-node i nodens metadata (JSONB):
```json
{
"bridge_enabled": true,
"bridge_discoverable": true
}
```
- `bridge_enabled`workspace kan søke i andre workspaces
- `bridge_discoverable` — andre workspaces kan finne noder i dette workspace-et
- `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.
@ -72,7 +75,7 @@ Begge må være `true` for at en kobling skal vises.
## 5. Instruks for Claude Code
* pgvector er en ny avhengighet — dokumenter i docker-compose og setup-guides.
* Cross-workspace søk er det **eneste** stedet i systemet der bruker-initierte handlinger bypasser RLS. Isolér denne koden grundig — egen funksjon med eksplisitt workspace-filtrering, aldri gjenbruk i andre kontekster.
* 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 workspaces.
* Bridge er **Lag 4+** — krever fylt kunnskapsgraf i minst to samlings-noder.

View file

@ -28,7 +28,6 @@ CREATE TYPE node_type AS ENUM (
CREATE TABLE nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
node_type node_type NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
@ -86,12 +85,11 @@ CREATE INDEX idx_segments_transcript_fts ON segments USING GIN (to_tsvector('nor
```
### 3.3 Kantene: `graph_edges`
All kobling skjer i én sentral tabell med ekte FK-integritet. `workspace_id` er denormalisert fra `nodes` for å muliggjøre RLS direkte på 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(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
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'
@ -107,7 +105,6 @@ CREATE TABLE graph_edges (
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);
CREATE INDEX idx_edges_workspace ON graph_edges(workspace_id);
```
**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.
@ -161,9 +158,9 @@ Grafen bygger seg opp organisk gjennom daglig bruk av Sidelinja-suiten:
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
* **Workspace-isolasjon:** Alle noder tilhører en workspace. Opprett alltid noder med riktig `workspace_id`. Spørringer mot detailtabeller (actors, topics, etc.) filtrerer alltid via JOIN med nodes.
* **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` krever `workspace_id`.** Ved opprettelse av edges, sett `workspace_id` fra kilde-noden. `UNIQUE(source_id, target_id, relation_type)` hindrer duplikater — bruk `ON CONFLICT` ved upsert.
* **`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.

View file

@ -63,4 +63,4 @@ Kill switch-status (`ai_enabled: bool`) lagres på LiveKit-rommet i SpacetimeDB
* 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.
* Alt er workspace-scopet.
* Tilgang styres via `node_access`-matrisen.

View file

@ -24,7 +24,7 @@ Den felles Whisper-pipelinen som brukes av flere konsepter for å transkribere l
* **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 workspace-config (`workspaces.settings.whisper_prompt`) + aktører i workspace-ets kunnskapsgraf.
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"
@ -32,9 +32,9 @@ Effekten er tydelig:
## 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, ett repo per workspace). Avledede formater (ren tekst, segmenter, søkeindeks) i PostgreSQL.
* **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`).
* Workspace-spesifikk config (prompts) hentes fra `workspaces.settings`.
* Config (prompts) hentes fra samlings-nodens metadata (JSONB).

View file

@ -19,7 +19,7 @@ Voice-to-text er en del av chat-featuren (se `docs/features/chat.md`, seksjon 8)
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 workspace-mappen (`/srv/sidelinja/media/{workspace_slug}/voice/`).
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.
@ -27,7 +27,7 @@ Voice-to-text er en del av chat-featuren (se `docs/features/chat.md`, seksjon 8)
Lydmeldingen er en node i Kunnskapsgrafen (`node_type = 'melding'`, `message_type = 'voice_memo'`):
```
nodes (workspace_id, node_type = 'melding')
nodes (node_type = 'melding')
→ messages (channel_id, message_type = 'voice_memo', body = transkripsjon, metadata = { duration, transcription_status })
→ message_attachments → media_files (lydfilen)
```
@ -70,7 +70,7 @@ AI Gateway (`sidelinja/rutine`) prosesserer transkripsjonen med instruksjon om
Dikterte notater er meldinger i en channel:
```
nodes (workspace_id, node_type = 'melding')
nodes (node_type = 'melding')
→ messages (channel_id, message_type = 'text', body = ryddet tekst, metadata = { raw_transcript, source: 'dictation' })
```
@ -100,7 +100,7 @@ Begge moduser bruker live transkripsjonspipelinen (se `docs/features/live_transk
- Diktering: `medium` + `initial_prompt` (kvalitet prioritert — teksten er master)
### 4.4 Personlig "Innboks"-channel
Ved workspace-opprettelse opprettes en privat channel per bruker for usorterte lydmeldinger og notater. Config:
Ved brukeropprettelse opprettes en privat channel per bruker for usorterte lydmeldinger og notater. Config:
```json
{
@ -130,8 +130,8 @@ Ved workspace-opprettelse opprettes en privat channel per bruker for usorterte l
## 7. Instruks for Claude Code
* Opptakskomponenten skal være en gjenbrukbar Svelte-komponent med modus-prop (`voice_memo` / `dictation`).
* Lydfiler lagres i `media/{workspace_slug}/voice/` — aldri i databasen.
* 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 ved workspace-medlemskap (som del av `workspace_members`-inserten).
* Alt er workspace-scopet.
* Personlig innboks-channel opprettes automatisk for nye brukere.
* Tilgang styres via `node_access`-matrisen.

View file

@ -50,7 +50,7 @@ CREATE TABLE messages (
body TEXT NOT NULL,
metadata JSONB, -- Ekstra data per message_type
pinned BOOLEAN NOT NULL DEFAULT false,
visibility TEXT NOT NULL DEFAULT 'workspace', -- 'workspace' | 'private'
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()
@ -65,11 +65,11 @@ CREATE TRIGGER trg_messages_updated_at BEFORE UPDATE ON messages
**Forskjeller fra gammel `messages`-tabell:**
- `id` er FK til `nodes(id)`**alle meldinger er noder** i kunnskapsgrafen
- Ingen `workspace_id`arves via `nodes.workspace_id` (RLS på nodes gjelder)
- 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 — `'workspace'` (alle i workspacet) eller `'private'` (kun forfatter). Private meldingsbokser kan brukes som kladd for notater, kanban-kort og kalenderhendelser. Endre til `'workspace'` for å dele.
- `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`
@ -219,7 +219,7 @@ Ved nivå 3 tilbyr systemet: **"Skill ut som egen diskusjon?"**
## 8. Eierskap, kurasjon og prominens
### 8.1 Eierskap
Trådstarter og workspace-admin deler eierskap over en diskusjonstråd. Eierskap gir tilgang til kurasjonsverktøy (se 7.2).
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):
@ -240,7 +240,7 @@ Beregnes ved visning eller caches i materialized view ved behov. Algoritmen kan
## 9. TTL og livsløp
### 9.1 To-trinns fading
1. **Skjult fra visning** — meldingen forsvinner fra default UI etter TTL (arvet fra channel/workspace). `messages.metadata.hidden_at` settes.
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
@ -260,7 +260,7 @@ Lever boksen, lever alt under den — svar beholdes uansett alder.
### 9.4 Konfigurerbarhet
```
Workspace-default TTL: 30 dager (workspaces.settings.default_ttl_days)
Samlings-node default TTL: 30 dager (samlings-node metadata.default_ttl_days)
└── Channel kan overstyre: config.ttl_days
└── Individuelle meldinger frittes via reglene over
```
@ -311,9 +311,9 @@ Migrasjonen konverterer eksisterende data:
- **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 personal/workspace channel eller er NULL.
- **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.
- **RLS:** Workspace-isolasjon arves fra `nodes.workspace_id`. Visibility håndteres i applikasjonskode (SvelteKit): `WHERE visibility = 'workspace' OR author_id = $current_user`. Ikke i RLS — RLS håndterer kun workspace-grenser.
- **Visibility:** Default `'workspace'`. Sett `'private'` for personlige kladder. Endre til `'workspace'` for å dele — ingen kopiering nødvendig.
- **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.

View file

@ -9,7 +9,7 @@ Et enkelt notatverktøy med automatisk lagring. Brukes som scratchpad i ulike ko
### Implementert
- Migrering `0004_notes.sql`: `notes`-tabell (FK→nodes)
- Notater er nodes — arver workspace-isolasjon automatisk
- 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)
@ -62,7 +62,7 @@ CREATE TABLE notes (
- Polling (10 sek) henter siste versjon, men hopper over overskriving mens `saving`-flagget er satt
## 7. Instruks for Claude Code
* Notater er workspace-scopet via node-modellen
* 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

View file

@ -5,13 +5,13 @@
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/sidelinja/logs/caddy/podcast_access.log`).
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 `workspace_id`, `date`, `episode_id`, `client_name`, `unique_downloads`). Workspace-tilhørighet utledes fra filstien i loggen (`/media/{workspace_slug}/...`).
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
@ -27,4 +27,4 @@ Caddy access-logger inneholder IP-adresser og User-Agent — dette er personoppl
* 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 workspace-scopet. `episode_stats` merkes med `workspace_id`. Admin-visningen filtrerer per workspace.
* Statistikk er tenant-scopet. `episode_stats` merkes med `tenant_id`. Admin-visningen filtrerer per tenant.

View file

@ -1,8 +1,10 @@
# 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 eget workspace — før de ruller ut i produksjon. Integrert med AI Gateway (LiteLLM) og Promptfoo-testsettene.
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.
@ -14,9 +16,9 @@ Et internt kvalitetssikringsverktøy der redaksjonen kan teste, sammenligne og g
### 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 `workspaces.settings`.
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 i workspace-et.
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.
@ -29,8 +31,8 @@ Et internt kvalitetssikringsverktøy der redaksjonen kan teste, sammenligne og g
### 3.3 Deploy
Når en prompt er verifisert:
1. Bruker klikker "Deploy til workspace".
2. Prompten skrives til `workspaces.settings.llm_prompts[jobbtype]`.
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.
@ -41,7 +43,7 @@ Når en prompt er verifisert:
```sql
prompt_test_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
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,
@ -53,7 +55,7 @@ prompt_test_runs (
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_prompt_runs_workspace ON prompt_test_runs(workspace_id, job_type, created_at DESC);
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.
@ -63,7 +65,7 @@ CREATE INDEX idx_prompt_runs_workspace ON prompt_test_runs(workspace_id, job_typ
```sql
prompt_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
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),
@ -92,10 +94,10 @@ Muliggjør rollback: "forrige prompt for research_clip fungerte bedre, rull tilb
| `prompt_eval` | (varierer) | Batch-evaluering av testsett mot valgte modeller |
## 7. Instruks for Claude Code
* Prompt Lab er et admin-verktøy — krev `admin` eller `owner`-rolle i workspace.
* 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 fra workspace-et (transkripsjoner, artikler) lastes via SvelteKit server-side fra PG. Gjesten-UX vises aldri her.
* `prompt_test_runs` og `prompt_history` er workspace-scopet men trenger ikke RLS — de er kun tilgjengelige for admins via applikasjonslogikk.
* Alt er workspace-scopet.
* 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.

View file

@ -88,7 +88,7 @@ Høyreklikk kort → "Send til..." →
└── 🎬 Storyboard: Episode 47
```
Listen viser alle blokker i det aktive workspacet som kan motta meldinger.
Listen viser alle blokker brukeren har tilgang til som kan motta meldinger.
### 3.3 Flytt vs. kopier
@ -148,7 +148,6 @@ pub struct MessagePlacement {
pub context_id: String,
pub entered_at: Timestamp,
pub position_json: String, // JSON-serialisert posisjon
pub workspace_id: String,
}
#[reducer]

View file

@ -17,4 +17,4 @@ En interaktiv graf-visning i SvelteKit som gjør Kunnskapsgrafen visuelt naviger
## 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.
* Workspace-scopet: alle noder filtreres via `nodes.workspace_id`.
* Tilgangsstyrt: noder filtreres via `node_access`-matrisen.

View file

@ -14,7 +14,7 @@ Et delt, sanntids tegnebrett for frihåndsskisser, diagrammer og visuell brainst
## 3. Sanntidssynkronisering
* **SpacetimeDB** synkroniserer strøk (penseltype, farge, koordinater) mellom alle deltakere i sanntid.
* Hvert whiteboard har en unik ID og tilhører en workspace.
* 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
@ -24,11 +24,11 @@ Et delt, sanntids tegnebrett for frihåndsskisser, diagrammer og visuell brainst
## 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` i workspace-mappen. Referansen knyttes til konteksten (melding, møte) via `message_attachments` eller tilsvarende.
* **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.
* Alle whiteboards er workspace-scopet.
* Tilgang til whiteboards styres via `node_access`-matrisen.

View file

@ -2,7 +2,7 @@
**Filsti:** `docs/infra/ai_gateway.md`
## 1. Konsept
Sidelinja 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.
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
@ -36,7 +36,7 @@ PostgreSQL er single source of truth for all modellkonfigurasjon. LiteLLM er en
### 3.2 Datamodell
```sql
-- Globale modellaliaser (server-nivå, ikke per workspace)
-- 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'
@ -180,14 +180,14 @@ tests/prompts/
## 6. Tokenregnskap og kostnadskontroll
### 6.1 Token-logging per workspace
### 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(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
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)
@ -199,30 +199,30 @@ CREATE TABLE ai_usage_log (
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_ai_usage_workspace_month ON ai_usage_log (workspace_id, created_at);
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 workspace_id, tokens og modellinfo
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
### 6.2 Visning -- to nivåer
**Admin (`/admin/ai`):**
Aggregert oversikt over alle workspaces. Tabell med totaler per workspace/modell/periode. Identifiserer kostnadsdrivere.
Aggregert oversikt over alle samlings-noder. Tabell med totaler per samlings-node/modell/periode. Identifiserer kostnadsdrivere.
**Workspace (sidebar-widget):**
Enkel tekst-indikator i workspace-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.
**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 Workspace-budsjett (fase 2)
### 6.3 Budsjett per samlings-node (fase 2)
Når token-logging er på plass, kan budsjett-tak legges til:
- Budsjett lagres i `workspaces.settings` (JSONB): `{ "ai_budget": { "monthly_limit_usd": 50 } }`
- 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 workspace-chat
- 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).

View file

@ -18,7 +18,7 @@ CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'error', 'ret
CREATE TABLE job_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
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',
@ -120,11 +120,11 @@ Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på
| `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. Workspace-isolasjon
Alle jobber merkes med `workspace_id`. Rust-workers kjører som superuser (bypasser RLS) og sikrer isolasjon i applikasjonskode:
* Worker leser `workspace_id` fra jobben og bruker det til å lagre resultater tilbake i riktig silo
* Workspace-spesifikk config (AI-prompts, navnelister) hentes fra `workspaces.settings`
* Feilede jobber vises kun for brukere i riktig workspace i admin-visningen
## 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`)
@ -135,6 +135,6 @@ Alle jobber merkes med `workspace_id`. Rust-workers kjører som superuser (bypas
- 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 `workspace_id` — hent fra konteksten (innlogget bruker, webhook, etc.)
- 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

View file

@ -1,176 +1,106 @@
# Infrastruktur: PostgreSQL ↔ SpacetimeDB Synkronisering
**Filsti:** `docs/infra/synkronisering.md`
## 1. Konsept
SpacetimeDB gir sanntidsopplevelsen, PostgreSQL er langtidsminnet. Denne spec-en definerer hvordan data flyter mellom dem, hvem som eier sannheten, og hva som skjer ved feil.
### 1.1 Grunnregel: SpacetimeDB er en ren sanntidsbuffer
SpacetimeDB skal **aldri** være eneste lagringssted for data med varig verdi. All data som SpacetimeDB holder skal enten:
1. Allerede eksistere i PostgreSQL (read-only cache, f.eks. aktør-navn for autocomplete), eller
2. Synkes til PostgreSQL innen ~1 sekund (chat, kanban, markører).
**Konsekvens for design:** Ethvert SpacetimeDB-modul-skjema skal ha en tilsvarende PostgreSQL-tabell som kan fungere som drop-in erstatning dersom SpacetimeDB fjernes. Frontend-kode som leser fra SpacetimeDB skal kunne peke om til et SvelteKit SSE-endepunkt + REST uten arkitekturendring.
### 1.2 PostgreSQL-fallback-prinsippet
Dersom SpacetimeDB fjernes fra stacken, skal systemet fungere med følgende erstatning:
- **Sanntidsoppdateringer:** PostgreSQL `LISTEN/NOTIFY` → SvelteKit Server-Sent Events (SSE)
- **Skriving:** Direkte til PostgreSQL via SvelteKit server-side
- **Autocomplete/cache:** PostgreSQL-spørringer med cursor-basert paginering
Denne fallbacken trenger ikke implementeres på forhånd, men SpacetimeDB-moduler skal designes slik at fallbacken forblir triviell.
## 2. Strategi: Event-drevet med kort forsinkelse
SpacetimeDB-modulene (Rust) produserer persisterings-events ved dataendringer. En Rust-worker konsumerer disse og skriver til PostgreSQL med ~1 sekunds intervall.
**Akseptabelt datatap:** Maks 1 sekund ved hard krasj av SpacetimeDB. Dette er akseptabelt for chat, kanban og show notes.
**Unntak — kritiske events:** Aha-markører fra studioet (live-innspilling) er tidssensitive og vanskelige å gjenskape. Disse bør flushes til PG umiddelbart (ikke batched) via en dedikert `sync_critical()`-funksjon som skriver direkte til PG i stedet for via `sync_outbox`. Alternativt kan SpacetimeDB-modulen skrive kritiske events til sin egen WAL/disk umiddelbart. Hvilke event-typer som er "kritiske" defineres per workspace i `workspaces.settings`.
## 3. Dataflyt
```
┌──────────────┐ events ┌──────────────┐ batch write ┌──────────────┐
│ SpacetimeDB │ ──────────────► │ Rust Worker │ ────────────────► │ PostgreSQL │
│ (sanntid) │ │ (sync_to_pg) │ │ (persistent)│
└──────────────┘ └──────────────┘ └──────────────┘
┌──────────────┐ oppvarming (oppstart / reconnect) ┌──────────────┐
│ PostgreSQL │ ──────────────────────────────────────► │ SpacetimeDB │
└──────────────┘ └──────────────┘
```
## 4. Eierskapsmodell
| Data | Autoritativ kilde | Synkretning | Merknad |
|---|---|---|---|
| Chatmeldinger | SpacetimeDB | → PG (event, batched) | |
| Kanban-posisjon | SpacetimeDB | → PG (event) | |
| Show notes | SpacetimeDB | → PG (event) | |
| Live studio-markører | SpacetimeDB | → PG (event) | |
| Kunnskapsgraf | PostgreSQL | → SpacetimeDB (oppvarming) | Read-only i SpacetimeDB |
| Episodemetadata | PostgreSQL | Ingen synk | |
| Brukerkontoer | PostgreSQL (Authentik) | Ingen synk | |
| Statistikk | PostgreSQL | Ingen synk | |
| Valgomat | TBD | TBD | Konseptet må modnes. Mulig PG-autoritativ med SpacetimeDB som serveringslag |
## 5. Mekanisme
### 5.1 SpacetimeDB → PostgreSQL (persistering)
- SpacetimeDB-reducerne legger sync-events i `sync_outbox`-tabellen med tidsstempel og payload
- Rust-workeren poller `sync_outbox` hvert sekund, leser alle usynkede events, skriver til PostgreSQL, og markerer dem som synket via `mark_synced`-reducer
- Ved PG-nedetid: events akkumuleres i `sync_outbox`. Workeren prøver igjen ved neste poll. Ingen data tapes så lenge SpacetimeDB kjører
### 5.2 PostgreSQL → SpacetimeDB (oppvarming)
- Ved oppstart laster Rust-workeren data fra PG per kanal, basert på `channels.config`:
- `warmup_mode: "all"` — alle meldinger i kanalen
- `warmup_mode: "messages"` — de N nyeste *trådene* (komplett med svar)
- `warmup_mode: "days"` — alle tråder med aktivitet siste N dager (komplett)
- `warmup_mode: "none"` — kanalen hoppes over (arkivert/inaktiv)
- Trådbasert henting: et svar som kvalifiserer tar med hele tråden (inkludert eldre trådstarter)
- Konfigureres per kanal via admin-UI (`/admin/channels`) eller `channels.config` JSONB
- Kanalen ryddes (`clear_channel`) før lasting for å unngå duplikater ved restart
- Reaksjoner lastes også inn via `load_reactions`-reducer
## 6. Feilhåndtering
- **SpacetimeDB krasjer:** Data siden siste synk (~1 sek) tapes. Ved restart oppvarmes fra PG
- **PostgreSQL nede:** Sanntidsfunksjoner fortsetter å fungere. `sync_outbox` vokser. Workeren logger advarsler. Ved PG-recovery synkes backloggen automatisk
- **Rust-worker krasjer:** `sync_outbox` akkumuleres. Ved restart plukker workeren opp der den slapp (usynkede events har ingen markering)
## 7. Workspace-isolasjon
SpacetimeDB-synkronisering er fullstendig workspace-scopet:
* SpacetimeDB-tilkoblinger bærer `workspace_id` som context-token. Modulen partisjonerer minnet og kringkaster kun til klienter i samme workspace.
* `sync_outbox`-events inkluderer `workspace_id` i payloaden, slik at Rust-workeren skriver til riktig silo i PostgreSQL.
* Ved oppvarming (PG → SpacetimeDB) laster workeren kun data for den aktuelle workspace-en.
## 8. Konflikthåndtering
### 8.1 Strategi: Last-Write-Wins (LWW) med reservasjoner
SpacetimeDB er autoritativ for sanntidsdata. Konflikter kan oppstå i to scenarioer:
**Scenario A — Samtidig redigering i SpacetimeDB:**
SpacetimeDB er single-threaded per modul. Reducer-funksjoner serialiseres automatisk. Ingen klassiske race conditions.
**Scenario B — PG-oppvarming kolliderer med fersk SpacetimeDB-state:**
Ved restart av SpacetimeDB lastes data fra PG (oppvarming). Hvis en klient skriver til SpacetimeDB *under* oppvarming, kan PG-dataen overskrive ferske endringer.
**Løsning:** Oppvarming setter et `warming_up`-flagg i SpacetimeDB-modulen. Klienttilkoblinger aksepteres, men skrivinger bufres til oppvarmingen er fullført. Buffrede skrivinger appliseres deretter i rekkefølge.
### 8.2 Kanban: Posisjonskonflikter
Kanban-kort har en `position`-kolonne (float). To brukere som drar kort samtidig kan skape overlappende posisjoner. Løsning: SpacetimeDB-modulen re-beregner posisjoner (midtpunkts-strategi) og kringkaster oppdatert rekkefølge til alle klienter.
### 8.3 Chat: Ingen konflikter
Meldinger er append-only. Redigering av egne meldinger er last-write-wins — akseptabelt fordi kun én bruker eier meldingen.
## 9. Workers som endrer data frontend ser
**Kritisk regel:** En worker (Rust) som transformerer data som frontend viser, MÅ skrive resultatet til SpacetimeDB — ikke direkte til PG. PG-oppdatering skjer via sync-worker i bakgrunnen.
### Eksempel: AI-vask av chatmelding
**Riktig flyt:**
```
Frontend viser melding fra SpacetimeDB
→ Bruker trykker ✨
→ SvelteKit oppretter jobb i PG job_queue
→ Worker plukker opp jobb
→ Worker leser prompt + routing fra PG (det er infrastrukturdata, ikke frontend-data)
→ Worker kaller AI Gateway
→ Worker skriver resultat til SpacetimeDB via edit_message reducer
→ SpacetimeDB pusher oppdatering til frontend via onUpdate
→ Sync-worker persisterer til PG i bakgrunnen
```
**Feil flyt (anti-pattern som har blitt implementert gjentatte ganger):**
```
Worker skriver til PG direkte
→ Frontend henter metadata fra PG via enrichFromPg() ← BRYTER ABSTRAKSJONEN
→ SpacetimeDB-oppdatering er "best-effort" ← FEIL PRIORITERING
```
### Hva betyr dette for nye felter?
Når frontend trenger å vise noe nytt (metadata, revisjoner, edited_at), er prosedyren:
1. **Utvid SpacetimeDB-modulen** — legg til feltet i Rust-strukturen
2. **Utvid warmup** — last feltet fra PG ved oppstart
3. **Utvid sync** — persist feltet til PG i bakgrunnen
4. **Worker skriver til SpacetimeDB** — via reducer, aldri direkte PG for synlig data
5. **Frontend leser fra SpacetimeDB** — ingen enrichFromPg, ingen PG API-kall
### Hva kan workers lese fra PG?
Workers kan og bør lese infrastrukturdata direkte fra PG:
- Jobbkø (`job_queue`) — det er jobbens opphavssted
- AI-prompts, modellkonfigurasjon, routing — det er infrastruktur, ikke brukerdata
- Workspace-konfigurasjon
Skillet er: **data frontend viser** → SpacetimeDB. **Data kun worker trenger** → PG direkte.
## 10. Implementeringsstatus (mars 2026)
### Ferdig
- **SpacetimeDB som cache foran PG:** PG er autoritativ, SpacetimeDB er varm cache. Frontend snakker kun med SpacetimeDB.
- **SpacetimeDB Rust-modul** (`spacetimedb/src/lib.rs`): `ChatMessage` (med `metadata`, `edited_at`), `MessageReaction`, `MessageRevision` og `SyncOutbox`-tabeller. Reducers: `send_message`, `delete_message`, `edit_message`, `add_reaction`, `remove_reaction`, `load_messages`, `load_reactions`, `load_revisions`, `clear_channel`, `mark_synced`, `set_ai_processing`, `clear_ai_processing`, `ai_update_message`.
- **Worker warmup** (`worker/src/warmup.rs`): Ved oppstart lastes meldinger (med metadata/edited_at), reaksjoner og revisjoner per kanal fra PG → SpacetimeDB. Originale timestamps parses korrekt. Kanaler ryddes først med `clear_channel` for å unngå duplikater.
- **Worker sync** (`worker/src/sync.rs`): Poller `sync_outbox` hvert 1. sekund. Håndterer insert/delete/update/ai_update for meldinger og insert/delete for reaksjoner.
- **SpacetimeDB-adapter** (`web/src/lib/chat/spacetime.svelte.ts`): Ren SpacetimeDB-adapter. Null PG API-kall (`fetch()`). Bruker `onInsert`/`onUpdate`/`onDelete` callbacks for sanntid. Reaksjoner bygges fra `message_reaction`-tabellen. Revisjoner leses fra `message_revision`-tabellen.
- **PG-fallback** (`web/src/lib/chat/pg.svelte.ts`): Brukes kun når SpacetimeDB ikke er konfigurert. Markert som `readonly: true`.
- **Adapter-mønster:** `ChatConnection`-interface med `send`, `edit`, `delete`, `react` metoder. Factory velger basert på env-variabel.
### Fikset (mars 2026)
- **`enrichFromPg` fjernet** — SpacetimeDB-modulen har nå `metadata` og `edited_at``ChatMessage`. Frontend leser alt fra SpacetimeDB, null `fetch()`-kall i adapteren.
- **Worker AI-vask via reducers**`set_ai_processing`, `ai_update_message` og `clear_ai_processing` reducers erstatter direkte PG-skriving. Sync-worker persisterer til PG via `ai_update` outbox-action.
- **Revisjoner i SpacetimeDB**`MessageRevision`-tabell med `load_revisions` warmup-reducer. Frontend leser revisjoner via `getRevisions()` fra `conn.db.message_revision`.
- **Warmup med metadata**`load_messages` inkluderer `metadata`, `edited_at` og parser originale timestamps korrekt.
### Gjenstår
- **Workspace-partisjonering (§7):** SpacetimeDB-modulen har `workspace_id`-felt men bruker ikke workspace-token på tilkobling ennå.
- **Pin/konvertering via SpacetimeDB:** Pin og kanban/kalender-konvertering går fortsatt direkte til PG API.
- **Lazy warmup per kanal:** Alle aktive kanaler oppvarmes ved oppstart. Kan optimaliseres til per-kanal ved tilkobling.
## 11. Instruks for Claude Code
- `sync_outbox`-tabellen i SpacetimeDB bør ha et `synced`-flagg og `created_at`-tidsstempel
- Workeren skal bruke jobbkø-infrastrukturen (se `docs/infra/jobbkø.md`) for sin egen helse/observabilitet, men selve pollingen er en egen loop — ikke en vanlig jobb i køen
- Hold sync-payloaden enkel: `{ "table": "chat_messages", "action": "insert", "data": {...} }` — workeren mapper dette til riktig PG-tabell
- Ikke optimaliser for store datamengder ennå. Enkle INSERTs er bra nok til volumet stabiliserer seg
- Alle sync-payloads må inkludere `workspace_id`. Workeren bruker dette til å sette RLS-kontekst (`SET app.current_workspace_id`) før PG-skriving, eller kjører som superuser og filtrerer eksplisitt
# 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.

117
docs/primitiver/edges.md Normal file
View file

@ -0,0 +1,117 @@
# 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.

140
docs/primitiver/nodes.md Normal file
View file

@ -0,0 +1,140 @@
# 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`
Primært tekstinnhold. Det du leser, søker i, viser i detalj.
Blogginnhold, chatmeldinger, transkripsjoner.
- Bloggpost: hele artikkelen
- Chatmelding: `'Hei, er du klar?'`
- Voice memo: `NULL` ved opprettelse, fylles etter transkribering
- Brukernode: `NULL`
- CAS-node: `NULL` (binærdata lever på disk)
### `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 tekstinnhold:
- 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" }`
### `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.

View file

@ -1,5 +1,12 @@
# Forslag: Personlig workspace
> **Superseded (v2):** Dette forslaget er erstattet av retningen
> "noder er sentrum" (se `docs/retninger/bruker_ikke_workspace.md`).
> I v2 finnes det 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.

View file

@ -23,10 +23,10 @@ andre dokumenter. En retning kan også forkastes eller parkeres.
|---------|--------|----------------|
| [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) | Åpen | Én multimodal input-primitiv + personlig mottaksflate, noder + edges i stedet for separate tabeller |
| [Maskinrommet](maskinrommet.md) | Åpen | Én Rust-tjeneste med fast grensesnitt for alle tekniske tjenester: fang, prosesser, lever |
| [Bruker, ikke workspace](bruker_ikke_workspace.md) | Åpen | Brukeren er sentrum, workspaces er frivillige samlings-noder — ikke containere |
| [Datalaget](datalaget.md) | Åpen | PG + Apache AGE som enhetlig graf og arkiv, SpacetimeDB som sanntidslag, CAS for binærdata |
| [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) | **Besluttet** | SpacetimeDB holder hele grafen, PG er persistent arkiv, CAS for binærdata, AGE ved behov |
## Format
- Hva er tesen?

View file

@ -1,154 +1,327 @@
# Bruker, ikke workspace
> Primærenheten er brukeren og brukerens edges. Workspaces er ikke
> containere — de er frivillige samlings-noder som gir felles kontekst.
## Observasjoner
I dag er workspaces den organisatoriske enheten. Du "er i" et workspace,
og det bestemmer hva du ser: kanaler, boards, kalendre. Vil du se noe
fra et annet workspace må du bytte. Det er en container-modell — ting
*bor* i workspaces.
Men i node+edge-modellen gir dette ikke mening. En node er ikke "i" noe
— den har edges til ting. Og brukeren er ikke "i" et workspace — brukeren
har edges til noder, personer, topics, samtaler.
## Tesen
**Brukeren er sentrum.** Du logger inn og ser dine edges — alt du er
koblet til. Ikke "velg workspace" som første handling, men "her er alt
ditt."
### Workspace som samlings-node, ikke container
Et workspace eksisterer fortsatt som konsept, men det er en node i
grafen — ikke en organisatorisk boks:
- **Tradisjonell modell:** Workspace inneholder kanaler, boards, filer.
Brukeren er "i" workspacet.
- **Node-modell:** Workspace er en node. Noder har edge til den. Brukeren
har edge til den. Workspace-noden bærer felles kontekst (tema,
pruning-profil, AI-konfig).
En node kan ha edge til flere workspaces. En faktoid om en gjest er
relevant for både podcast-prosjektet og research-samlingen. Ikke kopi —
samme node, to edges.
### Personlig er default
Alt uten workspace-edge eller mottaker-edge er privat — tilhører bare
deg. "Personlig workspace" er ikke en egen ting du oppretter. Det er
fravær av deling. Dine private notater, dagbok, voice memos — de har
bare edges til deg.
### Administrasjon er edges med roller
Brukerstyring forvaltes i nodene selv, ikke i et sentralt admin-panel:
| Edge-type | Hva den gir |
|-----------|------------|
| Eier-edge | Full kontroll — slette, endre tilgang, endre innstillinger |
| Admin-edge | Kan invitere/fjerne andre, endre konfigurasjon |
| Deltaker-edge | Kan gi input og motta |
| Leser-edge | Kan kun motta (observatør, lytter) |
Du oppretter en kommunikasjonsnode (podcast-prosjekt). Du er eier
automatisk. Du inviterer Trond → deltaker-edge. Trond kan gi input
men ikke slette eller endre tilgang. Du gir Trond admin-edge → nå
kan han invitere andre og endre innstillinger på den noden.
Dette skalerer fra en privat notat (kun eier-edge til deg) til en
organisasjon (samlings-node med mange admin- og deltaker-edges).
### Brukeropplevelsen
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** (det som før var workspaces) — grupperer
kontekst, men du trenger ikke "gå inn i" dem
- **Din mottaksflate** — alt som er relevant for deg nå, vektet
Du kan filtrere etter samlings-node hvis du vil fokusere ("vis bare
podcast-prosjektet"), men det er et filter — ikke en modebytte.
### Felles kontekst på samlings-noder
En samlings-node (workspace) bærer kontekst som arves av tilknyttede
noder:
- **Pruning-profil** — hvor aggressivt slettes binærdata?
- **Tema** — visuelt uttrykk (CSS custom properties)
- **AI-konfigurasjon** — hvilke prompts, hvilken modell, hvilke regler
- **Tilgangsnivå** — default synlighet for nye noder opprettet i
denne konteksten
- **Kapasitet** — ressursgrenser for maskinrommet (f.eks. maks
samtidige transkripsjoner)
## Implikasjoner
### Kryssgående noder er naturlig
En node med edge til to samlings-noder arver kontekst fra begge.
Konflikter (ulik pruning-profil) løses med prioriteringsregler:
mest konservativ vinner, eller eier-edge bestemmer.
### Onboarding forenkles
Ny bruker trenger ikke "settes opp i et workspace." De får edges til
de nodene de trenger tilgang til. Ferdig. Endre tilgang = endre edges.
### Forlate et prosjekt er å fjerne edges
Ingen "slett bruker fra workspace." Fjern deltaker-edges. Brukerens
private noder som hadde edge til samlings-noden beholder den edgen
(det er brukerens innhold), men de mister tilgang til andres noder.
## RLS og sikkerhet: samlings-noder som harde siloer
Viktig arkitekturbeslutning: **"bruker, ikke workspace" er en
UX-modell, ikke en sikkerhetsmodell.**
Ren edge-basert tilgangskontroll — der hver lesing traverserer grafen
for å sjekke om brukeren har en sti til noden — ville kvelt
databasen når grafen vokser. I dag har PG bunnsolid RLS med
`workspace_id = current_setting(...)` — instant og vanntett.
Løsningen er å skille UX fra sikkerhet:
- **UX-laget:** Brukeren ser sine edges. Ingen "velg workspace."
Filtrering etter samlings-node er frivillig.
- **Sikkerhetslaget:** Under panseret har hver node fortsatt en
`workspace_id` (eller samlings-node-id) som RLS sjekker. Det er
den harde sikkerhetsgensen — billig, velprøvd, instant.
- **Finere tilgang innenfor siloen:** Edge-basert tilgang (eier,
admin, deltaker, leser) sjekkes *innenfor* en silo, ikke som
erstatning for den.
Samlings-noder er altså ikke bare frivillig organisering — de er
sikkerhetsiloer i databasen. Brukeren merker det ikke, men PG
trenger det.
## Spenninger og åpne spørsmål
- **Overblikk.** Uten workspaces som organisatorisk enhet — hvordan
unngår du at alt flyter sammen? Samlings-noder er svaret, men de
må være intuitive å opprette og bruke uten å bli "workspaces med
ny navn."
- **Kryssgående noder og siloer.** Hvis noder har `workspace_id` for
RLS, kan en node da tilhøre to samlings-noder? Kanskje via en
"primær silo" for RLS + sekundære edges for visning. Trenger
gjennomtenkt design.
- **Arv og konflikter.** En node med edge til to samlings-noder med
ulike pruning-profiler — hva vinner? Trenger klare regler som er
intuitive for brukeren.
- **Migrering.** Eksisterende workspace-modell har innhold. Kan
workspaces bli samlings-noder gradvis? RLS-modellen gjør dette
enklere — workspace_id kan bli samlings-node-id uten endring i
sikkerhetslogikk.
## Forhold til andre retninger
- [Rom, ikke forum](rom_ikke_forum.md) — "rommet" er ikke et workspace,
det er summen av dine edges
- [Universell input og mottak](universell_input.md) — mottaksflaten er
"noder med edge til *meg*", ikke "noder i *mitt workspace*"
- [Maskinrommet](maskinrommet.md) — leser samlings-node-edges for
kontekst (pruning, kapasitet, AI-konfig)
# 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

View file

@ -1,182 +1,97 @@
# Datalaget — PG + AGE som enhetlig graf og arkiv
> PostgreSQL er den eneste databasen. Apache AGE gir Cypher-semantikk
> for graftraverseringer. SpacetimeDB er sanntidslaget over samme data.
> Én database, to tilgangsmønstre, ingen eierskapskonflikt.
## Observasjoner
Hele retningsarbeidet har bygget seg mot "alt er noder og edges."
Tilgang er edges, visninger er spørringer, administrasjon er
traversering. Spørsmålet er: hvilken teknologi støtter dette best?
### Neo4j ble vurdert og forkastet
Neo4j er best-in-class for dype graftraverseringer, men for dette
prosjektet gir den mer problemer enn den løser:
- **Tredje database.** PG + SpacetimeDB + Neo4j = tre systemer å
drifte, sikkerhetskopiere og overvåke. På én Hetzner VPS.
- **Mister PG-økosystemet.** pgvector (semantisk søk), fulltekstsøk,
JSONB, pg_cron, statistikk-aggregeringer — alt dette trenger vi
uansett, og det lever i PG. Med Neo4j trenger vi PG *i tillegg*.
- **Minnehungrig.** Neo4j anbefaler 8-16GB heap. På en delt VPS med
PG, SpacetimeDB, Whisper og LiteLLM er det for mye.
- **Lisens.** Community er AGPLv3. Enterprise-features (clustering,
avansert sikkerhet) krever betalt lisens.
### Apache AGE — Cypher i PG
Apache AGE er en PG-extension som legger til openCypher-støtte. Noder
og edges lagres i PG-tabeller, men spørres med Cypher-semantikk.
**Oppsider:**
- **Én database.** Ingen ny infrastruktur. Samme backup, connection
pooling, tooling, monitoring.
- **SQL + Cypher i samme spørring.** "Finn alle noder brukeren har
tilgang til via team-traversering, JOINet med fulltekstsøk og
pgvector." Det kan du ikke gjøre med en separat grafdatabase.
- **Null migrasjonskostnad.** Eksisterende nodes/edges-tabeller kan
eksponeres som graf. Legg til extension, ikke bytt database.
- **Økosystemet bevares.** pgvector, fulltekstsøk, JSONB, pg_cron —
alt fungerer side om side med Cypher-spørringer.
- **Produksjonsbruk.** Azure og EDB tilbyr AGE som managed extension.
**Nedsider:**
- **Ikke native graf.** Under panseret er det PG-tabeller. For svært
dype traverseringer (10+ hopp over millioner av noder) er Neo4j
raskere. Men de fleste spørringene våre er 1-5 hopp.
- **Yngre prosjekt.** Mindre community, tynnere dokumentasjon enn Neo4j.
- **Quirks.** Spørringer må være enten read-only eller write-only
(WITH-clause for overgang). Noen pg_upgrade-begrensninger.
## Beslutning
**PostgreSQL** som enhetlig datalager for noder og edges.
**Apache AGE** som planlagt utvidelse — ikke forpliktet fra dag én.
De fleste spørringer i dette systemet er grunne:
- "Vis noder med edge til denne brukeren" — 1 hopp
- "Sjekk tilgang via team" — 2 hopp
- "Finn alle kommunikasjonsnoder brukeren har tilgang til" — 2-3 hopp
- "Traverser kunnskapsgrafen mellom to emner" — 3-5 hopp
PG med rekursive CTEs håndterer 1-3 hopp utmerket. AGE legges til
når graftraversering faktisk blir en målbar flaskehals — ikke før.
AGE er en extension, ikke en migrering, så den kan boltes på uten
å endre eksisterende kode.
Pragmatisk rekkefølge:
1. **Nå:** PG med nodes/edges-tabeller og CTEs
2. **Når CTEs blir smertefulle:** Legg til AGE for Cypher-spørringer
3. **Usannsynlig:** Evaluer Neo4j hvis AGE ikke holder
## Lagmodell
```
┌─────────────────────────────────────┐
│ GUI (SvelteKit) │
│ Primitiver, visninger │
└────────┬──────────────────┬─────────┘
│ skriv │ les (sanntid)
│ │ direkte WebSocket
┌────────▼────────┐ ┌─────▼─────────┐
│ Maskinrommet │ │ SpacetimeDB │──┐
│ (Rust) │ │ (STDB) │ │
│ Orkestrering │ └───────────────┘ │
└──┬─────┬─────┬──┘ │
│ │ │ sync ↕ │
▼ ▼ ▼ │
┌─────┐┌─────┐┌─────┐┌─────────────┐ │
│ PG ││STDB ││ CAS ││ Whisper, │ │
│+AGE ││(skr)││ ││ LiteLLM, │ │
│ ││ ││ ││ LiveKit ... │ │
└─────┘└─────┘└─────┘└─────────────┘ │
```
### Skriv vs les — to stier med god grunn
**Skriv:** GUI → Maskinrommet (Rust) → tjenester (PG, STDB, CAS, ...)
All orkestrering, edge-logikk, ressursallokering og validering går
gjennom Rust. Maskinrommet bestemmer hva som skjer: hva som skrives
til PG, hva som speiles til SpacetimeDB, hva som lagres i CAS,
hvilke tjenester som trigges.
**Les (sanntid):** SpacetimeDB → GUI (direkte WebSocket)
SpacetimeDB sitt klient-SDK kobler seg direkte via WebSocket,
definerer SQL-subscriptions, og synkroniserer automatisk med lokal
cache — uten network round-trips for lesing (~10μs per transaksjon).
Å proxy dette gjennom Rust ville lagt til et hopp, serialisering og
kontekstbytte — en dårligere reimplementering av noe STDB gjør
optimalt. Den direkte lese-stien er en bevisst arkitekturbeslutning,
ikke et hull i lagmodellen.
**Les (tradisjonelt):** GUI → Maskinrommet (Rust) → PG
Søk, historikk, statistikk, arkiv — alt som ikke er sanntid går
gjennom Rust og PG. AGE-spørringer for graftraversering, pgvector
for semantisk søk, SQL for aggregeringer.
### PG er arkivet og grafen
Alle noder og edges lever i PG. AGE gir Cypher-spørringer for
traversering. Standard SQL for alt annet (aggregeringer, fulltekstsøk,
JOINs mot pgvector). Én database, to spørrespråk som utfyller
hverandre.
### SpacetimeDB er sanntidslaget
Aktive noder og edges speilet til SpacetimeDB for live-oppdateringer.
Ting som er "nå" lever i SpacetimeDB. Ting som er "ferdig" lever kun
i PG. Ingen eierskapskonflikt — SpacetimeDB er en live-visning av
en delmengde av PG-grafen.
### CAS er binærlageret
Lyd, bilde, video lagres content-addressable utenfor PG. Noder i
PG peker på CAS-hasher. Pruning-regler basert på edges, aksesslog
og modalitet (se maskinrommet).
## Migreringsstrategi
### Fase 1: AGE på eksisterende PG
Installer Apache AGE extension. Eksponer eksisterende nodes/edges-
tabeller som graf. Ingen endring for resten av systemet — bare en
ny måte å spørre på.
### Fase 2: Konsolider til node+edge-modellen
Migrer meldingsboks, kanban, kalender etc. til den universelle
node+edge-modellen gradvis. Gamle tabeller kan leve som views
over grafen i overgangsperioden.
### Fase 3: Parallell prototype
Det gamle systemet kjører uforstyrret. En ny frontend-prototype
bygges ved siden av som bruker AGE-grafen direkte. Live sync fra
gammelt til nytt via enveis oppdateringer. Testbrukere leker i
det nye, produksjon er trygt i det gamle.
### Fase 4: Cutover
Når det nye er godt nok, flyttes trafikk over. Det gamle systemet
kan kjøre som read-only fallback en periode.
## Spenninger og åpne spørsmål
- **AGE-modenhet.** Prosjektet er aktivt og støttet av Azure/EDB,
men community er mindre enn Neo4j. Risikoen er håndterbar fordi
dataene alltid er PG-tabeller — AGE er bare et spørrelag.
- **SpacetimeDB-synk.** Hvordan synkes noder/edges mellom PG (AGE)
og SpacetimeDB effektivt? Trenger en sync-mekanisme som forstår
"aktiv delmengde."
- **Skjemadesign.** Én node-tabell med alle typer innhold — hva er
felles kolonner vs edge-metadata vs JSONB-payload? Trenger
gjennomtenkt skjema som balanserer fleksibilitet og spørrbarhet.
## Forhold til andre retninger
- [Universell input og mottak](universell_input.md) — noder og edges
er den underliggende datamodellen for alle tre primitiver
- [Maskinrommet](maskinrommet.md) — CAS og pruning lever her, AGE
brukes for edge-drevet ressursorkestrering
- [Bruker, ikke workspace](bruker_ikke_workspace.md) — tilgang via
graf-traversering (AGE) i stedet for workspace-membership-tabeller
- [Rom, ikke forum](rom_ikke_forum.md) — to-lags-modellen:
SpacetimeDB for nåtid, PG+AGE for arkiv og graf
# 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``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

View file

@ -1,322 +1,196 @@
# Maskinrommet — teknisk tjenestelaget
> Én Rust-tjeneste med et fast grensesnitt. Alle tekniske tjenester beveger
> seg gjennom dette laget. Fang, prosesser, lever.
## Observasjoner
I dag er tekniske tjenester spredt:
- **Worker** (Rust) — kjører bakgrunnsjobber
- **Jobbkø** (PG) — koordinerer arbeid
- **AI Gateway** (LiteLLM) — ruter AI-kall
- **Whisper** — transkripsjon
- **LiveKit** — lyd/video-strømmer
Hver har sitt eget grensesnitt. Frontend og primitiv-laget må vite hva
som finnes under panseret. Det er ingen felles abstraksjon, ingen felles
logging, ingen felles kapasitetsstyring.
## Tesen
Alt som krever tunge ressurser eller eksterne tjenester går gjennom
**ett lag** med **ett grensesnitt**. Ikke fordi det er elegant — fordi
det gir et fast punkt som er enkelt å fange, modifisere og forbedre.
Maskinrommet gjør tre ting:
### 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, sentimentanalyse,
faktasjekk, edge-forslag
- **Beriking** — URL → metadata, bilde → beskrivelse, lyd → segmenter
- **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, snapshot)
- Strukturert data (noder, edges, metadata tilbake i grafen)
- Push (webhook, SSE, SpacetimeDB-reducer)
## Edge-drevet ressursorkestrering
Nøkkelinnsikten: **maskinrommet leser edges for å vite hva det skal gjøre.**
Noden selv er alltid enkel. Det er edgene som bestemmer hvilke ressurser
som spinnes opp.
### Security by default
Input uten mottaker-edge er automatisk privat. Du trenger ikke "velge
privat" — det er utgangspunktet. Ingen ser det. Ingen ressurser kobles
inn utover det grunnleggende (fang + transkriber). Privat er ikke en
innstilling, det er fravær av deling.
### Ressurser er proporsjonale med edges
Samme nodetype, vilt forskjellig ressursbruk:
```
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 + publiserings-edge
→ fang lyd → transkriber → stream via LiveKit → distribuer
→ generer segmenter → kjør live AI → publiser
Ressurser: STT + LiveKit + LLM + mediaprosessering
```
Maskinrommet gjør ikke mer enn det edges krever. Ingen overhead for
enkle ting. Noden vet ingenting om LiveKit — den har bare edges som
sier "stream til disse mottakerne", og maskinrommet bestemmer at det
betyr LiveKit.
### Naturlig eskalering
Du starter en privat voice-note. Bestemmer deg for å dele den med Trond
→ legg til mottaker-edge, maskinrommet begynner å levere. Trond foreslår
at dere tar det som et møte → legg til flere deltaker-edges, maskinrommet
kobler inn sanntidsstrømming. Møtet blir en innspilling → legg til
publiserings-edge, maskinrommet aktiverer produksjonspipeline.
Hvert steg er bare å legge til edges. Maskinrommet reagerer og kobler
inn flere ressurser etter hvert. Ingen migrering, ingen modebytte.
## Grensesnittet
Maskinrommet eksponerer et konsistent API — sannsynligvis en Rust trait
eller et sett traits:
```
fang(input: RåInput) → NodeId
prosesser(node: NodeId, operasjon: Operasjon) → Resultat
lever(node: NodeId, mottaker: Mottaker, format: Format) → Status
```
Men i praksis er mye av dette *reaktivt*: maskinrommet observerer
edge-endringer og handler automatisk. Legger noen til en mottaker-edge
→ maskinrommet begynner å levere. Legger noen til en stream-edge →
maskinrommet kobler inn LiveKit. Primitivene trenger ikke eksplisitt
kalle `lever()` — de manipulerer edges, og maskinrommet reagerer.
## Hva dette gir
### Isolasjon
Bytt Whisper med noe annet? Endre maskinrommet. Frontend vet ingenting.
Legg til bildegenerering? Ny operasjon i maskinrommet. Primitivene
kaller den uten å vite hva som skjer under.
### Observerbarhet
Alt går gjennom ett punkt. Logging, metrikker, kostnadsrapportering,
feilhåndtering — alt på ett sted. "Hva bruker vi AI-ressurser på?"
har ett svar.
### Kapasitetsstyring
Prioritering, kø, rate limiting, fallback mellom leverandører — alt
håndtert av maskinrommet. En podcastinnspilling som trenger live
transkripsjon kan prioriteres over en bakgrunns-oppsummering.
### Fast utviklingspunkt
To team (eller to hatter) med klart grensesnitt:
- **Over maskinrommet:** primitiver, noder, edges, UI, brukeropplevelse
- **I maskinrommet:** ytelse, integrasjoner, kapasitet, kostnad
Du kan perfeksjonere det ene uten å røre det andre.
## Content-Addressable Storage og intelligent pruning
Maskinrommet forvalter også lagring. Ikke alt kan lagres for evig — men
ikke alt trenger det heller. Signalene for hva som er viktig finnes
allerede i grafen.
### CAS som lagringsprimitiv
All binærdata (lyd, bilde, video) lagres i et content-addressable store.
Fordeler:
- **Deduplisering gratis** — samme fil delt i tre kontekster = én kopi
- **Separasjon** — "innholdet eksisterer" er adskilt fra "innholdet er
tilgjengelig." Noden peker på en hash, CAS har filen (eller ikke).
- **Enkel opprydning** — slett hashen fra CAS, alle noder som pekte
dit mister binærdataen men beholder metadata og transkripsjon.
### Lagringsregler per modalitet
| Modalitet | Default levetid | Begrunnelse |
|-----------|----------------|-------------|
| Tekst | Evig | Billig, er essensen av innholdet |
| Transkripsjon | Evig | Tekstlig representasjon av lyd/video — tar vare på meningen |
| Lyd | 30 dager | Mellomkostnad, transkripsjon bevarer innholdet |
| Bilde | 30 dager | Mellomkostnad, beskrivelse/metadata bevarer kontekst |
| Video | 14 dager | Dyrest, transkripsjon + thumbnail bevarer det meste |
### Signaler som forlenger levetid
Default-TTL er bare utgangspunktet. Maskinrommet justerer basert på:
- **Edges.** En lydfil med edge til episoderegisteret = publisert
podcast, beholdes. En privat voice-memo uten edges = 30-dagers TTL.
- **Aksesslog.** Hvis noen har spilt av lydfilen i løpet av TTL-perioden,
forlenges den. Ingen aksess = ingen verdi i å beholde binærdataen.
- **Transkripsjonsstatus.** Lyd som er transkribert har "overlevert sin
essens" til tekst. Lyd som *ikke* er transkribert (f.eks. musikk,
lydeffekter) kan trenge lengre TTL.
- **Edge-type.** Edge til publisert innhold = behold. Edge til arkivert
møte = transkripsjon holder. Edge til ingenting = teksten lever videre,
binærdataen kan dø.
### Eksempler
```
Privat voice-memo, aldri delt:
→ Lyd transkriberes → tekst lagres evig
→ Lydfil: 30 dager, ingen aksess, ingen edges → slettes
→ Noden lever videre med teksten
Podcastepisode:
→ Lyd har edge til episoderegister + publiserings-edge
→ Aksesseres regelmessig via podcastarkivet
→ Lydfil: beholdes så lenge edges og aksess tilsier det
Rutinemøte for et år siden:
→ Video (6 kanaler): ingen har sett den på 6 måneder → slettes
→ Lyd: ingen har spilt av → slettes
→ Transkripsjon: tekst, lagres evig. Søkbar, refererbar.
→ Noden lever med full kontekst minus binærdata
Viktig styremøte:
→ Video aksesseres av styremedlemmer → forlenges
→ Workspace-innstilling: "behold video i 1 år" → overrider default
```
### Generert innhold er en cache
TTS, thumbnails, AI-oppsummeringer, waveforms — alt som kan regenereres
fra kildedata er i praksis en cache. Det lagres i CAS med samme TTL-
mekanisme som alt annet:
- Peter ber om lyd-versjon av en tekstmelding → TTS genereres, lagres
- Ingen spiller den av på 30 dager → filen slettes fra CAS
- Peter (eller noen andre) ber om lyd igjen → regenereres on-demand
- Teksten er der alltid. Binærdataen er flyktig.
Maskinrommet trenger ikke skille mellom "original lyd" (voice memo) og
"generert lyd" (TTS) i pruning-logikken. Begge er binærdata i CAS med
en TTL som forlenges ved aksess. Forskjellen er bare at generert
innhold alltid kan gjenskapes fra kilden — så det er tryggere å prune.
### Workspace-styrt aggressivitet
Hvert workspace kan justere sin pruning-profil:
- **Konservativt** — behold alt lenge (f.eks. arkiv-workspace)
- **Aggressivt** — tekst bevares, binærdata prunes raskt (f.eks.
daglig drift-workspace med mye rutineinnhold)
- **Tilpasset** — egne regler per modalitet og edge-type
### Brukerens erfaringsbaserte meny
Over tid bruker du noen edges oftere enn andre, noen noder oftere enn
andre. Maskinrommet observerer dette og tilbyr en erfaringsbasert meny:
dine mest brukte koblinger, dine vanligste input-mønstre, dine
foretrukne modaliteter. Ikke som en rigid konfigurasjon — som en
adaptiv overflate du kan aktivere og deaktivere fortløpende.
Dette er ikke maskinlæring eller kompleks AI — det er frekvenstelling
på edges og aksesslog. Enkelt å implementere, intuitivt for brukeren.
## Pragmatisk vei dit
Ikke bygg dette fra scratch. Formaliser det som allerede finnes:
1. **Worker + jobbkø er allerede kjernen.** De trenger et konsistent
API, ikke en omskriving.
2. **AI Gateway (LiteLLM) absorberes** — i stedet for en separat proxy,
blir LLM-kall en operasjon i maskinrommet som alt annet.
3. **Whisper, TTS, mediaprosessering** — allerede planlagt som
worker-jobber. Gi dem samme grensesnitt.
4. **LiveKit** — den mest spesielle tjenesten (sanntidsstrømmer). Kan
starte som en separat integrasjon og formaliseres inn over tid.
Rekkefølge: definer traits → migrer eksisterende worker-jobber inn →
legg til nye tjenester etter hvert. Fast punkt fra dag én, full
dekning over tid.
## Compute-separasjon
Maskinrommet orkestrerer — men tunge jobber trenger ikke kjøre på
samme maskin. Hetzner CPX42 (8 vCPU, 16 GB RAM) skal håndtere state
(PG, SpacetimeDB) og sanntid (Caddy, LiveKit, SvelteKit). Whisper
(CPU-intensiv, spesielt large-v3) og lokal LLM (kildevern-modus)
vil konkurrere om ressurser under live innspilling.
Maskinrommets abstraksjon gjør dette løsbart:
- **Nå:** Alt på én VPS. Jobbkøen prioriterer sanntid over batch.
Whisper kjøres med lavere concurrency under live-sesjoner.
- **Senere:** Trekk ut tunge workers til en separat node (billig
ARM/Ampere-instans) som poller jobbkøen over internt nettverk.
Maskinrommet ruter transparent — primitivene merker ingenting.
- **Kildevern-modus:** Lokal LLM (Llama/Gemma) krever GPU eller
dedikert compute. Urealistisk på delt VPS. Egen node for dette.
Poenget: maskinrommet er designet for å rute arbeid, ikke for å
*utføre* alt selv. Compute-separasjon er en konfigurasjon, ikke en
arkitekturendring.
## Spenninger og åpne spørsmål
- **Synkron vs asynkron.** "Fang" og "lever" kan være instant, men
"prosesser" kan ta sekunder (TTS) eller minutter (full episode-
transkripsjon). Grensesnittet må håndtere begge naturlig.
- **Strømmer.** Live lyd/video er fundamentalt annerledes enn
request/response. Men edge-modellen løser mye: maskinrommet ser en
stream-edge og vet at det betyr LiveKit. Utfordringen er *reaktivitet*
— maskinrommet må observere edge-endringer i sanntid og koble inn/ut
ressurser dynamisk.
- **Granularitet.** Hvor mye skal maskinrommet vite om domenet? "Fang
lyd" er generisk, men "transkriber og splitt i segmenter med
taler-identifikasjon" er domenespesifikt. Hvor går grensen?
- **Overhead.** Et ekstra lag betyr et ekstra kall. For tunge
operasjoner (Whisper, LLM) er det neglisjerbart. For lette
operasjoner (slå opp metadata) kan det være unødvendig indirection.
## Plassering i lagmodellen
Maskinrommet (Rust) er det eneste orkestringslaget. Alle tjenester —
inkludert PG, SpacetimeDB, CAS, Whisper, LiteLLM, LiveKit — er
likeverdige tjenester *under* maskinrommet. SpacetimeDB er ikke et lag
mellom Rust og GUI, det er en tjeneste maskinrommet skriver til.
Ett unntak: SpacetimeDB har en direkte WebSocket-kobling til frontend
for sanntids lese-strøm. Dette er en bevisst optimering — STDB sitt
klient-SDK gir ~10μs-oppdateringer med automatisk synk og lokal cache.
Å proxy dette gjennom Rust ville vært å bygge en dårligere versjon av
noe STDB gjør optimalt.
Se [datalaget](datalaget.md) for full lagmodell med diagram.
## Forhold til andre retninger
Maskinrommet er infrastrukturen *under* de tre primitivene i
[universell input og mottak](universell_input.md):
- Input-primitiven kaller `fang()` + `prosesser()`
- Mottak-primitiven kaller `lever()`
- Kommunikasjonsnoden bruker alle tre (fang input fra deltakere,
prosesser sanntid, lever til mottakere)
Det er også det som gjør to-lags-modellen fra [rom, ikke forum](rom_ikke_forum.md)
praktisk: maskinrommet ruter til riktig lag (sanntid vs tradisjonelt)
uten at primitivene trenger å vite forskjellen.
# 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

View file

@ -104,7 +104,7 @@ 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
"Personlig workspace" trenger ikke være en egen ting. Privat/delt er bare en
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.

View file

@ -1,5 +1,10 @@
# 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 v2-retningene
> i `docs/retninger/`.
> En redaksjonell webapp med ambisiøse primitiver og tradisjonell overflate.
## Hva fungerer

View file

@ -1,299 +1,186 @@
# Universell input og mottak
> Én multimodal input-primitiv. Én personlig mottaksflate. Alt som fanges
> er samme type objekt. Hva det "er" bestemmes av edges, ikke av tabellen
> det ligger i. Hvordan det *presenteres* bestemmes av mottakeren.
## Observasjoner
I dag har vi meldingsboksen som "universell primitiv" — men den er egentlig en
*lagringsprimitiv*. Den samler chat, kanban-kort, kalenderoppføringer og notater
i én tabell, men *input* er fortsatt forskjellig per kontekst: chat har ett
tekstfelt, kanban har et skjema, kalender har en datovelger. Brukeren velger
kontekst først, deretter gir de input.
Og vi har separate pipelines for ulike modaliteter: tekst går én vei, lyd
(voice/transkripsjon) en annen, bilder en tredje. Hver med sin egen flyt.
## Tesen
**Én input-primitiv, to versjoner:**
- **Sanntidsversjon** — live i SpacetimeDB-laget. Brukes i rom, samarbeid,
samtaler. Streamer input og viser resultater i sanntid.
- **Flat versjon** — tradisjonelt mot PG. Brukes på bussen, alene, offline-aktig.
Fanger input og lagrer asynkront.
Begge aksepterer 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
- **Nettoppslag** — lim inn URL, den berikes automatisk
- **Kommunikasjon** — samme primitiv for alene (dagbok), en-til-en (melding),
gruppe (kanal)
Forskjellen mellom "jeg skriver dagbok", "jeg sender en melding" og "jeg lager
et kanban-kort" er ikke *hva brukeren gjør* — det er hvilke edges som knyttes
til resultatet.
## Én tabell, edges definerer alt
All output fra input-primitiven lander som noder i kunnskapsgrafen. Én tabell.
Ingen `messages`-tabell, ingen `cards`-tabell, ingen `notes`-tabell.
Hva en node "er" bestemmes utelukkende av 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) = dagboknotis
- Node + edge til topic = faktoid i kunnskapsgrafen
- Node uten edges = løs tanke, ennå uorganisert
**Retyping er trivielt.** Å gjøre en chatmelding om til et kanban-kort er å
legge til en edge til et board og en status-edge. Fjerne fra chat er å fjerne
kanal-edgen. Ingen datamigrering, ingen transformasjon av innhold. Bare edges.
**Multitype er naturlig.** En node kan være *både* et kanban-kort *og* en
kalenderoppføring *og* en faktoid. Det er ikke en edge case — det er
arkitekturen.
## Implikasjoner
### Meldingsboksen erstattes av noe dypere
Meldingsboksen var riktig intuisjon — men den er en lagringsprimitiv som
prøver å forene ulike domenemodeller. Universell input + kunnskapsgrafen
gjør det renere: det finnes bare noder og edges. "Meldingsboks" blir et
view-konsept (hvordan noder vises i en kontekst), ikke et lagrings-konsept.
### Input-metode og innholdstype er ortogonale
Du kan snakke inn et kanban-kort. Du kan tegne en kalenderoppføring. Du kan
skrive en voice memo (tekst som transkriberes til lyd for en annen bruker).
Input-primitiven bryr seg ikke om hva det *blir* — den fanger det som
kommer inn.
### Samme input, ulik routing
Lyd inn i input-primitiven kan routes helt forskjellig basert på edges:
- Edge til et møterom → streames live til andre deltakere (sanntidslaget)
- Edge til kun deg selv → transkriberes og lagres som personlig notat
- Edge til en podcast-kanal → goes into produksjonspipeline
- Edge til en person → sendes som lydmelding
Brukeren gjør det samme — snakker inn i input-feltet. *Systemet* router
basert på kontekst og edges. Det er ingen "møte-app" eller "notat-app"
eller "meldings-app" — det er én input med ulike destinasjoner.
### 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
- Anna skriver tekst → node med tekst + TTS-versjon
- Trond har lyd-preferanse → hører TTS-opplesning av Annas tekst
Senderen trenger ikke vite eller bry seg. Innholdet er det samme —
presentasjonen er en mottaker-side preferanse. Modalitet er ikke
en egenskap ved meldingen, men ved *lesningen* av den.
### Én overflate å perfeksjonere
Brukerens mentale modell kollapser til én ting: input-feltet. All
UX-investering konsentreres ett sted i stedet for å smøres tynt utover
ti ulike grensesnitt. Én perfekt input-opplevelse — responsiv, multimodal,
med god AI-støtte — i stedet for ti middelmådige spesialgrensesnitt.
Dette er en radikal forenkling av utviklingsoverflaten. I stedet for å
bygge og vedlikeholde chat-input, kanban-skjema, kalender-dialog,
notat-editor, voice-recorder, dagbok-felt — bygger vi *ett* grensesnitt
og investerer alt i å gjøre det feilfritt. Alt etterpå er edges.
### Visninger er spørringer mot grafen
Chat-visningen = "vis noder med edge til denne kanalen, sortert på tid."
Kanban-visningen = "vis noder med edge til dette boardet, gruppert på status."
Kalender-visningen = "vis noder med dato-edge, plassert på tidslinje."
Dagbok-visningen = "vis private noder for denne brukeren, sortert på tid."
Alle visninger leser fra samme graf. Ingen har "sin egen" data.
### To versjoner passer to-lags-modellen
Sanntidsversjonen lever i SpacetimeDB-laget: input streames, resultater er
live, andre ser hva du gjør. Flat versjonen lever i det tradisjonelle laget:
input sendes, lagres i PG, ferdig. Begge produserer identiske noder i grafen.
### Synlighet er bare en edge
Privat = edge kun til deg. Delt = edge til en gruppe/kanal. Publisert = edge
til en offentlig kontekst. Å "dele" noe er å legge til en edge. Å "gjøre
privat" er å fjerne den. Innholdet endres aldri.
## Universelt mottak — den andre primitiven
Input-primitiven er halvparten. Den andre halvdelen er *mottak*: hvordan
du konsumerer det andre produserer. Der input er "én overflate som fanger
alt", er mottak "én overflate som presenterer alt tilpasset *deg*."
### Dimensjoner ved mottak
**Format.** Lyd, tekst, visuelt — mottaker bestemmer (allerede beskrevet
over). Men det gjelder alt, ikke bare meldinger: en AI-oppsummering kan
leses eller høres. Et whiteboard-snapshot kan vises som bilde eller som
tekstlig beskrivelse.
**Filtrering.** Hva ser du? Alt fra alle er støy. Mottaksflaten filtrerer
basert på dine edges: hvilke kanaler du følger, hvilke personer du
samarbeider med, hvilke topics du er interessert i. Du kuraterer ikke
manuelt — du justerer edges, og mottaksflaten oppdateres.
**Prioritering.** Hva er viktig *nå*? En AI-assistert redaksjonell flate
som løfter frem det som trenger oppmerksomhet: ubesvarte meldinger,
oppgaver med frist, noder som er endret siden sist, tråder med aktivitet.
Ikke en notifikasjonsliste — en *vektet visning* av det som er relevant.
**Tempo.** Sanntid eller asynkront. I sanntidslaget: ting streamer inn
mens de skjer — en kollega snakker, du hører/leser live. I det
tradisjonelle laget: du får en digest, en oppsummering, et overblikk
over hva som har skjedd siden sist. Samme noder, ulikt tempo.
**Kilde.** Direkte fra en person, eller via en node. Et møte som
genererer innsikter. En AI-jobb som er ferdig. En tråd som har blitt
aktiv igjen. En podcast-episode som er klar for review. Kilden trenger
ikke være et menneske — det kan være en prosess, en hendelse, en
tilstandsendring i grafen.
### Mottaksflaten som speilbilde av input
| Input | Mottak |
|-------|--------|
| Én overflate for all input | Én overflate for alt mottak |
| Sender bestemmer ikke format | Mottaker bestemmer format |
| Modalitet er ortogonal | Presentasjon er ortogonal |
| Kontekst gir edges | Preferanser gir filtrering |
| Sanntid + flat versjon | Sanntid (stream) + asynkron (digest) |
### Mange-til-én og mange-via-mange
Mottaksflaten håndterer naturlig:
- **Én-til-én** — Trond sender deg en melding
- **Mange-til-én** — fem personer i en kanal, du ser alt
- **Via node** — et møte genererer et referat, du mottar det
- **Via kjede** — en chatmelding → blir oppgave → oppgaven fullføres →
du får oppdatering. Hele kjeden er edges, og du ser resultatet i din
mottaksflate uten å ha fulgt hvert steg.
### Mottaksflaten er også en visning av grafen
Akkurat som chat-visningen er "noder med kanal-edge sortert på tid", er
mottaksflaten "noder med edge til *meg*, vektet på relevans og tid."
Det er ikke en egen mekanisme — det er enda en spørring mot samme graf,
bare med *deg* som sentrum.
## Kommunikasjonsnoden — den tredje primitiven
Input fanger. Mottak presenterer. Men det mangler noe: *stedet* der folk
møtes. En kommunikasjonsnode er en node i grafen som samler deltakere,
definerer tilgangsregler, og fungerer som kontekst for input og mottak.
### Én node, mange former
En kommunikasjonsnode er konseptuelt identisk uansett skala:
| Variant | Deltakere | Input-tilgang | Mottak-tilgang |
|---------|-----------|---------------|----------------|
| Én-til-én samtale | 2 | Begge | Begge |
| Gruppechat | N | Alle medlemmer | Alle medlemmer |
| Redaksjonsmøte | N | Alle medlemmer | Alle medlemmer |
| Allmøte | 1 + N | Lederen snakker | Alle lytter, noen kan rekke opp hånden |
| Podcastinnspilling | 2-4 + N | Vertene snakker | Alle lytter, markører for produsent |
| Livesending | 1-4 + ∞ | Vertene | Streamet til nettside/app, lyd eller video |
| Asynkron gjest | 1 + 1 | Gjest gir input innen frist | Redaksjonen mottar |
Forskjellen er *ikke* ulike systemer — det er ulike edge-konfigurasjoner
på samme nodetype:
- **Eier-edge** — hvem kontrollerer noden (kan invitere, endre regler, avslutte)
- **Input-edge** — hvem kan gi input (snakke, skrive, tegne, dele skjerm)
- **Mottak-edge** — hvem kan motta (lytte, lese, se stream)
- **Rolle-edge** — spesialroller (moderator, produsent, gjest)
### Kommunikasjonsnoden er en kontekst for de andre primitivene
Når du gir input *i* en kommunikasjonsnode, arver inputen kontekst-edges
automatisk. Sier du noe i et møte → noden du skaper får edge til møtet.
Du trenger ikke tenke på det — konteksten følger med.
Mottak i en kommunikasjonsnode er det samme som universelt mottak, bare
scoped til den noden: du ser/hører det andre deltakere gir som input,
presentert etter dine preferanser.
### Livssyklus
En kommunikasjonsnode kan være:
- **Live** — aktiv i sanntidslaget. Deltakere er til stede, input streames.
- **Asynkron** — aktiv i det tradisjonelle laget. Deltakere gir input i
eget tempo (chat, asynkron gjest).
- **Avsluttet** — arkivert i PG. Alt som ble sagt/delt er noder med edges
til kommunikasjonsnoden. Kan søkes, gjenfinnes, refereres.
- **Gjenåpnet** — løftet tilbake til sanntidslaget. "Vi tar opp tråden
fra forrige møte" er bokstavelig talt å reaktivere en node.
### Skalering er en edge-endring, ikke en migrasjonsoperasjon
En samtale mellom to blir et møte ved å legge til flere deltaker-edges.
Et møte blir en livesending ved å legge til offentlige mottak-edges.
En livesending blir en podcast ved å legge til publiserings-edges på
arkivert innhold. Ingen migrering, ingen konvertering — bare edges.
## Tekniske forutsetninger
### STT (tale → tekst): løst
Faster-whisper kjører lokalt, god norsk kvalitet. Allerede i stacken.
### TTS (tekst → tale): løsbart
Norsk TTS lokalt er ikke godt nok ennå (Piper er "usable" men dårlig rytme,
XTTS-v2 støtter ikke norsk). Kommersielt er det løst:
- **ElevenLabs** — beste kvalitet, eksplisitt norsk med regionale aksenter
- **Azure Neural TTS** — god kvalitet, ~$15/M tegn
- **Google Cloud TTS** — god kvalitet, WaveNet/Neural2
Strategi: start med kommersiell API (ElevenLabs) bak AI Gateway, bytt til
lokal modell (Chatterbox Multilingual el.l.) når kvaliteten er god nok.
Brukeren merker ingenting — det er en backend-swap bak gatewayen. Samme
mønster som Whisper: tung jobb → jobbkø → worker → resultat som node-metadata.
Følg med på: **Chatterbox Multilingual** (Resemble AI) — annonsert norsk
støtte, 350M params, lovende for lokal kjøring.
## Spenninger og åpne spørsmål
- **Ytelse.** Én tabell med *alt* i — skalerer det? PG med riktige indekser
og partisjonering håndterer mye, men det er en reell designbeslutning.
- **Skjema.** Noder trenger noe felles skjema (innhold, created_at, author).
Men ulike modaliteter har ulike metadata (transkripsjon, bildestørrelse,
varighet). Hva er felles og hva er edge-metadata?
- **AI-klassifisering.** Når brukeren bare "sier noe" — hvem bestemmer hvilke
edges som knyttes? Manuelt? AI-foreslått? Kontekstbasert (sa det i en
kanal → kanal-edge)? Sannsynligvis en blanding, men det krever gjennomtenkt
UX.
- **Migrering.** Meldingsboksen har allerede innhold. Kan vi migrere til
node+edge-modellen gradvis, eller er det et brudd?
- **Kompleksitet for utviklere.** "Alt er noder og edges" er konseptuelt rent,
men å bygge en kanban-visning som spørrer en graf er mer komplekst enn å
lese fra en `cards`-tabell. Er abstraksjonen verdt kompleksiteten?
## Tre primitiver, én graf
| Primitiv | Hva den gjør | Brukerens opplevelse |
|----------|-------------|---------------------|
| **Input** | Fanger alt — tekst, lyd, bilde, AI | Én overflate å snakke/skrive/tegne i |
| **Mottak** | Presenterer alt tilpasset deg | Én personlig flate med det som er relevant |
| **Kommunikasjon** | Samler folk med tilgangsregler | Et sted å være — samtale, møte, sending |
Alt er noder og edges i samme graf. Input skaper noder. Mottak spør
grafen med deg som sentrum. Kommunikasjonsnoder gir kontekst og
tilgangsregler. Visninger (chat, kanban, kalender, dagbok, stream)
er bare spørringer med ulike filtre.
## Forhold til andre retninger
Denne retningen konkretiserer [rom, ikke forum](rom_ikke_forum.md):
- "Formløs input, struktur etterpå" → universell input + edges
- "To lag" → sanntidsversjon + flat versjon
- "Privat/delt som lag" → synlighet som edge
- "Siloer forsvinner" → alt er noder, visninger er spørringer
- "Rommet som primitiv" → kommunikasjonsnoden
# 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.
## 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"

View file

@ -10,8 +10,8 @@ Det lokale miljøet er et **utviklingsmiljø for kode**. Frontend (SvelteKit) kj
| 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 i prod | Serveren er dev-miljø |
| Caddy/Authentik/Forgejo config | Direkte i prod | Avhenger av domener, sertifikater, SSO |
| 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)
@ -37,8 +37,8 @@ source ~/.cargo/env
```bash
cd ~
git clone ssh://git@git.sidelinja.org:222/sidelinja/server.git sidelinja-v2
cd sidelinja-v2
git clone ssh://git@git.sidelinja.org:222/vegard/synops.git
cd synops
```
## 3. Miljøvariabler (.env.local)
@ -53,16 +53,13 @@ cp .env.example .env.local
## 4. Utviklingsflyt
```bash
# Start alt lokalt
./dev.sh
# SvelteKit med HMR
cd web && npm run dev
# Eller manuelt:
cd web && npm run dev # SvelteKit med HMR
cd rust && cargo run # Rust maskinrom
# Rust maskinrom
cd rust && cargo run
```
`dev.sh` er kanonisk — oppdater alltid scriptet når nye steg oppdages.
## 5. Deploy
```bash
@ -70,7 +67,7 @@ cd rust && cargo run # Rust maskinrom
git push forgejo main
# 2. Deploy til prod (krever eksplisitt godkjenning)
ssh sidelinja@157.180.81.26 "cd /srv/sidelinja && git pull && docker compose up -d --build"
ssh vegard@157.180.81.26 "cd /srv/synops && git pull && docker compose up -d --build"
```
## 6. Forskjeller fra produksjon (bevisste)

View file

@ -1,5 +1,14 @@
# Migration Safety Checklist
> **Merk (v2):** Denne sjekklisten er skrevet for v1-arkitekturen der RLS var
> basert på `workspace_id`-kolonner og `SET app.current_workspace_id`. I v2 er
> workspace-modellen 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

View file

@ -1,7 +1,7 @@
# Oppsett: Produksjonsserver (Hetzner VPS)
**Filsti:** `docs/setup/produksjon.md`
Denne oppskriften tar en fersk Ubuntu VPS fra null til en komplett Sidelinja-installasjon. Hvert steg er sekvensielt — ikke hopp over noe.
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)
@ -58,17 +58,17 @@ newgrp docker
## 3. Opprett mappestruktur
```bash
sudo mkdir -p /srv/sidelinja/{config,data,media,logs}
sudo mkdir -p /srv/sidelinja/config/{caddy,authentik}
sudo mkdir -p /srv/sidelinja/data/{postgres,spacetimedb,forgejo,authentik}
sudo mkdir -p /srv/sidelinja/media/podcast
sudo mkdir -p /srv/sidelinja/logs/caddy
sudo chown -R sidelinja:sidelinja /srv/sidelinja
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/sidelinja/
/srv/synops/
├── docker-compose.yml
├── .env
├── config/
@ -88,7 +88,7 @@ Resultat:
## 4. Miljøvariabler (.env)
```bash
cat > /srv/sidelinja/.env << 'EOF'
cat > /srv/synops/.env << 'EOF'
# === Domener ===
DOMAIN_SIDELINJA=sidelinja.org
DOMAIN_VEGARD=vegard.info
@ -122,7 +122,7 @@ OPENROUTER_API_KEY=<fra openrouter.ai>
# Ingen porter eksponeres utenom 80/443. Alt rutes internt via Docker-nettverket.
EOF
chmod 600 /srv/sidelinja/.env
chmod 600 /srv/synops/.env
```
## 5. Tjeneste-installasjon (rekkefølge)
@ -153,7 +153,7 @@ Tjenestene startes i rekkefølge fordi noen avhenger av andre. Alle defineres i
# REGLER:
# - Ingen "ports:" mot host UTENOM Caddy (80, 443)
# - Alle tjenester på samme interne nettverk (sidelinja-net)
# - Volumer bruker bind mounts til /srv/sidelinja/
# - 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.
@ -165,10 +165,10 @@ networks:
services:
caddy: # Eneste tjeneste med eksponerte porter (80, 443)
postgres: # data:/srv/sidelinja/data/postgres
postgres: # data:/srv/synops/data/postgres
authentik: # SSO for alle domener, på auth.sidelinja.org
forgejo: # data:/srv/sidelinja/data/forgejo, på git.sidelinja.org
spacetimedb: # data:/srv/sidelinja/data/spacetimedb
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
@ -199,13 +199,13 @@ sidelinja.org {
# Podcast media (statiske filer med byte-range support)
handle_path /media/* {
root * /srv/sidelinja/media
root * /srv/synops/media
file_server
}
# Podcast access log (kun media-forespørsler)
log {
output file /srv/sidelinja/logs/caddy/podcast_access.log
output file /srv/synops/logs/caddy/podcast_access.log
format json
}
}
@ -261,10 +261,10 @@ Se `docs/arkitektur.md` seksjon 2.2 for full dataklassifisering. Kun kategori 1
```bash
# pg_dump er konsistent selv under last — ingen nedetid
docker compose exec -T postgres pg_dump -U sidelinja -Fc sidelinja \
> /srv/sidelinja/backup/pg/sidelinja_$(date +%Y%m%d).dump
> /srv/synops/backup/pg/sidelinja_$(date +%Y%m%d).dump
# Behold 30 dager, slett eldre
find /srv/sidelinja/backup/pg/ -name "*.dump" -mtime +30 -delete
find /srv/synops/backup/pg/ -name "*.dump" -mtime +30 -delete
```
### 11.1b PostgreSQL WAL-arkivering (kontinuerlig, PITR)
@ -307,21 +307,21 @@ repo1-retention-diff=14
### 11.2 Media-filer (daglig, 03:30)
```bash
# Inkrementell med rsync til lokal backup-disk eller ekstern lagring
rsync -a --delete /srv/sidelinja/media/ /srv/sidelinja/backup/media/
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/sidelinja/data/forgejo/ /srv/sidelinja/backup/forgejo/
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/sidelinja/.env /srv/sidelinja/backup/env_$(date +%Y%m%d)
chmod 600 /srv/sidelinja/backup/env_*
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)
@ -335,33 +335,33 @@ rclone config
# Opprett remote "hetzner-s3" med Hetzner Object Storage credentials
# (S3-kompatibelt, endpoint: fsn1.your-objectstorage.com eller nbg1)
# /srv/sidelinja/scripts/backup-offsite.sh
# /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/sidelinja/backup/pg/*.dump 2>/dev/null | head -1)
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/sidelinja/media/ "$BUCKET/media/" --transfers 4
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/sidelinja/logs/backup-offsite.log
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/sidelinja/scripts/backup-pg.sh
30 3 * * * sidelinja /srv/sidelinja/scripts/backup-media.sh
0 4 * * * sidelinja /srv/sidelinja/scripts/backup-forgejo.sh
30 4 * * * sidelinja /srv/sidelinja/scripts/backup-offsite.sh
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)
@ -376,14 +376,14 @@ echo "$(date): Off-site backup ferdig" >> /srv/sidelinja/logs/backup-offsite.log
```bash
# 1. PostgreSQL
docker compose exec -T postgres pg_restore -U sidelinja -d sidelinja --clean \
< /srv/sidelinja/backup/pg/sidelinja_YYYYMMDD.dump
< /srv/synops/backup/pg/sidelinja_YYYYMMDD.dump
# 2. Media
rsync -a /srv/sidelinja/backup/media/ /srv/sidelinja/media/
rsync -a /srv/synops/backup/media/ /srv/synops/media/
# 3. Forgejo
docker compose down forgejo
rsync -a /srv/sidelinja/backup/forgejo/ /srv/sidelinja/data/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
@ -399,7 +399,7 @@ git push forgejo main
# SSH inn til server:
ssh sidelinja@<server-ip>
cd /srv/sidelinja
cd /srv/synops
git pull
docker compose build --no-cache <tjeneste>
docker compose up -d <tjeneste>

View file

@ -13,7 +13,7 @@ men ikke implementert.
## Sjekkliste
### 1. Git-status
- [ ] Er prod-server på siste commit? (`ssh sidelinja@157.180.81.26 'cd /srv/sidelinja/server && git log -1'`)
- [ ] Er prod-server på siste commit? (`ssh vegard@157.180.81.26 'cd /srv/synops/app && git log -1'`)
- [ ] Er det ucommittede endringer lokalt som burde vært pushet?
- [ ] Er det commits på Forgejo som ikke er deployet til prod?
@ -24,7 +24,6 @@ men ikke implementert.
### 3. Docker-tjenester
- [ ] Kjører alle forventede containere i prod? (`docker compose ps`)
- [ ] Er det tjenester i `docker-compose.dev.yml` som mangler i prod (eller omvendt)?
- [ ] Er image-versjoner oppdatert?
### 4. Miljøvariabler

View file

@ -55,10 +55,9 @@ fjerne utdaterte referanser, og sikre at dokumentasjon stemmer med virkeligheten
- [ ] Er det gjort arbeid nylig som mangler erfaringsdokumentasjon i `docs/erfaringer/`?
- [ ] Er eksisterende erfaringsdokumenter fortsatt relevante og korrekte?
### 8. dev.sh og utviklermiljø
- [ ] Fungerer `./dev.sh` fra scratch?
- [ ] Er alle nødvendige tjenester dekket?
- [ ] Er det nye quirks eller workarounds som bør inn i scriptet?
### 8. Utviklermiljø
- [ ] Fungerer lokal utvikling mot server (SvelteKit HMR, Rust build)?
- [ ] Er `docs/setup/lokal.md` oppdatert med eventuelle nye steg?
## Sist kjørt

View file

@ -1,7 +1,7 @@
# Server-tilstand ved v1→v2 overgang (mars 2025)
# Server-tilstand ved overgang fra Sidelinja v1 (mars 2025)
Denne filen dokumenterer hva som kjører på serveren ved overgangen.
Slettes når v2 er oppe og stabilt.
Slettes når Synops er oppe og stabilt.
## Kjørende containere (docker-compose)
@ -56,7 +56,7 @@ warmup_config, ai_config, ai_prompts, provider_extra_params, openrouter_only,
api_keys_toggle, api_keys_values, drop_priority_unique, workspace_ai_prompts,
usage_action_column. Pluss seed_dev.sql.
v2 starter med nytt skjema (noder+edges). Disse migreringene er irrelevante.
Synops starter med nytt skjema (noder+edges). Disse migreringene er irrelevante.
## Crontab
Ingen crontab konfigurert (backup-strategi er dokumentert men ikke implementert).

View file

@ -1,47 +1,81 @@
#!/usr/bin/env bash
# Samler all prosjektdokumentasjon til én fil for deling med AI-er etc.
# Bruk: ./scripts/summary.sh → skriver scripts/summary.md
# ./scripts/summary.sh - → skriver til stdout (for piping)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUT="$SCRIPT_DIR/summary.md"
files=(
# Overblikk — visjon og retning først
"$ROOT/docs/arkitektur.md"
"$ROOT/CLAUDE.md"
"$ROOT"/docs/retninger/*.md
# Primitiver
"$ROOT"/docs/primitiver/*.md
# Infrastruktur
"$ROOT"/docs/infra/*.md
# Erfaringer
"$ROOT"/docs/erfaringer/*.md
)
collect() {
for f in "${files[@]}"; do
[[ -f "$f" ]] || continue
rel="${f#"$ROOT/"}"
echo "================================================================"
echo "FILE: $rel"
echo "================================================================"
echo ""
cat "$f"
echo ""
echo ""
done
}
if [[ "${1:-}" == "-" ]]; then
collect
else
collect > "$OUT"
echo "Wrote $OUT ($(wc -l < "$OUT") lines)"
fi
#!/usr/bin/env bash
# Samler all prosjektdokumentasjon til én fil for deling med AI-er etc.
# Bruk: ./scripts/summary.sh → skriver scripts/synops.md
# ./scripts/summary.sh - → skriver til stdout (for piping)
#
# Rekkefølge: overblikk → arkitektur → konsepter → tekniske detaljer → drift
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUT="$SCRIPT_DIR/synops.md"
# Prioritert rekkefølge av mapper under docs/
# Fra overblikk og visjon → ned til implementeringsdetaljer
DOC_ORDER=(
retninger # Arkitektoniske teser og vedtatte retninger
primitiver # Spesifikasjoner for kjerneprimitivene
concepts # Brukeropplevelser og produktområder
features # Tekniske byggeklosser
infra # Infrastruktur og drift
setup # Oppsett og driftsprosedyrer
erfaringer # Lærdommer fra implementering
proposals # Idébank (uimplementerte forslag)
)
collect() {
# 1. Prosjektguide (CLAUDE.md) først — gir komplett overblikk
if [[ -f "$ROOT/CLAUDE.md" ]]; then
emit "$ROOT/CLAUDE.md"
fi
# 2. Overordnet arkitektur
if [[ -f "$ROOT/docs/arkitektur.md" ]]; then
emit "$ROOT/docs/arkitektur.md"
fi
# 3. Dokumentmapper i prioritert rekkefølge
for dir in "${DOC_ORDER[@]}"; do
[[ -d "$ROOT/docs/$dir" ]] || continue
for f in "$ROOT/docs/$dir"/*.md; do
[[ -f "$f" ]] || continue
emit "$f"
done
done
# 4. Eventuelle docs/-mapper som ikke er i DOC_ORDER (fang nye mapper automatisk)
for dir in "$ROOT"/docs/*/; do
[[ -d "$dir" ]] || continue
dirname="$(basename "$dir")"
# Hopp over de vi allerede har tatt
skip=false
for known in "${DOC_ORDER[@]}"; do
[[ "$dirname" == "$known" ]] && skip=true && break
done
$skip && continue
for f in "$dir"*.md; do
[[ -f "$f" ]] || continue
emit "$f"
done
done
}
emit() {
local f="$1"
local rel="${f#"$ROOT/"}"
echo "================================================================"
echo "FILE: $rel"
echo "================================================================"
echo ""
cat "$f"
echo ""
echo ""
}
if [[ "${1:-}" == "-" ]]; then
collect
else
collect > "$OUT"
echo "Wrote $OUT ($(wc -l < "$OUT") lines)"
fi