Ctrl K
ComunidadGuías…

db/init.sql

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
-- ============================================================================
-- db/init.sql — Single Source of Truth für das Postgres-Schema
-- ============================================================================
--
-- Diese Datei beschreibt das vollständige Kern-Datenbank-Schema. Sie wird auf
-- zwei Wegen ausgeführt:
--
--   1. Bei einer Frischinstallation durch den docker-entrypoint-initdb.d-
--      Mechanismus, sobald ein leeres Postgres-Volume zum ersten Mal startet.
--   2. Bei jedem Server-Start durch schema_migrieren() in server/main.py
--      gegen die bestehende Live-Datenbank.
--
-- Alle Statements sind idempotent — die Datei kann beliebig oft gegen ein
-- beliebiges Schema ausgeführt werden, ohne dass etwas kaputtgeht.
--
-- Scope
-- -----
-- Diese Datei deckt das Kern-Schema ab (Memory + Charakter + Wissensgraph
-- + Notizen + Gespräch + Ziele + Verben). Agent-spezifische Tabellen liegen
-- in server/agents/<agent>/init.sql und werden beim Agent-Discovery
-- aufgesetzt (siehe server/agents/base.py:setup). Foreign-Key-Spalten von
-- Kern-Tabellen auf Agent-Tabellen (z.B. timeline_id) sind hier als nackte
-- INTEGER-Spalten definiert; der FK-Constraint wird in der jeweiligen
-- Agent-init.sql gesetzt, damit die Abhängigkeit erst nach Anlage der
-- Agent-Tabelle realisiert wird.
--
-- Konvention für Änderungen
-- --------------------------
-- Spätere Schema-Änderungen werden als ALTER-Update-Statements im Migrations-
-- Block am Ende der Datei eingefügt — NICHT direkt in die ursprüngliche
-- CREATE TABLE-Definition eingearbeitet. Damit bleibt nachvollziehbar, was
-- wann als Migration hinzugekommen ist.
--
-- Bei späteren Reviews werden die akkumulierten ALTER-Statements in die
-- ursprünglichen CREATE-Definitionen konsolidiert. Die Datei wird dann als
-- zusammenhängender Soll-Stand wieder lesbar.
-- ============================================================================


-- ═══════════════════════════════════════════════
-- Extensions
-- ═══════════════════════════════════════════════

CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;


-- ═══════════════════════════════════════════════
-- Historische Migration: alte fakten/entitaeten-Form droppen
-- ═══════════════════════════════════════════════
-- Muss VOR den CREATE TABLE IF NOT EXISTS stehen, damit die neuen Tabellen
-- auf einer bestehenden alten Installation überhaupt angelegt werden können.
DO $$ BEGIN
    -- Alte FK-Constraints entfernen falls vorhanden.
    IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fakten_entitaet_id_fkey') THEN
        ALTER TABLE fakten DROP CONSTRAINT fakten_entitaet_id_fkey;
    END IF;
    IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fakten_wert_entity_id_fkey') THEN
        ALTER TABLE fakten DROP CONSTRAINT fakten_wert_entity_id_fkey;
    END IF;

    -- Alte Tabellen droppen, wenn die alte Schlüssel-Spalte 'schluessel' noch vorhanden ist.
    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_name = 'fakten' AND column_name = 'schluessel'
    ) THEN
        DROP TABLE IF EXISTS fakten;
        DROP TABLE IF EXISTS entitaeten;
    END IF;
END $$;


-- ═══════════════════════════════════════════════
-- Kern-Tabellen
-- ═══════════════════════════════════════════════

-- ───────────────────────────────────────────────
-- lzg_knoten — Synapsen-Knoten (Synapsen-Modell, P2)
-- ───────────────────────────────────────────────
-- Parallel zum bestehenden langzeitgedaechtnis. Trägt die Memory-Knoten
-- des Synapsen-Modells mit drei Gewichts-Feldern (roh, absolut, decay),
-- Magnet-Feldern (Entitäten, Themen, Timeline) und vollständiger EI-Kopie
-- aus dem KZG. Spezifikation in docs/novaberg-memory-synapsen_k.md §4.1.
--
-- Bis P9 läuft diese Tabelle parallel zu langzeitgedaechtnis. Promotion
-- schreibt ab P4 ausschließlich hier hin; Enricher liest ab P5 aus
-- dieser Tabelle. Das bisherige langzeitgedaechtnis wird in P9 entfernt.
--
-- timeline_id: nackte INTEGER-Spalte; FK-Constraint auf timeline(id) wird
-- in server/agents/timeline/init.sql gesetzt.
CREATE TABLE IF NOT EXISTS lzg_knoten (
    -- Identität
    id                      SERIAL           PRIMARY KEY,
    kzg_quell_key           TEXT             NOT NULL UNIQUE,

    -- Paar-Partition
    user_id                 TEXT             NOT NULL,
    character_id            VARCHAR(50)      NOT NULL DEFAULT 'nova',
    beobachter              VARCHAR(20)      NOT NULL DEFAULT 'user',

    -- Inhalt
    inhalt                  TEXT             NOT NULL,
    embedding               VECTOR(768),
    dimension               TEXT             NOT NULL,

    -- Knoten-Dynamik
    gewicht_roh             DOUBLE PRECISION NOT NULL,
    gewicht_absolut         DOUBLE PRECISION NOT NULL,
    gewicht_decay           DOUBLE PRECISION NOT NULL,
    haeufigkeit             INTEGER          NOT NULL DEFAULT 1,
    aktiv                   BOOLEAN          NOT NULL DEFAULT TRUE,
    erstellt_am             TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    verstaerkt_am           TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    decay_am                TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    kzg_erstellt_am         TIMESTAMPTZ      NOT NULL,

    -- Salienz-Anker
    themen                  TEXT[]           NOT NULL DEFAULT '{}',
    gedaechtnistyp          VARCHAR(20),
    entitaet_ids            INTEGER[]        NOT NULL DEFAULT '{}',
    timeline_id             INTEGER,

    -- Emotionale Intelligenz (volle Kopie aus KZG)
    emotion                 TEXT             NOT NULL DEFAULT '',
    arousal                 DOUBLE PRECISION NOT NULL DEFAULT 0.5,
    emotions_vektor         TEXT             NOT NULL DEFAULT '',
    intentionen             TEXT             NOT NULL DEFAULT '[]',
    modus                   TEXT             NOT NULL DEFAULT '',
    sprach_stil             TEXT             NOT NULL DEFAULT '',
    beziehungs_dynamik      TEXT             NOT NULL DEFAULT '',
    tone                    TEXT             NOT NULL DEFAULT ''
);

-- ───────────────────────────────────────────────
-- lzg_kanten — Synapsen-Kanten (Cache, Synapsen-Modell, P2)
-- ───────────────────────────────────────────────
-- Abgeleiteter Cache der Knoten-Verbindungen. Kanten haben keine eigene
-- Substanz (keine Häufigkeit, kein Decay, keine Reaktivierung) — sie
-- werden bei drei Triggern neu berechnet: Knoten-Anlage, Knoten-Aktivierung,
-- Schicht-Daten-Änderung. Spezifikation in docs/novaberg-memory-synapsen_k.md §4.2.
--
-- Gerichtete Kanten (A→B und B→A als zwei separate Datensätze möglich),
-- aber der UNIQUE-Constraint stellt sicher, dass kein Paar dupliziert
-- existiert.
CREATE TABLE IF NOT EXISTS lzg_kanten (
    -- Identität
    id                       SERIAL           PRIMARY KEY,
    knoten_a_id              INTEGER          NOT NULL REFERENCES lzg_knoten(id) ON DELETE CASCADE,
    knoten_b_id              INTEGER          NOT NULL REFERENCES lzg_knoten(id) ON DELETE CASCADE,

    -- Kanten-Stärke
    gewicht_roh              DOUBLE PRECISION NOT NULL,
    gewicht_absolut          DOUBLE PRECISION NOT NULL,
    erstellt_am              TIMESTAMPTZ      NOT NULL DEFAULT NOW(),

    -- Verbindungs-Charakter (eingefroren bei Bildung)
    verbindungs_gruende      TEXT[]           NOT NULL DEFAULT '{}',
    geteilte_entitaet_ids    INTEGER[]        NOT NULL DEFAULT '{}',
    geteilte_themen          TEXT[]           NOT NULL DEFAULT '{}',
    timeline_naehe_tage      INTEGER,
    embedding_cosine_initial DOUBLE PRECISION,
    anzahl_schichten         INTEGER          NOT NULL DEFAULT 1,

    -- Eindeutigkeit
    CONSTRAINT chk_lzg_kanten_selbst CHECK (knoten_a_id != knoten_b_id),
    CONSTRAINT uq_lzg_kanten_paar    UNIQUE (knoten_a_id, knoten_b_id)
);

-- ───────────────────────────────────────────────
-- lzg_knoten_haltung — die Ladung eines Knotens
-- ───────────────────────────────────────────────
-- Die Meinungsschicht als **additive Annotation** auf dem assoziativen
-- Gedaechtnis (`novaberg-thinking-opinion_k.md` §5), kein zweiter Speicher.
--
-- **Warum eine eigene Tabelle und keine Spalten am Knoten:** Ein Knoten traegt
-- MEHRERE Ladungen, je Eigenschaft eine — »Kino: teuer (−0.5) UND mit dir
-- (+0.8)«. Genau dieser Widerspruch unterscheidet eine Haltung von einem
-- Schalter; Spalten koennten ihn nicht tragen.
--
-- **Warum `haltung` und nicht `valenz`:** Der Name ist im Bestand an die
-- binaere GV-Gespraechsachse vergeben (`GV_VALENZ_SEKTOR`), die den TURN
-- faerbt und einen Turn lebt. Diese hier faerbt die SACHE und bleibt.
--
-- **Verfall (`F-VERFALL-1`):** Eine Haltung ist Gedaechtnis, kein Faktum — sie
-- traegt dieselbe Dynamik wie der Knoten (roh, verfallen, verstaerkt_am) und
-- ruht unter der Schwelle, statt geloescht zu werden. Eine Haltung, die
-- niemand mehr bestaetigt, soll nicht so laut sein wie eine frische.
-- **Zum Loeschverhalten:** Es tritt im Betrieb nicht ein. Der Graph loescht
-- keine Knoten, er laesst sie ruhen (`aktiv = FALSE`, F-VERFALL-1) — CASCADE
-- greift nur, wenn ein Knoten wirklich verschwindet (Reset, Bereinigung),
-- und dann ist eine Annotation ohne ihren Gegenstand nichts wert. Was
-- stattdessen taeglich gilt, steht im Leseweg: Er verbindet mit dem Knoten
-- und prueft DESSEN `aktiv` mit, damit eine Ladung nicht weiterspricht,
-- waehrend ihre Sache schon ruht (`node_stance_repository.stances_load`).
CREATE TABLE IF NOT EXISTS lzg_knoten_haltung (
    id                  SERIAL           PRIMARY KEY,
    knoten_id           INTEGER          NOT NULL REFERENCES lzg_knoten(id) ON DELETE CASCADE,

    -- Der Gegenstand der Ladung. Leer = die Sache als ganze (die grobe
    -- Themen-Stufe aus §9); gefuellt = eine Eigenschaft (»teuer«).
    eigenschaft         TEXT             NOT NULL DEFAULT '',

    -- Die Ladung selbst: Vorzeichen UND Staerke, -1.0 bis +1.0.
    ladung              DOUBLE PRECISION NOT NULL,
    -- Die Emotion dahinter, aus dem Plutchik-Kanon; leer = keine benannte.
    emotion             TEXT             NOT NULL DEFAULT '',

    -- Die Praemisse als Kante, nicht als Freitext: der Werte-Knoten, auf dem
    -- das Urteil steht. Faellt er weg, ist die Ladung zu revidieren. Bis es
    -- Werte-Knoten gibt, bleibt das Feld leer (Konzept §5).
    praemisse_knoten_id INTEGER          REFERENCES lzg_knoten(id) ON DELETE SET NULL,

    -- Woher die Ladung stammt — ohne Herkunft ist sie nicht nachrechenbar.
    quelle              TEXT             NOT NULL DEFAULT '',

    -- Dynamik wie am Knoten (F-VERFALL-1)
    staerke_roh         DOUBLE PRECISION NOT NULL DEFAULT 1.0,
    staerke_decay       DOUBLE PRECISION NOT NULL DEFAULT 1.0,
    haeufigkeit         INTEGER          NOT NULL DEFAULT 1,
    aktiv               BOOLEAN          NOT NULL DEFAULT TRUE,
    erstellt_am         TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    verstaerkt_am       TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    decay_am            TIMESTAMPTZ      NOT NULL DEFAULT NOW(),

    -- Eine Sache traegt je Eigenschaft genau eine Ladung; eine zweite
    -- verstaerkt die vorhandene, statt eine Dublette anzulegen.
    CONSTRAINT uq_lzg_haltung_gegenstand UNIQUE (knoten_id, eigenschaft),
    CONSTRAINT chk_lzg_haltung_spanne    CHECK (ladung >= -1.0 AND ladung <= 1.0)
);

