-- pgmnemo--0.11.1--0.12.0.sql
-- pgmnemo upgrade 0.11.1 → 0.12.0
-- v0.12.0: Typed Write API — remember_fact / remember_event / remember_relation
-- RFC-001 §D2 + ADDENDUM-2 (7 correctness requirements)
--
-- No breaking changes. No schema column additions. All new symbols.
-- SPDX-License-Identifier: Apache-2.0

\echo Use "ALTER EXTENSION pgmnemo UPDATE TO '0.12.0'" to load this file. \quit

DO $$ BEGIN RAISE NOTICE 'pgmnemo: upgrading to version 0.12.0 (Typed Write API)'; END; $$;

-- ADDENDUM-2 R8: Ensure uq_mem_edge_active partial index exists.
-- add_edge() uses ON CONFLICT (source_id, target_id, relation_type) WHERE valid_until IS NULL
-- which requires this partial unique index. Created in 0.5.0 delta; re-asserted here
-- defensively (IF NOT EXISTS) to guard against non-standard upgrade paths or direct SQL installs.
CREATE UNIQUE INDEX IF NOT EXISTS uq_mem_edge_active
    ON pgmnemo.mem_edge (source_id, target_id, relation_type)
    WHERE valid_until IS NULL;

COMMENT ON INDEX pgmnemo.uq_mem_edge_active IS
    'Partial unique index on active edges (valid_until IS NULL). '
    'Enables ON CONFLICT upsert in add_edge(). Created in 0.5.0; re-asserted in 0.12.0 (ADDENDUM-2 R8).';

CREATE UNIQUE INDEX IF NOT EXISTS ix_entity_canonical_name_prj
    ON pgmnemo.agent_lesson (
        lower(metadata->>'canonical_name'),
        COALESCE(project_id, -1)
    )
    WHERE content_type = 'entity'
      AND is_active
      AND t_valid_to = 'infinity'::TIMESTAMPTZ;

COMMENT ON INDEX pgmnemo.ix_entity_canonical_name_prj IS
    'Unique entity hub per (lower(canonical_name), project_id). v0.12.0.';

-- guard_no_test_project
CREATE OR REPLACE FUNCTION pgmnemo.guard_no_test_project(
    p_project_id INT,
    p_allowed_db TEXT DEFAULT NULL
) RETURNS VOID LANGUAGE plpgsql AS $$
BEGIN
    IF p_project_id IS NULL THEN
        RAISE EXCEPTION 'pgmnemo.guard_no_test_project: p_project_id IS NULL — tests must use an explicit test project_id';
    END IF;
    IF p_project_id <= 100 THEN
        RAISE EXCEPTION 'pgmnemo.guard_no_test_project: project_id=% looks like a production ID (<=100). Use project_id > 10000.', p_project_id;
    END IF;
    IF p_allowed_db IS NOT NULL AND current_database() <> p_allowed_db THEN
        RAISE EXCEPTION 'pgmnemo.guard_no_test_project: must run on ''%'', current db is ''%''.', p_allowed_db, current_database();
    END IF;
END;
$$;
COMMENT ON FUNCTION pgmnemo.guard_no_test_project(INT, TEXT) IS 'Safety guard for test harnesses (R6, v0.12.0). Raises when project_id<=100 (production sentinel).';

-- _evict_prior_lesson: shared bitemporal eviction helper (RFC-001 §D3 / ADDENDUM-2 R3)
-- Called by remember_fact and remember_relation to close a prior active row.
-- Centralises the t_valid_to / state / is_active / updated_at update so that
-- all supersession paths are byte-for-byte identical and a single fix propagates everywhere.
CREATE OR REPLACE FUNCTION pgmnemo._evict_prior_lesson(p_lesson_id BIGINT)
RETURNS VOID LANGUAGE sql AS $$
    UPDATE pgmnemo.agent_lesson
    SET t_valid_to = NOW(),
        state      = 'superseded',
        is_active  = FALSE,
        updated_at = NOW()
    WHERE id = p_lesson_id;
