package httpapi import ( "context" "encoding/json" "errors" "log/slog" "net/http" "net/url" "regexp" "strings" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // Operator-facing write path for the knowledge base. Until this file, the // only way anything reached knowledge_entities was the MCP tool // upsert_knowledge (internal/mcp/server.go) — an agent-only surface. The web // UI could search and read but never create, correct, or remove a note, so // the operator's own knowledge had nowhere to go and an agent mistake had no // fix short of psql. // // All routes here are non-OpenAPI custom routes, consistent with the existing // knowledge read routes (see the carve-out block in server.go): they trade in // raw markdown and ad-hoc aggregates rather than generated schema types. // // Deletion is soft (deleted_at) — see migrations/022_knowledge_revisions.up.sql // for why — so every read path in this file filters on `ke.deleted_at IS NULL`. // knowledgeSlugSegmentRe strips a title down to a single slug segment. // Mirrors knowledgeSlugRe in internal/mcp/server.go; duplicated rather than // exported across the package boundary because the two callers namespace // their output differently (see knowledgeSlugFor). var knowledgeSlugSegmentRe = regexp.MustCompile(`[^a-z0-9]+`) // knowledgeSlugFor builds `:/`. The MCP tool's // equivalent hardcodes the `nomos/` folder; operator-created notes need to // land somewhere else so the navigator tree can tell at a glance who wrote // what, and so an operator note can never collide with an agent note that // happens to share a title. func knowledgeSlugFor(kind, folder, title string) string { s := strings.ToLower(strings.TrimSpace(title)) s = knowledgeSlugSegmentRe.ReplaceAllString(s, "-") s = strings.Trim(s, "-") if s == "" { s = "note" } if len(s) > 80 { s = s[:80] } folder = strings.Trim(strings.ToLower(strings.TrimSpace(folder)), "/") folder = knowledgeSlugSegmentRe.ReplaceAllString(folder, "-") folder = strings.Trim(folder, "-") if folder == "" { folder = "operator" } return kind + ":" + folder + "/" + s } // validKnowledgeKind mirrors the three entity types that knowledge_entities // rows are allowed to hang off (see upsert_knowledge's own check). func validKnowledgeKind(kind string) bool { switch kind { case "document", "investigation", "runbook": return true } return false } // resolveKnowledgeEntity maps an id-or-slug path segment to the entity id of // a live (non-deleted) knowledge note. Returns pgx.ErrNoRows when there's no // such note, which callers turn into a 404. func (s *Server) resolveKnowledgeEntity(ctx context.Context, idOrSlug string) (uuid.UUID, error) { var id uuid.UUID err := s.pool.QueryRow(ctx, ` SELECT ke.entity_id FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`, idOrSlug).Scan(&id) return id, err } // resolveKnowledgeEntityAny is resolveKnowledgeEntity without the // deleted_at filter — for the one read path (revisions) that must still work // on a deleted note. The whole point of soft-delete is that a note's history // stays inspectable after removal (e.g. to confirm what was lost before // restoring it); requiring the note to be live first would defeat that. func (s *Server) resolveKnowledgeEntityAny(ctx context.Context, idOrSlug string) (uuid.UUID, error) { var id uuid.UUID err := s.pool.QueryRow(ctx, ` SELECT ke.entity_id FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).Scan(&id) return id, err } // pathParam pulls a chi URL param and percent-decodes it. Knowledge slugs // contain both ':' and '/' (e.g. "document:containers/101-jellyfin"), so they // reach the handler still encoded — chi.URLParam does no decoding of its own // on manually-registered routes (unlike the OpenAPI-generated ones, which // decode via runtime.BindStyledParameterWithOptions). func pathParam(req *http.Request, name string) (string, error) { return url.PathUnescape(chi.URLParam(req, name)) } // serveKnowledgeList returns every live note without its body — the backing // data for the wiki navigator tree. Distinct from /knowledge/recent, which // caps at 200 and exists to answer "what changed lately" for the stats view: // the tree needs the complete set, and needs the linked-entity slugs so it // can offer a group-by-entity arrangement without N+1 fetches. // // Body text is deliberately excluded — with ~100 notes averaging ~1 KB the // full payload would be ~100 KB per app open, to render a list that shows // only titles. func (s *Server) serveKnowledgeList(w http.ResponseWriter, req *http.Request) { ctx := req.Context() type item struct { ID string `json:"id"` Slug string `json:"slug"` Title string `json:"title"` Kind string `json:"kind"` Source string `json:"source"` EditedBy string `json:"edited_by"` Tags []string `json:"tags"` About []string `json:"about"` Size int `json:"size"` UpdatedAt string `json:"updated_at"` CreatedAt string `json:"created_at"` Revisions int `json:"revisions"` } // The `about` aggregate mirrors GetEntityKnowledge's first UNION branch // (documents/about edges) — the 'procedure-for' branch is left out here // because it joins against entity *types* rather than entities and can't // produce a per-note slug list. rows, err := s.pool.Query(ctx, ` SELECT e.id::text, e.slug, ke.title, e.type, COALESCE(ke.source,''), COALESCE(ke.edited_by,''), COALESCE(ke.tags, '{}'), COALESCE(( SELECT array_agg(DISTINCT t.slug) FROM relationships r JOIN entities t ON t.id = r.target_id WHERE r.source_id = ke.entity_id AND r.valid_to IS NULL AND r.type IN ('documents', 'about') ), '{}'), length(ke.content), ke.updated_at::text, ke.created_at::text, (SELECT count(*) FROM knowledge_revisions kr WHERE kr.entity_id = ke.entity_id) FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id WHERE ke.deleted_at IS NULL ORDER BY ke.updated_at DESC`) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error()) return } defer rows.Close() items := []item{} for rows.Next() { var it item if err := rows.Scan(&it.ID, &it.Slug, &it.Title, &it.Kind, &it.Source, &it.EditedBy, &it.Tags, &it.About, &it.Size, &it.UpdatedAt, &it.CreatedAt, &it.Revisions); err != nil { slog.Error("httpapi: knowledge/list row scan failed", "error", err) continue } items = append(items, it) } writeJSON(w, map[string]any{"items": items}) } // serveKnowledgeTrash lists soft-deleted notes — the counterpart to // serveKnowledgeList, and what the "restore" affordance in the UI browses. // Without this, a deleted note is invisible from every list endpoint // (correctly — they all filter deleted_at) with no way to even discover it // exists to restore. func (s *Server) serveKnowledgeTrash(w http.ResponseWriter, req *http.Request) { ctx := req.Context() rows, err := s.pool.Query(ctx, ` SELECT e.slug, ke.title, e.type, COALESCE(ke.edited_by,''), ke.deleted_at::text FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id WHERE ke.deleted_at IS NOT NULL ORDER BY ke.deleted_at DESC`) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error()) return } defer rows.Close() type item struct { Slug string `json:"slug"` Title string `json:"title"` Kind string `json:"kind"` DeletedBy string `json:"deleted_by"` DeletedAt string `json:"deleted_at"` } items := []item{} for rows.Next() { var it item if err := rows.Scan(&it.Slug, &it.Title, &it.Kind, &it.DeletedBy, &it.DeletedAt); err != nil { slog.Error("httpapi: knowledge/trash row scan failed", "error", err) continue } items = append(items, it) } writeJSON(w, map[string]any{"items": items}) } // knowledgeWriteBody is the shared request shape for create and update. // Every field is a pointer so update can distinguish "not supplied" (leave // alone) from "supplied empty" (clear it) — a PUT that only changes tags // must not blank the body. type knowledgeWriteBody struct { Title *string `json:"title"` Content *string `json:"content"` Kind *string `json:"kind"` Tags *[]string `json:"tags"` Folder *string `json:"folder"` About *[]string `json:"about"` } // serveCreateKnowledge creates a note plus its backing entity, and links it // to whatever entities it's about. func (s *Server) serveCreateKnowledge(w http.ResponseWriter, req *http.Request) { ctx := req.Context() var body knowledgeWriteBody if err := json.NewDecoder(req.Body).Decode(&body); err != nil { writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error()) return } title := strings.TrimSpace(deref(body.Title)) content := strings.TrimSpace(deref(body.Content)) if title == "" || content == "" { writeProblem(w, req, http.StatusBadRequest, "title and content are required", "") return } kind := deref(body.Kind) if kind == "" { kind = "document" } if !validKnowledgeKind(kind) { writeProblem(w, req, http.StatusBadRequest, "invalid kind", "kind must be document, investigation, or runbook") return } tags := normalizeTags(derefSlice(body.Tags)) slug := knowledgeSlugFor(kind, deref(body.Folder), title) _, actorLabel := actorInfo(ctx) tx, err := s.pool.Begin(ctx) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error()) return } defer tx.Rollback(ctx) docID, _ := uuid.NewV7() // ON CONFLICT covers the soft-deleted case: the entity row survives a // delete, so recreating a note under the same slug must reuse it rather // than fail the unique constraint. if err := tx.QueryRow(ctx, ` INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, $3, $4, '{}') ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now() RETURNING id`, docID, slug, kind, title).Scan(&docID); err != nil { writeProblem(w, req, http.StatusInternalServerError, "create entity failed", err.Error()) return } // Refuse to silently overwrite an existing LIVE note — upsert_knowledge // (the MCP tool) deliberately upserts by title (the agent re-records the // same finding as it learns more), but an operator hitting "create" with // a colliding title almost certainly means to write something new. // // The `WHERE knowledge_entities.deleted_at IS NOT NULL` guard makes this // check atomic with the write, rather than a separate SELECT before it: // a plain pre-check has a TOCTOU race where two concurrent creates of // the same title can both pass the check and then both proceed to // INSERT ON CONFLICT DO UPDATE, silently clobbering each other. Here, // the UPDATE branch only actually applies when the conflicting row is // soft-deleted (a legitimate "resurrect" case). When it isn't, the row // is left untouched, RETURNING yields no row, and pgx.ErrNoRows below // becomes the 409 — the collision can never be missed, no matter how // the two writers interleave. var wroteID uuid.UUID err = tx.QueryRow(ctx, ` INSERT INTO knowledge_entities (entity_id, title, content, source, tags, edited_by, updated_at, deleted_at) VALUES ($1, $2, $3, $4, $5, $4, now(), NULL) ON CONFLICT (entity_id) DO UPDATE SET title = EXCLUDED.title, content = EXCLUDED.content, tags = EXCLUDED.tags, edited_by = EXCLUDED.edited_by, updated_at = now(), deleted_at = NULL WHERE knowledge_entities.deleted_at IS NOT NULL RETURNING entity_id`, docID, title, content, actorLabel, tags).Scan(&wroteID) if errors.Is(err, pgx.ErrNoRows) { writeProblem(w, req, http.StatusConflict, "a note with this title already exists", slug) return } else if err != nil { writeProblem(w, req, http.StatusInternalServerError, "write knowledge failed", err.Error()) return } linked := s.linkKnowledgeAbout(ctx, tx, docID, derefSlice(body.About)) if err := tx.Commit(ctx); err != nil { writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error()) return } slog.Info("knowledge created", "slug", slug, "kind", kind, "actor", actorLabel, "linked", linked) // Content-Type before WriteHeader — setting it after is a no-op, the // status line is already on the wire. w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(map[string]any{ "slug": slug, "id": docID.String(), "linked": linked, }); err != nil { slog.Error("httpapi: json encode failed", "error", err) } } // serveUpdateKnowledge edits a live note in place. The prior version is // captured by the trg_knowledge_revision trigger, not by this handler — see // the migration for why that lives in the database. // // Note the slug is intentionally NOT recomputed when the title changes: // slugs are the wiki's stable link target ([[slug]] references, relationship // rows, bookmarked window ids), and silently re-slugging on a typo fix would // break every inbound link. func (s *Server) serveUpdateKnowledge(w http.ResponseWriter, req *http.Request) { ctx := req.Context() idOrSlug, err := pathParam(req, "id") if err != nil { writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error()) return } var body knowledgeWriteBody if err := json.NewDecoder(req.Body).Decode(&body); err != nil { writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error()) return } if body.Title == nil && body.Content == nil && body.Tags == nil && body.About == nil { writeProblem(w, req, http.StatusBadRequest, "nothing to update", "supply at least one of title, content, tags, about") return } if body.Title != nil && strings.TrimSpace(*body.Title) == "" { writeProblem(w, req, http.StatusBadRequest, "title cannot be empty", "") return } if body.Content != nil && strings.TrimSpace(*body.Content) == "" { writeProblem(w, req, http.StatusBadRequest, "content cannot be empty", "") return } entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug) if err != nil { writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "") return } _, actorLabel := actorInfo(ctx) tx, err := s.pool.Begin(ctx) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error()) return } defer tx.Rollback(ctx) // COALESCE keeps unsupplied fields untouched; edited_by and updated_at // always move so the UI can show who last touched it. The trigger only // snapshots when title/content/tags actually differ, so a no-op save // doesn't manufacture a revision. var newTitle *string if body.Title != nil { t := strings.TrimSpace(*body.Title) newTitle = &t } var newContent *string if body.Content != nil { c := strings.TrimSpace(*body.Content) newContent = &c } var newTags *[]string if body.Tags != nil { t := normalizeTags(*body.Tags) newTags = &t } if _, err := tx.Exec(ctx, ` UPDATE knowledge_entities SET title = COALESCE($2, title), content = COALESCE($3, content), tags = COALESCE($4, tags), edited_by = $5, updated_at = now() WHERE entity_id = $1`, entityID, newTitle, newContent, newTags, actorLabel); err != nil { writeProblem(w, req, http.StatusInternalServerError, "update failed", err.Error()) return } // Keep the entity's display name in step with the note title — the graph // and the fleet table read entities.name, and leaving it stale is exactly // the drift this app exists to fight. if newTitle != nil { if _, err := tx.Exec(ctx, `UPDATE entities SET name = $2, updated_at = now() WHERE id = $1`, entityID, *newTitle); err != nil { writeProblem(w, req, http.StatusInternalServerError, "rename entity failed", err.Error()) return } } // About is replace-semantics, not merge: the editor presents the full // link set, so an absent slug means the operator removed it. Existing // edges are closed (valid_to) rather than deleted, preserving history. var linked []string if body.About != nil { if _, err := tx.Exec(ctx, ` UPDATE relationships SET valid_to = now() WHERE source_id = $1 AND valid_to IS NULL AND type IN ('documents', 'about')`, entityID); err != nil { writeProblem(w, req, http.StatusInternalServerError, "unlink failed", err.Error()) return } linked = s.linkKnowledgeAbout(ctx, tx, entityID, *body.About) } if err := tx.Commit(ctx); err != nil { writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error()) return } slog.Info("knowledge updated", "entity_id", entityID, "actor", actorLabel) // `linked` lets the caller diff against what it submitted and warn about // any slug that didn't resolve — see linkKnowledgeAbout: a typo'd entity // slug otherwise fails with nothing but a server-side slog.Warn, so the // operator gets no feedback that one of their About links didn't take. writeJSON(w, map[string]any{"ok": true, "linked": linked}) } // serveDeleteKnowledge soft-deletes a note. The row, its revision trail and // its entity all survive; only the deleted_at stamp changes, and every read // path filters on it. func (s *Server) serveDeleteKnowledge(w http.ResponseWriter, req *http.Request) { ctx := req.Context() idOrSlug, err := pathParam(req, "id") if err != nil { writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error()) return } entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug) if err != nil { writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "") return } _, actorLabel := actorInfo(ctx) // Snapshot the live version before tombstoning. The trigger fires on // title/content/tags changes only, and a delete changes none of them — // without this the most recent version would be the one version missing // from the history if the note is later restored. if _, err := s.pool.Exec(ctx, ` INSERT INTO knowledge_revisions (entity_id, title, content, source, tags, edited_by, version_at) SELECT entity_id, title, content, source, tags, COALESCE(edited_by,''), updated_at FROM knowledge_entities WHERE entity_id = $1`, entityID); err != nil { writeProblem(w, req, http.StatusInternalServerError, "snapshot failed", err.Error()) return } if _, err := s.pool.Exec(ctx, ` UPDATE knowledge_entities SET deleted_at = now(), edited_by = $2 WHERE entity_id = $1`, entityID, actorLabel); err != nil { writeProblem(w, req, http.StatusInternalServerError, "delete failed", err.Error()) return } slog.Info("knowledge deleted", "entity_id", entityID, "actor", actorLabel) writeJSON(w, map[string]any{"ok": true}) } // serveRestoreKnowledge undoes a soft delete. The counterpart to // serveDeleteKnowledge — without it, "recoverable by clearing the column" // (see the migration) would only be true via psql, which isn't a real // recovery path for an operator using the wiki. func (s *Server) serveRestoreKnowledge(w http.ResponseWriter, req *http.Request) { ctx := req.Context() idOrSlug, err := pathParam(req, "id") if err != nil { writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error()) return } entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug) if err != nil { writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "") return } _, actorLabel := actorInfo(ctx) ct, err := s.pool.Exec(ctx, ` UPDATE knowledge_entities SET deleted_at = NULL, edited_by = $2 WHERE entity_id = $1 AND deleted_at IS NOT NULL`, entityID, actorLabel) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "restore failed", err.Error()) return } if ct.RowsAffected() == 0 { writeProblem(w, req, http.StatusConflict, "note is not deleted", "") return } slog.Info("knowledge restored", "entity_id", entityID, "actor", actorLabel) writeJSON(w, map[string]any{"ok": true}) } // serveKnowledgeRevisions returns the note's superseded versions, newest // first. Bodies are included: revisions are small (~1 KB) and few, and the // diff view needs both sides anyway — paginating would cost a round trip per // comparison to save nothing. func (s *Server) serveKnowledgeRevisions(w http.ResponseWriter, req *http.Request) { ctx := req.Context() idOrSlug, err := pathParam(req, "id") if err != nil { writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error()) return } entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug) if err != nil { writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "") return } rows, err := s.pool.Query(ctx, ` SELECT id, title, content, COALESCE(edited_by,''), COALESCE(tags,'{}'), version_at::text, revised_at::text FROM knowledge_revisions WHERE entity_id = $1 ORDER BY version_at DESC`, entityID) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error()) return } defer rows.Close() type revision struct { ID int64 `json:"id"` Title string `json:"title"` Content string `json:"content"` EditedBy string `json:"edited_by"` Tags []string `json:"tags"` VersionAt string `json:"version_at"` RevisedAt string `json:"revised_at"` } items := []revision{} for rows.Next() { var r revision if err := rows.Scan(&r.ID, &r.Title, &r.Content, &r.EditedBy, &r.Tags, &r.VersionAt, &r.RevisedAt); err != nil { slog.Error("httpapi: knowledge/revisions row scan failed", "error", err) continue } items = append(items, r) } writeJSON(w, map[string]any{"items": items}) } // linkKnowledgeAbout points a note at the entities it concerns, skipping // slugs that don't resolve and edges that already exist. Returns the slugs // actually linked so the caller can report what stuck — a typo'd slug is a // silent no-op otherwise. func (s *Server) linkKnowledgeAbout(ctx context.Context, tx pgx.Tx, docID uuid.UUID, slugs []string) []string { linked := []string{} for _, raw := range slugs { slug := strings.TrimSpace(raw) if slug == "" { continue } var targetID uuid.UUID if err := tx.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&targetID); err != nil { slog.Warn("knowledge: about slug not found, skipping", "slug", slug) continue } if _, err := tx.Exec(ctx, ` INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) SELECT $1, $2, 'about', '{"by":"operator"}'::jsonb, now() WHERE NOT EXISTS ( SELECT 1 FROM relationships WHERE source_id = $1 AND target_id = $2 AND type = 'about' AND valid_to IS NULL)`, docID, targetID); err != nil { slog.Warn("knowledge: link failed", "slug", slug, "error", err) continue } linked = append(linked, slug) } return linked } // normalizeTags trims, lowercases and de-duplicates while preserving order. // Lowercasing is the fix for the casing drift already in the data — `oom` // and `OOM` were separate tags on separate notes, so neither tag page showed // the full set. Applied on every write so the split can't reopen. func normalizeTags(in []string) []string { seen := map[string]bool{} out := []string{} for _, t := range in { t = strings.ToLower(strings.TrimSpace(t)) if t == "" || seen[t] { continue } seen[t] = true out = append(out, t) } return out } func deref(p *string) string { if p == nil { return "" } return *p } func derefSlice(p *[]string) []string { if p == nil { return nil } return *p } // writeJSON is the success-path counterpart to writeProblem, so the handlers // in this file don't each repeat the header/encode dance. func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(v); err != nil { slog.Error("httpapi: json encode failed", "error", err) } }