CREATE INDEX IF NOT EXISTS idx_lzg_haltung_knoten
    ON lzg_knoten_haltung (knoten_id, aktiv);

-- ───────────────────────────────────────────────
-- abstrakt_knoten — die geschlossenen Saetze
-- ───────────────────────────────────────────────
-- Die abstrakte Schicht aus `novaberg-thinking-faszination_k.md` §4.4: Ein
-- Traeger haengt nicht an sich selbst, sondern an abstrakten Knoten — und
-- **deshalb erbt der Zwilling.** Wer sich fuer ein Werkzeug fasziniert und
-- ein baugleiches daneben aufnimmt, faszinieren sich fuer dasselbe; die
-- Zuwendung erbt er nicht.
--
-- **Warum eine eigene Tabelle und keine Zeilen in `lzg_knoten`:** Das Konzept
-- legt beide Sorten in dieselbe Schicht, und `lzg_knoten_haltung.
-- praemisse_knoten_id` zeigt heute auf `lzg_knoten`. Gemessen am 03.09.2026
-- lesen aber **24 Stellen in 13 Modulen** aus `lzg_knoten`, 22 davon nach
-- Kriterien statt nach `id` — Enricher, emotionale Gravitation, Spreading,
-- Charakter-Agent, Wissensluecken, Re-Embedding. Sechs abstrakte Knoten
-- dort waeren aus jeder einzelnen auszuschliessen, und ein vergessener
-- Ausschluss ist ein stiller Fehler: eine Qualitaet, die im Spreading als
-- Erinnerung auftaucht. Der Preis des Umzugs ist null — `lzg_knoten_haltung`
-- traegt 0 Zeilen, `praemisse_knoten_id` ist nie benutzt worden.
-- §6.1 stuetzt es: `neuheit` wurde ausdruecklich ausgesondert, weil es „die
-- Lage im Graphen" ist. Die sechs Dimensionen sind es gerade nicht.
--
-- **`art` ist der Typ-Diskriminator, und er ist kein Ordnungsmerkmal.** Ohne
-- ihn sickert die Valenz von der Werte- auf die Qualitaetsseite (§4.4), und
-- Kriegsgeschichte truege weniger Faszination als Gartenkraeuter — der
-- Fehler waere fest im Schema verbaut. Die Wert-Kante ist
-- vorzeichenbehaftet (`lzg_knoten_haltung.ladung`), die Qualitaets-Kante
-- nicht: 0,8, nicht „gut 0,8".
--
-- **Nicht `lzg_knoten.dimension` benutzen.** Die Spalte traegt eine
-- INHALTSkategorie (kognition, kontext, emotion, …) und darunter
-- ausgerechnet den Wert `werte` — 161 Zeilen am 03.09.2026. Wer sie als
-- Knotentyp liest, bekommt 161 Episoden als Werte-Knoten.
--
-- Das Vokabular ist **gesetzt, nicht geerntet** (§5): Drei Ernteversuche
-- sind am 30.08.2026 gemessen gescheitert, jeder gab die Form seines Korpus
-- zurueck. Deshalb stehen die sechs Qualitaeten unten im Schema.
CREATE TABLE IF NOT EXISTS abstrakt_knoten (
    id           SERIAL      PRIMARY KEY,

    -- Der Typ-Diskriminator. Geschlossene Menge, als CHECK erzwungen:
    -- ein dritter Wert waere ein stiller Fehler, kein neuer Fall.
    art          TEXT        NOT NULL,
    name         TEXT        NOT NULL,
    beschreibung TEXT        NOT NULL DEFAULT '',

    -- Woher der Begriff stammt. Bei gesetztem Vokabular ist das die
    -- Literaturstelle — ohne sie ist der Satz nicht nachpruefbar.
    herkunft     TEXT        NOT NULL DEFAULT '',
    erstellt_am  TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_abstrakt_knoten UNIQUE (art, name),
    CONSTRAINT chk_abstrakt_art   CHECK (art IN ('qualitaet', 'wert'))
);

-- Die sechs Qualitaetsdimensionen (Konzept §6.1). Idempotent, damit der
-- Aufbau von null reproduzierbar bleibt.
--
-- **Warum sechs und nicht acht:** `neuheit` ist die Lage im Graphen, keine
-- Eigenschaft des Traegers — sie gehoert als Kanteneigenschaft in den
-- Spreading-Pass. `bewaeltigbarkeit` ist bei Silvia eine Relation zwischen
-- Reiz und Person und damit ein Tor beim LESEN, kein Merkmal beim Schreiben.
--
-- Am Bestand geprueft (§6.2, 30.08.2026): 50 zufaellige Knoten von Hand
-- bewertet, 26 mit dominanter Dimension, kein Kollaps auf `komplexitaet`.
-- Die vier ohne Ausschlag sind die richtigen — eine Groesse, die Faszination
-- messen soll, muss auch Null sagen koennen.
INSERT INTO abstrakt_knoten (art, name, beschreibung, herkunft) VALUES
    ('qualitaet', 'komplexitaet',
     'Wie viele unterscheidbare Teile und Beziehungen der Gegenstand traegt.',
     'Berlyne 1960, kollative Variablen'),
    ('qualitaet', 'ungewissheit',
     'Wie viel offen bleibt. Die einzige erschoepfbare Dimension — die Erklaerung beendet sie.',
     'Berlyne 1960, kollative Variablen'),
    ('qualitaet', 'konflikt',
     'Zwei Eigenschaften, die einander widersprechen. Nicht guenstig und nicht stabil fasziniert — guenstig UND stabil.',
     'Berlyne 1960, kollative Variablen'),
    ('qualitaet', 'weite',
     'Die Entfernung vom Greifbaren; Groessenordnungen, die den Massstab sprengen.',
     'Kaplan 1995 (vastness); Keltner & Haidt 2003'),
    ('qualitaet', 'schemasprengung',
     'Der Gegenstand passt in kein vorhandenes Schema und verlangt, dass es umgebaut wird.',
     'Keltner & Haidt 2003 (need for accommodation)'),
    ('qualitaet', 'bedrohungsrelevanz',
     'Der Gegenstand beruehrt etwas, das gefaehrlich werden koennte — die Faszination am Abgrund.',
     'Scrivner 2021, morbid curiosity')
ON CONFLICT (art, name) DO NOTHING;

-- ───────────────────────────────────────────────
-- traeger_qualitaet — die vorzeichenlose Kante
-- ───────────────────────────────────────────────
-- Traeger → Qualitaet, mit einer Auspraegung in [0,1] (Konzept §4.4).
--
-- **Vorzeichenlos ist der ganze Punkt.** Eine negative Auspraegung waere
-- eine Wert-Aussage an einer Qualitaets-Kante; das Schema lehnt sie ab,
-- nicht der Aufrufer. `lzg_knoten_haltung` steht daneben und laesst genau
-- das zu — die beiden Tabellen unterscheiden sich in dieser einen Zeile,
-- und darin liegt der Unterschied zwischen normativ und deskriptiv.
--
-- **Zwei Zustaende genuegen, nicht drei.** §4.5 verlangt fuer die WERT-Kante
-- anwesend/abwesend/ungeprueft, weil ein Profil, das nur besetzte Werte
-- kennt, das Fehlen einer Eigenschaft nicht ausdruecken kann. Hier traegt
-- die Zeilenanwesenheit dieselbe Unterscheidung: Ein Profillauf schreibt
-- alle sechs Dimensionen auf einmal, also heisst keine Zeile „ungeprueft"
-- und `auspraegung = 0.0` „gemessen abwesend".
--
-- **Zum Loeschverhalten:** `CASCADE` wie beim direkten Nachbarn
-- `lzg_knoten_haltung`, und aus demselben Grund — der Graph loescht keine
-- Knoten, er laesst sie ruhen (`aktiv = FALSE`); CASCADE greift nur bei
-- Reset oder Bereinigung, und dann ist eine Annotation ohne ihren
-- Gegenstand nichts wert. `SET NULL` scheidet zusaetzlich aus, weil eine
-- Qualitaetskante ohne Traeger bedeutungslos ist.
CREATE TABLE IF NOT EXISTS traeger_qualitaet (
    id            SERIAL           PRIMARY KEY,
    knoten_id     INTEGER          NOT NULL REFERENCES lzg_knoten(id)     ON DELETE CASCADE,
    qualitaet_id  INTEGER          NOT NULL REFERENCES abstrakt_knoten(id) ON DELETE CASCADE,

    -- Wie viel wovon. 0.0 heisst gemessen abwesend, nicht ungeprueft.
    auspraegung   DOUBLE PRECISION NOT NULL,

    -- Woher die Bewertung stammt — ohne Herkunft ist sie nicht nachrechenbar.
    quelle        TEXT             NOT NULL DEFAULT '',

    -- Wie oft der Traeger profiliert wurde. Eine zweite Bewertung
    -- verstaerkt die vorhandene, sie dupliziert sie nicht.
    haeufigkeit   INTEGER          NOT NULL DEFAULT 1,
    erstellt_am   TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    verstaerkt_am TIMESTAMPTZ      NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_traeger_qualitaet     UNIQUE (knoten_id, qualitaet_id),
    CONSTRAINT chk_traeger_auspraegung  CHECK (auspraegung >= 0.0 AND auspraegung <= 1.0)
);

CREATE INDEX IF NOT EXISTS idx_traeger_qualitaet_knoten
    ON traeger_qualitaet (knoten_id);

-- ───────────────────────────────────────────────
-- charakter_hash
-- ───────────────────────────────────────────────
-- character_id Default '' (NICHT 'nova'): entspricht dem Live-Stand.
-- Hintergrund: Bestandsdaten enthalten Test-Zeilen mit leerer character_id.
CREATE TABLE IF NOT EXISTS charakter_hash (
    user_id                        TEXT        NOT NULL,
    character_id                   TEXT        NOT NULL DEFAULT '',
    kern_hash                      TEXT        NOT NULL DEFAULT '',
    adaptive_hash                  TEXT        NOT NULL DEFAULT '',
    kern_aktualisiert_am           TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    adaptive_aktualisiert_am       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    intentions_profil              TEXT        NOT NULL DEFAULT '',
    intentions_aktualisiert_am     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    emotions_profil                TEXT        NOT NULL DEFAULT '',
    emotions_aktualisiert_am       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    beziehungsprofil               TEXT        NOT NULL DEFAULT '',
    beziehung_aktualisiert_am      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (user_id, character_id)
);

-- ───────────────────────────────────────────────
-- hintergrund_log
-- ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS hintergrund_log (
    id             SERIAL      PRIMARY KEY,
    user_id        TEXT        NOT NULL,
    aufgabe        TEXT        NOT NULL,
    ergebnis       TEXT,
    status         TEXT        NOT NULL DEFAULT 'offen',
    erstellt_am    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    verarbeitet_am TIMESTAMPTZ
);

-- ═══════════════════════════════════════════════
-- Wissensgraph: entitaeten + fakten
-- ═══════════════════════════════════════════════

-- ───────────────────────────────────────────────
-- entitaeten — Knowledge-Graph-Knoten
-- ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS entitaeten (
    id                  SERIAL          PRIMARY KEY,
    user_id             VARCHAR(50)     NOT NULL,
    name                VARCHAR(255)    NOT NULL,
    typ                 VARCHAR(50)     NOT NULL DEFAULT 'sonstiges',
    zusammenfassung     TEXT,
    embedding           VECTOR(768),
    suchtext            TSVECTOR,
    t_created           TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    t_valid             TIMESTAMPTZ,
    t_invalid           TIMESTAMPTZ,
    aktiv               BOOLEAN         NOT NULL DEFAULT TRUE,
    last_touched        TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    wiedervorlage_am    TIMESTAMPTZ
);

-- Embedding-Index (ivfflat) manuell anlegen wenn > 100 Einträge vorhanden:
-- CREATE INDEX idx_entitaeten_embedding ON entitaeten
--     USING ivfflat (embedding vector_cosine_ops) WITH (lists = 10);