$$;
COMMENT ON FUNCTION pgmnemo._evict_prior_lesson(BIGINT) IS
    'Bitemporal eviction: close active lesson (t_valid_to=NOW, state=superseded, is_active=FALSE). '
    'Shared helper called by remember_fact + remember_relation supersession paths. '
    'RFC-001 §D3 + ADDENDUM-2 R3. v0.12.0.';

-- _has_contact_pii
CREATE OR REPLACE FUNCTION pgmnemo._has_contact_pii(p_property TEXT)
RETURNS BOOLEAN LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$
    SELECT p_property IN ('email', 'phone', 'address', 'telegram', 'full_name');
$$;
COMMENT ON FUNCTION pgmnemo._has_contact_pii(TEXT) IS 'PII property detector. Returns TRUE for {email,phone,address,telegram,full_name}. PROPERTY_CONVENTIONS §5.1 / RFC-001 §D4. v0.12.0.';

-- canonical_slug
CREATE OR REPLACE FUNCTION pgmnemo.canonical_slug(p_type TEXT, p_label TEXT)
RETURNS TEXT LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE AS $$
DECLARE
    _id    TEXT;
    _slug  TEXT;
    _valid CONSTANT TEXT[] := ARRAY['person','org','project','product','location','concept'];
BEGIN
    IF p_type IS NULL OR NOT (p_type = ANY(_valid)) THEN
        RAISE EXCEPTION 'pgmnemo.canonical_slug: unknown type prefix ''%'' — must be one of: person, org, project, product, location, concept', p_type;
    END IF;
    IF p_label IS NULL OR length(trim(p_label)) = 0 THEN
        RAISE EXCEPTION 'pgmnemo.canonical_slug: p_label must not be NULL or empty';
    END IF;
    _id := lower(p_label);
    _id := regexp_replace(_id, '[^a-z0-9]+', '_', 'g');
    _id := trim(BOTH '_' FROM _id);
    IF length(_id) > 64 THEN
        _id := substring(_id FOR 64);
        IF _id ~ '_' THEN _id := regexp_replace(_id, '_[^_]*$', ''); END IF;
        _id := trim(BOTH '_' FROM _id);
    END IF;
    _slug := p_type || ':' || _id;
    IF _id = '' OR _slug !~ '^(person|org|project|product|location|concept):[a-z0-9_]+$' THEN
        RAISE EXCEPTION 'pgmnemo.canonical_slug: could not normalise ''%'' (result: ''%'')', p_label, _slug;
    END IF;
    IF length(_slug) > 72 THEN
        RAISE EXCEPTION 'pgmnemo.canonical_slug: slug ''%'' exceeds 72 chars', _slug;
    END IF;
    RETURN _slug;
END;
$$;
COMMENT ON FUNCTION pgmnemo.canonical_slug(TEXT, TEXT) IS 'Normalise label into canonical slug ^(person|org|project|product|location|concept):[a-z0-9_]+$ <=72. SLUG_CONVENTION §4. v0.12.0.';

-- remember_fact
CREATE OR REPLACE FUNCTION pgmnemo.remember_fact(
    p_role            TEXT,
    p_entity_key      TEXT,
    p_property        TEXT,
    p_value           TEXT,
    p_confidence      REAL        DEFAULT 0.7,
    p_has_contact_pii BOOLEAN     DEFAULT NULL,
    p_embedding       vector(1024) DEFAULT NULL,
    p_source_type     TEXT        DEFAULT NULL,
    p_project_id      INT         DEFAULT NULL,
    p_commit_sha      TEXT        DEFAULT NULL,
    p_artifact_hash   TEXT        DEFAULT NULL
) RETURNS TABLE(id BIGINT, final_state TEXT)
LANGUAGE plpgsql AS $$
DECLARE
    _topic         TEXT;
    _artifact_hash TEXT;
    _is_pii        BOOLEAN;
    _final_state   TEXT;
    _merge_state   TEXT;    -- actual post-merge state (may differ from _final_state for promote)
    _prior         pgmnemo.agent_lesson%ROWTYPE;
    _new_id        BIGINT;
    _version_n     INT;
    _eff_source    TEXT;
