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: # Kopier til .env.local og fyll inn verdier:
# cp .env.example .env.local # 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, - **Standard arbeidsmodus:** Start i planleggingsmodus. Lag en grundig plan,
få godkjenning, deretter implementer. Jobbene er ment å kunne kjøre få godkjenning, deretter implementer. Jobbene er ment å kunne kjøre
lenge autonomt uten input underveis. lenge autonomt uten input underveis.
- **Testmiljø:** `./dev.sh` er den kanoniske måten å starte utviklingsmiljøet. - **Utvikling mot server.** Ingen lokale databaser eller tjenester.
Når nye steg eller oppsett-quirks oppdages, oppdater alltid `dev.sh`. Frontend (SvelteKit) utvikles lokalt med HMR mot server-API.
Før Vegard tester i browser: kjør `./dev.sh`, verifiser med Rust bygges lokalt, deployes til server for integrasjonstest.
`cargo check`/`svelte-check`/`curl`, og meld tilbake at det er klart.
- **Browser-testing:** Claude har ikke tilgang til browser. Visuell testing - **Browser-testing:** Claude har ikke tilgang til browser. Visuell testing
gjøres av Vegard. Claude verifiserer backend (kompilering, API, DB-state). 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. - **Commit og push:** Bruk egen vurdering. Trygt og reverserbart.
- **Deploy til produksjon:** Krever alltid eksplisitt godkjenning fra Vegard. - **Deploy til produksjon:** Krever alltid eksplisitt godkjenning fra Vegard.
- **Diskusjon:** Forklar og diskuter før arkitekturendringer. - **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 - `rom_ikke_forum.md` — Opplevelse-først, to-lags-modell, administrativ opplevelse
- `universell_input.md` — Tre primitiver (input, mottak, kommunikasjon), noder+edges - `universell_input.md` — Tre primitiver (input, mottak, kommunikasjon), noder+edges
- `maskinrommet.md` — Rust-orkestrator: fang, prosesser, lever - `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 - `datalaget.md` — PG(+AGE) som graf og arkiv, SpacetimeDB som sanntidslag
- `docs/primitiver/` — Spesifikasjoner for kjerneprimitivene: - `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: - `docs/concepts/` — Brukeropplevelser/produktområder:
- `studioet.md`, `møterommet.md`, `redaksjonen.md`, `podcastfabrikken.md`, - `studioet.md`, `møterommet.md`, `redaksjonen.md`, `podcastfabrikken.md`,
`kunnskapsgrafen.md`, `valgomaten.md`, `den_asynkrone_gjesten.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: - `docs/setup/` — Oppsett og drift:
- `produksjon.md` — Steg-for-steg oppsett av Hetzner VPS fra scratch - `produksjon.md` — Steg-for-steg oppsett av Hetzner VPS fra scratch
- `lokal.md` — Lokalt utviklingsmiljø (WSL2, mot server) - `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: - `docs/infra/` — Infrastruktur og drift:
- `ai_gateway.md` — LiteLLM som sentralisert AI-ruter (BYOK + fallback) - `ai_gateway.md` — LiteLLM som sentralisert AI-ruter (BYOK + fallback)
- `api_grensesnitt.md` — Kommunikasjonskart: SvelteKit er web-API, Rust er worker - `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 ## Aktører
- **Vegard** — serveradmin, utvikler, bruker. SSH: `vegard@157.180.81.26` - **Vegard** — serveradmin, utvikler, bruker. SSH: `vegard@157.180.81.26`
- **Claude** — AI-agent, utvikler. SSH: `claude@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 ## Stack
- **Orkestrator/Backend:** Rust (maskinrommet) - **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å) - `synops.no` — Plattformdomene (reservert, ikke i bruk ennå)
## Git ## Git
- **Forgejo-org:** `sidelinja` - **Repos i Forgejo:**
- **Repos:** - `vegard/synops` — plattformkode og arkitektur: `ssh://git@git.sidelinja.org:222/vegard/synops.git`
- `synops` — plattformkode og arkitektur - `sidelinja/sidelinja` — podcastinnhold: `ssh://git@git.sidelinja.org:222/sidelinja/sidelinja.git`
- `sidelinja` — podcastinnhold: `ssh://git@git.sidelinja.org:222/sidelinja/sidelinja.git`
- **Git-identitet:** vegard / vnotnes@pm.me - **Git-identitet:** vegard / vnotnes@pm.me
- **Forgejo-bruker:** vegard (admin) - **Forgejo-bruker:** vegard (admin)
- **CLI:** Bruk `tea`, ikke `gh` (vi bruker Forgejo, ikke GitHub) - **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. (samler folk). Alt annet er visninger og edges.
3. **Maskinrommet orkestrerer alt.** Fang, prosesser, lever. Edge-drevet 3. **Maskinrommet orkestrerer alt.** Fang, prosesser, lever. Edge-drevet
ressursallokering. Tjenester under er utbyttbare. ressursallokering. Tjenester under er utbyttbare.
4. **Brukeren er sentrum.** Ingen workspace-velger. Du ser dine edges. 4. **Noder er sentrum.** Brukere, team, innhold — alt er noder.
Samlings-noder gir felles kontekst med RLS-siloer under. Du ser dine edges. Tilgang via materialisert tilgangsmatrise.
5. **Privat er default.** Input uten mottaker-edge er privat. Security 5. **Privat er default.** Input uten mottaker-edge er privat. Security
by design, ikke konfigurasjon. by design, ikke konfigurasjon.
6. **PG er arkivet, SpacetimeDB er nåtid.** Ingen eierskapskonflikt. 6. **PG er arkivet, SpacetimeDB er nåtid.** Ingen eierskapskonflikt.

View file

@ -1,158 +1,172 @@
# Arkitektur — Sidelinja v2 # Arkitektur — Synops
## Visjon ## Visjon
Sidelinja er en plattform for redaksjonelt arbeid og podcast-produksjon. Synops er en plattform for redaksjonelt arbeid og podcast-produksjon.
Ikke en webapp med features — en plattform med primitiver som kan bli Ikke en webapp med features — en plattform med primitiver som kan bli
hva som helst. hva som helst. Sidelinja (podcastredaksjonen) er en tenant som bruker
Synops.
Alt er noder og edges i en graf. Input fanger, mottak presenterer,
kommunikasjonsnoder samler folk. Maskinrommet orkestrerer tekniske Alt er noder og edges i en graf. En bruker er en node. Et team er en
tjenester under. Frontend er et tynt lag som viser grafen. node. En mediefil er en node. Hva noe "er" bestemmes av edges, ikke
av noden selv. Maskinrommet eier alle skrivinger. Frontend er et tynt
## Lagmodell lag som leser grafen fra SpacetimeDB.
``` ## Lagmodell
┌─────────────────────────────────────┐
│ GUI (SvelteKit) │ ```
│ Visninger: spørringer mot grafen │ ┌─────────────────────────────────────┐
└────────┬──────────────────┬─────────┘ │ GUI (SvelteKit) │
│ skriv │ les (sanntid) │ Visninger: spørringer mot STDB │
│ │ direkte WebSocket └────────┬──────────────────┬─────────┘
┌────────▼────────┐ ┌─────▼─────────┐ │ intensjoner │ les (sanntid)
│ Maskinrommet │ │ SpacetimeDB │ │ │ direkte WebSocket
│ (Rust) │ │ Aktive noder │ ┌────────▼────────┐ ┌──────▼──────────┐
│ Orkestrering │ └───────────────┘ │ Maskinrommet │ │ SpacetimeDB │
└──┬─────┬─────┬──┘ │ (Rust) │ │ Hele grafen │
│ │ │ │ Eier alle │ │ (noder+edges) │
▼ ▼ ▼ │ skrivinger │ └─────────────────┘
┌─────┐┌─────┐┌─────┐┌─────────────┐ └──┬─────┬─────┬──┘
│ PG ││STDB ││ CAS ││ Whisper, │ │ │ │
│ ││(skr)││ ││ LiteLLM, │ ▼ ▼ ▼
│ ││ ││ ││ LiveKit ... │ ┌─────┐┌─────┐┌─────┐┌─────────────┐
└─────┘└─────┘└─────┘└─────────────┘ │ PG ││STDB ││ CAS ││ Whisper, │
``` │(bak)││(skr)││ ││ LiteLLM, │
│ ││ ││ ││ LiveKit ... │
### Skrivestien └─────┘└─────┘└─────┘└─────────────┘
GUI → Maskinrommet (Rust) → tjenester ```
All orkestrering, edge-logikk, validering og ressursallokering ### Skrivestien
går gjennom Rust. Maskinrommet leser edges og bestemmer hvilke GUI → intensjon → Maskinrommet (Rust) → SpacetimeDB (instant) → PG (async)
tjenester som trigges.
Frontend sender intensjoner (ikke data). Maskinrommet validerer,
### Lesestien (sanntid) skriver til SpacetimeDB først for umiddelbar oppdatering, deretter
SpacetimeDB → GUI (direkte WebSocket) persisterer til PG asynkront. Maskinrommet leser edges og bestemmer
hvilke tjenester som trigges.
STDB klient-SDK gir ~10μs-oppdateringer med lokal cache.
En bevisst optimering — ikke et hull i lagmodellen. ### Lesestien (sanntid)
SpacetimeDB → GUI (direkte WebSocket)
### Lesestien (tradisjonell)
GUI → Maskinrommet (Rust) → PG SpacetimeDB holder hele grafen — alle noder og edges. Frontend
abonnerer via WebSocket med edge-filtre. Visninger er spørringer
Søk, historikk, statistikk, arkiv. Cypher via AGE for mot STDB, ikke forhåndsdefinerte API-endepunkter.
graftraversering når CTEs ikke holder.
### Lesestien (tunge spørringer)
## Datamodell GUI → Maskinrommet (Rust) → PG
### Noder Søk, statistikk, semantisk søk (pgvector), graftraversering
Én tabell. Alt innhold er noder: meldinger, oppgaver, notater, (AGE/Cypher). For operasjoner der STDB ikke er egnet.
faktoider, mediefiler, kommunikasjonsrom, samlings-noder, brukere.
## Datamodell
Felles skjema: id, innhold, created_at, author, content_hash (→ CAS).
Modalitetsspesifikk metadata i JSONB. ### Alt er noder
Én `nodes`-tabell. Alt er noder: brukere, team, meldinger, oppgaver,
### Edges notater, mediefiler, kommunikasjonsrom, samlings-noder. En bruker er
Én tabell. Alle relasjoner er edges: tilhørighet, tilgang, type, en node som tilfeldigvis kan logge inn.
synlighet, rolle, status.
Felles skjema: id, innhold, created_at, content_hash (→ CAS).
Hva en node "er" bestemmes utelukkende av edges: Modalitetsspesifikk metadata i JSONB.
- Node + edge til kanal = chatmelding
- Node + edge til board + status-edge = kanban-kort ### Edges definerer alt
- Node + edge til dato = kalenderoppføring Én `edges`-tabell. Edge-typer er frie strenger. Hva en node "er"
- Node + edge til kun bruker = privat notat bestemmes utelukkende av dens edges:
- Node + edge til topic = faktoid
- Node uten edges = løs tanke - Node + edge til kanal = chatmelding
- Node + edge til board + status-edge = kanban-kort
### Visninger - Node + edge til dato = kalenderoppføring
Visninger er spørringer mot grafen med ulike filtre: - Node + edge til kun bruker = privat notat
- Chat = noder med kanal-edge, sortert på tid - Node uten edges = løs tanke
- Kanban = noder med board-edge, gruppert på status
- Kalender = noder med dato-edge, på tidslinje ### Visninger er spørringer
- Mottaksflate = noder med edge til deg, vektet på relevans Visninger er spørringer mot SpacetimeDB med edge-filtre:
- Chat = noder med kanal-edge, sortert på tid
## Tre primitiver - Kanban = noder med board-edge, gruppert på status
- Kalender = noder med dato-edge, på tidslinje
### 1. Input - Mottaksflate = noder med edge til deg, vektet på relevans
Én multimodal overflate som fanger alt: tekst, lyd, bilde, AI,
URL. To versjoner — sanntid (STDB-lag) og flat (PG-lag). Ingen forhåndsdefinerte visningstyper. Nye visninger er nye filtre.
Output er alltid en node.
## Input
### 2. Mottak
Én personlig flate som presenterer alt tilpasset deg. Format Én universell input-komponent, gjenbrukt overalt. Fanger tekst, lyd,
bestemmes av mottaker (tekst/lyd). Filtrering via dine edges. bilde, AI, URL. Konteksten (hvor du er) bestemmer hvilke edges som
Sanntid (stream) eller asynkront (digest). legges til. Output er alltid en node.
### 3. Kommunikasjon ## Struktur uten workspaces
En node som samler folk med tilgangsregler. Samme type fra
én-til-én-samtale til livesending. Forskjellen er edges: Ingen workspace-velger. Ingen `workspace_id`. Samlings-noder gir
eier, input-tilgang, mottak-tilgang, rolle. struktur: et team er en samlings-node, et prosjekt er en samlings-node.
Du ser noder du har tilgang til via dine edges.
## Maskinrommet
### Aliaser
Rust-tjeneste med tre operasjoner: **fang**, **prosesser**, **lever**. En bruker kan ha alias-noder (f.eks. for ulike roller). Koblet med
system-edges som er usynlige for traversering.
Edge-drevet ressursorkestrering: maskinrommet leser edges og
bestemmer hvilke tjenester som spinnes opp. Dagboknotat = bare ## Maskinrommet
transkriber. Livesending = transkriber + LiveKit + AI + mediaprosessering.
Rust-tjeneste med tre operasjoner: **fang**, **prosesser**, **lever**.
Forvalter også CAS (binærlagring) med intelligent pruning basert
på modalitet, edges og aksessmønstre. Eier alle skrivinger. Frontend sender intensjoner, maskinrommet
validerer og utfører. Edge-drevet ressursorkestrering: maskinrommet
## Sikkerhet leser edges og bestemmer hvilke tjenester som spinnes opp.
### RLS-siloer Forvalter også CAS (binærlagring) med intelligent pruning basert
Samlings-noder (det som var workspaces) er harde sikkerhetsgenser på modalitet, edges og aksessmønstre.
i PG med RLS. `workspace_id = current_setting(...)` — instant,
vanntett. Edge-basert tilgang er UX *innenfor* siloen. ## Sikkerhet
### Privat er default ### Synlighet (visibility)
Input uten mottaker-edge er automatisk privat. Ingen ser det. Noder har en visibility-egenskap med fire nivåer:
Deling er å legge til edges. - **hidden** — usynlig, kun tilgjengelig via system-edges
- **discoverable** — kan finnes, men innhold skjult
## Datalag - **readable** — innhold lesbart for de med tilgang
- **open** — tilgjengelig for alle med traverseringssti
### PostgreSQL
Arkiv og graf. Alle noder og edges. Fulltekstsøk, pgvector Traversering respekterer visibility. Du kan ikke følge edges gjennom
(semantisk søk), JSONB. Apache AGE for Cypher når CTEs ikke holder. noder du ikke har lov til å se.
### SpacetimeDB ### Materialisert tilgangsmatrise
Sanntidslag. Aktive noder og edges speilet fra PG. Frontend `node_access`-tabell som cacher beregnet tilgang fra edge-grafen.
abonnerer via WebSocket. Ingen eierskapskonflikt — STDB er en Oppdateres ved edge-endring, ikke ved lesing. Rask oppslag — ingen
live-visning av en delmengde av PG-grafen. rekursiv graftraversering per forespørsel.
### CAS (Content-Addressable Store) ### Privat er default
Binærdata (lyd, bilde, video) lagret med hash. TTL basert på Input uten mottaker-edge er automatisk privat. Ingen ser det.
modalitet, edges og aksesslog. Tekst lever evig, lyd 30 dager, Deling er å legge til edges.
video 14 dager — forlenges ved aksess. Generert innhold (TTS,
thumbnails) er en cache som regenereres on-demand. ## Datalag
## Teknologivalg ### PostgreSQL
Persistent backup og arkiv. Alle noder og edges. Fulltekstsøk,
| Rolle | Teknologi | Begrunnelse | pgvector (semantisk søk), JSONB. Apache AGE for Cypher ved behov.
|-------|-----------|-------------|
| Orkestrator | Rust | Ytelse, typesikkerhet, allerede i stacken | ### SpacetimeDB
| Frontend | SvelteKit | PWA, SSR, allerede i stacken | Holder hele grafen — alle noder og edges. Frontend abonnerer via
| Database | PostgreSQL | Økosystem (pgvector, fulltekstsøk, AGE), stabilitet | WebSocket. Maskinrommet skriver hit først for umiddelbar oppdatering,
| Sanntid | SpacetimeDB | In-memory, WebSocket-subscriptions, ~10μs | PG synkroniseres asynkront.
| Binærlagring | CAS (filsystem) | Enkel, deduplisering, ingen ekstern avhengighet |
| AI Gateway | LiteLLM | Multi-provider, BYOK, OpenRouter fallback | ### CAS (Content-Addressable Store)
| STT | faster-whisper | Lokal, god norsk kvalitet | Binærdata (lyd, bilde, video) lagret med hash. TTL basert på
| TTS | ElevenLabs (→ lokal) | Kommersiell start, lokal når kvaliteten holder | modalitet, edges og aksesslog. Generert innhold (TTS, thumbnails)
| Auth | Authentik | SSO, OIDC, self-hosted | er en cache som regenereres on-demand.
| Reverse proxy | Caddy | Auto-TLS, enkel config |
| Lyd/video | LiveKit | WebRTC, self-hosted | ## Teknologivalg
## Retninger | Rolle | Teknologi | Begrunnelse |
|-------|-----------|-------------|
Arkitekturen er basert på vedtatte retninger dokumentert i | Orkestrator | Rust | Ytelse, typesikkerhet, eier alle skrivinger |
`docs/retninger/`. Se `docs/retninger/README.md` for oversikt. | 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 ```sql
guest_tokens ( guest_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, -- Samlings- eller tema-node
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
guest_name TEXT NOT NULL, -- Visningsnavn ("Erna Solberg") guest_name TEXT NOT NULL, -- Visningsnavn ("Erna Solberg")
questions JSONB NOT NULL, -- [{ "sort": 1, "text": "Hva tenker du om...?" }] questions JSONB NOT NULL, -- [{ "sort": 1, "text": "Hva tenker du om...?" }]
token TEXT UNIQUE NOT NULL, -- Kryptografisk sikker, URL-safe token token TEXT UNIQUE NOT NULL, -- Kryptografisk sikker, URL-safe token
@ -58,9 +57,9 @@ guest_tokens (
### 4.2 Sikkerhet ### 4.2 Sikkerhet
- Token er kryptografisk tilfeldig (256-bit, URL-safe base64). - 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. - 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. - Tokenet kan revokeres manuelt av redaksjonen.
### 4.2b Sikkerhetsdybde (mot token-lekkasje og misbruk) ### 4.2b Sikkerhetsdybde (mot token-lekkasje og misbruk)
@ -81,7 +80,7 @@ clamav:
image: clamav/clamav:latest image: clamav/clamav:latest
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /srv/sidelinja/media:/scan:ro - /srv/synops/media:/scan:ro
networks: networks:
- sidelinja-net - sidelinja-net
``` ```
@ -101,7 +100,7 @@ Gjest åpner URL med token
→ SvelteKit validerer token → SvelteKit validerer token
→ Viser spørsmål + opptaksknapp → Viser spørsmål + opptaksknapp
→ Gjest tar opp svar → 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 message (voice_memo) i channelen
→ Oppretter whisper_transcribe-jobb i jobbkøen → Oppretter whisper_transcribe-jobb i jobbkøen
→ Inkrementerer recordings_count → Inkrementerer recordings_count
@ -118,8 +117,8 @@ Gjest åpner URL med token
## 6. Instruks for Claude Code ## 6. Instruks for Claude Code
* `guest_tokens`-tabellen er **ikke** en node i grafen — den er ren tilgangsstyring. * `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. * 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). * 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. * 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`. **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 ## 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: **SRT** (nedlastbar undertekstfil — master-kopi fra Git)
* Fane: **Ren tekst** (lesbart transkripsjonsdokument — avledet fra SRT, lagret i PG) * 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) ## 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: 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 Peter Hagen, Nicolai Buzatu, Bjørn Einar Drag, Øystein Sjølie
``` ```
## 5. Workspace-spesifikk konfigurasjon ## 5. Per samlings-node konfigurasjon
Hver workspace har sin egen podcast-konfigurasjon, lagret i `workspaces.settings` (JSONB): Hver samlings-node (f.eks. Sidelinja) har sin egen podcast-konfigurasjon, lagret som JSONB-metadata på noden:
### 5.1 Mediefiler ### 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) ### 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 #### 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 #### Filnavnkonvensjon
Flat struktur med prosesseringstidspunkt som filnavn: 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. * **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 #### 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) #### 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. 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 #### Webhook-flyt
``` ```
Forgejo push-webhook → SvelteKit POST /api/webhooks/forgejo 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 → 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. 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. 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 ### 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. * **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 `settings.llm_prompts` slik at AI-en kjenner konteksten og vertene for akkurat den podcasten. * **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 ### 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 ### 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 ## 6. Instruks for Claude Code
* **Lydfiler:** Håndter filopplasting i SvelteKit strømmende (streaming) for filer >100MB for å unngå minne-lekkasjer. * **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. * **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. * **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. * **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 ## 4. Instruks for Claude Code
* Bruk SvelteKit for Drag-and-Drop. Unngå tunge biblioteker hvis native HTML5 Drag and Drop er tilstrekkelig. * 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. * 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 . --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) ## 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 ## 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`: SpacetimeDB eksponerer Rust-parameternavn direkte i HTTP JSON API-et. Underscore-prefix (`_workspace_id`) blir til `_workspace_id` i JSON, ikke `workspace_id`:
```rust ```rust