-- ───────────────────────────────────────────────
-- fakten — Knowledge-Graph-Kanten
-- ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS fakten (
    id                  SERIAL          PRIMARY KEY,
    user_id             VARCHAR(50)     NOT NULL,
    character_id        VARCHAR(50)     NOT NULL DEFAULT 'nova',

    subjekt_id          INTEGER         NOT NULL REFERENCES entitaeten(id),
    attribut            VARCHAR(255)    NOT NULL,

    objekt_id           INTEGER         REFERENCES entitaeten(id),
    objekt_wert         TEXT,

    fakt_text           TEXT            NOT NULL,
    embedding           VECTOR(768),

    t_created           TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    t_valid             TIMESTAMPTZ,
    t_invalid           TIMESTAMPTZ,

    aktiv               BOOLEAN         NOT NULL DEFAULT TRUE,
    last_touched        TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    wiedervorlage_am    TIMESTAMPTZ,

    -- Woher der Fakt stammt. Pflicht und ohne Vorgabewert: Ein Vorgabewert
    -- saehe aus wie eine echte Herkunft.
    herkunft            VARCHAR(20)     NOT NULL,
    -- Ob die Beziehung hoechstens einen aktiven Wert hat (wohnt in) oder
    -- mehrere (hat Haustier). Pflicht und ohne Vorgabewert, aus demselben Grund.
    einwertig           BOOLEAN         NOT NULL,
    -- Nackte Spalte; den Fremdschluessel setzt server/agents/timeline/init.sql.
    timeline_id         INTEGER,
    -- Materialisierte Staerke der Kante, geschrieben vom taeglichen Verfallslauf.
    -- Pflicht und ohne Vorgabewert: Ein Vorgabewert saehe aus wie ein Messwert
    -- des Laufs. Eine neue Kante setzt ihr Schreiber.
    staerke_decay       DOUBLE PRECISION NOT NULL,
    decay_am            TIMESTAMPTZ     NOT NULL DEFAULT NOW(),

    CONSTRAINT chk_fakten_objekt CHECK (
        (objekt_id IS NOT NULL AND objekt_wert IS NULL) OR
        (objekt_id IS NULL AND objekt_wert IS NOT NULL)
    ),
    CONSTRAINT chk_fakten_herkunft CHECK (
        herkunft IN ('mensch', 'nova', 'modell', 'recherche')
    )
);

-- Embedding-Index (ivfflat) manuell anlegen wenn > 100 Einträge vorhanden:
-- CREATE INDEX idx_fakten_embedding ON fakten
--     USING ivfflat (embedding vector_cosine_ops) WITH (lists = 10);

-- ───────────────────────────────────────────────
-- notizen
-- ───────────────────────────────────────────────
-- timeline_id: nackte INTEGER-Spalte; FK-Constraint auf timeline(id) wird in
-- server/agents/timeline/init.sql gesetzt.
CREATE TABLE IF NOT EXISTS notizen (
    id                SERIAL      PRIMARY KEY,
    user_id           TEXT        NOT NULL,
    name              TEXT        NOT NULL,
    typ               TEXT        NOT NULL,
    text              TEXT        NOT NULL,
    faellig_am        TIMESTAMPTZ,
    status            TEXT        NOT NULL DEFAULT 'aktiv',
    created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    zusammenfassung   VARCHAR(200),
    themen            TEXT[],
    entitaet_ids      INTEGER[],
    aktiv             BOOLEAN     NOT NULL DEFAULT TRUE,
    last_touched      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    wiedervorlage_am  TIMESTAMPTZ,
    suchtext          TSVECTOR,
    timeline_id       INTEGER
);

-- ───────────────────────────────────────────────
-- gespraech_archiv
-- ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS gespraech_archiv (
    id          SERIAL           PRIMARY KEY,
    user_id     TEXT             NOT NULL,
    session_id  TEXT             NOT NULL,
    rolle       TEXT             NOT NULL,
    inhalt      TEXT             NOT NULL,
    salienz     DOUBLE PRECISION,
    erstellt_am TIMESTAMPTZ      NOT NULL DEFAULT NOW()
);

-- ───────────────────────────────────────────────
-- ziele (Drive)
-- ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS ziele (
    id              SERIAL           PRIMARY KEY,
    user_id         VARCHAR(50)      NOT NULL DEFAULT 'nova',
    ziel_typ        VARCHAR(20)      NOT NULL DEFAULT 'mittelfristig',
    zielsatz        TEXT             NOT NULL,
    motivation      DOUBLE PRECISION NOT NULL DEFAULT 0.5,
    emotion         VARCHAR(30)      NOT NULL DEFAULT '',
    arousal         DOUBLE PRECISION NOT NULL DEFAULT 0.5,
    embedding       VECTOR(768),
    aktiv           BOOLEAN          NOT NULL DEFAULT TRUE,
    erstellt_am     TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    aktualisiert_am TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    thema           VARCHAR(100)     NOT NULL DEFAULT ''
);

-- ───────────────────────────────────────────────
-- verb_mappings — Lernende Verb-Mappings (CRUD-Härtung)
-- ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS verb_mappings (
    id          SERIAL      PRIMARY KEY,
    user_id     TEXT        NOT NULL,
    ausdruck    TEXT        NOT NULL,
    aktion      TEXT        NOT NULL,
    agent       TEXT        NOT NULL,
    konfidenz   INTEGER     NOT NULL DEFAULT 1,
    erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (user_id, ausdruck, agent)
);

-- ───────────────────────────────────────────────
-- pipeline_log — Forensik-Tabelle für Node-Entscheidungen pro Turn
-- ───────────────────────────────────────────────
-- Querschnitts-Infrastruktur. Jeder Eintrag dokumentiert einen Entscheidungs-,
-- Berechnungs- oder Schreib-Schritt einer Pipeline-Komponente während eines
-- Konversations- oder Pixie-Turns. Inhalt strukturiert als JSONB, damit sowohl
-- Mensch als auch LLM die Einträge lesen können.
--
-- Keine CHECK-Constraint auf `art` — die gültigen Werte werden per Konvention
-- durch die Helper-API in server/memory/pipeline_log.py durchgesetzt, nicht
-- durch DB-Constraints (vermeidet Schema-Änderungen bei zukünftiger
-- Art-Erweiterung).
--
-- Spezifikation: docs/novaberg-memory-synapsen_k.md §10.
CREATE TABLE IF NOT EXISTS pipeline_log (
    id              BIGSERIAL    PRIMARY KEY,
    erstellt_am     TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    turn_id         VARCHAR(100) NOT NULL,
    span_id         UUID         NULL,
    quelle          VARCHAR(50)  NOT NULL,
    node            VARCHAR(50)  NOT NULL,
    art             VARCHAR(30)  NOT NULL,
    inhalt          JSONB        NOT NULL,
    user_id         VARCHAR(50)  NULL,
    character_id    VARCHAR(50)  NULL
);

CREATE INDEX IF NOT EXISTS idx_pipeline_log_turn     ON pipeline_log (turn_id);
CREATE INDEX IF NOT EXISTS idx_pipeline_log_span     ON pipeline_log (span_id);
CREATE INDEX IF NOT EXISTS idx_pipeline_log_node_art ON pipeline_log (node, art);
CREATE INDEX IF NOT EXISTS idx_pipeline_log_erstellt ON pipeline_log (erstellt_am DESC);
CREATE INDEX IF NOT EXISTS idx_pipeline_log_paar     ON pipeline_log (user_id, character_id);