BEGIN
    -- Input guards
    IF p_entity_key IS NULL THEN
        RAISE EXCEPTION 'pgmnemo.remember_fact: p_entity_key must not be NULL';
    END IF;
    IF p_entity_key !~ '^(person|org|project|product|location|concept):[a-z0-9_]+$' THEN
        RAISE EXCEPTION 'pgmnemo.remember_fact: invalid entity_key ''%'' — must match slug regex', p_entity_key;
    END IF;
    IF p_property IS NULL OR length(trim(p_property)) = 0 THEN
        RAISE EXCEPTION 'pgmnemo.remember_fact: p_property must not be NULL or empty';
    END IF;
    IF p_value IS NULL THEN
        RAISE EXCEPTION 'pgmnemo.remember_fact: p_value must not be NULL';
    END IF;
    IF p_confidence IS NOT NULL AND (p_confidence < 0.0 OR p_confidence > 1.0) THEN
        RAISE EXCEPTION 'pgmnemo.remember_fact: p_confidence % out of [0,1]', p_confidence;
    END IF;
    IF p_source_type IS NOT NULL AND
       p_source_type NOT IN ('system','agent_authored','auto_captured','imported') THEN
        RAISE EXCEPTION 'pgmnemo.remember_fact: invalid source_type ''%''', p_source_type;
    END IF;

    -- R2: Synthesize artifact_hash (COALESCE BEFORE gate can inspect)
    -- topic: lower(entity_key)/lower(property) per RFC-001 §D2
    _topic         := lower(p_entity_key) || '/' || lower(p_property);
    _artifact_hash := COALESCE(p_artifact_hash, 'fact-' || p_entity_key || ':' || p_property);

    -- R1/R7: PII detection — explicit override wins, else auto-detect
    _is_pii := COALESCE(
        p_has_contact_pii,
        pgmnemo._has_contact_pii(p_property) AND (p_entity_key LIKE 'person:%')
    );

    -- R1: State routing (RFC-001 §D4)
    -- PII on person:* → candidate ALWAYS (even system source)
    IF _is_pii THEN
        _final_state := 'candidate';
    ELSIF p_source_type = 'system' THEN
        _final_state := 'validated';
    ELSIF p_source_type = 'auto_captured' THEN
        _final_state := 'candidate';
    ELSIF p_source_type = 'agent_authored' AND COALESCE(p_confidence, 0.0) >= 0.8 THEN
        _final_state := 'validated';
    ELSE
        -- agent_authored low-conf, imported, NULL source_type → candidate
        _final_state := 'candidate';
    END IF;

    _eff_source := COALESCE(p_source_type, 'agent_authored');

    -- R3: Identity/dedup on (lower(topic), project_id) with FOR UPDATE
    SELECT * INTO _prior
    FROM pgmnemo.agent_lesson
    WHERE lower(topic) = lower(_topic)
      AND (p_project_id IS NULL OR project_id = p_project_id)
      AND is_active
      AND t_valid_to = 'infinity'::TIMESTAMPTZ
    ORDER BY version_n DESC
    LIMIT 1
    FOR UPDATE;

    IF FOUND THEN
        IF lower(trim(_prior.lesson_text)) = lower(trim(p_value)) THEN
            -- MERGE: same value — update confidence + promote state, no new version.
            -- Promotion rule: 'validated' wins over 'candidate'/'draft'; never demote.
            UPDATE pgmnemo.agent_lesson
            SET confidence = GREATEST(confidence, COALESCE(p_confidence, 0.7)),
                state      = CASE
                                 WHEN _final_state = 'validated'
                                      AND state NOT IN ('validated', 'canonical')
                                 THEN 'validated'
                                 ELSE state
                             END
            WHERE agent_lesson.id = _prior.id
            RETURNING agent_lesson.state INTO _merge_state;
            RETURN QUERY SELECT _prior.id, _merge_state;
            RETURN;
        ELSE
            -- SUPERSEDE: different value — close prior row via shared eviction helper, open new version
            PERFORM pgmnemo._evict_prior_lesson(_prior.id);
            _version_n := COALESCE(_prior.version_n, 0) + 1;
        END IF;
    ELSE
        _version_n := 1;
    END IF;

    -- INSERT new fact row
    INSERT INTO pgmnemo.agent_lesson (
        role, project_id, topic, lesson_text, importance,
        embedding, commit_sha, artifact_hash, metadata,
        source_type, content_type, state, confidence,
        version_n, verified_at, t_valid_from, t_valid_to
    ) VALUES (
        p_role, p_project_id, _topic, p_value, 3,
        p_embedding, p_commit_sha, _artifact_hash,
        jsonb_build_object(
            'canonical_name', p_entity_key,
            'entity_key',     p_entity_key,
            'property',       p_property
        ),
        _eff_source, 'fact', _final_state,
        COALESCE(p_confidence, 0.7),
        _version_n,
        -- validated → visible to recall; candidate → ghost (verified_at NULL)
        CASE WHEN _final_state = 'validated' THEN NOW() ELSE NULL END,
        NOW(),
        'infinity'::TIMESTAMPTZ
    )
    RETURNING agent_lesson.id INTO _new_id;

    RETURN QUERY SELECT _new_id, _final_state;