View file

@ -68,14 +68,11 @@ Eksempel:
``` ```
**Hvorfor `users.settings` og ikke en egen tabell?** **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 - JSONB er fleksibelt — nye innstillinger legges til uten migrering
- Ingen relasjoner, ingen spørringer mot enkeltfelt — bare hent hele objektet - Ingen relasjoner, ingen spørringer mot enkeltfelt — bare hent hele objektet
**Workspace-spesifikke overrides** kan legges i `workspace_members` ved behov: **Samlings-node-spesifikke overrides** kan legges som edges med metadata mellom bruker og samlings-node ved behov.
```sql
ALTER TABLE workspace_members ADD COLUMN settings JSONB NOT NULL DEFAULT '{}';
```
Men dette er fase 2. Start med globale brukerinnstillinger. Men dette er fase 2. Start med globale brukerinnstillinger.
## 4. Frontend ## 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. 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 ## 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 ┌─────────────┐ ┌─────────────┐ 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` | | **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` | | **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 ### 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. 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 ### Gjenstår
- **Vedlegg, TTL** — avventer implementering. - **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). - **Pin/konvertering:** Går fortsatt direkte til PG API (ikke via SpacetimeDB).
## 11. Instruks for Claude Code ## 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. * **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`. * **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. * **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. * **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 ### Implementert
- Migrering `0003_calendar.sql`: `calendars` + `calendar_events` (begge FK→nodes) - 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 - Heldagshendelser (`T12:00:00` for tidssone-trygghet) vs. tidshendelser med klokkeslett
- Fargekoder per hendelse (7 forhåndsdefinerte) + standard kalenderfarge - Fargekoder per hendelse (7 forhåndsdefinerte) + standard kalenderfarge
- REST API: GET med tidsvindu-filtrering, POST/PATCH/DELETE hendelser - 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 - Dra-og-slipp for å flytte hendelser mellom datoer
- Flerdag-hendelser (vises over flere celler) - Flerdag-hendelser (vises over flere celler)
- Abonnementsmodell (kalender → kalender via graph_edges) - Abonnementsmodell (kalender → kalender via graph_edges)
- Personlige vs. workspace-kalendere - Personlige vs. delte kalendere (via samlings-noder)
- ICS/CalDAV-eksport - ICS/CalDAV-eksport
- SpacetimeDB-modul + hybrid-adapter - SpacetimeDB-modul + hybrid-adapter
- Varsler/påminnelser via jobbkøen - 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 | | 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 | | Personlig | Bruker | Kun eier + eksplisitt deling |
| Offentlig | Workspace | Alle som abonnerer | | Offentlig | Samlings-node | Alle som abonnerer |
## 8. Fremtidig: ICS-eksport ## 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 ## 9. Instruks for Claude Code
* Bruk `eventsForDate()` med lokal dato-konvertering, ikke UTC-substring * Bruk `eventsForDate()` med lokal dato-konvertering, ikke UTC-substring
* Heldagshendelser: `T12:00:00`, aldri `T00:00:00` (tidssone-felle) * 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 * 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 ### Implementert
- Migrering `0002_kanban.sql`: `kanban_boards`, `kanban_columns`, `kanban_cards` - 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 - 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 - 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 - 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) 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 ## 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 ## 6. Instruks for Claude Code
* Bruk native HTML5 Drag and Drop i SvelteKit, unngå tunge biblioteker. * Bruk native HTML5 Drag and Drop i SvelteKit, unngå tunge biblioteker.
* PG-adapter er autoritativ inntil SpacetimeDB-sync er på plass. * 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. * 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` **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 ## 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 ## 2. Avgrensning: Hva dette IKKE er
- **Ikke datadeling.** Ingen data kopieres mellom workspaces. Bridge viser kun at en relevant node *finnes*. - **Ikke datadeling.** Ingen data kopieres mellom samlings-noder. Bridge viser kun at en relevant node *finnes*.
- **Ikke automatisk.** Begge workspaces må ha Bridge eksplisitt aktivert av en admin. - **Ikke automatisk.** Begge samlings-noder må ha Bridge eksplisitt aktivert av en admin.
- **Ikke synlig for gjester.** Kun for workspace-medlemmer med tilgang til begge sider. - **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 workspaces du allerede er medlem av. - **Ikke et søk i andres data.** Du ser bare treff i samlings-noder du allerede har tilgang til.
## 3. Teknisk arkitektur ## 3. Teknisk arkitektur
@ -35,32 +37,33 @@ En jobbkø-jobb som genererer embeddings for nye/endrede noder:
4. Lagrer vektoren i pgvector-kolonnen. 4. Lagrer vektoren i pgvector-kolonnen.
5. Re-genereres ved vesentlige endringer av noden. 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"): Når en bruker utforsker en node (f.eks. Tema "Skolepolitikk"):
1. SvelteKit server-side henter brukerens tilgjengelige workspaces fra `workspace_members`. 1. SvelteKit server-side henter brukerens tilgjengelige samlings-noder fra `node_access`-matrisen.
2. Kjører et similarity-søk med `<=>` (cosine distance) **som superuser** (bypasser RLS) — men filtrerer eksplisitt mot brukerens workspace-liste: 2. Kjører et similarity-søk med `<=>` (cosine distance), filtrert mot brukerens tilgjengelige noder:
```sql ```sql
SELECT n.workspace_id, t.name, t.embedding <=> $target_embedding AS distance SELECT na.node_id, e.name, e.embedding <=> $target_embedding AS distance
FROM topics t FROM entities e
JOIN nodes n ON t.id = n.id JOIN nodes n ON e.id = n.id
WHERE n.workspace_id = ANY($user_workspace_ids) JOIN node_access na ON n.id = na.node_id
AND n.workspace_id != $current_workspace_id WHERE na.user_id = $current_user
AND t.embedding <=> $target_embedding < 0.3 AND n.id != $current_node_id
AND e.embedding <=> $target_embedding < 0.3
ORDER BY distance ORDER BY distance
LIMIT 10; LIMIT 10;
``` ```
3. Resultatet vises som en diskret "Finnes også i..."-seksjon i UI-et. 3. Resultatet vises som en diskret "Finnes også i..."-seksjon i UI-et.
### 3.4 Workspace-config ### 3.4 Samlings-node config
Bridge aktiveres per workspace i `workspaces.settings`: Bridge aktiveres per samlings-node i nodens metadata (JSONB):
```json ```json
{ {
"bridge_enabled": true, "bridge_enabled": true,
"bridge_discoverable": true "bridge_discoverable": true
} }
``` ```
- `bridge_enabled`workspace kan søke i andre workspaces - `bridge_enabled`samlings-noden kan søke i andre samlings-noder
- `bridge_discoverable` — andre workspaces kan finne noder i dette workspace-et - `bridge_discoverable` — andre samlings-noder kan finne noder under denne
Begge må være `true` for at en kobling skal vises. 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 ## 5. Instruks for Claude Code
* pgvector er en ny avhengighet — dokumenter i docker-compose og setup-guides. * 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. * Embedding-dimensjon (768) bør matche modellen som brukes. Konfigurér som konstant, ikke hardkod overalt.
* Jobbtype `generate_embeddings` bruker `sidelinja/rutine` som modellalias. * 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 ( CREATE TABLE nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, node_type node_type NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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` ### 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 ```sql
CREATE TABLE graph_edges ( CREATE TABLE graph_edges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, source_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
target_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' 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_source ON graph_edges(source_id);
CREATE INDEX idx_edges_target ON graph_edges(target_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_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. **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. 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 ## 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. * **`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. * **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": [...] }`. * **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. * **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. * 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. * 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. * 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. * **Benchmark og modellvalg:** Se `docs/concepts/podcastfabrikken.md` seksjon 4 for ytelsestall og anbefalinger.
### 4.1 initial_prompt (navneliste) ### 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: Effekten er tydelig:
* Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja" * Uten prompt: "Vegard Nøgnes", "SideLinja", "Sidlinja"
@ -32,9 +32,9 @@ Effekten er tydelig:
## 5. Lagring ## 5. Lagring
* **Live-modus:** Transkripsjonen er flyktig (kategori 4, TTL 30 dager). Lagres i `live_transcription_log` for feilsøking. * **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 ## 6. Instruks for Claude Code
* Live transkripsjon blokkerer ALDRI web-requests — prosesseres i Rust-worker eller separat tjeneste. * 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`). * 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). 2. Trykker record. Nettleseren fanger lyd via `MediaRecorder` API (WebM/Opus).
3. Kan valgfritt velge kontekst: et Tema, en Aktør, eller "Usortert". 3. Kan valgfritt velge kontekst: et Tema, en Aktør, eller "Usortert".
4. Ved stopp lastes lydfilen opp til SvelteKit (streaming for store filer). 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. 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. 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'`): 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 }) → messages (channel_id, message_type = 'voice_memo', body = transkripsjon, metadata = { duration, transcription_status })
→ message_attachments → media_files (lydfilen) → 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: 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' }) → 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) - Diktering: `medium` + `initial_prompt` (kvalitet prioritert — teksten er master)
### 4.4 Personlig "Innboks"-channel ### 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 ```json
{ {
@ -130,8 +130,8 @@ Ved workspace-opprettelse opprettes en privat channel per bruker for usorterte l
## 7. Instruks for Claude Code ## 7. Instruks for Claude Code
* Opptakskomponenten skal være en gjenbrukbar Svelte-komponent med modus-prop (`voice_memo` / `dictation`). * 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. * Diktering: slett lydfilen først *etter* at brukeren har godkjent den ryddede teksten.
* `voice_memo` er en ny `message_type` — utvid enum i migrasjonen. * `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). * Personlig innboks-channel opprettes automatisk for nye brukere.
* Alt er workspace-scopet. * Tilgang styres via `node_access`-matrisen.

View file

@ -50,7 +50,7 @@ CREATE TABLE messages (
body TEXT NOT NULL, body TEXT NOT NULL,
metadata JSONB, -- Ekstra data per message_type metadata JSONB, -- Ekstra data per message_type
pinned BOOLEAN NOT NULL DEFAULT false, 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, edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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:** **Forskjeller fra gammel `messages`-tabell:**
- `id` er FK til `nodes(id)`**alle meldinger er noder** i kunnskapsgrafen - `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 - `channel_id` er nullable — notater og standalone-bokser trenger ikke en channel
- `title` er førsteklasses felt — brukes av kanban-kort, notater, kalenderhendelser - `title` er førsteklasses felt — brukes av kanban-kort, notater, kalenderhendelser
- `pinned` for manuell fritak fra TTL-sletting - `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` ### 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. Eierskap, kurasjon og prominens
### 8.1 Eierskap ### 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) ### 8.2 Kurasjon (TODO — UI-features, bygges inkrementelt)
Datamodellen trenger ikke endres for disse — alt håndteres via `messages.metadata` (JSONB): 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. TTL og livsløp
### 9.1 To-trinns fading ### 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. 2. **Slettet** — etter tilleggsperiode (dobbel TTL) fjernes raden permanent.
### 9.2 Alder som dynamisk faktor ### 9.2 Alder som dynamisk faktor
@ -260,7 +260,7 @@ Lever boksen, lever alt under den — svar beholdes uansett alder.
### 9.4 Konfigurerbarhet ### 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 └── Channel kan overstyre: config.ttl_days
└── Individuelle meldinger frittes via reglene over └── 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. - **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. - **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. - **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. - **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. - **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. - **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. - **Tilgang:** Styres via `node_access`-matrisen. Visibility håndteres i applikasjonskode (SvelteKit): `WHERE visibility = 'shared' OR author_id = $current_user`.
- **Visibility:** Default `'workspace'`. Sett `'private'` for personlige kladder. Endre til `'workspace'` for å dele — ingen kopiering nødvendig. - **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 ### Implementert
- Migrering `0004_notes.sql`: `notes`-tabell (FK→nodes) - 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") - Auto-save med 500ms debounce (visuell feedback: "Lagrer..."/"Lagret")
- REST API: GET og PATCH (tittel + innhold) - REST API: GET og PATCH (tittel + innhold)
- PG polling-adapter med 10 sek intervall (tregere enn chat/kanban — notater endres sjeldnere) - 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 - Polling (10 sek) henter siste versjon, men hopper over overskriving mens `saving`-flagget er satt
## 7. Instruks for Claude Code ## 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 * Auto-save bruker debounce — ikke send PATCH ved hvert tastetrykk
* `updated_at` brukes til UI-feedback, ikke til conflict resolution (ennå) * `updated_at` brukes til UI-feedback, ikke til conflict resolution (ennå)
* Sjekk `docs/erfaringer/adapter_moenster.md` for hybrid-strategi * 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. 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 ## 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. 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. 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 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 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. * **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 ## 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. * 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). * 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. * 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 # Feature: Prompt-Laboratorium
**Filsti:** `docs/features/prompt_lab.md` **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 ## 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 ## 2. Problemet det løser
- Modellkvalitet på norsk varierer kraftig mellom leverandører og versjoner. - 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) ### 3.1 Playground (Ad-hoc testing)
1. Bruker velger en **jobbtype** (research_clip, whisper_postprocess, metadata_extract, dictation_cleanup, etc.). 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. 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`). 5. Velger **modeller** å teste mot (f.eks. `sidelinja/rutine` + `sidelinja/resonering`).
6. Kjører testen — ser resultatene side-om-side. 6. Kjører testen — ser resultatene side-om-side.
7. Kan iterere: endre prompten, kjør igjen, sammenlign. 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 ### 3.3 Deploy
Når en prompt er verifisert: Når en prompt er verifisert:
1. Bruker klikker "Deploy til workspace". 1. Bruker klikker "Deploy".
2. Prompten skrives til `workspaces.settings.llm_prompts[jobbtype]`. 2. Prompten skrives til samlings-nodens metadata (`metadata.llm_prompts[jobbtype]`).
3. Alle fremtidige jobber av den typen bruker den nye prompten. 3. Alle fremtidige jobber av den typen bruker den nye prompten.
4. Tidligere prompt lagres i en `prompt_history`-logg for rollback. 4. Tidligere prompt lagres i en `prompt_history`-logg for rollback.
@ -41,7 +43,7 @@ Når en prompt er verifisert:
```sql ```sql
prompt_test_runs ( prompt_test_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, job_type TEXT NOT NULL,
system_prompt TEXT NOT NULL, system_prompt TEXT NOT NULL,
model_alias TEXT NOT NULL, model_alias TEXT NOT NULL,
@ -53,7 +55,7 @@ prompt_test_runs (
created_at TIMESTAMPTZ NOT NULL DEFAULT now() 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. `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 ```sql
prompt_history ( prompt_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, job_type TEXT NOT NULL,
system_prompt TEXT NOT NULL, system_prompt TEXT NOT NULL,
deployed_by TEXT NOT NULL REFERENCES users(authentik_id), 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 | | `prompt_eval` | (varierer) | Batch-evaluering av testsett mot valgte modeller |
## 7. Instruks for Claude Code ## 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. * 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. * Batch-evaluering kjøres via jobbkø for å unngå timeouts.
* Vis alltid token-bruk og latens — dette er et kostnadsbevisst verktøy. * 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. * Testdata (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. * `prompt_test_runs` og `prompt_history` er knyttet til en samlings-node og trenger ikke RLS — de er kun tilgjengelige for admins via applikasjonslogikk.
* Alt er workspace-scopet. * Tilgang styres via `node_access`-matrisen.

View file

@ -88,7 +88,7 @@ Høyreklikk kort → "Send til..." →
└── 🎬 Storyboard: Episode 47 └── 🎬 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 ### 3.3 Flytt vs. kopier
@ -148,7 +148,6 @@ pub struct MessagePlacement {
pub context_id: String, pub context_id: String,
pub entered_at: Timestamp, pub entered_at: Timestamp,
pub position_json: String, // JSON-serialisert posisjon pub position_json: String, // JSON-serialisert posisjon
pub workspace_id: String,
} }
#[reducer] #[reducer]

View file

@ -17,4 +17,4 @@ En interaktiv graf-visning i SvelteKit som gjør Kunnskapsgrafen visuelt naviger
## 4. Instruks for Claude Code ## 4. Instruks for Claude Code
* Design JSON-responsen slik at den lett kan mates inn i graf-visualiseringsbiblioteker (D3.js, Vis.js). * 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. * 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 ## 3. Sanntidssynkronisering
* **SpacetimeDB** synkroniserer strøk (penseltype, farge, koordinater) mellom alle deltakere i sanntid. * **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. * Tilgangskontroll følger konteksten: møterom-deltakere, tema-medlemmer, eller kun eieren for personlige tavler.
## 4. Funksjonalitet ## 4. Funksjonalitet
@ -24,11 +24,11 @@ Et delt, sanntids tegnebrett for frihåndsskisser, diagrammer og visuell brainst
## 5. Lagring og Eksport ## 5. Lagring og Eksport
* **Sanntidsdata (SpacetimeDB):** Strøk-historikk holdes i minnet så lenge tavlen er aktiv. * **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. * **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 ## 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. * 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`. * 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. * 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` **Filsti:** `docs/infra/ai_gateway.md`
## 1. Konsept ## 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: Fordeler:
* **BYOK (Bring Your Own Key):** Direkte API-nøkler til Anthropic, Google, xAI — ingen markup * **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 ### 3.2 Datamodell
```sql ```sql
-- Globale modellaliaser (server-nivå, ikke per workspace) -- Globale modellaliaser (server-nivå)
CREATE TABLE ai_model_aliases ( CREATE TABLE ai_model_aliases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
alias TEXT NOT NULL, -- 'sidelinja/rutine', 'sidelinja/resonering' alias TEXT NOT NULL, -- 'sidelinja/rutine', 'sidelinja/resonering'
@ -180,14 +180,14 @@ tests/prompts/
## 6. Tokenregnskap og kostnadskontroll ## 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: Rust-workeren logger tokenforbruk etter hvert AI-kall. Dataen lagres i PG:
```sql ```sql
CREATE TABLE ai_usage_log ( CREATE TABLE ai_usage_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, job_id UUID REFERENCES job_queue(id) ON DELETE SET NULL,
model_alias TEXT NOT NULL, -- 'sidelinja/rutine' model_alias TEXT NOT NULL, -- 'sidelinja/rutine'
model_actual TEXT, -- 'gemini/gemini-2.5-flash' (fra LiteLLM-respons) 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() 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:** **Flyten:**
1. Rust-worker sender AI-kall via gateway, får tilbake `usage` i responsen 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) 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`):** **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):** **Samlings-node (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. 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: 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 - Rust-worker sjekker aggregert forbruk før AI-kall
- Ved budsjett nær: fall tilbake til `sidelinja/rutine` (billigste) - 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 ### 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). 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 ( CREATE TABLE job_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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' job_type TEXT NOT NULL, -- 'whisper_transcribe', 'openrouter_analyze', 'stats_parse', 'research_clip'
payload JSONB NOT NULL, -- Inputdata (filsti, tekst, tema_id, etc.) payload JSONB NOT NULL, -- Inputdata (filsti, tekst, tema_id, etc.)
status job_status NOT NULL DEFAULT 'pending', 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 | | `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 | | `generate_waveform` | Waveforms (proposal) | Generer audio-peaks fra lydfil for visuell bølgeform |
## 6. Workspace-isolasjon ## 6. Tilgangsisolasjon
Alle jobber merkes med `workspace_id`. Rust-workers kjører som superuser (bypasser RLS) og sikrer isolasjon i applikasjonskode: Alle jobber merkes med `collection_node_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 * Worker leser `collection_node_id` fra jobben og bruker det til å lagre resultater tilbake i riktig samlings-node
* Workspace-spesifikk config (AI-prompts, navnelister) hentes fra `workspaces.settings` * Per samlings-node config (AI-prompts, navnelister) hentes fra samlings-nodens JSONB-metadata
* Feilede jobber vises kun for brukere i riktig workspace i admin-visningen * Feilede jobber vises kun for brukere med tilgang til samlings-noden (via node_access) i admin-visningen
## 7. Observabilitet ## 7. Observabilitet
- Jobber med `status = 'error'` skal være synlige i admin-visningen (SvelteKit `/admin/jobs`) - 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>` - Hver jobbtype implementeres som en handler-funksjon som registreres i en `HashMap<String, Handler>`
- Bruk `tokio` med semaphore for concurrency-kontroll (`--max-concurrent`) - Bruk `tokio` med semaphore for concurrency-kontroll (`--max-concurrent`)
- Aldri lagre lydfiler i `payload` — bruk filstier - 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 - 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 - 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 # Synkronisering: SpacetimeDB ↔ PostgreSQL
**Filsti:** `docs/infra/synkronisering.md`
## Konsept
## 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. SpacetimeDB holder hele grafen (alle noder og edges) i minne.
PostgreSQL er persistent backup og arkiv. Skriving går til begge.
### 1.1 Grunnregel: SpacetimeDB er en ren sanntidsbuffer Lesing går fra SpacetimeDB.
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). GUI (SvelteKit)
│ skriv │ les (sanntid)
**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. ▼ ▼
Maskinrommet (Rust) SpacetimeDB ──→ GUI
### 1.2 PostgreSQL-fallback-prinsippet │ ▲
Dersom SpacetimeDB fjernes fra stacken, skal systemet fungere med følgende erstatning: ├──→ PostgreSQL (persistent) │
- **Sanntidsoppdateringer:** PostgreSQL `LISTEN/NOTIFY` → SvelteKit Server-Sent Events (SSE) └──→ SpacetimeDB ───────────┘
- **Skriving:** Direkte til PostgreSQL via SvelteKit server-side ```
- **Autocomplete/cache:** PostgreSQL-spørringer med cursor-basert paginering
## Hvorfor hele grafen i SpacetimeDB
Denne fallbacken trenger ikke implementeres på forhånd, men SpacetimeDB-moduler skal designes slik at fallbacken forblir triviell.
Node- og edge-skjemaet er minimalt: åtte kolonner på nodes, åtte
## 2. Strategi: Event-drevet med kort forsinkelse på edges. For en liten brukerbase er hele grafen triviell å holde
SpacetimeDB-modulene (Rust) produserer persisterings-events ved dataendringer. En Rust-worker konsumerer disse og skriver til PostgreSQL med ~1 sekunds intervall. i minne. Dette forenkler alt:
**Akseptabelt datatap:** Maks 1 sekund ved hard krasj av SpacetimeDB. Dette er akseptabelt for chat, kanban og show notes. - Ingen sync-logikk for å bestemme hva som er "aktivt"
- Ingen henting fra PG når noen åpner noe gammelt
**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`. - Frontend har alltid alt tilgjengelig via WebSocket
- Én lesekilde — ingen tvetydighet
## 3. Dataflyt
Når dette *blir* problematisk (hundretusenvis av noder, minnepress),
``` innføres eviction basert på aksessmønstre. Men det er en
┌──────────────┐ events ┌──────────────┐ batch write ┌──────────────┐ optimaliseringsbeslutning, ikke en arkitekturbeslutning. Modellen
│ SpacetimeDB │ ──────────────► │ Rust Worker │ ────────────────► │ PostgreSQL │ endres ikke.
│ (sanntid) │ │ (sync_to_pg) │ │ (persistent)│
└──────────────┘ └──────────────┘ └──────────────┘ ## Skrivestien
┌──────────────┐ oppvarming (oppstart / reconnect) ┌──────────────┐ Maskinrommet validerer, så skriver i to steg:
│ PostgreSQL │ ──────────────────────────────────────► │ SpacetimeDB │
└──────────────┘ └──────────────┘ 1. **Validering** — tilgangssjekk, unique-constraints, forretningsregler
``` 2. **SpacetimeDB først** — frontend oppdateres umiddelbart (~10μs)
3. **PG asynkront** — persistent backup i bakgrunnen
## 4. Eierskapsmodell
Frontend er allerede oppdatert mens PG-skrivingen skjer. Brukeren
| Data | Autoritativ kilde | Synkretning | Merknad | merker ingenting.
|---|---|---|---|
| Chatmeldinger | SpacetimeDB | → PG (event, batched) | | Hvis PG-skrivingen feiler: maskinrommet logger og prøver igjen.
| Kanban-posisjon | SpacetimeDB | → PG (event) | | SpacetimeDB har dataen — den er bare ikke persistert ennå. Ingen
| Show notes | SpacetimeDB | → PG (event) | | datatap så lenge SpacetimeDB kjører.
| Live studio-markører | SpacetimeDB | → PG (event) | |
| Kunnskapsgraf | PostgreSQL | → SpacetimeDB (oppvarming) | Read-only i SpacetimeDB | ## Lesestien
| Episodemetadata | PostgreSQL | Ingen synk | |
| Brukerkontoer | PostgreSQL (Authentik) | Ingen synk | | Frontend leser **kun fra SpacetimeDB** via WebSocket-subscriptions.
| Statistikk | PostgreSQL | Ingen synk | | Ingen PG-kall fra frontend for data som vises i sanntid.
| Valgomat | TBD | TBD | Konseptet må modnes. Mulig PG-autoritativ med SpacetimeDB som serveringslag |
Unntak: tunge spørringer som ikke passer sanntidslaget — statistikk,
## 5. Mekanisme fulltekstsøk, pgvector-søk, AGE-traverseringer. Disse går
GUI → Maskinrommet → PG.
### 5.1 SpacetimeDB → PostgreSQL (persistering)
- SpacetimeDB-reducerne legger sync-events i `sync_outbox`-tabellen med tidsstempel og payload ## Oppvarming (PG → SpacetimeDB)
- 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 Ved oppstart av SpacetimeDB laster maskinrommet hele grafen fra PG:
### 5.2 PostgreSQL → SpacetimeDB (oppvarming) 1. Alle noder
- Ved oppstart laster Rust-workeren data fra PG per kanal, basert på `channels.config`: 2. Alle edges
- `warmup_mode: "all"` — alle meldinger i kanalen 3. `node_access`-matrisen
- `warmup_mode: "messages"` — de N nyeste *trådene* (komplett med svar)
- `warmup_mode: "days"` — alle tråder med aktivitet siste N dager (komplett) Rekkefølge: noder først (edges refererer til noder).
- `warmup_mode: "none"` — kanalen hoppes over (arkivert/inaktiv) CAS-noder lastes uten binærinnhold (bare metadata).
- 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 ## Feilhåndtering
- Kanalen ryddes (`clear_channel`) før lasting for å unngå duplikater ved restart
- Reaksjoner lastes også inn via `load_reactions`-reducer | Scenario | Konsekvens | Håndtering |
|----------|-----------|------------|
## 6. Feilhåndtering | SpacetimeDB krasjer | Sanntid forsvinner, upersistert data kan tapes | Restart + oppvarming fra PG. Tap begrenset til skrivinger som ikke rakk PG. |
- **SpacetimeDB krasjer:** Data siden siste synk (~1 sek) tapes. Ved restart oppvarmes fra PG | PG nede | Persistering stopper | SpacetimeDB serverer lesing og mottar skrivinger. PG-backlog tas igjen ved recovery. |
- **PostgreSQL nede:** Sanntidsfunksjoner fortsetter å fungere. `sync_outbox` vokser. Workeren logger advarsler. Ved PG-recovery synkes backloggen automatisk | Maskinrommet krasjer | Ingen skriving | Frontend ser siste state. Restart plukker opp. |
- **Rust-worker krasjer:** `sync_outbox` akkumuleres. Ved restart plukker workeren opp der den slapp (usynkede events har ingen markering)
## SpacetimeDB som utbyttbar
## 7. Workspace-isolasjon
SpacetimeDB-synkronisering er fullstendig workspace-scopet: SpacetimeDB er en sanntidscache, ikke en avhengighet. Hvis den
* SpacetimeDB-tilkoblinger bærer `workspace_id` som context-token. Modulen partisjonerer minnet og kringkaster kun til klienter i samme workspace. fjernes fra stacken:
* `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. - **Sanntid:** PG `LISTEN/NOTIFY` → SvelteKit SSE
- **Skriving:** Maskinrommet → PG direkte
## 8. Konflikthåndtering - **Lesing:** Maskinrommet → PG → GUI
### 8.1 Strategi: Last-Write-Wins (LWW) med reservasjoner Denne fallbacken trenger ikke implementeres, men arkitekturen
SpacetimeDB er autoritativ for sanntidsdata. Konflikter kan oppstå i to scenarioer: skal aldri gjøre den umulig.
**Scenario A — Samtidig redigering i SpacetimeDB:** ## Tilgangsmatrise i SpacetimeDB
SpacetimeDB er single-threaded per modul. Reducer-funksjoner serialiseres automatisk. Ingen klassiske race conditions.
`node_access`-matrisen lastes inn i SpacetimeDB ved oppvarming.
**Scenario B — PG-oppvarming kolliderer med fersk SpacetimeDB-state:** Når maskinrommet oppdaterer matrisen i PG (ved edge-endring),
Ved restart av SpacetimeDB lastes data fra PG (oppvarming). Hvis en klient skriver til SpacetimeDB *under* oppvarming, kan PG-dataen overskrive ferske endringer. oppdaterer den også SpacetimeDB. SpacetimeDB-modulen bruker
matrisen for å filtrere subscriptions — klienter ser kun noder
**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. de har tilgang til.
### 8.2 Kanban: Posisjonskonflikter ## Konflikthåndtering
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.
SpacetimeDB er single-threaded per modul — reducer-funksjoner
### 8.3 Chat: Ingen konflikter serialiseres automatisk. Ingen klassiske race conditions.
Meldinger er append-only. Redigering av egne meldinger er last-write-wins — akseptabelt fordi kun én bruker eier meldingen.
Samtidig redigering (to brukere endrer samme node): maskinrommet
## 9. Workers som endrer data frontend ser serialiserer via SpacetimeDB. Last-write-wins. SpacetimeDB
kringkaster resultatet til alle klienter. PG oppdateres asynkront
**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. med det endelige resultatet.
### 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

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 # 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é ## 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. 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. | | [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? | | [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 | | [Universell input og mottak](universell_input.md) | **Besluttet** | Én multimodal input-primitiv, én mottaksflate, kommunikasjonsnoder. Edges definerer alt. |
| [Maskinrommet](maskinrommet.md) | Åpen | Én Rust-tjeneste med fast grensesnitt for alle tekniske tjenester: fang, prosesser, lever | | [Maskinrommet](maskinrommet.md) | **Besluttet** | Én Rust-tjeneste: fang, prosesser, lever. Eier all skriving. Edge-drevet ressursorkestrering. |
| [Bruker, ikke workspace](bruker_ikke_workspace.md) | Åpen | Brukeren er sentrum, workspaces er frivillige samlings-noder — ikke containere | | [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) | Åpen | PG + Apache AGE som enhetlig graf og arkiv, SpacetimeDB som sanntidslag, CAS for binærdata | | [Datalaget](datalaget.md) | **Besluttet** | SpacetimeDB holder hele grafen, PG er persistent arkiv, CAS for binærdata, AGE ved behov |
## Format ## Format
- Hva er tesen? - Hva er tesen?

View file

@ -1,154 +1,327 @@
# Bruker, ikke workspace # Noder er sentrum
> Primærenheten er brukeren og brukerens edges. Workspaces er ikke **Status: Besluttet.**
> containere — de er frivillige samlings-noder som gir felles kontekst.
> Alt er noder. En bruker er en node. Et team er en node. Et møte er
## Observasjoner > en node. Sidelinja er en node. Relasjoner mellom dem er edges.
> Tilgangskontroll via materialisert tilgangsmatrise beregnet fra
I dag er workspaces den organisatoriske enheten. Du "er i" et workspace, > edge-grafen.
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 ## Beslutningen
*bor* i workspaces.
1. **Alt er noder.** Brukere, team, prosjekter, innhold, møter —
Men i node+edge-modellen gir dette ikke mening. En node er ikke "i" noe alt er rader i `nodes`-tabellen. En bruker er en node som
— den har edges til ting. Og brukeren er ikke "i" et workspace — brukeren tilfeldigvis kan logge inn.
har edges til noder, personer, topics, samtaler. 2. **Relasjoner er edges.** Vegard → Sidelinja (`owner`).
Trond → Sidelinja (`member`). Møtereferat → møte (`belongs_to`).
## Tesen 3. **Ingen containere.** Hva du ser er summen av dine edges.
4. **Samlings-noder gir struktur** — de er vanlige noder som
**Brukeren er sentrum.** Du logger inn og ser dine edges — alt du er fungerer som gravitasjonspunkt.
koblet til. Ikke "velg workspace" som første handling, men "her er alt 5. **Privat er default** — en node uten edges til andre er kun din.
ditt." 6. **Tilgangskontroll via `node_access`-matrise**, oppdatert ved
edge-endring, brukt av RLS ved lesing.
### Workspace som samlings-node, ikke container
## Visibility
Et workspace eksisterer fortsatt som konsept, men det er en node i
grafen — ikke en organisatorisk boks: Visibility er en egenskap på noden som definerer hva som gjelder
for alle *uten* eksplisitt edge. Eksplisitte edges overrider alltid
- **Tradisjonell modell:** Workspace inneholder kanaler, boards, filer. oppover.
Brukeren er "i" workspacet.
- **Node-modell:** Workspace er en node. Noder har edge til den. Brukeren ```sql
har edge til den. Workspace-noden bærer felles kontekst (tema, CREATE TYPE visibility AS ENUM ('hidden', 'discoverable', 'readable', 'open');
pruning-profil, AI-konfig). ```
En node kan ha edge til flere workspaces. En faktoid om en gjest er | Nivå | Oppdagbar | Lesbar | Interagerbar |
relevant for både podcast-prosjektet og research-samlingen. Ikke kopi — |------|-----------|--------|-------------|
samme node, to edges. | `hidden` | Nei | Nei | Nei — kun via eksplisitt edge |
| `discoverable` | Ja (søk/katalog) | Nei | Kontaktforespørsel |
### Personlig er default | `readable` | Ja | Ja | Nei — krever edge |
| `open` | Ja | Ja | Ja |
Alt uten workspace-edge eller mottaker-edge er privat — tilhører bare
deg. "Personlig workspace" er ikke en egen ting du oppretter. Det er Eksempler:
fravær av deling. Dine private notater, dagbok, voice memos — de har - Spøkelsesbruker: `hidden` — usynlig, kun invitasjon
bare edges til deg. - Katalogbruker: `discoverable` — finnes i søk, profil krever edge
- Podcast-episode: `readable` — alle kan lytte, bare teamet kan redigere
### Administrasjon er edges med roller - Kunnskapsgraf-entitet: `open` — alle kan se og bidra
Brukerstyring forvaltes i nodene selv, ikke i et sentralt admin-panel: ### Traverseringsregelen
| Edge-type | Hva den gir | **Visibility er en hard grense ved traversering.** Når du følger
|-----------|------------| edges i grafen, kan du bare se noder hvis deres visibility tillater
| Eier-edge | Full kontroll — slette, endre tilgang, endre innstillinger | det — eller du har en eksplisitt edge.
| Admin-edge | Kan invitere/fjerne andre, endre konfigurasjon |
| Deltaker-edge | Kan gi input og motta | Eksempel: Episode #42 (`readable`) har en `host`-edge til Peter
| Leser-edge | Kan kun motta (observatør, lytter) | (`hidden`). Anonym bruker leser episoden, følger `host`-edge →
Peter er `hidden`**stopp, usynlig.** Trond (som har edge til
Du oppretter en kommunikasjonsnode (podcast-prosjekt). Du er eier Peter) følger samme edge → **ser Peter.**
automatisk. Du inviterer Trond → deltaker-edge. Trond kan gi input
men ikke slette eller endre tilgang. Du gir Trond admin-edge → nå Ingen transitivitet bryter denne regelen. Uansett hvor mange
kan han invitere andre og endre innstillinger på den noden. offentlige noder som har edges til en `hidden` node, forblir den
skjult for de uten eksplisitt edge.
Dette skalerer fra en privat notat (kun eier-edge til deg) til en
organisasjon (samlings-node med mange admin- og deltaker-edges). ## Brukere er noder
### Brukeropplevelsen En brukernode er en node med en kobling til Authentik for
autentisering. Alt annet — navn, preferanser, roller, relasjoner —
Når du logger inn ser du: er noden og dens edges.
- **Dine aktive samtaler** — kommunikasjonsnoder med edge til deg
- **Dine noder** — alt du har skapt eller er koblet til `users`-tabellen krymper til autentisering:
- **Dine samlings-noder** (det som før var workspaces) — grupperer
kontekst, men du trenger ikke "gå inn i" dem ```sql
- **Din mottaksflate** — alt som er relevant for deg nå, vektet CREATE TABLE auth_identities (
node_id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
Du kan filtrere etter samlings-node hvis du vil fokusere ("vis bare authentik_sub TEXT UNIQUE NOT NULL, -- Authentik subject ID
podcast-prosjektet"), men det er et filter — ikke en modebytte. email TEXT UNIQUE NOT NULL
);
### Felles kontekst på samlings-noder ```
En samlings-node (workspace) bærer kontekst som arves av tilknyttede Brukerens profil, innstillinger og metadata lever på noden (JSONB)
noder: eller som edges. Autentiseringstabellen er en tynn bro mellom
"denne HTTP-sesjonen" og "denne noden i grafen."
- **Pruning-profil** — hvor aggressivt slettes binærdata?
- **Tema** — visuelt uttrykk (CSS custom properties) ## Aliaser
- **AI-konfigurasjon** — hvilke prompts, hvilken modell, hvilke regler
- **Tilgangsnivå** — default synlighet for nye noder opprettet i En bruker kan opprette aliaser — separate noder som representerer
denne konteksten en offentlig persona eller anonym identitet.
- **Kapasitet** — ressursgrenser for maskinrommet (f.eks. maks
samtidige transkripsjoner) ```
Aleks (hidden) ──alias──→ Bjørn (readable)
## Implikasjoner ```
### Kryssgående noder er naturlig Bjørn er en egen node med eget navn, egen profil, egen visibility.
En node med edge til to samlings-noder arver kontekst fra begge. Omverdenen ser bare Bjørn. `alias`-edgen er en **systemedge**
Konflikter (ulik pruning-profil) løses med prioriteringsregler: usynlig ved traversering, aldri eksponert utad. Ellers ville
mest konservativ vinner, eller eier-edge bestemmer. "hvem kontrollerer Bjørn?" lekke Aleks' identitet.
### Onboarding forenkles ### Én flate, kontekst bestemmer identitet
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. Aleks ser *alt* i sin mottaksflate — egne noder og alt som
kommer til Bjørn. Han svarer fra sin egen flate. Systemet
### Forlate et prosjekt er å fjerne edges bestemmer `created_by` basert på konteksten:
Ingen "slett bruker fra workspace." Fjern deltaker-edges. Brukerens
private noder som hadde edge til samlings-noden beholder den edgen - NN skrev til Bjørn → Aleks svarer → meldingen stemplet
(det er brukerens innhold), men de mister tilgang til andres noder. med Bjørn som `created_by`. Automatisk, ingen bytte.
- Vegard skrev til Aleks direkte → svaret kommer fra Aleks.
## RLS og sikkerhet: samlings-noder som harde siloer
**Ingen manuell identitetsbytte.** Aleks trenger aldri å "logge
Viktig arkitekturbeslutning: **"bruker, ikke workspace" er en inn som Bjørn" eller bytte modus. Konteksten (hvilken samtale,
UX-modell, ikke en sikkerhetsmodell.** hvilken kanal, hvem mottakeren kjenner) bestemmer hvilken node
som er avsender.
Ren edge-basert tilgangskontroll — der hver lesing traverserer grafen
for å sjekke om brukeren har en sti til noden — ville kvelt ### Identitetsvalg ved tvetydighet
databasen når grafen vokser. I dag har PG bunnsolid RLS med
`workspace_id = current_setting(...)` — instant og vanntett. Noen ganger kjenner mottakeren *både* personen og aliaset.
Vegard kjenner Aleks og vet hvem Bjørn er. Hvis Vegard sender
Løsningen er å skille UX fra sikkerhet: en melding til Bjørn under en episode, og Aleks svarer:
- **UX-laget:** Brukeren ser sine edges. Ingen "velg workspace."
Filtrering etter samlings-node er frivillig. - **Default:** svaret kommer fra Bjørn (konteksten er Bjørn).
- **Sikkerhetslaget:** Under panseret har hver node fortsatt en - **Valg:** systemet vet at Vegard har edge til Aleks. Aleks
`workspace_id` (eller samlings-node-id) som RLS sjekker. Det er kan velge "svar som Aleks" — da startes en ny, separat
den harde sikkerhetsgensen — billig, velprøvd, instant. samtale mellom Vegard og Aleks.
- **Finere tilgang innenfor siloen:** Edge-basert tilgang (eier,
admin, deltaker, leser) sjekkes *innenfor* en silo, ikke som **Alias-grensen brytes aldri automatisk.** Bare med eksplisitt
erstatning for den. handling fra personen bak aliaset.
Samlings-noder er altså ikke bare frivillig organisering — de er ### Flere aliaser
sikkerhetsiloer i databasen. Brukeren merker det ikke, men PG
trenger det. Ingenting stopper en bruker fra å ha flere aliaser — Bjørn for
podcast, et anonymt alias for publisering, seg selv for privat.
## Spenninger og åpne spørsmål Hver er en node med en `alias`-edge fra brukernoden. Alle samles
i én mottaksflate.
- **Overblikk.** Uten workspaces som organisatorisk enhet — hvordan
unngår du at alt flyter sammen? Samlings-noder er svaret, men de ### Alias vs. owner
må være intuitive å opprette og bruke uten å bli "workspaces med
ny navn." | Edge | Betydning | Eksempel |
- **Kryssgående noder og siloer.** Hvis noder har `workspace_id` for |------|-----------|----------|
RLS, kan en node da tilhøre to samlings-noder? Kanskje via en | `alias` | Jeg *er* denne noden. Usynlig systemedge. | Aleks → Bjørn |
"primær silo" for RLS + sekundære edges for visning. Trenger | `owner` | Jeg *forvalter* denne noden. Synlig for medlemmer. | Vegard → Sidelinja |
gjennomtenkt design.
- **Arv og konflikter.** En node med edge til to samlings-noder med `alias` = identitet. `owner` = ansvar. Aleks *er* Bjørn. Vegard
ulike pruning-profiler — hva vinner? Trenger klare regler som er *eier* Sidelinja, men han *er* ikke Sidelinja.
intuitive for brukeren.
- **Migrering.** Eksisterende workspace-modell har innhold. Kan ## Team er noder
workspaces bli samlings-noder gradvis? RLS-modellen gjør dette
enklere — workspace_id kan bli samlings-node-id uten endring i Et team er en node med `member`-edges til brukernoder. Gi teamet
sikkerhetslogikk. tilgang til en samlings-node → alle teammedlemmer arver tilgang
via transitiv traversering. Ingen egen team-mekanisme.
## Forhold til andre retninger
```
- [Rom, ikke forum](rom_ikke_forum.md) — "rommet" er ikke et workspace, Vegard (node) ──member──→ Podcastteamet (node) ──member──→ Sidelinja (node)
det er summen av dine edges Trond (node) ──member──→ Podcastteamet (node)
- [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 Trond får tilgang til alt under Sidelinja fordi han er medlem av
kontekst (pruning, kapasitet, AI-konfig) 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 # Datalaget
> PostgreSQL er den eneste databasen. Apache AGE gir Cypher-semantikk **Status: Besluttet.**
> for graftraverseringer. SpacetimeDB er sanntidslaget over samme data.
> Én database, to tilgangsmønstre, ingen eierskapskonflikt. > SpacetimeDB holder hele grafen i minne og mottar skrivinger først.
> PostgreSQL er persistent arkiv og backup. CAS lagrer binærdata.
## Observasjoner > Apache AGE legges til ved behov for Cypher-traverseringer.
Hele retningsarbeidet har bygget seg mot "alt er noder og edges." ## Lagmodell
Tilgang er edges, visninger er spørringer, administrasjon er
traversering. Spørsmålet er: hvilken teknologi støtter dette best? ```
GUI (SvelteKit)
### Neo4j ble vurdert og forkastet │ skriv │ les (sanntid, WebSocket)
▼ ▼
Neo4j er best-in-class for dype graftraverseringer, men for dette Maskinrommet (Rust) SpacetimeDB ──→ GUI
prosjektet gir den mer problemer enn den løser: │ validering ▲
├──→ SpacetimeDB (først) ───┘
- **Tredje database.** PG + SpacetimeDB + Neo4j = tre systemer å └──→ PostgreSQL (asynk, persistent)
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 ### Skrivestien
uansett, og det lever i PG. Med Neo4j trenger vi PG *i tillegg*. GUI → Maskinrommet → validering → SpacetimeDB (instant) → PG (asynk).
- **Minnehungrig.** Neo4j anbefaler 8-16GB heap. På en delt VPS med Frontend oppdateres umiddelbart. PG persisterer i bakgrunnen.
PG, SpacetimeDB, Whisper og LiteLLM er det for mye.
- **Lisens.** Community er AGPLv3. Enterprise-features (clustering, ### Lesestien (sanntid)
avansert sikkerhet) krever betalt lisens. SpacetimeDB → GUI (direkte WebSocket, ~10μs).
Hele grafen er i SpacetimeDB. Frontend har alltid alt tilgjengelig.
### Apache AGE — Cypher i PG
### Lesestien (tunge spørringer)
Apache AGE er en PG-extension som legger til openCypher-støtte. Noder GUI → Maskinrommet → PG.
og edges lagres i PG-tabeller, men spørres med Cypher-semantikk. Fulltekstsøk, pgvector (semantisk søk), statistikk, AGE-traverseringer.
**Oppsider:** ## SpacetimeDB — hele grafen i minne
- **Én database.** Ingen ny infrastruktur. Samme backup, connection
pooling, tooling, monitoring. Node- og edge-skjemaet er minimalt (åtte kolonner hver). For en
- **SQL + Cypher i samme spørring.** "Finn alle noder brukeren har liten brukerbase er hele grafen triviell å holde i minne. Fordeler:
tilgang til via team-traversering, JOINet med fulltekstsøk og
pgvector." Det kan du ikke gjøre med en separat grafdatabase. - Ingen sync-logikk for å bestemme hva som er "aktivt"
- **Null migrasjonskostnad.** Eksisterende nodes/edges-tabeller kan - Ingen henting fra PG når noen åpner noe gammelt
eksponeres som graf. Legg til extension, ikke bytt database. - Frontend har alltid alt via WebSocket
- **Økosystemet bevares.** pgvector, fulltekstsøk, JSONB, pg_cron — - Én lesekilde — ingen tvetydighet
alt fungerer side om side med Cypher-spørringer.
- **Produksjonsbruk.** Azure og EDB tilbyr AGE som managed extension. Når dette blir problematisk (hundretusenvis av noder), innføres
eviction. Det er en optimalisering, ikke en arkitekturendring.
**Nedsider:**
- **Ikke native graf.** Under panseret er det PG-tabeller. For svært ## PostgreSQL — arkiv og kraftspørringer
dype traverseringer (10+ hopp over millioner av noder) er Neo4j
raskere. Men de fleste spørringene våre er 1-5 hopp. PG er persistent backup for hele grafen, pluss hjemsted for
- **Yngre prosjekt.** Mindre community, tynnere dokumentasjon enn Neo4j. tunge operasjoner SpacetimeDB ikke er laget for:
- **Quirks.** Spørringer må være enten read-only eller write-only
(WITH-clause for overgang). Noen pg_upgrade-begrensninger. - **Fulltekstsøk**`tsvector``nodes.content` og `nodes.title`
- **Semantisk søk** — pgvector for embedding-basert likhet
## Beslutning - **Graftraversering** — rekursive CTEs, Apache AGE ved behov
- **Statistikk** — aggregeringer, tidsserier
**PostgreSQL** som enhetlig datalager for noder og edges. - **Tilgangsmatrise**`node_access` beregnes her, speiles til STDB
**Apache AGE** som planlagt utvidelse — ikke forpliktet fra dag én.
### Apache AGE — ved behov
De fleste spørringer i dette systemet er grunne:
- "Vis noder med edge til denne brukeren" — 1 hopp De fleste spørringer er grunne (1-3 hopp) og håndteres av CTEs.
- "Sjekk tilgang via team" — 2 hopp AGE legges til som PG-extension når Cypher-semantikk faktisk trengs:
- "Finn alle kommunikasjonsnoder brukeren har tilgang til" — 2-3 hopp
- "Traverser kunnskapsgrafen mellom to emner" — 3-5 hopp 1. **Nå:** PG med nodes/edges-tabeller og CTEs
2. **Når CTEs blir smertefulle:** Legg til AGE
PG med rekursive CTEs håndterer 1-3 hopp utmerket. AGE legges til 3. **Usannsynlig:** Evaluer Neo4j hvis AGE ikke holder
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 AGE er en extension, ikke en migrering — boltes på uten å endre
å endre eksisterende kode. eksisterende kode.
Pragmatisk rekkefølge: ## CAS — binærlagring
1. **Nå:** PG med nodes/edges-tabeller og CTEs
2. **Når CTEs blir smertefulle:** Legg til AGE for Cypher-spørringer Lyd, bilde, video lagres content-addressable på disk. CAS-noder
3. **Usannsynlig:** Evaluer Neo4j hvis AGE ikke holder i grafen bærer metadata (`cas_hash`, `mime`, `size_bytes`).
Selve biten lever utenfor PG.
## Lagmodell
Pruning-regler basert på modalitet, edges og aksessmønstre.
``` Se [maskinrommet](maskinrommet.md).
┌─────────────────────────────────────┐
│ GUI (SvelteKit) │ ## SpacetimeDB som utbyttbar
│ Primitiver, visninger │
└────────┬──────────────────┬─────────┘ SpacetimeDB er en sanntidscache, ikke en avhengighet. Hvis den
│ skriv │ les (sanntid) fjernes:
│ │ direkte WebSocket
┌────────▼────────┐ ┌─────▼─────────┐ - **Sanntid:** PG `LISTEN/NOTIFY` → SvelteKit SSE
│ Maskinrommet │ │ SpacetimeDB │──┐ - **Skriving:** Maskinrommet → PG direkte
│ (Rust) │ │ (STDB) │ │ - **Lesing:** Maskinrommet → PG → GUI
│ Orkestrering │ └───────────────┘ │
└──┬─────┬─────┬──┘ │ Trenger ikke implementeres, men arkitekturen skal aldri gjøre
│ │ │ sync ↕ │ det umulig. Se [synkronisering](../infra/synkronisering.md).
▼ ▼ ▼ │
┌─────┐┌─────┐┌─────┐┌─────────────┐ │ ## Forhold til andre retninger
│ PG ││STDB ││ CAS ││ Whisper, │ │
│+AGE ││(skr)││ ││ LiteLLM, │ │ - [Noder er sentrum](bruker_ikke_workspace.md) — tilgangsmatrise
│ ││ ││ ││ LiveKit ... │ │ 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
### Skriv vs les — to stier med god grunn ressursorkestrering, validering før skriving
**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

View file

@ -1,322 +1,196 @@
# Maskinrommet — teknisk tjenestelaget # Maskinrommet
> Én Rust-tjeneste med et fast grensesnitt. Alle tekniske tjenester beveger **Status: Besluttet.**
> seg gjennom dette laget. Fang, prosesser, lever.
> Én Rust-tjeneste med et fast grensesnitt. Alt som krever tunge
## Observasjoner > ressurser eller eksterne tjenester går gjennom dette laget.
> Fang, prosesser, lever. Maskinrommet er det eneste som skriver
I dag er tekniske tjenester spredt: > noder og edges.
- **Worker** (Rust) — kjører bakgrunnsjobber
- **Jobbkø** (PG) — koordinerer arbeid ## Tre operasjoner
- **AI Gateway** (LiteLLM) — ruter AI-kall
- **Whisper** — transkripsjon ### 1. Fang (input-absorpsjon)
- **LiveKit** — lyd/video-strømmer Ta imot råmateriale i alle modaliteter:
- Tekst (melding, URL, dokument)
Hver har sitt eget grensesnitt. Frontend og primitiv-laget må vite hva - Lyd (voice memo, live stream, filopplasting)
som finnes under panseret. Det er ingen felles abstraksjon, ingen felles - Bilde (foto, skjermbilde, tegning)
logging, ingen felles kapasitetsstyring. - Video (stream, opptak)
- Strukturert data (JSON, metadata, edges)
## Tesen
### 2. Prosesser (transformasjon)
Alt som krever tunge ressurser eller eksterne tjenester går gjennom Analyser, transformer, berik og systematiser:
**ett lag** med **ett grensesnitt**. Ikke fordi det er elegant — fordi - **STT** — lyd → tekst (Whisper)
det gir et fast punkt som er enkelt å fange, modifisere og forbedre. - **TTS** — tekst → lyd (ElevenLabs / lokal modell)
- **AI-analyse** — oppsummering, klassifisering, edge-forslag
Maskinrommet gjør tre ting: - **Beriking** — URL → metadata, bilde → beskrivelse
- **Søk** — fulltekst, semantisk (pgvector), graftraversering
### 1. Fang (input-absorpsjon) - **Mediaprosessering** — transcode, thumbnail, waveform
Ta imot råmateriale i alle modaliteter:
- Tekst (melding, URL, dokument) ### 3. Lever (output-distribusjon)
- Lyd (voice memo, live stream, filopplasting) Lever resultat i riktig modalitet til riktig mottaker:
- Bilde (foto, skjermbilde, tegning) - Tekst (melding, notifikasjon, digest)
- Video (stream, opptak) - Lyd (TTS-opplesning, lydstream)
- Strukturert data (JSON, metadata, edges) - Video/bilde (stream, thumbnail)
- Strukturert data (noder, edges tilbake i grafen)
### 2. Prosesser (transformasjon) - Push (SpacetimeDB-reducer)
Analyser, transformer, berik og systematiser:
- **STT** — lyd → tekst (Whisper) ## Maskinrommet eier all skriving
- **TTS** — tekst → lyd (ElevenLabs / lokal modell)
- **AI-analyse** — oppsummering, klassifisering, sentimentanalyse, Frontend sender intensjoner. Maskinrommet utfører.
faktasjekk, edge-forslag
- **Beriking** — URL → metadata, bilde → beskrivelse, lyd → segmenter ```
- **Søk** — fulltekst, semantisk (pgvector), graftraversering Frontend: "legg til Trond i møtet"
- **Mediaprosessering** — transcode, thumbnail, waveform → Maskinrommet validerer
→ Skriver edge til SpacetimeDB (instant)
### 3. Lever (output-distribusjon) → Oppdaterer tilgangsmatrise
Lever resultat i riktig modalitet til riktig mottaker: → Persisterer til PG (asynk)
- Tekst (melding, notifikasjon, digest) → Reagerer på konsekvensene (koble inn LiveKit, starte transkripsjon)
- Lyd (TTS-opplesning, lydstream) ```
- Video/bilde (stream, thumbnail, snapshot)
- Strukturert data (noder, edges, metadata tilbake i grafen) Alt i én operasjon. Maskinrommet er ikke reaktivt i en pub/sub-
- Push (webhook, SSE, SpacetimeDB-reducer) forstand — det orkestrerer hele sekvensen. Enklere å forstå,
enklere å debugge.
## Edge-drevet ressursorkestrering
Skrivestien: validering → SpacetimeDB (instant) → PG (asynk).
Nøkkelinnsikten: **maskinrommet leser edges for å vite hva det skal gjøre.** Se [synkronisering](../infra/synkronisering.md).
Noden selv er alltid enkel. Det er edgene som bestemmer hvilke ressurser
som spinnes opp. ## Edge-drevet ressursorkestrering
### Security by default Maskinrommet leser edges for å vite hva det skal gjøre. Noden
Input uten mottaker-edge er automatisk privat. Du trenger ikke "velge er alltid enkel. Edges bestemmer hvilke ressurser som spinnes opp.
privat" — det er utgangspunktet. Ingen ser det. Ingen ressurser kobles
inn utover det grunnleggende (fang + transkriber). Privat er ikke en ### Privat er default
innstilling, det er fravær av deling. Input uten mottaker-edge er automatisk privat. Ingen ressurser
kobles inn utover det grunnleggende (fang + transkriber).
### Ressurser er proporsjonale med edges
Samme nodetype, vilt forskjellig ressursbruk: ### Ressurser er proporsjonale med edges
``` ```
Dagboknotat (privat voice memo): Dagboknotat (privat voice memo):
node → fang lyd → transkriber (Whisper) → lagre node → fang lyd → transkriber (Whisper) → lagre
Ressurser: minimal Ressurser: minimal
Samtale med Trond: Samtale med Trond:
node + mottaker-edge(Trond) node + mottaker-edge(Trond)
→ fang lyd → transkriber → lever tekst/lyd til Trond → fang lyd → transkriber → lever tekst/lyd til Trond
Ressurser: STT + levering til én Ressurser: STT + levering til én
Redaksjonsmøte (5 deltakere): Redaksjonsmøte (5 deltakere):
node + mottaker-edges(5) + rolle-edges node + mottaker-edges(5) + rolle-edges
→ fang lyd fra alle → transkriber → lever til alle → AI-referent → fang lyd fra alle → transkriber → lever til alle → AI-referent
Ressurser: STT + levering til 5 + LLM Ressurser: STT + levering til 5 + LLM
Livesending (1000 lyttere): Livesending (1000 lyttere):
node + mottaker-edges(∞) + stream-edge + publiserings-edge node + mottaker-edges(∞) + stream-edge
→ fang lyd → transkriber → stream via LiveKit → distribuer → fang lyd → transkriber → stream via LiveKit → distribuer
→ generer segmenter → kjør live AI → publiser → generer segmenter → kjør live AI → publiser
Ressurser: STT + LiveKit + LLM + mediaprosessering Ressurser: STT + LiveKit + LLM + mediaprosessering
``` ```
Maskinrommet gjør ikke mer enn det edges krever. Ingen overhead for ### Naturlig eskalering
enkle ting. Noden vet ingenting om LiveKit — den har bare edges som Du starter en privat voice-note. Deler den med Trond → legg til
sier "stream til disse mottakerne", og maskinrommet bestemmer at det edge, maskinrommet begynner å levere. Trond foreslår møte → flere
betyr LiveKit. edges, maskinrommet kobler inn sanntidsstrømming. Møtet blir
innspilling → publiserings-edge, maskinrommet aktiverer
### Naturlig eskalering produksjonspipeline. Hvert steg er bare å legge til edges.
Du starter en privat voice-note. Bestemmer deg for å dele den med Trond
→ legg til mottaker-edge, maskinrommet begynner å levere. Trond foreslår ## Grensesnittet
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. fang(input: RåInput) → NodeId
prosesser(node: NodeId, operasjon: Operasjon) → Resultat
Hvert steg er bare å legge til edges. Maskinrommet reagerer og kobler lever(node: NodeId, mottaker: Mottaker, format: Format) → Status
inn flere ressurser etter hvert. Ingen migrering, ingen modebytte. ```
## Grensesnittet I praksis er mye av dette implisitt: maskinrommet ser hvilke edges
som ble skrevet og handler deretter. Primitivene manipulerer edges
Maskinrommet eksponerer et konsistent API — sannsynligvis en Rust trait via maskinrommet, og maskinrommet kobler inn riktige ressurser.
eller et sett traits:
## CAS og intelligent pruning
```
fang(input: RåInput) → NodeId ### CAS som lagringsprimitiv
prosesser(node: NodeId, operasjon: Operasjon) → Resultat All binærdata (lyd, bilde, video) lagres content-addressable.
lever(node: NodeId, mottaker: Mottaker, format: Format) → Status CAS-noder i grafen bærer metadata (`cas_hash`, `mime`, `size`).
``` Selve biten lever på disk.
Men i praksis er mye av dette *reaktivt*: maskinrommet observerer - **Deduplisering gratis** — samme fil delt i tre kontekster = én kopi
edge-endringer og handler automatisk. Legger noen til en mottaker-edge - **Separasjon** — "innholdet eksisterer" er adskilt fra "innholdet
→ maskinrommet begynner å levere. Legger noen til en stream-edge → er tilgjengelig"
maskinrommet kobler inn LiveKit. Primitivene trenger ikke eksplisitt - **Enkel opprydning** — slett filen fra CAS, noden beholder metadata
kalle `lever()` — de manipulerer edges, og maskinrommet reagerer.
### Lagringsregler per modalitet
## Hva dette gir
| Modalitet | Default levetid | Begrunnelse |
### Isolasjon |-----------|----------------|-------------|
Bytt Whisper med noe annet? Endre maskinrommet. Frontend vet ingenting. | Tekst | Evig | Billig, essensen av innholdet |
Legg til bildegenerering? Ny operasjon i maskinrommet. Primitivene | Transkripsjon | Evig | Tekstlig representasjon — bevarer meningen |
kaller den uten å vite hva som skjer under. | Lyd | 30 dager | Transkripsjon bevarer innholdet |
| Bilde | 30 dager | Beskrivelse/metadata bevarer kontekst |
### Observerbarhet | Video | 14 dager | Dyrest, transkripsjon + thumbnail bevarer det meste |
Alt går gjennom ett punkt. Logging, metrikker, kostnadsrapportering,
feilhåndtering — alt på ett sted. "Hva bruker vi AI-ressurser på?" ### Signaler som forlenger levetid
har ett svar.
- **Edges.** Lydfil med publiserings-edge = beholdes. Privat
### Kapasitetsstyring voice-memo uten edges = 30-dagers TTL.
Prioritering, kø, rate limiting, fallback mellom leverandører — alt - **Aksesslog.** Avspilt i løpet av TTL-perioden = forlenges.
håndtert av maskinrommet. En podcastinnspilling som trenger live - **Transkripsjonsstatus.** Utranskribert lyd kan trenge lengre TTL.
transkripsjon kan prioriteres over en bakgrunns-oppsummering. - **Edge-type.** Publisert = behold. Arkivert møte = transkripsjon
holder.
### Fast utviklingspunkt
To team (eller to hatter) med klart grensesnitt: ### Generert innhold er en cache
- **Over maskinrommet:** primitiver, noder, edges, UI, brukeropplevelse TTS, thumbnails, AI-oppsummeringer, waveforms — alt som kan
- **I maskinrommet:** ytelse, integrasjoner, kapasitet, kostnad regenereres er en cache i CAS med samme TTL-mekanisme.
Du kan perfeksjonere det ene uten å røre det andre. ### Samlings-node-styrt aggressivitet
Hver samlings-node kan justere sin pruning-profil:
## Content-Addressable Storage og intelligent pruning - **Konservativt** — behold alt lenge (arkiv-node)
- **Aggressivt** — tekst bevares, binærdata prunes raskt
Maskinrommet forvalter også lagring. Ikke alt kan lagres for evig — men - **Tilpasset** — egne regler per modalitet og edge-type
ikke alt trenger det heller. Signalene for hva som er viktig finnes
allerede i grafen. ### Disk-nødventil
Maskinrommet overvåker diskbruk:
### CAS som lagringsprimitiv - **>85 %:** Genererte filer slettes (kan regenereres)
All binærdata (lyd, bilde, video) lagres i et content-addressable store. - **>90 %:** Aggressiv pruning for alle samlings-noder
Fordeler: - **>95 %:** Kritisk alarm. Alt uten publiserings-edge slettes.
- **Deduplisering gratis** — samme fil delt i tre kontekster = én kopi Tekst og transkripsjoner bevares alltid.
- **Separasjon** — "innholdet eksisterer" er adskilt fra "innholdet er
tilgjengelig." Noden peker på en hash, CAS har filen (eller ikke). ## Isolasjon og observerbarhet
- **Enkel opprydning** — slett hashen fra CAS, alle noder som pekte
dit mister binærdataen men beholder metadata og transkripsjon. ### Isolasjon
Bytt Whisper med noe annet? Endre maskinrommet. Frontend vet
### Lagringsregler per modalitet ingenting. Legg til bildegenerering? Ny operasjon. Primitivene
kaller den uten å vite hva som skjer under.
| Modalitet | Default levetid | Begrunnelse |
|-----------|----------------|-------------| ### Observerbarhet
| Tekst | Evig | Billig, er essensen av innholdet | Alt går gjennom ett punkt. Logging, metrikker, kostnadsrapportering
| Transkripsjon | Evig | Tekstlig representasjon av lyd/video — tar vare på meningen | — alt på ett sted. "Hva bruker vi AI-ressurser på?" har ett svar.
| Lyd | 30 dager | Mellomkostnad, transkripsjon bevarer innholdet |
| Bilde | 30 dager | Mellomkostnad, beskrivelse/metadata bevarer kontekst | ### Kapasitetsstyring
| Video | 14 dager | Dyrest, transkripsjon + thumbnail bevarer det meste | Prioritering, kø, rate limiting, fallback mellom leverandører.
Live transkripsjon prioriteres over bakgrunns-oppsummering.
### Signaler som forlenger levetid
## Compute-separasjon
Default-TTL er bare utgangspunktet. Maskinrommet justerer basert på:
Maskinrommet orkestrerer — tunge jobber trenger ikke kjøre på
- **Edges.** En lydfil med edge til episoderegisteret = publisert samme maskin.
podcast, beholdes. En privat voice-memo uten edges = 30-dagers TTL.
- **Aksesslog.** Hvis noen har spilt av lydfilen i løpet av TTL-perioden, - **Nå:** Alt på én VPS. Jobbkøen prioriterer sanntid over batch.
forlenges den. Ingen aksess = ingen verdi i å beholde binærdataen. - **Snart:** Trekk ut tunge workers til separat node (billig
- **Transkripsjonsstatus.** Lyd som er transkribert har "overlevert sin ARM-instans) som poller jobbkøen. Maskinrommet ruter transparent.
essens" til tekst. Lyd som *ikke* er transkribert (f.eks. musikk, - **Kildevern-modus:** Lokal LLM krever dedikert compute med
lydeffekter) kan trenge lengre TTL. fysisk isolasjon.
- **Edge-type.** Edge til publisert innhold = behold. Edge til arkivert
møte = transkripsjon holder. Edge til ingenting = teksten lever videre, Compute-separasjon er en konfigurasjon, ikke en arkitekturendring.
binærdataen kan dø.
## Forhold til andre retninger
### Eksempler
Maskinrommet er infrastrukturen *under* de tre primitivene i
``` [universell input og mottak](universell_input.md):
Privat voice-memo, aldri delt: - Input-primitiven → `fang()` + `prosesser()`
→ Lyd transkriberes → tekst lagres evig - Mottak-primitiven → `lever()`
→ Lydfil: 30 dager, ingen aksess, ingen edges → slettes - Kommunikasjonsnoden → alle tre
→ Noden lever videre med teksten
- [Noder er sentrum](bruker_ikke_workspace.md) — maskinrommet
Podcastepisode: eier tilgangsmatrise-oppdatering
→ Lyd har edge til episoderegister + publiserings-edge - [Datalaget](datalaget.md) — maskinrommet skriver SpacetimeDB
→ Aksesseres regelmessig via podcastarkivet først, PG asynk
→ 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.

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. kanban, kalender er ikke apper — de er visninger av samme tilstandsrom.
### Privat og delt som lag, ikke separate systemer ### 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 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. du dagbok — begge er meldingsbokser, den ene er delt, den andre er privat.
De eksisterer side om side i samme rom. De eksisterer side om side i samme rom.

View file

@ -1,5 +1,10 @@
# Status quo — Hva Sidelinja er i dag # 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. > En redaksjonell webapp med ambisiøse primitiver og tradisjonell overflate.
## Hva fungerer ## Hva fungerer

View file

@ -1,299 +1,186 @@
# Universell input og mottak # Universell input og mottak
> Én multimodal input-primitiv. Én personlig mottaksflate. Alt som fanges **Status: Besluttet.**
> er samme type objekt. Hva det "er" bestemmes av edges, ikke av tabellen
> det ligger i. Hvordan det *presenteres* bestemmes av mottakeren. > Én multimodal input-primitiv. Én personlig mottaksflate. Alt som
> fanges er en node. Hva det "er" bestemmes av edges. Hvordan det
## Observasjoner > presenteres bestemmes av mottakeren.
I dag har vi meldingsboksen som "universell primitiv" — men den er egentlig en ## Input-primitiven
*lagringsprimitiv*. Den samler chat, kanban-kort, kalenderoppføringer og notater
i én tabell, men *input* er fortsatt forskjellig per kontekst: chat har ett Én overflate som fanger alt:
tekstfelt, kanban har et skjema, kalender har en datovelger. Brukeren velger
kontekst først, deretter gir de input. - **Tekst** — skriving, Markdown, kodeblokker
- **Lyd** — voice memo, diktering → automatisk transkribert
Og vi har separate pipelines for ulike modaliteter: tekst går én vei, lyd - **Bilde** — foto, skjermbilde, tegning
(voice/transkripsjon) en annen, bilder en tredje. Hver med sin egen flyt. - **AI-støtte** — spør AI, få forslag, la den transformere input
- **URL** — lim inn lenke, den berikes automatisk
## Tesen
Brukeren gjør det samme uansett kontekst — skriver, snakker eller
**Én input-primitiv, to versjoner:** tegner i input-feltet. Forskjellen mellom "dagbok", "chatmelding"
og "kanban-kort" er ikke hva brukeren gjør — det er hvilke edges
- **Sanntidsversjon** — live i SpacetimeDB-laget. Brukes i rom, samarbeid, som knyttes til resultatet.
samtaler. Streamer input og viser resultater i sanntid.
- **Flat versjon** — tradisjonelt mot PG. Brukes på bussen, alene, offline-aktig. ### Én pipeline
Fanger input og lagrer asynkront.
All input går gjennom samme tekniske pipeline:
Begge aksepterer alt: maskinrommet → SpacetimeDB (instant) → PG (asynk).
- **Tekst** — skriving, Markdown, kodeblokker
- **Lyd** — voice memo, diktering → automatisk transkribert Konteksten bestemmer routing, ikke en teknisk modus:
- **Bilde** — foto, skjermbilde, tegning
- **AI-støtte** — spør AI, få forslag, la den transformere input - Du er i et møte → inputen streames live til andre deltakere
- **Nettoppslag** — lim inn URL, den berikes automatisk - Du er alene på bussen → inputen lander som privat node
- **Kommunikasjon** — samme primitiv for alene (dagbok), en-til-en (melding), - Du er i en podcast-kanal → inputen går inn i produksjonspipeline
gruppe (kanal)
Samme pipeline. Ulike edges.
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 ### Input-metode og innholdstype er ortogonale
til resultatet.
Du kan snakke inn et kanban-kort. Du kan tegne en kalenderoppføring.
## Én tabell, edges definerer alt Input-primitiven bryr seg ikke om hva det *blir* — den fanger det
som kommer inn. Alt etterpå er edges.
All output fra input-primitiven lander som noder i kunnskapsgrafen. Én tabell.
Ingen `messages`-tabell, ingen `cards`-tabell, ingen `notes`-tabell. ### Én overflate å perfeksjonere
Hva en node "er" bestemmes utelukkende av edges: All UX-investering konsentreres ett sted. Én perfekt input-opplevelse
- Node + edge til kanal = chatmelding — responsiv, multimodal, med god AI-støtte — i stedet for ti
- Node + edge til board + status-edge = kanban-kort middelmådige spesialgrensesnitt.
- Node + edge til dato = kalenderoppføring
- Node + edge til kun bruker (privat) = dagboknotis ## Edges definerer alt
- Node + edge til topic = faktoid i kunnskapsgrafen
- Node uten edges = løs tanke, ennå uorganisert Hva en node "er" bestemmes utelukkende av edges:
**Retyping er trivielt.** Å gjøre en chatmelding om til et kanban-kort er å - Node + `belongs_to` → kanal = chatmelding
legge til en edge til et board og en status-edge. Fjerne fra chat er å fjerne - Node + `belongs_to` → board + `status` = kanban-kort
kanal-edgen. Ingen datamigrering, ingen transformasjon av innhold. Bare edges. - Node + `scheduled` → tidspunkt = kalenderoppføring
- Node uten edges til andre = privat notat
**Multitype er naturlig.** En node kan være *både* et kanban-kort *og* en - Node + `mentions` → topic = faktoid i kunnskapsgrafen
kalenderoppføring *og* en faktoid. Det er ikke en edge case — det er - Node uten edges = løs tanke, ennå uorganisert
arkitekturen.
**Retyping er trivielt.** Chatmelding → kanban-kort = legg til
## Implikasjoner board-edge og status-edge. Ingen datamigrering, bare edges.
### Meldingsboksen erstattes av noe dypere **Multitype er naturlig.** En node kan være *både* kanban-kort
Meldingsboksen var riktig intuisjon — men den er en lagringsprimitiv som *og* kalenderoppføring *og* faktoid. Det er ikke en edge case —
prøver å forene ulike domenemodeller. Universell input + kunnskapsgrafen det er arkitekturen.
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. ### Edge-tildeling
### Input-metode og innholdstype er ortogonale Når du "bare sier noe" — hvem bestemmer edges?
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). 1. **Kontekst gir det meste.** Du er i en samtale → `belongs_to`-
Input-primitiven bryr seg ikke om hva det *blir* — den fanger det som edge til samtalen. Du er alene → privat. Dekker 80%.
kommer inn. 2. **Eksplisitt handling.** Du drar en node til kanban-brettet.
Du tagger noe. Du setter en dato.
### Samme input, ulik routing 3. **AI-foreslått.** Systemet foreslår `mentions`-edge når du
Lyd inn i input-primitiven kan routes helt forskjellig basert på edges: nevner en person. Foreslår kanban når noe ligner en oppgave.
- Edge til et møterom → streames live til andre deltakere (sanntidslaget)
- Edge til kun deg selv → transkriberes og lagres som personlig notat Detaljer for AI-foreslåtte edges avklares ved implementering.
- Edge til en podcast-kanal → goes into produksjonspipeline
- Edge til en person → sendes som lydmelding ## Mottak-primitiven
Brukeren gjør det samme — snakker inn i input-feltet. *Systemet* router Der input er "én overflate som fanger alt", er mottak "én overflate
basert på kontekst og edges. Det er ingen "møte-app" eller "notat-app" som presenterer alt tilpasset *deg*."
eller "meldings-app" — det er én input med ulike destinasjoner.
### Mottaker bestemmer format
### Mottaker bestemmer format
All lyd transkriberes. All tekst kan leses opp (TTS). Noden har alltid All lyd transkriberes. All tekst kan leses opp (TTS). Noden har
begge representasjoner. Mottaker setter sin preferanse: alltid begge representasjoner. Mottaker setter sin preferanse:
- Trond snakker inn en tanke → node med lyd + transkripsjon
- Peter har tekst-preferanse → ser transkripsjonen - Trond snakker inn en tanke → node med lyd + transkripsjon
- Vegard har lyd-preferanse → hører originallyd - Peter har tekst-preferanse → ser transkripsjonen
- Anna skriver tekst → node med tekst + TTS-versjon - Vegard har lyd-preferanse → hører originallyd
- Trond har lyd-preferanse → hører TTS-opplesning av Annas tekst
Modalitet er ikke en egenskap ved meldingen, men ved *lesningen*.
Senderen trenger ikke vite eller bry seg. Innholdet er det samme —
presentasjonen er en mottaker-side preferanse. Modalitet er ikke ### Dimensjoner ved mottak
en egenskap ved meldingen, men ved *lesningen* av den.
**Format.** Lyd, tekst, visuelt — mottaker bestemmer.
### Én overflate å perfeksjonere
Brukerens mentale modell kollapser til én ting: input-feltet. All **Filtrering.** Mottaksflaten filtrerer basert på dine edges:
UX-investering konsentreres ett sted i stedet for å smøres tynt utover kanaler du følger, personer du samarbeider med, topics du er
ti ulike grensesnitt. Én perfekt input-opplevelse — responsiv, multimodal, interessert i.
med god AI-støtte — i stedet for ti middelmådige spesialgrensesnitt.
**Prioritering.** AI-assistert vekting: ubesvarte meldinger,
Dette er en radikal forenkling av utviklingsoverflaten. I stedet for å oppgaver med frist, noder endret siden sist. Ikke en
bygge og vedlikeholde chat-input, kanban-skjema, kalender-dialog, notifikasjonsliste — en vektet visning av det som er relevant.
notat-editor, voice-recorder, dagbok-felt — bygger vi *ett* grensesnitt
og investerer alt i å gjøre det feilfritt. Alt etterpå er edges. **Tempo.** Sanntid (ting streamer inn) eller asynkront (digest,
oppsummering).
### Visninger er spørringer mot grafen
Chat-visningen = "vis noder med edge til denne kanalen, sortert på tid." ### Mottaksflaten er en visning av grafen
Kanban-visningen = "vis noder med edge til dette boardet, gruppert på status."
Kalender-visningen = "vis noder med dato-edge, plassert på tidslinje." "Noder med edge til *meg*, vektet på relevans og tid." Ikke en
Dagbok-visningen = "vis private noder for denne brukeren, sortert på tid." egen mekanisme — en spørring mot grafen med deg som sentrum.
Alle visninger leser fra samme graf. Ingen har "sin egen" data. ## Kommunikasjonsnoden — den tredje primitiven
### To versjoner passer to-lags-modellen Input fanger. Mottak presenterer. Kommunikasjonsnoden er *stedet*
Sanntidsversjonen lever i SpacetimeDB-laget: input streames, resultater er der folk møtes — en node som samler deltakere, definerer
live, andre ser hva du gjør. Flat versjonen lever i det tradisjonelle laget: tilgangsregler, og fungerer som kontekst.
input sendes, lagres i PG, ferdig. Begge produserer identiske noder i grafen.
### Én node, mange former
### Synlighet er bare en edge
Privat = edge kun til deg. Delt = edge til en gruppe/kanal. Publisert = edge | Variant | Deltakere | Input | Mottak |
til en offentlig kontekst. Å "dele" noe er å legge til en edge. Å "gjøre |---------|-----------|-------|--------|
privat" er å fjerne den. Innholdet endres aldri. | Én-til-én | 2 | Begge | Begge |
| Gruppechat | N | Alle | Alle |
## Universelt mottak — den andre primitiven | Møte | N | Alle | Alle |
| Allmøte | 1 + N | Leder | Alle lytter |
Input-primitiven er halvparten. Den andre halvdelen er *mottak*: hvordan | Podcastinnspilling | 2-4 + N | Verter | Alle lytter |
du konsumerer det andre produserer. Der input er "én overflate som fanger | Livesending | 1-4 + ∞ | Verter | Streamet |
alt", er mottak "én overflate som presenterer alt tilpasset *deg*." | Asynkron gjest | 1 + 1 | Gjest | Redaksjonen |
### Dimensjoner ved mottak Forskjellen er edge-konfigurasjoner, ikke ulike systemer:
- `owner`-edge — kontrollerer noden
**Format.** Lyd, tekst, visuelt — mottaker bestemmer (allerede beskrevet - `member`-edge — kan gi input og motta
over). Men det gjelder alt, ikke bare meldinger: en AI-oppsummering kan - `reader`-edge — kan kun motta
leses eller høres. Et whiteboard-snapshot kan vises som bilde eller som
tekstlig beskrivelse. ### Kontekst arves automatisk
**Filtrering.** Hva ser du? Alt fra alle er støy. Mottaksflaten filtrerer Input i en kommunikasjonsnode arver kontekst-edges. Sier du noe
basert på dine edges: hvilke kanaler du følger, hvilke personer du i et møte → noden får `belongs_to`-edge til møtet automatisk.
samarbeider med, hvilke topics du er interessert i. Du kuraterer ikke
manuelt — du justerer edges, og mottaksflaten oppdateres. ### Livssyklus
**Prioritering.** Hva er viktig *nå*? En AI-assistert redaksjonell flate - **Live** — deltakere til stede, input streames
som løfter frem det som trenger oppmerksomhet: ubesvarte meldinger, - **Asynkron** — deltakere gir input i eget tempo
oppgaver med frist, noder som er endret siden sist, tråder med aktivitet. - **Avsluttet** — arkivert, alt som ble sagt er noder med edges
Ikke en notifikasjonsliste — en *vektet visning* av det som er relevant. - **Gjenåpnet** — reaktivert ("vi tar opp tråden fra forrige møte")
**Tempo.** Sanntid eller asynkront. I sanntidslaget: ting streamer inn ### Skalering er edge-endring
mens de skjer — en kollega snakker, du hører/leser live. I det
tradisjonelle laget: du får en digest, en oppsummering, et overblikk Samtale → møte = flere deltaker-edges.
over hva som har skjedd siden sist. Samme noder, ulikt tempo. Møte → livesending = offentlige mottak-edges.
Livesending → podcast = publiserings-edges på arkivert innhold.
**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 ## Tekniske forutsetninger
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 ### STT (tale → tekst): løst
tilstandsendring i grafen. Faster-whisper kjører lokalt, god norsk kvalitet.
### Mottaksflaten som speilbilde av input ### TTS (tekst → tale): løsbart
Start med ElevenLabs bak AI Gateway, bytt til lokal modell når
| Input | Mottak | kvaliteten holder. Backend-swap bak gatewayen — brukeren merker
|-------|--------| ingenting.
| Én overflate for all input | Én overflate for alt mottak |
| Sender bestemmer ikke format | Mottaker bestemmer format | ## Visninger er spørringer
| Modalitet er ortogonal | Presentasjon er ortogonal |
| Kontekst gir edges | Preferanser gir filtrering | Chat = noder med kanal-edge, sortert på tid.
| Sanntid + flat versjon | Sanntid (stream) + asynkron (digest) | Kanban = noder med board-edge, gruppert på status.
Kalender = noder med dato-edge, på tidslinje.
### Mange-til-én og mange-via-mange Dagbok = private noder, sortert på tid.
Mottaksflaten håndterer naturlig: Mottaksflate = noder med edge til deg, vektet.
- **Én-til-én** — Trond sender deg en melding
- **Mange-til-én** — fem personer i en kanal, du ser alt Alle leser fra samme graf. Ingen har "sin egen" data.
- **Via node** — et møte genererer et referat, du mottar det
- **Via kjede** — en chatmelding → blir oppgave → oppgaven fullføres → ## Forhold til andre retninger
du får oppdatering. Hele kjeden er edges, og du ser resultatet i din
mottaksflate uten å ha fulgt hvert steg. - [Noder er sentrum](bruker_ikke_workspace.md) — visibility,
tilgangsmatrise, aliaser
### Mottaksflaten er også en visning av grafen - [Datalaget](datalaget.md) — SpacetimeDB holder hele grafen,
Akkurat som chat-visningen er "noder med kanal-edge sortert på tid", er PG persisterer asynkront
mottaksflaten "noder med edge til *meg*, vektet på relevans og tid." - [Maskinrommet](maskinrommet.md) — validering, routing, CAS,
Det er ikke en egen mekanisme — det er enda en spørring mot samme graf, tunge jobber (Whisper, TTS, AI)
bare med *deg* som sentrum. - [Rom, ikke forum](rom_ikke_forum.md) — kommunikasjonsnoden
er den konkrete realiseringen av "rommet"
## 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

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 | | Skrive/teste kode (Rust, SvelteKit, TypeScript) | Lokalt | Rask iterasjon, HMR |
| PG-skjema og migrasjoner | Mot server-PG | Én sannhetskilde | | PG-skjema og migrasjoner | Mot server-PG | Én sannhetskilde |
| Whisper/AI-eksperimentering | Via AI Gateway på server | Felles tjeneste | | Whisper/AI-eksperimentering | Via AI Gateway på server | Felles tjeneste |
| Docker-compose endringer | Direkte i prod | Serveren er dev-miljø | | Docker-compose endringer | Direkte på server | Serveren er dev-miljø |
| Caddy/Authentik/Forgejo config | Direkte i prod | Avhenger av domener, sertifikater, SSO | | Caddy/Authentik/Forgejo config | Direkte på server | Avhenger av domener, sertifikater, SSO |
## 0. Forutsetninger ## 0. Forutsetninger
- Windows 11 med WSL2 (Ubuntu 24.04 LTS) - Windows 11 med WSL2 (Ubuntu 24.04 LTS)
@ -37,8 +37,8 @@ source ~/.cargo/env
```bash ```bash
cd ~ cd ~
git clone ssh://git@git.sidelinja.org:222/sidelinja/server.git sidelinja-v2 git clone ssh://git@git.sidelinja.org:222/vegard/synops.git
cd sidelinja-v2 cd synops
``` ```
## 3. Miljøvariabler (.env.local) ## 3. Miljøvariabler (.env.local)
@ -53,16 +53,13 @@ cp .env.example .env.local
## 4. Utviklingsflyt ## 4. Utviklingsflyt
```bash ```bash
# Start alt lokalt # SvelteKit med HMR
./dev.sh cd web && npm run dev
# Eller manuelt: # Rust maskinrom
cd web && npm run dev # SvelteKit med HMR cd rust && cargo run
cd rust && cargo run # Rust maskinrom
``` ```
`dev.sh` er kanonisk — oppdater alltid scriptet når nye steg oppdages.
## 5. Deploy ## 5. Deploy
```bash ```bash
@ -70,7 +67,7 @@ cd rust && cargo run # Rust maskinrom
git push forgejo main git push forgejo main
# 2. Deploy til prod (krever eksplisitt godkjenning) # 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) ## 6. Forskjeller fra produksjon (bevisste)

View file

@ -1,5 +1,14 @@
# Migration Safety Checklist # 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. Sjekkliste for alle som kjører PostgreSQL-migrasjoner — lokalt eller i prod.
## Før migrering ## Før migrering

View file

@ -1,7 +1,7 @@
# Oppsett: Produksjonsserver (Hetzner VPS) # Oppsett: Produksjonsserver (Hetzner VPS)
**Filsti:** `docs/setup/produksjon.md` **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 ## 0. Forutsetninger
- Hetzner VPS med Ubuntu 24.04 LTS (8 vCPU, 16 GB RAM minimum) - Hetzner VPS med Ubuntu 24.04 LTS (8 vCPU, 16 GB RAM minimum)
@ -58,17 +58,17 @@ newgrp docker
## 3. Opprett mappestruktur ## 3. Opprett mappestruktur
```bash ```bash
sudo mkdir -p /srv/sidelinja/{config,data,media,logs} sudo mkdir -p /srv/synops/{config,data,media,logs}
sudo mkdir -p /srv/sidelinja/config/{caddy,authentik} sudo mkdir -p /srv/synops/config/{caddy,authentik}
sudo mkdir -p /srv/sidelinja/data/{postgres,spacetimedb,forgejo,authentik} sudo mkdir -p /srv/synops/data/{postgres,spacetimedb,forgejo,authentik}
sudo mkdir -p /srv/sidelinja/media/podcast sudo mkdir -p /srv/synops/media/podcast
sudo mkdir -p /srv/sidelinja/logs/caddy sudo mkdir -p /srv/synops/logs/caddy
sudo chown -R sidelinja:sidelinja /srv/sidelinja sudo chown -R sidelinja:sidelinja /srv/synops
``` ```
Resultat: Resultat:
``` ```
/srv/sidelinja/ /srv/synops/
├── docker-compose.yml ├── docker-compose.yml
├── .env ├── .env
├── config/ ├── config/
@ -88,7 +88,7 @@ Resultat:
## 4. Miljøvariabler (.env) ## 4. Miljøvariabler (.env)
```bash ```bash
cat > /srv/sidelinja/.env << 'EOF' cat > /srv/synops/.env << 'EOF'
# === Domener === # === Domener ===
DOMAIN_SIDELINJA=sidelinja.org DOMAIN_SIDELINJA=sidelinja.org
DOMAIN_VEGARD=vegard.info 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. # Ingen porter eksponeres utenom 80/443. Alt rutes internt via Docker-nettverket.
EOF EOF
chmod 600 /srv/sidelinja/.env chmod 600 /srv/synops/.env
``` ```
## 5. Tjeneste-installasjon (rekkefølge) ## 5. Tjeneste-installasjon (rekkefølge)
@ -153,7 +153,7 @@ Tjenestene startes i rekkefølge fordi noen avhenger av andre. Alle defineres i
# REGLER: # REGLER:
# - Ingen "ports:" mot host UTENOM Caddy (80, 443) # - Ingen "ports:" mot host UTENOM Caddy (80, 443)
# - Alle tjenester på samme interne nettverk (sidelinja-net) # - 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 # - .env-filen lastes automatisk av Docker Compose
# - RESSURSGRENSER: Worker-containere (Whisper) MÅ ha deploy.resources.limits # - RESSURSGRENSER: Worker-containere (Whisper) MÅ ha deploy.resources.limits
# for å forhindre at de sultefôrer LiveKit og PostgreSQL. # for å forhindre at de sultefôrer LiveKit og PostgreSQL.
@ -165,10 +165,10 @@ networks:
services: services:
caddy: # Eneste tjeneste med eksponerte porter (80, 443) 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 authentik: # SSO for alle domener, på auth.sidelinja.org
forgejo: # data:/srv/sidelinja/data/forgejo, på git.sidelinja.org forgejo: # data:/srv/synops/data/forgejo, på git.sidelinja.org
spacetimedb: # data:/srv/sidelinja/data/spacetimedb spacetimedb: # data:/srv/synops/data/spacetimedb
livekit: # Intern port, proxyet via Caddy livekit: # Intern port, proxyet via Caddy
sveltekit: # Intern port, proxyet via Caddy sveltekit: # Intern port, proxyet via Caddy
workers: # Rust job workers, ingen porter workers: # Rust job workers, ingen porter
@ -199,13 +199,13 @@ sidelinja.org {
# Podcast media (statiske filer med byte-range support) # Podcast media (statiske filer med byte-range support)
handle_path /media/* { handle_path /media/* {
root * /srv/sidelinja/media root * /srv/synops/media
file_server file_server
} }
# Podcast access log (kun media-forespørsler) # Podcast access log (kun media-forespørsler)
log { log {
output file /srv/sidelinja/logs/caddy/podcast_access.log output file /srv/synops/logs/caddy/podcast_access.log
format json format json
} }
} }
@ -261,10 +261,10 @@ Se `docs/arkitektur.md` seksjon 2.2 for full dataklassifisering. Kun kategori 1
```bash ```bash
# pg_dump er konsistent selv under last — ingen nedetid # pg_dump er konsistent selv under last — ingen nedetid
docker compose exec -T postgres pg_dump -U sidelinja -Fc sidelinja \ 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 # 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) ### 11.1b PostgreSQL WAL-arkivering (kontinuerlig, PITR)
@ -307,21 +307,21 @@ repo1-retention-diff=14
### 11.2 Media-filer (daglig, 03:30) ### 11.2 Media-filer (daglig, 03:30)
```bash ```bash
# Inkrementell med rsync til lokal backup-disk eller ekstern lagring # 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) ### 11.3 Forgejo-data (daglig, 04:00)
```bash ```bash
# Forgejo-repos kan gjenskapes, men det er tidkrevende. # Forgejo-repos kan gjenskapes, men det er tidkrevende.
# Sikkerhetsnett-backup av hele data-mappen: # 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) ### 11.4 Hemmeligheter (.env)
```bash ```bash
# Manuell kopi ved endring — ALDRI i Git # Manuell kopi ved endring — ALDRI i Git
cp /srv/sidelinja/.env /srv/sidelinja/backup/env_$(date +%Y%m%d) cp /srv/synops/.env /srv/synops/backup/env_$(date +%Y%m%d)
chmod 600 /srv/sidelinja/backup/env_* chmod 600 /srv/synops/backup/env_*
``` ```
### 11.5 Off-site backup (rclone → Hetzner Object Storage) ### 11.5 Off-site backup (rclone → Hetzner Object Storage)
@ -335,33 +335,33 @@ rclone config
# Opprett remote "hetzner-s3" med Hetzner Object Storage credentials # Opprett remote "hetzner-s3" med Hetzner Object Storage credentials
# (S3-kompatibelt, endpoint: fsn1.your-objectstorage.com eller nbg1) # (S3-kompatibelt, endpoint: fsn1.your-objectstorage.com eller nbg1)
# /srv/sidelinja/scripts/backup-offsite.sh # /srv/synops/scripts/backup-offsite.sh
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
BUCKET="s3:hetzner-s3/sidelinja-backup" BUCKET="s3:hetzner-s3/sidelinja-backup"
# PG-dump (siste lokale dump) # 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 if [ -n "$LATEST_DUMP" ]; then
rclone copy "$LATEST_DUMP" "$BUCKET/pg/" rclone copy "$LATEST_DUMP" "$BUCKET/pg/"
fi fi
# Media (inkrementell sync) # 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 # Behold 90 dager PG-dumper off-site
rclone delete "$BUCKET/pg/" --min-age 90d 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 ### 11.6 Cron-oppsett
```bash ```bash
# /etc/cron.d/sidelinja-backup # /etc/cron.d/sidelinja-backup
0 3 * * * sidelinja /srv/sidelinja/scripts/backup-pg.sh 0 3 * * * sidelinja /srv/synops/scripts/backup-pg.sh
30 3 * * * sidelinja /srv/sidelinja/scripts/backup-media.sh 30 3 * * * sidelinja /srv/synops/scripts/backup-media.sh
0 4 * * * sidelinja /srv/sidelinja/scripts/backup-forgejo.sh 0 4 * * * sidelinja /srv/synops/scripts/backup-forgejo.sh
30 4 * * * sidelinja /srv/sidelinja/scripts/backup-offsite.sh 30 4 * * * sidelinja /srv/synops/scripts/backup-offsite.sh
``` ```
### 11.7 Hva som IKKE backupes (bevisst) ### 11.7 Hva som IKKE backupes (bevisst)
@ -376,14 +376,14 @@ echo "$(date): Off-site backup ferdig" >> /srv/sidelinja/logs/backup-offsite.log
```bash ```bash
# 1. PostgreSQL # 1. PostgreSQL
docker compose exec -T postgres pg_restore -U sidelinja -d sidelinja --clean \ 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 # 2. Media
rsync -a /srv/sidelinja/backup/media/ /srv/sidelinja/media/ rsync -a /srv/synops/backup/media/ /srv/synops/media/
# 3. Forgejo # 3. Forgejo
docker compose down 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 docker compose up -d forgejo
# 4. Avledede data: trigges automatisk ved webhook eller manuelt # 4. Avledede data: trigges automatisk ved webhook eller manuelt
@ -399,7 +399,7 @@ git push forgejo main
# SSH inn til server: # SSH inn til server:
ssh sidelinja@<server-ip> ssh sidelinja@<server-ip>
cd /srv/sidelinja cd /srv/synops
git pull git pull
docker compose build --no-cache <tjeneste> docker compose build --no-cache <tjeneste>
docker compose up -d <tjeneste> docker compose up -d <tjeneste>

View file

@ -13,7 +13,7 @@ men ikke implementert.
## Sjekkliste ## Sjekkliste
### 1. Git-status ### 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 ucommittede endringer lokalt som burde vært pushet?
- [ ] Er det commits på Forgejo som ikke er deployet til prod? - [ ] Er det commits på Forgejo som ikke er deployet til prod?
@ -24,7 +24,6 @@ men ikke implementert.
### 3. Docker-tjenester ### 3. Docker-tjenester
- [ ] Kjører alle forventede containere i prod? (`docker compose ps`) - [ ] 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? - [ ] Er image-versjoner oppdatert?
### 4. Miljøvariabler ### 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 det gjort arbeid nylig som mangler erfaringsdokumentasjon i `docs/erfaringer/`?
- [ ] Er eksisterende erfaringsdokumenter fortsatt relevante og korrekte? - [ ] Er eksisterende erfaringsdokumenter fortsatt relevante og korrekte?
### 8. dev.sh og utviklermiljø ### 8. Utviklermiljø
- [ ] Fungerer `./dev.sh` fra scratch? - [ ] Fungerer lokal utvikling mot server (SvelteKit HMR, Rust build)?
- [ ] Er alle nødvendige tjenester dekket? - [ ] Er `docs/setup/lokal.md` oppdatert med eventuelle nye steg?
- [ ] Er det nye quirks eller workarounds som bør inn i scriptet?
## Sist kjørt ## 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. 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) ## 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, api_keys_toggle, api_keys_values, drop_priority_unique, workspace_ai_prompts,
usage_action_column. Pluss seed_dev.sql. 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 ## Crontab
Ingen crontab konfigurert (backup-strategi er dokumentert men ikke implementert). Ingen crontab konfigurert (backup-strategi er dokumentert men ikke implementert).

View file

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