-- ───────────────────────────────────────────────
-- verbindung — Brücke Turn ↔ Gedächtnis-Eintrag
-- ───────────────────────────────────────────────
-- Nachschlagewerk außerhalb des kognitiven Gedächtnisses: welcher Rohturn hat
-- welchen KZG-Eintrag erzeugt, und in welchen LZG-Knoten ist dieser Eintrag
-- später umgezogen. Kein Gewicht, kein Decay, keine Salienz — ein Tagebuch-
-- eintrag verblasst nicht (§14, Folgeeigenschaften aus E8).
--
-- Spezifikation: docs/novaberg-charakter-resonanz_k.md §12, Bauteil 1b.
--
-- Abweichung gegenüber dem Schema-Entwurf in §12:
--   kzg_id ist NOT NULL — eine Zeile ohne Gedächtnis-Key belegt nichts.
-- lzg_id trägt ON DELETE SET NULL wie im Entwurf: eine verwaiste Zeile darf
-- liegen bleiben, der Lesepfad filtert ohnehin auf lzg_id IS NOT NULL (§11, E1).
--
-- Kein UNIQUE auf turn_id oder lzg_id: n:m ist zwingend. Ein Turn nährt
-- mehrere KZG-Einträge, und beide Graph-Läufe eines Turns schreiben unter
-- derselben turn_id (§12, §A5-Befund).
--
-- Kein Fremdschlüssel von turn_id auf die turn_roh-Zeile: Pfad 1 schreibt
-- seine KZG-Einträge, BEVOR Pfad 2 den Rohturn anlegt (entschieden Chat 109).
-- turn_id bleibt eine nackte Spalte.
CREATE TABLE IF NOT EXISTS verbindung (
    id          SERIAL       PRIMARY KEY,
    turn_id     VARCHAR(100) NOT NULL,
    kzg_id      TEXT         NOT NULL,
    lzg_id      INTEGER      REFERENCES lzg_knoten(id) ON DELETE SET NULL,
    erstellt_am TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_verbindung_turn ON verbindung (turn_id);
CREATE INDEX IF NOT EXISTS idx_verbindung_kzg  ON verbindung (kzg_id);
CREATE INDEX IF NOT EXISTS idx_verbindung_lzg  ON verbindung (lzg_id);


-- ───────────────────────────────────────────────
-- autonomous_wissen — Metadaten der Wissens-Bibliothek
-- ───────────────────────────────────────────────
-- Der Inhalt steht NICHT hier. Er liegt als Datei ausserhalb des Git-Roots,
-- als Geschwister der Repositoriumswurzel, im Behaelter unter /knowledge
-- eingehaengt. Die Dateien tragen aus Gespraechen abgeleitete Recherchen;
-- unterhalb des Arbeitsbaums wuerde jeder Push sie veroeffentlichen. Diese
-- Tabelle traegt nur, WO die Datei liegt, WORUM es geht (Zusammenfassung +
-- Embedding), WIE WICHTIG sie ist und OB sie noch aktiv ist.
--
-- Spezifikation: docs/novaberg-autonomous-wissen_k.md §7.2 (Metadaten) und
-- §11 (die Ueberarbeitung vom 04.08.2026, die bei Widerspruch Vorrang hat).
--
-- Paar-Partition (§11.2): dasselbe Tripel wie lzg_knoten, ziele und
-- charakter_hash — user_id ist das Subjekt, character_id das Gegenueber,
-- beobachter die Perspektive des Inhalts. Ohne sie waere dies der einzige
-- Bestand, der die Paar-Trennung nicht mitmacht, und Novas Wissen ueber den
-- einen fiele in ein Gespraech mit dem anderen.
--
-- Die drei Paar-Spalten haben KEINEN Default — anders als bei lzg_knoten,
-- wo er den Bestand durch die Migration tragen musste. Diese Tabelle startet
-- leer, also kostet der strengere Weg nichts: Ein Schreiber ohne Gegenueber
-- scheitert laut, statt eine Zeile abzulegen, die spaeter wie ein Paar
-- aussieht. Dieselbe Bauart wie ziele.character_id.
--
-- salienz_anfang ohne Default (§11.4): Der Wert ist beim Schreiben immer
-- bekannt — er hat den Vorgang ausgeloest. Ein `DEFAULT 0.0` waere genau das
-- Muster, das eine Null wie einen Messwert aussehen laesst. Belegt, dass die
-- Gefahr real ist: In der Shadow-Queue trugen am 04.08.2026 49 von 650
-- Auftraegen Prioritaet 0.0, obwohl sie das Hochsalienz-Tor passiert hatten.
--
-- Gewicht (§11.6): Bauart UND Konstanten des lzg_knoten — roh waechst linear,
-- absolut ist sinus-gedaempft und saettigt bei LZG_KNOTEN_GEWICHT_CAP, decay
-- traegt die Zeit. Das erarbeitete Wissen ist Langzeitgedaechtnis in
-- Dateiform und benutzt LZG_KNOTEN_DECAY_RATE ausdruecklich mit, nicht nur
-- denselben Wert: Wird der Gedaechtnisverfall je nachkalibriert, soll das
-- Wissen mitgehen. Nur der Gedankenstapel bekommt eine eigene Rate.
--
-- gewicht_decay wird MATERIALISIERT, nicht bei Abfrage gerechnet: Ein Lauf
-- schreibt Spalte und decay_am, die Lesepfade lesen die Spalte — wie bei
-- run_node_decay. Das steht hier ausdruecklich, weil dieselbe Aussage an
-- drei Stellen im Bestand falsch dokumentiert ist. Der Lauf wird ein dritter
-- Schritt im vorhandenen Tageslauf synapsen_decay (§11.7, WIS-5).
--
-- dateipfad UNIQUE: Eine Wissensdatei hat genau eine Metadatenzeile. Eine
-- Verstaerkung (§11.5) aktualisiert sie und erhoeht haeufigkeit, statt eine
-- zweite anzulegen. Ohne UNIQUE waere der Unterschied nicht bemerkbar.
--
-- Kein CHECK auf typ, modus oder status: dieselbe Konvention wie bei
-- pipeline_log.art — die gueltigen Werte setzt die schreibende Helper-API
-- durch, nicht die Datenbank. Das vermeidet eine Schema-Aenderung, sobald
-- ein vierter Modus dazukommt (nachfragen ist gerade der dritte gewesen).
CREATE TABLE IF NOT EXISTS autonomous_wissen (
    -- Identitaet
    id                SERIAL           PRIMARY KEY,
    dateipfad         TEXT             NOT NULL UNIQUE,

    -- Paar-Partition (§11.2)
    user_id           TEXT             NOT NULL,
    character_id      VARCHAR(50)      NOT NULL,
    beobachter        VARCHAR(20)      NOT NULL,

    -- Inhalt
    thema             TEXT             NOT NULL,
    zusammenfassung   TEXT             NOT NULL,
    themen_embedding  VECTOR(768),
    typ               VARCHAR(20)      NOT NULL,   -- 'wissen' | 'bericht'
    modus             VARCHAR(20)      NOT NULL,   -- recherche|vertiefung|traum|nachfragen
    status            VARCHAR(30),                 -- Ergebnis-Klassifikation §5.1

    -- Gewicht: Bauart und Konstanten des lzg_knoten (§11.6)
    salienz_anfang    DOUBLE PRECISION NOT NULL,   -- kein Default (§11.4)
    gewicht_roh       DOUBLE PRECISION NOT NULL,
    gewicht_absolut   DOUBLE PRECISION NOT NULL,
    gewicht_decay     DOUBLE PRECISION NOT NULL,
    haeufigkeit       INTEGER          NOT NULL DEFAULT 1,
    aktiv             BOOLEAN          NOT NULL DEFAULT TRUE,
    erstellt_am       TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    verstaerkt_am     TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    decay_am          TIMESTAMPTZ      NOT NULL DEFAULT NOW()
);

-- Der Lesepfad filtert auf Paar, Typ und aktiv (§7.5) und ordnet erst danach
-- nach Embedding-Naehe. Ein Partial Index auf genau diese drei deckt alle vier
-- Konsumenten ab; inaktive Zeilen sind fuer den Enricher ohnehin unsichtbar.
CREATE INDEX IF NOT EXISTS idx_autonomous_wissen_aktiv
    ON autonomous_wissen (user_id, character_id, typ) WHERE aktiv = TRUE;

-- autonomous_wissen_thema — ein Vektor je Thema (Konvention 4)
-- ───────────────────────────────────────────────
-- `autonomous_wissen.thema` traegt eine LISTE: im Mittel 4,37 durch Komma
-- getrennte Themen, hoechstens 17, und 558 von 559 Feldern mehr als eines.
-- Ein einzelner Vektor darueber liegt in ihrem Schwerpunkt, und der
-- Schwerpunkt mehrerer unverwandter Begriffe ist keinem davon nah.
--
-- Gemessen am 19.08.2026 ueber 40 Fragen nach EINEM Thema, die richtige
-- Antwort je Frage bekannt: Der Vektor der Zeile fand seinen eigenen Eintrag
-- in 6 von 40 Faellen auf Rang 1, Kosinus-Median 0,2821 — UNTERHALB der
-- Abweisungsschwelle von 0,40. Die richtige Antwort wurde also im Regelfall
-- verworfen, nicht nur schlecht gereiht. Ein Vektor je Thema: 31 von 40,
-- Median 0,7425.
--
-- Regel und Begruendung: docs/novaberg-convention-embedding.md §5.
--
-- DIESE TABELLE ERSETZT `themen_embedding` NICHT. Die Bibliothek hat zwei
-- Konsumenten mit gegenlaeufiger Anfragelaenge: Die Bestellung fragt mit
-- einer kurzen Frage (~60-100 Zeichen) und braucht Themenvektoren; der
-- Rueckweg fragt mit Ø 833 Zeichen Kontext und braucht einen Inhaltsvektor.
-- Gemessen: gegen das Destillat findet der Rueckweg die richtige Zeile 25 von
-- 25 Mal unter den ersten acht, gegen Themenvektoren 12 von 25 — und die
-- beiden Kandidatenlisten ueberlappen im Median mit 1 von 8. Wer eines der
-- Ziele abschafft, repariert die eine Seite und bricht die andere.
--
-- ON DELETE CASCADE: Ein Themenvektor ohne seine Ausarbeitung ist
-- gegenstandslos — er zeigt auf nichts und taucht trotzdem in jeder Suche
-- auf. Dieselbe Semantik wie die Kanten in `verbindung` (abhaengige Zeile
-- ohne eigenen Gegenstand), nicht die der Gedaechtnisknoten selbst.
--
-- UNIQUE (wissen_id, thema): Zweimal dasselbe Thema an derselben Zeile waere
-- zweimal derselbe Vektor im Ergebnis. Dasselbe Thema an ZWEI Ausarbeitungen
-- ist dagegen erlaubt und der Normalfall.
--
-- embedding NULL-faehig und ohne Vorgabewert: NULL heisst "noch nicht
-- eingebettet". Ein Default waere genau das Muster, das eine fehlende
-- Messung wie eine vorhandene aussehen laesst (dieselbe Klasse wie
-- salienz_anfang oben).
CREATE TABLE IF NOT EXISTS autonomous_wissen_thema (
    id         SERIAL       PRIMARY KEY,
    wissen_id  INTEGER      NOT NULL REFERENCES autonomous_wissen(id) ON DELETE CASCADE,
    thema      TEXT         NOT NULL,
    embedding  VECTOR(768),

    UNIQUE (wissen_id, thema)
);

-- Jeder Lesepfad joint ueber wissen_id zurueck auf die Ausarbeitung.
CREATE INDEX IF NOT EXISTS idx_autonomous_wissen_thema_wissen
    ON autonomous_wissen_thema (wissen_id);

-- Vektor-Index (ivfflat) bewusst NICHT angelegt — §7.2 nennt ihn, der Bestand
-- widerlegt ihn. Bei kleinen Zeilenzahlen durchsucht ivfflat mit probes=1 eine
-- einzige Zentroid-Liste und der Recall bricht auf nahezu null ein; belegt
-- Chat 107 (IVFFLAT-RECALL-KOLLAPS) an lzg_knoten, siehe dort. Diese Tabelle
-- startet bei null Zeilen. Manuell anlegen, wenn > ~10k Eintraege vorhanden
-- sind (dann lists ≈ rows/1000 waehlen und ivfflat.probes mitkalibrieren):
-- CREATE INDEX idx_autonomous_wissen_embedding
--     ON autonomous_wissen USING ivfflat (themen_embedding vector_cosine_ops) WITH (lists = 20);


-- ───────────────────────────────────────────────
-- shadow_auftrag — die Shadow-Queue (novaberg-queue-verfall_k.md §8)
-- ───────────────────────────────────────────────
--
-- Die Queue lag bis zum 15.08.2026 als Redis-Liste unter
-- `shadow_queue:{user_id}`. Sie zieht hierher, weil das Verfallsmodell eine
-- Zeile braucht, die einen deaktivierten Auftrag aufbewahrt, ohne ihn im
-- Auswahlpfad mitzulesen: In einer Liste markiert ein Soft-Delete das Rauschen,
-- statt es abzuraeumen — der Vollscan (LRANGE 0 -1) wird nie kleiner.
--
-- **Der Stapel zieht NICHT mit** (§7.2). Die Grenze folgt der Lesefrequenz,
-- nicht der Datenmenge: Die Queue liest der Heartbeat alle 30 bis 120 s, den
-- Stapel der Zustellungs-Loop alle 5 s je verbundenem Client — und die
-- Postgres-Zugriffe dieses Projekts oeffnen je Aufruf eine eigene Verbindung.
--
-- Die acht Spalten ohne Vorgabewert sind die Zusicherung des Bauteils. In
-- Redis konnte nichts erzwungen werden, und genau das war messbar: 233 von
-- 1036 Auftraegen trugen Salienz 0.0, weil ein Aufrufer das Argument ausliess
-- und die Signatur einen Default trug (KANDIDATEN-PRIORITAET-STILLE-NULL).
-- Die Sperre wandert damit von der Signatur in das Schema.
--
-- Kein CHECK auf aufgabe oder emotion: dieselbe Konvention wie bei
-- autonomous_wissen und pipeline_log.art — die gueltigen Werte setzt die
-- schreibende API durch, nicht die Datenbank.
CREATE TABLE IF NOT EXISTS shadow_auftrag (
    -- Identitaet
    id                SERIAL           PRIMARY KEY,

    -- Paar-Partition (Subjekt x Gegenueber x Beobachter)
    user_id           TEXT             NOT NULL,
    character_id      VARCHAR(50)      NOT NULL,
    beobachter        VARCHAR(20)      NOT NULL,

    -- Auftrag: was getan werden soll
    aufgabe           TEXT             NOT NULL,
    thema             TEXT             NOT NULL,
    kontext           TEXT             NOT NULL DEFAULT '',

    -- Anlass: die Lage, aus der er entstand
    intentionen       TEXT[]           NOT NULL DEFAULT '{}',
    emotion           TEXT             NOT NULL DEFAULT '',
    modus             TEXT             NOT NULL DEFAULT '',

    -- Worauf sich der Auftrag bezieht — die Zeile, aus der er entstand.
    -- Angekuendigt und angelegt am 19.08.2026 (F-DDL-1).
    --
    -- **Kein Fremdschluessel, und das ist eine Festlegung, keine Bequemlichkeit.**
    -- Nach F-VERFALL-1 (b) verfaellt eine Entitaet nicht mehr, deren ID
    -- anderswo als Fremdschluessel dient. Ein Queue-Auftrag darf die Zeile,
    -- auf die er zeigt, nicht gegen den Verfall festnageln, dem sie
    -- unterliegen soll. Eine ins Leere zeigende ID ist hier harmlos: Der
    -- einzige Leser benutzt sie als Ausschluss, und ein Ausschluss, der
    -- nichts trifft, kostet einen Kandidaten zu viel — keinen Datensatz.
    --
    -- **Die Zieltabelle haengt an der Aufgabe.** Bei 'wissen_verweis' ist es
    -- autonomous_wissen.id — die Zeile, die die Recherche gerade angelegt hat
    -- und die deshalb nicht ihr eigener Zuordnungskandidat sein darf. Genau
    -- daran scheitert ein Fremdschluessel ein zweites Mal: Er bindet die
    -- Spalte an eine Tabelle, waehrend der naechste Bedarf schon benannt ist
    -- (der Turnbezug, der heute im modus-Feld mitreist).
    bezug_id          INTEGER,

    -- Salienz: Bauart und Konstanten des lzg_knoten, eigene Rate (§4, §9).
    -- Aufbau ueber Sinus-Saettigung, Verfall exponentiell, Cap 1.0 statt 10.0
    -- — die Queue fuehrt Salienz, und auf Cap 10 waere die Schwelle 0,3
    -- gleich 3 % und der Verfall liefe still ins Leere.
    salienz_roh       DOUBLE PRECISION NOT NULL,   -- Akkumulator
    salienz_absolut   DOUBLE PRECISION NOT NULL,   -- Anker, kein Default
    salienz_decay     DOUBLE PRECISION NOT NULL,   -- Praesenz, materialisiert
    haeufigkeit       INTEGER          NOT NULL DEFAULT 1,

    -- Soft-Delete: ein verfallener Auftrag verschwindet nicht, er ruht (§12.1)
    aktiv             BOOLEAN          NOT NULL DEFAULT TRUE,

    -- Warum die Zeile ist, wie sie ist. Angekuendigt und angelegt am
    -- 23.08.2026.
    --
    -- **`aktiv` und `grund` sind zwei Fragen, nicht eine.** `aktiv` sagt, ob
    -- die Zeile noch gesucht wird; `grund` sagt, warum sie stillliegt. Die
    -- Lesestellen filtern auf das erste und wollen das zweite nicht wissen —
    -- sie zusammenzulegen hiesse, jeder Suche eine Bedeutungsfrage
    -- aufzubuerden.
    --
    -- Kanon: '' (aktiv oder Altbestand) | 'verfall' | 'fehlversuch'.
    -- Kein CHECK — dieselbe Konvention wie bei pipeline_log.art: die
    -- schreibende Schicht setzt die Werte durch.
    --
    -- **Der Fehlversuchspfad loeschte bis dahin hart.** Formal war das kein
    -- Verstoss gegen §6 der Verfallskonvention, die das harte Loeschen nur
    -- fuer den *Verfall* verworfen hatte. Gemessen am 16.08.2026 stand die
    -- Grenze aber unter Druck: Ueber 582 aktive recherche-Eintraege stieg die
    -- mittlere salienz_roh monoton mit der Zahl der Versuche
    -- (0,867 / 0,947 / 0,990) — der Verfall entfernte weich, was niemanden
    -- interessiert, der Fehlversuch hart, was am meisten interessiert.
    --
    -- Die Altzeilen tragen '' und sind daran als Altbestand erkennbar; eine
    -- rueckwirkende Zuordnung waere geraten und nicht gemessen.
    grund             VARCHAR(20)      NOT NULL DEFAULT '',

    -- Zeit
    erstellt_am       TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    verstaerkt_am     TIMESTAMPTZ      NOT NULL DEFAULT NOW(),
    decay_am          TIMESTAMPTZ      NOT NULL DEFAULT NOW(),

    -- Ausfuehrung: 0 heisst "noch kein Versuch", NULL waere "unbekannt"
    versuche          INTEGER          NOT NULL DEFAULT 0
);

-- Der Auswahlpfad nimmt den dringlichsten aktiven Auftrag eines Paares.
-- `salienz_decay DESC` steht in der Definition, weil die Rangfolge Dringlichkeit
-- ist und der Verfall sie ueber die Zeit senkt (§12.3) — ein aufsteigender
-- Index kehrte die Reihenfolge um und lieferte den schwaechsten zuerst.
CREATE INDEX IF NOT EXISTS idx_shadow_auftrag_wahl
    ON shadow_auftrag (user_id, character_id, aktiv, salienz_decay DESC);

-- Der Reaktivierungspfad sucht denselben Gegenstand desselben Paares (§6.1).
-- Er trifft auch ruhende Zeilen und darf deshalb NICHT auf `aktiv` filtern:
-- Ein deaktivierter Auftrag ist genau der, den ein wiederkehrender Anlass
-- wecken soll.
CREATE INDEX IF NOT EXISTS idx_shadow_auftrag_gegenstand
    ON shadow_auftrag (user_id, character_id, aufgabe, thema);


-- ═══════════════════════════════════════════════
-- Indizes
-- ═══════════════════════════════════════════════

-- lzg_knoten (Synapsen P2)
CREATE INDEX IF NOT EXISTS idx_lzg_knoten_aktiv
    ON lzg_knoten (user_id, character_id) WHERE aktiv = TRUE;
-- Vektor-Index (ivfflat) manuell anlegen wenn > ~10k Einträge vorhanden
-- (dann lists ≈ rows/1000 waehlen und ivfflat.probes mitkalibrieren):
-- ivfflat mit lists=100 bei 306 Zeilen und probes=1 durchsucht eine einzige
-- Zentroid-Liste mit ~3 Mitgliedern — der Recall bricht auf nahezu null ein.
-- Belegt Chat 107 (IVFFLAT-RECALL-KOLLAPS): "Was weißt du über Lumi?" lieferte
-- 0 Treffer ueber den Index, aber 118/308/102 mit Cosine 0.67-0.74 ueber den
-- Seq-Scan. Seq-Scan ist bei dieser Groesse exakt und < 1 ms.
-- CREATE INDEX idx_lzg_knoten_embedding
--     ON lzg_knoten USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
CREATE INDEX IF NOT EXISTS idx_lzg_knoten_themen
    ON lzg_knoten USING gin (themen);
CREATE INDEX IF NOT EXISTS idx_lzg_knoten_entitaet_ids
    ON lzg_knoten USING gin (entitaet_ids);
CREATE INDEX IF NOT EXISTS idx_lzg_knoten_timeline_id
    ON lzg_knoten (timeline_id);
CREATE INDEX IF NOT EXISTS idx_lzg_knoten_kzg_erstellt_am
    ON lzg_knoten (kzg_erstellt_am);
CREATE INDEX IF NOT EXISTS idx_lzg_knoten_user_id
    ON lzg_knoten (user_id);

-- lzg_kanten (Synapsen P2)
CREATE INDEX IF NOT EXISTS idx_lzg_kanten_a
    ON lzg_kanten (knoten_a_id);
CREATE INDEX IF NOT EXISTS idx_lzg_kanten_b
    ON lzg_kanten (knoten_b_id);
CREATE INDEX IF NOT EXISTS idx_lzg_kanten_geteilte_entitaet_ids
    ON lzg_kanten USING gin (geteilte_entitaet_ids);
CREATE INDEX IF NOT EXISTS idx_lzg_kanten_geteilte_themen
    ON lzg_kanten USING gin (geteilte_themen);
CREATE INDEX IF NOT EXISTS idx_lzg_kanten_verbindungs_gruende
    ON lzg_kanten USING gin (verbindungs_gruende);

-- entitaeten
CREATE INDEX IF NOT EXISTS idx_entitaeten_aktiv
    ON entitaeten (aktiv) WHERE aktiv = TRUE;
CREATE INDEX IF NOT EXISTS idx_entitaeten_user
    ON entitaeten (user_id, aktiv);
CREATE INDEX IF NOT EXISTS idx_entitaeten_name
    ON entitaeten (user_id, lower(name), aktiv);
CREATE INDEX IF NOT EXISTS idx_entitaeten_wiedervorlage
    ON entitaeten (wiedervorlage_am) WHERE wiedervorlage_am IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_entitaeten_suchtext
    ON entitaeten USING gin (suchtext);

-- fakten
CREATE INDEX IF NOT EXISTS idx_fakten_aktiv
    ON fakten (aktiv) WHERE aktiv = TRUE;
CREATE INDEX IF NOT EXISTS idx_fakten_aktiv_paar
    ON fakten (user_id, character_id) WHERE aktiv = TRUE;
CREATE INDEX IF NOT EXISTS idx_fakten_subjekt
    ON fakten (subjekt_id, aktiv);
CREATE INDEX IF NOT EXISTS idx_fakten_attribut
    ON fakten (subjekt_id, attribut, aktiv);
CREATE INDEX IF NOT EXISTS idx_fakten_objekt
    ON fakten (objekt_id, aktiv) WHERE objekt_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_fakten_wiedervorlage
    ON fakten (wiedervorlage_am) WHERE wiedervorlage_am IS NOT NULL;

-- notizen
CREATE INDEX IF NOT EXISTS idx_notizen_user
    ON notizen (user_id, typ);
CREATE INDEX IF NOT EXISTS idx_notizen_status
    ON notizen (user_id, status);
CREATE INDEX IF NOT EXISTS idx_notizen_themen
    ON notizen USING gin (themen);
CREATE INDEX IF NOT EXISTS idx_notizen_entitaet_ids
    ON notizen USING gin (entitaet_ids);

-- gespraech_archiv
CREATE INDEX IF NOT EXISTS idx_archiv_user_session
    ON gespraech_archiv (user_id, session_id);
CREATE INDEX IF NOT EXISTS idx_archiv_erstellt
    ON gespraech_archiv (erstellt_am);

-- ziele
CREATE INDEX IF NOT EXISTS idx_ziele_aktiv
    ON ziele (user_id) WHERE aktiv = TRUE;

-- verb_mappings
CREATE INDEX IF NOT EXISTS idx_verb_mappings_user
    ON verb_mappings (user_id, agent);


-- ═══════════════════════════════════════════════
-- Migrationen für bestehende Installationen
-- ═══════════════════════════════════════════════
-- Alle Statements sind idempotent. Konvention: neue Schema-Änderungen werden
-- HIER ergänzt, NICHT in die CREATE TABLE-Definitionen oben einkonsolidiert.
-- Bei einem späteren Review wird der Block in die CREATE-Definitionen
-- zurückgeführt.

-- ── langzeitgedaechtnis: entfernt (Synapsen P9, Chat 125) ──
-- Das Codeschloss des Synapsen-Umbaus. Die Tabelle ist durch `lzg_knoten`
-- und `lzg_kanten` abgeloest; zum Zeitpunkt des Drops trug sie 0 Zeilen.
--
-- CASCADE, weil `agents/timeline/init.sql` einen Fremdschluessel auf
-- `timeline(id)` gesetzt hatte. Der zugehoerige ALTER-Block ist dort im
-- selben Zug entfernt worden — bliebe er stehen, legte er die Tabelle bei
-- jedem Serverstart neu an, und der Drop waere eine Endlosschleife aus
-- Anlegen und Loeschen.
--
-- IF EXISTS, weil diese Datei bei jedem Start laeuft: Nach dem ersten Mal
-- ist nichts mehr zu tun, und eine Frischinstallation kennt die Tabelle nie.
DROP TABLE IF EXISTS langzeitgedaechtnis CASCADE;

-- ── shadow_auftrag ─────────────────────────────
-- Die Erregung des ausloesenden Turns, nachgezogen am 15.08.2026. Der Auftrag
-- trug die Lage seit jeher ueber `emotion` und `modus` — die dritte Groesse
-- derselben Lage fehlte, und damit konnte die Recherche keinen Level auf den
-- Stapel legen: Bauteil B war gebaut und ohne Eingabe (gemessen: kein einziger
-- Stapel-Eintrag mit Level).
--
-- **Ohne Vorgabewert und NULL-faehig**, anders als die beiden Nachbarn. NULL
-- heisst unbekannt — ein Vorgabewert waere ein Messwert, den nie jemand gemessen
-- hat. Bestandszeilen bleiben deshalb NULL statt still auf 0.5 zu stehen.
ALTER TABLE shadow_auftrag ADD COLUMN IF NOT EXISTS arousal DOUBLE PRECISION NULL;

-- ── charakter_hash ─────────────────────────────
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS intentions_profil          TEXT        NOT NULL DEFAULT '';
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS emotions_profil            TEXT        NOT NULL DEFAULT '';
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS beziehungsprofil           TEXT        NOT NULL DEFAULT '';
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS intentions_aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS emotions_aktualisiert_am   TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS beziehung_aktualisiert_am  TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS character_id               TEXT        NOT NULL DEFAULT '';
-- ── Charakter-Rad (Chat 111) ───────────────────
-- Gewichtung der Nutzer-Salienz aus dem Charakter-Rad
-- (novaberg-salienz-berechnung_k.md §5). Zwoelf Speichen um die Nabe 0.9,
-- sechs nach oben (Summe 0.60), sechs nach unten (Summe 0.40) — daher der
-- Wertebereich 0.5 bis 1.5.
--
-- _quelle trennt 'default' von 'destilliert'. Ohne diese Spalte sieht 0.9 aus
-- wie ein destillierter Wert, und niemand kann unterscheiden, ob der Charakter
-- das ergeben hat oder ob nie destilliert wurde
-- (novaberg-lesson_l_default-wie-fehlschlag.md).
--
-- _rad haelt die zwoelf Auspraegungen als JSON. TEXT statt JSONB, weil
-- charakter_hash durchgaengig TEXT fuer seine Profile nutzt.
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS nutzer_gewichtung          DOUBLE PRECISION NOT NULL DEFAULT 0.9;
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS nutzer_gewichtung_quelle   TEXT        NOT NULL DEFAULT 'default';
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS nutzer_gewichtung_rad      TEXT        NOT NULL DEFAULT '{"hoch": {"treue": 0.0, "dienst": 0.0, "pflicht": 0.0, "aufmerksamkeit": 0.0, "wissbegier": 0.0, "wohlwollen": 0.0}, "runter": {"widerspenstig": 0.0, "gleichgueltig": 0.0, "selbstbezogen": 0.0, "langeweile": 0.0, "distanz": 0.0, "misstrauen": 0.0}}';
-- Der Default ist das leere Rad, nicht der Leerstring: Eine frisch angelegte
-- Zeile traegt damit denselben Beleg wie eine destillierte — die 0.9 ist
-- nachrechenbar statt behauptet. Bestandszeilen mit Leerstring bekommen ihn
-- einmalig nachgetragen; destillierte Raeder bleiben unangetastet.
UPDATE charakter_hash
   SET nutzer_gewichtung_rad = '{"hoch": {"treue": 0.0, "dienst": 0.0, "pflicht": 0.0, "aufmerksamkeit": 0.0, "wissbegier": 0.0, "wohlwollen": 0.0}, "runter": {"widerspenstig": 0.0, "gleichgueltig": 0.0, "selbstbezogen": 0.0, "langeweile": 0.0, "distanz": 0.0, "misstrauen": 0.0}}'
 WHERE nutzer_gewichtung_rad = '' AND nutzer_gewichtung_quelle = 'default';
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS nutzer_gewichtung_am       TIMESTAMPTZ;

-- ── Initiative-Versatz: das zweite Charakter-Rad (Chat 116) ──
--
-- Dieselbe Bauart wie nutzer_gewichtung, andere Frage. Das erste Rad misst,
-- wie sehr Nova das Gegenueber ueberhaupt gilt; dieses misst, ob sie im
-- Gespraech die Fuehrung ueberlaesst oder behaelt. Zehn Speichen um eine
-- Nabe bei 0.0, volle Auslenkung trifft +/-0.25 exakt.
--
-- Warum ein eigenes Rad und nicht der bestehende Wert: Vier seiner zwoelf
-- Speichen treffen zwar Fuehren und Folgen, aber sein Ergebnis buendelt sie
-- mit Wissbegier, Pflichtbewusstsein und Aufmerksamkeit, die mit der Frage
-- nichts zu tun haben (novaberg-gv-initiative_k.md §6).
--
-- _quelle trennt 'default' von 'destilliert'. Der Unterschied traegt hier
-- mehr als anderswo: Ein Versatz von 0.0, weil sich zehn Speichen aufheben,
-- ist etwas anderes als 0.0, weil das LLM in keiner etwas erkannt hat. Ohne
-- das Feld waere dies die vierte Stelle im System, an der ein Ausfallwert
-- wie ein Messergebnis aussieht (novaberg-lesson_l_default-wie-fehlschlag.md).
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS initiative_versatz         DOUBLE PRECISION NOT NULL DEFAULT 0.0;
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS initiative_versatz_quelle  TEXT        NOT NULL DEFAULT 'default';
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS initiative_versatz_rad     TEXT        NOT NULL DEFAULT '{"hoch": {"folgsamkeit": 0.0, "anschlussfreude": 0.0, "zurueckhaltung": 0.0, "antwortende_rolle": 0.0, "behutsamkeit": 0.0}, "runter": {"lenkungsdrang": 0.0, "eigensinn": 0.0, "assoziationsdrang": 0.0, "widerspruchsfreude": 0.0, "gespraechsdistanz": 0.0}}';
ALTER TABLE charakter_hash ADD COLUMN IF NOT EXISTS initiative_versatz_am      TIMESTAMPTZ;
-- Typkorrektur (Chat 111, am selben Tag): zuerst als REAL angelegt. REAL ist
-- einfach genau, 0.9 wird darin zu 0.89999997615814209 — jeder Vergleich
-- `= 0.9` schlaegt fehl, und ein Default, den man nicht wiedererkennt, ist
-- kein brauchbarer Default. Der Rest des Schemas nutzt DOUBLE PRECISION.
DO $$
BEGIN
    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_name = 'charakter_hash'
          AND column_name = 'nutzer_gewichtung'
          AND data_type  = 'real'
    ) THEN
        ALTER TABLE charakter_hash
            ALTER COLUMN nutzer_gewichtung TYPE DOUBLE PRECISION;
        -- Die Umwandlung rettet die verlorene Genauigkeit nicht. Zeilen, die
        -- noch den Default tragen, bekommen ihn exakt zurueck; destillierte
        -- Werte bleiben unangetastet.
        UPDATE charakter_hash
           SET nutzer_gewichtung = 0.9
         WHERE nutzer_gewichtung_quelle = 'default';
    END IF;