END;
$$;
COMMENT ON FUNCTION pgmnemo.remember_fact(TEXT,TEXT,TEXT,TEXT,REAL,BOOLEAN,vector,TEXT,INT,TEXT,TEXT) IS
    'Typed fact write with bitemporal supersession, PII-aware state routing, synthesized artifact_hash. RFC-001 §D2 + ADDENDUM-2 R1-R7. v0.12.0.';

-- remember_event
CREATE OR REPLACE FUNCTION pgmnemo.remember_event(
    p_role          TEXT,
    p_entity_key    TEXT,
    p_event_label   TEXT,
    p_event_body    TEXT,
    p_occurred_at   TIMESTAMPTZ  DEFAULT NOW(),
    p_confidence    REAL         DEFAULT 0.8,
    p_embedding     vector(1024) DEFAULT NULL,
    p_source_type   TEXT         DEFAULT NULL,
    p_project_id    INT          DEFAULT NULL,
    p_commit_sha    TEXT         DEFAULT NULL,
    p_artifact_hash TEXT         DEFAULT NULL
) RETURNS BIGINT LANGUAGE plpgsql AS $$
DECLARE
    _topic         TEXT;
    _artifact_hash TEXT;
    _final_state   TEXT;
    _new_id        BIGINT;
BEGIN
    IF p_entity_key IS NULL OR p_entity_key !~ '^(person|org|project|product|location|concept):[a-z0-9_]+$' THEN
        RAISE EXCEPTION 'pgmnemo.remember_event: invalid entity_key ''%''', p_entity_key;
    END IF;
    IF p_event_label IS NULL OR length(trim(p_event_label)) = 0 THEN
        RAISE EXCEPTION 'pgmnemo.remember_event: p_event_label must not be NULL or empty';
    END IF;
    IF p_event_body IS NULL OR length(trim(p_event_body)) < 1 THEN
        RAISE EXCEPTION 'pgmnemo.remember_event: p_event_body must not be NULL or empty';
    END IF;
    _topic         := p_entity_key || ':event:' || p_event_label;
    _artifact_hash := COALESCE(p_artifact_hash, 'event-' || p_entity_key || ':' || p_event_label);

    -- R3: Idempotency — FOR UPDATE dedup on (lower(topic), project_id).
    -- Events are append-only (no supersession); same (entity_key, event_label, project_id)
    -- returns the existing row id. Prevents duplicate event rows across concurrent writes.
    SELECT id INTO _new_id
    FROM pgmnemo.agent_lesson
    WHERE lower(topic) = lower(_topic)
      AND (p_project_id IS NULL OR project_id = p_project_id)
      AND is_active
      AND t_valid_to = 'infinity'::TIMESTAMPTZ
    LIMIT 1 FOR UPDATE;
    IF FOUND THEN
        RETURN _new_id;
    END IF;

    IF p_source_type = 'auto_captured' THEN
        _final_state := 'candidate';
    ELSIF p_source_type = 'system' THEN
        _final_state := 'validated';
    ELSIF p_source_type = 'agent_authored' AND COALESCE(p_confidence, 0.8) >= 0.8 THEN
        _final_state := 'validated';
    ELSE
        _final_state := 'candidate';
    END IF;
    INSERT INTO pgmnemo.agent_lesson (
        role, project_id, topic, lesson_text, importance,
        embedding, commit_sha, artifact_hash, metadata,
        source_type, content_type, state, confidence,
        version_n, verified_at, t_valid_from, t_valid_to
    ) VALUES (
        p_role, p_project_id, _topic, p_event_body, 3,
        p_embedding, p_commit_sha, _artifact_hash,
        jsonb_build_object('entity_key', p_entity_key, 'event_label', p_event_label, 'occurred_at', p_occurred_at),
        COALESCE(p_source_type, 'agent_authored'), 'event', _final_state,
        COALESCE(p_confidence, 0.8), 1,
        CASE WHEN _final_state = 'validated' THEN NOW() ELSE NULL END,
        COALESCE(p_occurred_at, NOW()), 'infinity'::TIMESTAMPTZ
    ) RETURNING agent_lesson.id INTO _new_id;
    RETURN _new_id;