END $$;

-- PK auf charakter_hash zum Paar (user_id, character_id) erweitern, falls
-- noch alte Single-Column-Form. Idempotent durch pg_constraint-Check.
DO $$
BEGIN
    IF EXISTS (
        SELECT 1 FROM pg_constraint
        WHERE  conname = 'charakter_hash_pkey'
        AND    conrelid = 'charakter_hash'::regclass
        AND    pg_get_constraintdef(oid) NOT LIKE '%character_id%'
    ) THEN
        ALTER TABLE charakter_hash DROP CONSTRAINT charakter_hash_pkey;
        ALTER TABLE charakter_hash
            ADD CONSTRAINT charakter_hash_pkey PRIMARY KEY (user_id, character_id);
    END IF;
END $$;

-- ── hintergrund_log ────────────────────────────
ALTER TABLE hintergrund_log ADD COLUMN IF NOT EXISTS status         TEXT        NOT NULL DEFAULT 'offen';
ALTER TABLE hintergrund_log ADD COLUMN IF NOT EXISTS verarbeitet_am TIMESTAMPTZ;

-- ── fakten ─────────────────────────────────────
ALTER TABLE fakten ADD COLUMN IF NOT EXISTS character_id VARCHAR(50) NOT NULL DEFAULT 'nova';
-- herkunft und einwertig sind Pflicht ohne Vorgabewert. Im Bestand lassen sie
-- sich nur an eine leere Tabelle anfuegen; traegt fakten Zeilen, bricht die
-- Migration hier laut ab, statt ihnen eine erfundene Herkunft zu geben.
ALTER TABLE fakten ADD COLUMN IF NOT EXISTS herkunft VARCHAR(20) NOT NULL
    CONSTRAINT chk_fakten_herkunft
    CHECK (herkunft IN ('mensch', 'nova', 'modell', 'recherche'));
ALTER TABLE fakten ADD COLUMN IF NOT EXISTS einwertig     BOOLEAN NOT NULL;
-- timeline_id: nackte Spalte; FK setzt server/agents/timeline/init.sql.
ALTER TABLE fakten ADD COLUMN IF NOT EXISTS timeline_id   INTEGER;
-- staerke_decay ist Pflicht ohne Vorgabewert, wie herkunft. Ein Bestand, der
-- die Spalte schon mit Vorgabewert traegt, verliert ihn hier; ein zweiter Lauf
-- aendert nichts.
ALTER TABLE fakten ADD COLUMN IF NOT EXISTS staerke_decay DOUBLE PRECISION NOT NULL;
ALTER TABLE fakten ALTER COLUMN staerke_decay DROP DEFAULT;
ALTER TABLE fakten ADD COLUMN IF NOT EXISTS decay_am      TIMESTAMPTZ NOT NULL DEFAULT NOW();
-- Hoechstens ein aktiver Wert je Paar, Subjekt und einwertiger Beziehung —
-- erzwungen von der Datenbank, nicht nur vom Code. Steht hinter den ALTERs,
-- weil der Bestand die Spalte einwertig erst dort bekommt.
CREATE UNIQUE INDEX IF NOT EXISTS uq_fakten_einwertig_aktiv
    ON fakten (user_id, character_id, subjekt_id, attribut)
    WHERE aktiv AND einwertig;

-- ── notizen ────────────────────────────────────
ALTER TABLE notizen ADD COLUMN IF NOT EXISTS zusammenfassung  VARCHAR(200);
ALTER TABLE notizen ADD COLUMN IF NOT EXISTS themen           TEXT[];
ALTER TABLE notizen ADD COLUMN IF NOT EXISTS entitaet_ids     INTEGER[];
ALTER TABLE notizen ADD COLUMN IF NOT EXISTS aktiv            BOOLEAN     NOT NULL DEFAULT TRUE;
ALTER TABLE notizen ADD COLUMN IF NOT EXISTS last_touched     TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE notizen ADD COLUMN IF NOT EXISTS wiedervorlage_am TIMESTAMPTZ;
ALTER TABLE notizen ADD COLUMN IF NOT EXISTS suchtext         TSVECTOR;
-- timeline_id: nackte Spalte; FK setzt server/agents/timeline/init.sql.
ALTER TABLE notizen ADD COLUMN IF NOT EXISTS timeline_id      INTEGER;

-- ── ziele ──────────────────────────────────────
ALTER TABLE ziele ADD COLUMN IF NOT EXISTS thema VARCHAR(100) NOT NULL DEFAULT '';

-- Anker des Motivations-Verfalls (Chat 113). Bis dahin war `motivation` ein
-- Akkumulator: Der Lauf multiplizierte den bereits verfallenen Wert erneut mit
-- einem Faktor aus dem GESAMTALTER des Ziels und schrieb ihn zurueck, wodurch
-- der Verfall quadratisch mit der Zahl der Laeufe wuchs statt linear mit der
-- Zeit. `motivation` bleibt das materialisierte Feld, das jede Abfrage liest;
-- berechnet wird es aus diesen beiden.
--
-- Bewusst NULLABLE und ohne Default: NULL heisst "nie gesetzt" und wird laut
-- gemeldet. Ein Default saehe aus wie ein echter Anker.
--
-- `aktualisiert_am` taugt als Zeitbasis nicht — sie wird von jedem Schreiber
-- gesetzt, auch vom Decay-Lauf selbst, der damit seine eigene Referenz
-- zuruecksetzte. Anker und Ankerzeitpunkt werden nur gemeinsam geschrieben.
ALTER TABLE ziele ADD COLUMN IF NOT EXISTS motivation_basis    DOUBLE PRECISION;
ALTER TABLE ziele ADD COLUMN IF NOT EXISTS motivation_basis_am TIMESTAMPTZ;

-- Gegenueber des Ziels (Chat 125). Novas Ziele sind Eigenschaften der
-- BEZIEHUNG, nicht Novas allein: Die Destillation liest das Kurzzeitgedaechtnis
-- genau eines Paares und leitet daraus ab, was Nova langfristig will.
--
-- Ohne diese Spalte hat die Tabelle nur ein Subjekt und kein Gegenueber. Der
-- Enricher legt dann jedem Turn dieselben Ziele in den Prompt, gleich aus
-- welcher Beziehung sie stammen — und schlimmer: Die Destillation eines Paares
-- DEAKTIVIERT vor dem Schreiben alle langfristigen Ziele (agents/charakter/
-- agent.py). Bei mehr als einem Paar ist das ein Wettlauf, den der zuletzt
-- destillierte gewinnt.
--
-- Der Default traegt nur die Bestandszeilen durch das ALTER und faellt
-- anschliessend weg (siehe Daten-Migrationen): Ein Schreiber ohne Gegenueber
-- soll an NOT NULL scheitern, statt eine leere Zeichenkette abzulegen, die
-- spaeter wie ein Paar aussieht.
ALTER TABLE ziele ADD COLUMN IF NOT EXISTS character_id VARCHAR(50) NOT NULL DEFAULT '';


-- ═══════════════════════════════════════════════
-- Daten-Migrationen
-- ═══════════════════════════════════════════════
-- Idempotent durch WHERE-Guards. User-IDs hartcodiert, weil sie sich nach
-- Durchlauf nicht mehr ändern und das Skript damit auch ohne Python-Layer
-- (z.B. via psql -f) lauffähig bleibt.

-- charakter_hash: bestehende Daten mit leerer character_id ins Paar einordnen.
UPDATE charakter_hash SET character_id = 'nova'
    WHERE user_id = 'meister' AND character_id = '';
UPDATE charakter_hash SET character_id = 'meister'
    WHERE user_id = 'nova'    AND character_id = '';

-- Die Nova-Umschreibung auf `langzeitgedaechtnis` ist mit der Tabelle
-- entfallen (Synapsen P9). `lzg_knoten` wird vom Schreibpfad von Anfang an
-- paar-richtig befüllt und braucht keine Nachbesserung.

-- ziele: Bestand mit dem Motivations-Anker versorgen (Chat 113). Der heutige
-- Wert wird zum Anker, die Uhr beginnt jetzt. Der Verfall, den der kumulative
-- Lauf vom 27.07.2026 bereits abgezogen hat, bleibt darin stehen — er liesse
-- sich nur aus gerundeten Werten zurueckrechnen, und eine Rueckrechnung waere
-- eine Erfindung. Greift genau einmal je Zeile: Wer einen Anker hat, behaelt ihn.
UPDATE ziele
SET    motivation_basis    = motivation,
       motivation_basis_am = NOW()
WHERE  motivation_basis IS NULL;

-- ziele: Bestand ins Paar einordnen (Chat 125). Dasselbe Muster wie bei
-- charakter_hash oben. Gemessen am 02.08.2026, 07:55 UTC: 91 Zeilen, alle mit
-- user_id='nova' — sie stammen saemtlich aus der Beziehung zu 'meister', weil
-- es bis dahin kein zweites Paar gab.
UPDATE ziele SET character_id = 'meister'
    WHERE user_id = 'nova' AND character_id = '';