END;
$$;
COMMENT ON FUNCTION pgmnemo.remember_event(TEXT,TEXT,TEXT,TEXT,TIMESTAMPTZ,REAL,vector,TEXT,INT,TEXT,TEXT) IS 'Immutable event record. Append-only. content_type=event. RFC-001 §D2. v0.12.0.';

-- remember_relation
CREATE OR REPLACE FUNCTION pgmnemo.remember_relation(
    p_role          TEXT,
    p_from_key      TEXT,
    p_to_key        TEXT,
    p_relation_type TEXT,
    p_confidence    REAL         DEFAULT 0.7,
    p_embedding     vector(1024) DEFAULT NULL,
    p_source_type   TEXT         DEFAULT NULL,
    p_project_id    INT          DEFAULT NULL,
    p_commit_sha    TEXT         DEFAULT NULL,
    p_artifact_hash TEXT         DEFAULT NULL
) RETURNS BIGINT LANGUAGE plpgsql AS $$
DECLARE
    _topic         TEXT;
    _artifact_hash TEXT;
    _final_state   TEXT;
    _prior_id      BIGINT;
    _new_id        BIGINT;
    _from_hub_id   BIGINT;
    _to_hub_id     BIGINT;
BEGIN
    IF p_from_key IS NULL OR p_from_key !~ '^(person|org|project|product|location|concept):[a-z0-9_]+$' THEN
        RAISE EXCEPTION 'pgmnemo.remember_relation: invalid from_key ''%''', p_from_key;
    END IF;
    IF p_to_key IS NULL OR p_to_key !~ '^(person|org|project|product|location|concept):[a-z0-9_]+$' THEN
        RAISE EXCEPTION 'pgmnemo.remember_relation: invalid to_key ''%''', p_to_key;
    END IF;
    IF p_relation_type IS NULL OR length(trim(p_relation_type)) = 0 THEN
        RAISE EXCEPTION 'pgmnemo.remember_relation: p_relation_type must not be NULL or empty';
    END IF;
    _topic         := p_from_key || ':' || p_relation_type || ':' || p_to_key;
    _artifact_hash := COALESCE(p_artifact_hash, 'rel-' || p_from_key || ':' || p_relation_type || ':' || p_to_key);
    IF p_source_type = 'system' THEN _final_state := 'validated';
    ELSIF p_source_type = 'auto_captured' THEN _final_state := 'candidate';
    ELSIF p_source_type = 'agent_authored' AND COALESCE(p_confidence, 0.7) >= 0.8 THEN _final_state := 'validated';
    ELSE _final_state := 'candidate';
    END IF;
    SELECT id INTO _prior_id FROM pgmnemo.agent_lesson
    WHERE lower(topic) = lower(_topic)
      AND (p_project_id IS NULL OR project_id = p_project_id)
      AND is_active AND t_valid_to = 'infinity'::TIMESTAMPTZ
    LIMIT 1 FOR UPDATE;
    IF FOUND THEN
        UPDATE pgmnemo.agent_lesson SET confidence = GREATEST(confidence, COALESCE(p_confidence, 0.7)), updated_at = NOW() WHERE agent_lesson.id = _prior_id;
        RETURN _prior_id;
    END IF;
    INSERT INTO pgmnemo.agent_lesson (
        role, project_id, topic, lesson_text, importance,
        embedding, commit_sha, artifact_hash, metadata,
        source_type, content_type, state, confidence,
        version_n, verified_at, t_valid_from, t_valid_to
    ) VALUES (
        p_role, p_project_id, _topic,
        p_from_key || ' ' || p_relation_type || ' ' || p_to_key, 3,
        p_embedding, p_commit_sha, _artifact_hash,
        jsonb_build_object('from_key', p_from_key, 'to_key', p_to_key, 'relation_type', p_relation_type),
        COALESCE(p_source_type, 'agent_authored'), 'relation', _final_state,
        COALESCE(p_confidence, 0.7), 1,
        CASE WHEN _final_state = 'validated' THEN NOW() ELSE NULL END,
        NOW(), 'infinity'::TIMESTAMPTZ
    ) RETURNING agent_lesson.id INTO _new_id;
    BEGIN
        SELECT id INTO _from_hub_id FROM pgmnemo.agent_lesson WHERE content_type = 'entity' AND lower(topic) = lower(p_from_key) AND (p_project_id IS NULL OR project_id = p_project_id) AND is_active AND t_valid_to = 'infinity'::TIMESTAMPTZ LIMIT 1;
        SELECT id INTO _to_hub_id FROM pgmnemo.agent_lesson WHERE content_type = 'entity' AND lower(topic) = lower(p_to_key) AND (p_project_id IS NULL OR project_id = p_project_id) AND is_active AND t_valid_to = 'infinity'::TIMESTAMPTZ LIMIT 1;
        IF _from_hub_id IS NOT NULL AND _to_hub_id IS NOT NULL THEN
            PERFORM pgmnemo.add_edge(_from_hub_id, _to_hub_id, p_relation_type, COALESCE(p_confidence, 0.7)::FLOAT8, jsonb_build_object('relation_lesson_id', _new_id), 'max');
        ELSE
            RAISE NOTICE 'pgmnemo.remember_relation: entity hubs not found for ''%'' or ''%'' — mem_edge skipped; lesson_id=% recorded.', p_from_key, p_to_key, _new_id;
        END IF;
    EXCEPTION WHEN OTHERS THEN
        RAISE NOTICE 'pgmnemo.remember_relation: add_edge failed (lesson_id=%): %', _new_id, SQLERRM;
    END;
    RETURN _new_id;
END;
$$;
COMMENT ON FUNCTION pgmnemo.remember_relation(TEXT,TEXT,TEXT,TEXT,REAL,vector,TEXT,INT,TEXT,TEXT) IS 'Directed typed relation. Idempotent on triple. content_type=relation. RFC-001 §D2. v0.12.0.';