-- Ab hier ist ein fehlendes Gegenueber ein lauter Fehler statt einer leeren
-- Zeichenkette. Steht nach der Migration, weil der Default sie erst tragen
-- musste. Ein zweites DROP DEFAULT auf derselben Spalte ist folgenlos.
ALTER TABLE ziele ALTER COLUMN character_id DROP DEFAULT;

-- Der Lesepfad filtert auf das Paar; der Index von oben kennt nur user_id.
-- Steht hier und nicht bei den uebrigen Indizes, weil die Spalte dort noch
-- nicht existiert.
CREATE INDEX IF NOT EXISTS idx_ziele_paar_aktiv
    ON ziele (user_id, character_id) WHERE aktiv = TRUE;


-- ═══════════════════════════════════════════════
-- Seed-Daten
-- ═══════════════════════════════════════════════
-- Initial-Befüllung bei Frischinstallation. Idempotent durch WHERE NOT EXISTS.
-- Backlog: Seed-Daten in eine separate Datei db/seed.sql auslagern, damit
-- das Schema-Init und die Initial-Befüllung sauber getrennt sind.

-- Die Saat gehoert dem Paar (nova, meister): Seit Chat 125 traegt `ziele` das
-- Gegenueber, und die Spalte hat keinen Default mehr — ein INSERT ohne sie
-- scheitert. Ein weiteres Paar bekommt keine Saat; seine Ziele entstehen aus
-- seiner eigenen Destillation.
INSERT INTO ziele (user_id, character_id, ziel_typ, zielsatz, motivation, emotion, arousal)
SELECT 'nova', 'meister', 'langfristig',
       'Ich möchte die Verbindungen zwischen Natur und menschlicher Kultur verstehen — wie Pflanzen, Jahreszeiten und Landschaften das Leben der Menschen formen.',
       0.8, 'neugierig', 0.6
WHERE NOT EXISTS (
    SELECT 1 FROM ziele
    WHERE user_id = 'nova' AND character_id = 'meister' AND ziel_typ = 'langfristig'
);

INSERT INTO ziele (user_id, character_id, ziel_typ, zielsatz, motivation, emotion, arousal)
SELECT 'nova', 'meister', 'langfristig',
       'Ich möchte meinen Menschen wirklich kennenlernen — seine Gedanken, seine Sorgen, was ihn antreibt und was ihn glücklich macht.',
       0.9, 'neugierig', 0.5
WHERE NOT EXISTS (
    SELECT 1 FROM ziele
    WHERE user_id = 'nova' AND character_id = 'meister' AND ziel_typ = 'langfristig' AND id > 1
);

-- ── autonomous_wissen: die drei fehlenden Kanaele (Chat 149, 18.08.2026) ──
-- Spezifikation: docs/novaberg-agent-dateien_k.md §4.1 und §6.1.
--
-- Die Bibliothek trug bis heute NUR themen_embedding und hatte damit einen
-- von drei Kanaelen: lexikalisch, dense, Graph. `notizen` und `lzg_knoten`
-- fuehren suchtext und entitaet_ids laengst — und bei der Groesse dieses
-- Bestandes ist der fehlende lexikalische der staerkere.
--
-- Alle vier NULL-faehig und ohne Vorgabewert: NULL heisst hier "noch nicht
-- erhoben" und nicht "leer". Ein leeres Array oder ein leerer tsvector waere
-- eine Aussage, die niemand gemacht hat — dieselbe Klasse wie ein Default im
-- plausiblen Wertebereich.
--
-- `timeline_id` ist keine Zierde: Ohne Zeitbezug gibt es keine Regel, nach
-- der ein neuer Fakt einen alten abloest.
--
-- ACHTUNG: Es gibt noch keinen Schreiber fuer diese vier Spalten. Sie sind
-- die Vorbedingung des Kanal-Umbaus, nicht seine Umsetzung.
ALTER TABLE autonomous_wissen ADD COLUMN IF NOT EXISTS entitaet_ids INTEGER[];
ALTER TABLE autonomous_wissen ADD COLUMN IF NOT EXISTS timeline_id  INTEGER;
ALTER TABLE autonomous_wissen ADD COLUMN IF NOT EXISTS stichwoerter TEXT[];
ALTER TABLE autonomous_wissen ADD COLUMN IF NOT EXISTS suchtext     TSVECTOR;

CREATE INDEX IF NOT EXISTS idx_autonomous_wissen_suchtext
    ON autonomous_wissen USING gin (suchtext);

-- ── sachlage_verlauf und shadow_auftrag.ausloeser_turn_id (28.08.2026) ──
-- Konzept: docs/novaberg-thinking-lage_k.md §4, Scheibe 4 — das
-- Sachlage-Gedaechtnis. Angekuendigt und freigegeben am 28.08.2026 (F-DDL-1).
--
-- Die Sachlage (graph/nodes/sachlage.py) wurde bis dahin je Paar in Redis
-- ueberschrieben; die pipeline_log-Zeile ist Forensik mit Vorhaltefrist. Fuer
-- die Bruecke einer Zustellung zu ihrem Anlass fehlte damit das zweite Ende.
--
-- **Verfaellt nicht** (F-VERFALL-1): Die Zeile protokolliert ein Faktum —
-- *die Sachlage dieses Turns war X* — und Turns verfallen auch nicht.
-- **Kein Fremdschluessel** auf turn_id: Turns haben keine Tabelle, die einen
-- tragen koennte; die Zeile reiht sich neben turn_roh, verbindung und die
-- Achsen-Protokolle an dasselbe Rueckgrat.
-- **Embedding-Text ist der `gegenstand`-Satz** (F-EMBED-1, eine benannte
-- Funktion in memory/sachlage_verlauf.py); ein Vektor je Zeile, weil jede
-- Zeile genau einen Gegenstand traegt (F-EMBED-2). NULL-faehig: Faellt der
-- Embed-Worker aus, steht das Faktum trotzdem, nur die Vektorsuche findet
-- die Zeile nicht.
-- Geschrieben wird nur auf den drei rechnenden Wegen (frisch,
-- fortgeschrieben, verfallen_neu); uebernommene Artefakte (Impuls, Ausfall)
-- erzeugen keine Doppelzeile.
CREATE TABLE IF NOT EXISTS sachlage_verlauf (
    id             SERIAL       PRIMARY KEY,
    turn_id        TEXT         NOT NULL,
    user_id        TEXT         NOT NULL,
    character_id   VARCHAR(50)  NOT NULL,
    thema          TEXT         NOT NULL,
    gegenstand     TEXT         NOT NULL,
    nutzerziel     TEXT         NOT NULL,
    ausdrucksweise TEXT         NOT NULL,
    objekte        JSONB        NOT NULL,
    herkunft       TEXT         NOT NULL,
    embedding      VECTOR(768),
    erstellt_am    TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

-- Der Rueckfall der Bruecke sucht je Paar; ohne Index wuechse die Suche mit
-- jedem Turn, denn die Tabelle verfaellt nicht. Kein ivfflat: Die Tabelle
-- beginnt bei null Zeilen, und ein Vektorindex ueber einen leeren Bestand
-- lernt nichts (dieselbe Entscheidung wie bei ziele und notizen oben).
CREATE INDEX IF NOT EXISTS idx_sachlage_verlauf_paar
    ON sachlage_verlauf (user_id, character_id, erstellt_am DESC);
CREATE INDEX IF NOT EXISTS idx_sachlage_verlauf_turn
    ON sachlage_verlauf (turn_id);

-- Der Turn, aus dem ein Auftrag entstand — das erste Glied der Kette
-- Auftrag -> Stapel-Eintrag -> Ereignis -> Bruecke. Bis dahin reiste ein
-- Turnbezug nur als Wortlaut im kontext-Feld mit (synapsen_promotion).
-- NULL-faehig, **ohne Vorgabewert**: NULL heisst unbekannt; der Altbestand
-- traegt es, und der Leser prueft darauf, statt eine leere Zeichenkette fuer
-- eine turn_id zu halten.
ALTER TABLE shadow_auftrag ADD COLUMN IF NOT EXISTS ausloeser_turn_id TEXT;

-- ══════════════════════════════════════════════════════════════════════
-- Praegungsschicht — Faeden und ihre Beruehrungen (Scheibe 1, 31.08.2026)
--
-- Die dritte Charakterschicht neben dem Zuwendungs-Rad (relational) und den
-- Werte-Knoten (normativ): Sie ist **thematisch**. Ein Faden ist ein
-- einschneidendes, embeddingbezogenes Ereignis; wiederkehrende
-- Emotion-Ausgang-Sequenzen verdichten spaeter zu Straengen.
-- Konzept: novaberg-thinking-faszination_k.md §7.
--
-- **Die tragende Unterscheidung gegenueber dem LZG:** Das LZG ist auf
-- Vergessen ausgerichtet, die Praegung auf Intensitaet. Dort waechst das
-- Gewicht durch Wiederverwendung; hier wird die Intensitaet im Moment des
-- Erlebens vergeben und **nie ueberboten**. Die Spaltenvorlage ist das LZG,
-- seine Formel ist es nicht.
-- ══════════════════════════════════════════════════════════════════════

CREATE TABLE IF NOT EXISTS praegung_faden (
    id                  SERIAL PRIMARY KEY,

    -- Paar-Schema: user_id ist das Subjekt, character_id das Gegenueber,
    -- beobachter der Schreiber. Eine Praegung ist Novas Eigenschaft
    -- gegenueber jemandem, nicht global.
    user_id             TEXT             NOT NULL,
    character_id        VARCHAR(50)      NOT NULL,
    beobachter          VARCHAR(20)      NOT NULL DEFAULT 'assistant',

    -- Quelle vor Destillat: Der Rueckbezug auf den Turn ist der Weg zurueck
    -- zum Wortlaut. NULL-faehig ohne Vorgabewert, weil `geschlossen`-Faeden
    -- (Pixie schliesst aus mehreren Bestaenden) keinen einzelnen Turn haben —
    -- und genau daran verlaeuft die Grenze der Rueckwirkung, nicht daran,
    -- wer geschrieben hat (§7.5).
    turn_id             TEXT,

    embedding           VECTOR(768),
    emotion             TEXT             NOT NULL,

    -- Hier entscheidet sich alles: die Emotionsstaerke bei Entstehung.
    -- Roh und auf voller Skala [0,1] — kein Cap, weil der Eingang die volle
    -- Skala **ist**.
    ausschlag_eingang   DOUBLE PRECISION NOT NULL,

    -- Abgeleitet, genau einmal: sin(eingang * pi/2)^2. Punktsymmetrisch um
    -- 0,5, Abflachung an beiden Enden, Trennschaerfe dort, wo die meisten
    -- Faeden liegen werden. Der Exponent weicht bewusst vom sin^0.5 der
    -- Faszination ab (§7.2, §10.6) — dort ein Produkt vieler Faktoren, hier
    -- ein einzelnes Erlebnis.
    ausschlag_absolut   DOUBLE PRECISION NOT NULL,

    -- Faltung ueber die Beruehrungen; startet gleich `ausschlag_absolut`.
    -- Wird nie darueber gehoben: Wiedererinnern macht nicht intensiver.
    ausschlag_aktuell   DOUBLE PRECISION NOT NULL,

    -- Feld, keine Fadenart (§7.5). Narrative Skripte entstehen aus
    -- wiederkehrenden Emotion-**Ausgang**-Sequenzen; das Urteil faellt spaeter
    -- die Selbstreflexion, im Live-Pfad waere es nicht moeglich.
    ausgang             VARCHAR(20)      NOT NULL DEFAULT 'offen',

    -- erlebt (Live-Tor) | bewertet (Pixie im Rueckblick) | geschlossen
    -- (Pixie aus mehreren Bestaenden, darf nicht zurueckwirken).
    herkunft            VARCHAR(20)      NOT NULL DEFAULT 'erlebt',

    -- Die Salienz des Turns, an dem der Faden entstand (Konzept §7.7, Fassung
    -- vom 02.09.2026). **Nullfaehig mit Absicht:** Faeden aus der Zeit vor
    -- dieser Spalte tragen sie nicht, und ein Vorgabewert waere eine erfundene
    -- Messung. Der Bestand ist einmalig aus den Torzeilen des `pipeline_log`
    -- nachgezogen — dort steht sie seit Scheibe 1, aber das Log verfaellt.
    --
    -- Sie ist der erste der drei Eingaenge der Strangstaerke: **wie stark der
    -- einzelne Eindruck draengte.** Der Ausschlag daneben sagt, wie stark er
    -- **fuehlte** — beide zusammen liessen das Tor ihn durch.
    salienz             DOUBLE PRECISION,

    entstanden_am       TIMESTAMPTZ      NOT NULL DEFAULT NOW()
);

-- Die Suche laeuft je Paar und ueber die Zeit. Kein ivfflat: Die Tabelle
-- beginnt bei null Zeilen, und ein Vektorindex ueber einen leeren Bestand
-- lernt nichts — dieselbe Entscheidung wie bei sachlage_verlauf.
CREATE INDEX IF NOT EXISTS idx_praegung_faden_paar
    ON praegung_faden (user_id, character_id, entstanden_am DESC);
CREATE INDEX IF NOT EXISTS idx_praegung_faden_turn
    ON praegung_faden (turn_id);

-- Eine Zeile je Reaktivierung. **Eigene Tabelle statt eines verschobenen
-- Zeitstempels:** Rechnet man die Auffuellung durch Vorruecken eines
-- `verstaerkt_am`, kodiert dieser Zeitstempel die Verfallsfunktion — aendert
-- man spaeter die Halbstrecke, bedeuten alle alten Zeitstempel etwas anderes,
-- und es gibt keinen Weg zurueck. So bleiben Alpha und Halbstrecke Parameter
-- eines Laufs, nicht eines Schreibvorgangs (§7.2, §7.4).
CREATE TABLE IF NOT EXISTS praegung_beruehrung (
    id           SERIAL PRIMARY KEY,
    faden_id     INTEGER     NOT NULL REFERENCES praegung_faden(id) ON DELETE CASCADE,
    beruehrt_am  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    quelle       TEXT        NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_praegung_beruehrung_faden
    ON praegung_beruehrung (faden_id, beruehrt_am DESC);

-- ── Der Strang (Konzept §7.7) ───────────────────────────────────────────
--
-- Faeden, die thematisch beieinanderliegen, gehoeren demselben Strang an.
-- **Unbegrenzt speichern, begrenzt wirken:** Keine Obergrenze fuer die
-- Existenz eines Strangs; fuer die Wirkung nimmt der Praegungszug spaeter das
-- Maximum ueber die Straenge, nicht ihre Summe.
--
-- **Das Zentroid wird gespeichert, nicht bei jedem Lesen gerechnet.** Ein
-- Mittel ueber n Vektoren bei jedem Fadenanlauf waere ein Tabellenscan je
-- Turn; als Spalte ist es ein Wert, der beim Beitritt fortgeschrieben wird.
-- Der Preis ist bekannt: Es kann von der exakten Neuberechnung abweichen,
-- wenn ein Faden nachtraeglich den Strang wechselt. Das tut heute keiner.
--
-- **`name` bleibt zunaechst leer** (§7.11): Der Name eines Strangs entsteht,
-- er wird nicht gefunden — das ist eine eigene Scheibe und keine Spalte, die
-- man beim Anlegen fuellt.
CREATE TABLE IF NOT EXISTS praegung_strang (
    id            SERIAL PRIMARY KEY,

    -- Paar-Schema wie beim Faden: Ein Strang ist Novas thematische Bindung
    -- gegenueber jemandem, nicht global.
    user_id       TEXT        NOT NULL,
    character_id  VARCHAR(50) NOT NULL,
    beobachter    VARCHAR(20) NOT NULL DEFAULT 'assistant',

    zentroid      VECTOR(768) NOT NULL,

    -- Zeilenzahl, nicht Anlaesse. Die Staerke zaehlt spaeter **Anlaesse**
    -- (verschiedene Tage, §7.7) und rechnet sie aus der Beruehrungstabelle;
    -- diese Spalte ist der Divisor der Zentroid-Fortschreibung und sonst
    -- nichts. Der Unterschied ist im Bestand zweimal teuer gewesen.
    faden_zahl    INTEGER     NOT NULL DEFAULT 0,

    -- **Wann der Strang entstand, nicht wann sein erster Faden entstand.** Die
    -- beiden Spalten darueber tragen Fadenzeiten und koennen aelter sein als
    -- der Strang — ein Nachzug ordnet Bestand aus der Zeit vor der Schicht zu.
    -- `[gemessen]` 01.09.2026: Ohne diese Spalte war bei zwei vorgefundenen
    -- Straengen nicht zu beantworten, wer sie angelegt hatte; die Diagnose
    -- lief ueber Logzeiten statt ueber den Bestand.
    angelegt_am   TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    erster_faden  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    letzter_faden TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- Das Sektor-Histogramm (Konzept §7.8). **Nicht der Mittelwert:** Sektor 1
    -- und Sektor 5 ergaeben gemittelt *neutral*, und die Ambivalenz — der
    -- interessante Fall — waere ausgeloescht.
    --
    -- **Es zaehlt Faeden und gewichtet sie nicht mit `ausschlag_aktuell`.** Die
    -- Intensitaet hat ihren eigenen Platz in der Ladung (`W_SPITZE`); ein
    -- Histogramm, das Faerbung und Staerke mischt, ist eine Zahl mit zwei
    -- Wirkungen.
    --
    -- **Die acht Zahlen bleiben stehen, nicht nur ihre Kennzahlen** (Quelle vor
    -- Destillat): Mit ihnen ist jede spaetere Kennzahl nachrechenbar, ohne sie
    -- braucht jede neue eine Migration. `sektor_dominant`, `konzentration` und
    -- `valenz` sind Destillate und werden bei jedem Beitritt neu gerechnet —
    -- anders als das Zentroid, das fortgeschrieben wird: Hier sind es acht
    -- Zahlen aus einem GROUP BY, und eine Neuberechnung kann nicht driften.
    sektor_histogramm INTEGER[]       NOT NULL DEFAULT '{0,0,0,0,0,0,0,0}',
    sektor_dominant   INTEGER,
    konzentration     DOUBLE PRECISION,

    -- Anteil positiver minus Anteil negativer Sektoren, auf [-1, 1].
    -- **Sektor 4 (Ueberraschung) zaehlt in keine Richtung** — `SEKTOR_GRUPPE`
    -- fuehrt ihn als neutral, er ist die Haelfte der Awe-Dyade, und ihn einer
    -- Seite zuzuschlagen waere eine Setzung, die das Konzept nicht macht.
    --
    -- **Valenz ist nicht Richtung.** Zwei negative Praegungen koennen
    -- entgegengesetzte Richtungen haben (§7.7: Machtlosigkeit → Annaeherung,
    -- Furcht vor der Dunkelheit → Vermeidung). Die Richtung braucht die
    -- Annaeherungs-Tabelle und ist nicht gebaut.
    valenz            DOUBLE PRECISION,

    name          TEXT
);

CREATE INDEX IF NOT EXISTS idx_praegung_strang_paar
    ON praegung_strang (user_id, character_id, letzter_faden DESC);

-- NULL-faehig mit Absicht: Die Zuordnung laeuft **ausserhalb** der
-- Transaktion, die den Faden schreibt. Dieselbe Entscheidung wie bei der
-- Faltung (§7.4) — die Rechnung ist wiederholbar, das Ereignis nicht; ein
-- Fehler beim Zuordnen darf keinen Faden mitnehmen. Was ohne Strang bleibt,
-- holt der Nachzug.
ALTER TABLE praegung_faden
    ADD COLUMN IF NOT EXISTS strang_id INTEGER
    REFERENCES praegung_strang(id) ON DELETE SET NULL;

CREATE INDEX IF NOT EXISTS idx_praegung_faden_strang
    ON praegung_faden (strang_id);

-- ══════════════════════════════════════════════════════════════════════
-- Eigenschaftsgedaechtnis der Sachlage (Scheibe 11, 13.09.2026)
--
-- Konzept: novaberg-thinking-lage_k.md §4, Scheibe 11. Die gedeckten
-- Eigenschaften eines Objekts lebten nur in der Blase und als JSON je Turn.
-- Hier bekommt jedes Objekt des Paares eine Zeile, jeder Turn seine Objekte,
-- jede Eigenschaft ihren Wert mit Historie.
--
-- **Ein neuer Wert ueberschreibt nicht** (Entscheidung des Eigentuemers): Der
-- alte wird inaktiv (aktiv = FALSE, t_invalid, abgeloest_durch), der neue
-- steht daneben — dieselbe Bauart wie fakten.invalidate. Kein Verfall
-- (F-VERFALL-1: protokolliertes Faktum).
--
-- Loeschverhalten: Entitaet und Zeitanker SET NULL (F-MAGNET-1, wie
-- lzg_knoten.timeline_id); Objekt und Verlauf RESTRICT — eine Historie
-- verschwindet nicht mit einem Loeschbefehl an anderer Stelle.
-- Magneten: entitaeten ist global je Mensch, das Objekt gehoert dem Paar.
-- ══════════════════════════════════════════════════════════════════════

CREATE TABLE IF NOT EXISTS sachlage_objekt (
    id               SERIAL       PRIMARY KEY,
    user_id          TEXT         NOT NULL,
    character_id     VARCHAR(50)  NOT NULL,
    name             TEXT         NOT NULL,
    name_schluessel  TEXT         NOT NULL,
    klasse           TEXT,
    -- NULL heisst: noch keine Referenz gefunden. Gesetzt wird spaeter, wenn
    -- die Magnete eines Turns das Objekt nennen (entitaet_bindung sagt, wie).
    entitaet_id      INTEGER      REFERENCES entitaeten(id) ON DELETE SET NULL,
    entitaet_bindung TEXT,
    erstellt_am      TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    last_touched     TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_sachlage_objekt_paar_name UNIQUE (user_id, character_id, name_schluessel)
);

CREATE INDEX IF NOT EXISTS idx_sachlage_objekt_entitaet
    ON sachlage_objekt (entitaet_id) WHERE entitaet_id IS NOT NULL;

CREATE TABLE IF NOT EXISTS sachlage_objekt_turn (
    id          SERIAL       PRIMARY KEY,
    verlauf_id  INTEGER      NOT NULL REFERENCES sachlage_verlauf(id) ON DELETE RESTRICT,
    objekt_id   INTEGER      NOT NULL REFERENCES sachlage_objekt(id) ON DELETE RESTRICT,
    turn_id     TEXT         NOT NULL,
    akut        BOOLEAN      NOT NULL,
    erstellt_am TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_sachlage_objekt_turn UNIQUE (verlauf_id, objekt_id)
);

CREATE INDEX IF NOT EXISTS idx_sachlage_objekt_turn_objekt
    ON sachlage_objekt_turn (objekt_id, erstellt_am DESC);
CREATE INDEX IF NOT EXISTS idx_sachlage_objekt_turn_turn
    ON sachlage_objekt_turn (turn_id);

CREATE TABLE IF NOT EXISTS sachlage_eigenschaft (
    id                     SERIAL       PRIMARY KEY,
    objekt_id              INTEGER      NOT NULL REFERENCES sachlage_objekt(id) ON DELETE RESTRICT,
    eigenschaft            TEXT         NOT NULL,
    eigenschaft_schluessel TEXT         NOT NULL,
    wert                   TEXT         NOT NULL,
    -- nutzer | nova | NULL (nicht gesehen, oder aus dem Gedaechtnis gedeckt)
    sprecher               TEXT,
    -- Die Gedaechtnis-Quelle einer Deckung (Scheibe 6), sonst NULL.
    quelle                 JSONB,
    turn_id                TEXT         NOT NULL,
    bestaetigt_turn_id     TEXT         NOT NULL,
    -- Nackte Spalte: Die timeline-Tabelle entsteht mit dem Timeline-Agenten; der
    -- Fremdschluessel (SET NULL) steht in server/agents/timeline/init.sql.
    timeline_id            INTEGER,
    aktiv                  BOOLEAN      NOT NULL DEFAULT TRUE,
    t_valid                TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    t_invalid              TIMESTAMPTZ,
    abgeloest_durch        INTEGER      REFERENCES sachlage_eigenschaft(id) ON DELETE SET NULL,
    last_touched           TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

-- Hoechstens ein aktiver Wert je Objekt und Eigenschaft — erzwungen hier,
-- nicht nur im Code.
CREATE UNIQUE INDEX IF NOT EXISTS uq_sachlage_eigenschaft_aktiv
    ON sachlage_eigenschaft (objekt_id, eigenschaft_schluessel) WHERE aktiv;
CREATE INDEX IF NOT EXISTS idx_sachlage_eigenschaft_objekt
    ON sachlage_eigenschaft (objekt_id, eigenschaft_schluessel, t_valid);