think
16px
820px

Semantic Folder Suggestion (AI smart-filing) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: When a user files a document, rank the caller's write-accessible folders by semantic similarity of the file's content to each folder's existing contents (centroid) and an optional admin description, and surface the top match as a one-click chip — all gated end-to-end by a new, separately-sellable semantic license module.

Architecture: A swappable Embedder port (mock / fastembed sidecar / OpenAI) feeds a pgvector index (document_embeddings). Documents embed best-effort/async when their extracted text lands (SetContentText hook) with a scheduled backfill safety-net. A new semantic application context computes folder centroids on the fly (avg(embedding) over document_embeddings ⋈ documents, restricted to the caller's write-accessible folders) and ranks them against the query embedding by cosine. The whole pipeline — embed-on-upload, backfill, the suggest endpoint, the UI chip, and the admin folder description — no-ops / hides when semantic is unlicensed (fail-closed).

Tech Stack: Go modular monolith (go/internal/, go/cmd/, chi, pgx v5, goose), pgvector (pgvector/pgvector:pg17, already the postgres image), FastAPI + fastembed (ONNX intfloat/multilingual-e5-small, 384-dim, CPU-only, air-gapped), React/Carbon SPA (web/src/), docker-compose.


Working discipline (applies to EVERY task — do not skip)

These override the writing-plans skill's default go test / TDD-failing-test cadence. The repo's test DSN points at the live demo Postgres, so:

  • NEVER run go test ./... (it writes the live demo DB). Verify Go via cd go && go build ./... && go vet ./....
  • Verify the frontend via cd web && npx tsc --noEmit && npx vite build. Run npm run gen:api after any OpenAPI edit (it regenerates web/src/api/schema.ts; i18n en/id parity is enforced by tsc).
  • Deploy ONLY from the repo root, exactly:
    bash docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build
    (The --env-file deploy/mekari.env is mandatory — the esign callback fail-closed guard refuses to boot ESIGN_PROVIDER=mekari without MEKARI_CALLBACK_KEY; that key lives only in the gitignored deploy/mekari.env.) This now also builds embed-sidecar.
  • After every deploy, assert the module set and that the demo is intact:
    bash TOKEN=$(curl -s -XPOST localhost:38080/api/v1/auth/dev-login -H 'content-type: application/json' -d '{"email":"admin@obscura.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])') curl -s localhost:38080/api/v1/me -H "authorization: Bearer $TOKEN" | python3 -c 'import sys,json;print(sorted(json.load(sys.stdin)["enabled_modules"]))'
    Expected after Phase 1 Task 1.2 onward: ['ai', 'correspondence', 'esign', 'semantic', 'watermarking'] (the four originals plus semantic). (dev-login can race startup right after up -d; retry for ~30s.)
  • Fail-closed licensing is non-negotiable: with no semantic license, no embeddings are written, the suggest endpoint 403s (module.not_licensed), the UI chip is hidden, and no sidecar is required to boot.
  • Commit per task locally on main. Do NOT push unless the user asks. Clean up any test artifacts (documents/folders) you create during e2e.
  • pgvector dimension is fixed at 384 (e5-small). Switching to a different-dimension model later requires a re-embed + a dim migration — call this out, never silently change the vector(384) column.
  • CLAUDE.md: any .md file you create/modify this session must be uploaded: curl -F "file=@<path>.md" https://x056.think.val.id/upload, then give the user the returned URL.

File Structure

Phase 1 — module + seam (no behavior):
- Modify go/internal/platform/config/config.go — add "semantic" to KnownModules; add EmbedConfig + Embed field + Validate switch.
- Modify go/internal/httpapi/licensing_test.go — add "semantic" to the expected-disabled list (edit only, never run).
- Modify web/src/features/admin/LicensingTab.tsx — add 'semantic' to KNOWN_MODULES.
- Create go/internal/ai/app/embed.goEmbedder port, EmbedKind, EmbedInfo.
- Create go/internal/ai/adapters/embed_mock.go — deterministic MockEmbedder + hashVector.
- Create go/internal/ai/adapters/embed_select.goSelectEmbedder factory (default mock).
- Regenerate deploy/secrets/obscura.license.json (demo license, +semantic).
- Modify LICENSING.md — add semantic to the module list prose.

Phase 2 — sidecar + pipeline:
- Create deploy/embed-sidecar/app.py, deploy/embed-sidecar/requirements.txt, deploy/Dockerfile.embed.
- Modify deploy/docker-compose.ymlembed-sidecar service + EMBED_* env + depends_on.
- Create go/internal/ai/adapters/embed_sidecar.go, go/internal/ai/adapters/embed_openai.go; extend embed_select.go.
- Create go/migrations/00071_pgvector_embeddings.sql.
- Create go/internal/semantic/app/service.go, go/internal/semantic/app/ports.go, go/internal/semantic/adapters/pg.go, go/internal/semantic/adapters/vector.go.
- Modify go/internal/dms/app/service.go + go/internal/dms/app/acl.goWritableFolderIDs, DocumentEmbedSource, the embedHook field + SetEmbedHook + the SetContentText call.
- Modify go/internal/dms/adapters/acl_pg.goWritableFolderIDs SQL.
- Modify go/cmd/obscura-server/wire.go — build embedder + semantic store/service, set enabled func, set dms hook, register semantic.embed_backfill.
- Modify go/cmd/obscura-server/jobs.gorunEmbedBackfill.

Phase 3 — suggest + chip:
- Modify go/internal/semantic/adapters/pg.goFolderCentroids.
- Modify go/internal/semantic/app/service.goSuggestFolders + SuggestQuery/Suggestion.
- Create go/internal/httpapi/handlers_semantic.go; modify go/internal/httpapi/server.go (Deps/Server/NewServer/route).
- Modify api/openapi.yamlPOST /api/v1/semantic/suggest-folder; then npm run gen:api.
- Create web/src/api/semantic.ts, web/src/features/semantic/i18n.ts; modify web/src/features/documents/NewDocumentModal.tsx, web/src/features/documents/DocumentsPage.tsx, web/src/i18n/locales/en.ts, web/src/i18n/locales/id.ts.

Phase 4 — admin folder descriptions:
- Create go/migrations/00072_folder_description.sql.
- Modify dms (Folder.Description, SetFolderDescription, repo/Store) + semantic (EmbedFolderDescription, FolderDescEmbeddings, blend in SuggestFolders).
- Modify api/openapi.yaml (PUT /folders/{id}/description) + regen; folder-edit UI + i18n.


Phase 1 — The semantic module + Embedder seam + mock + config

Goal: semantic is a real licensed module end-to-end and the Embedder port exists with a mock — no embedding behavior yet.

Task 1.1: Register semantic as a known module (backend + UI + test fixture)

Files:
- Modify: go/internal/platform/config/config.go:60
- Modify: go/internal/httpapi/licensing_test.go:77 (edit only — do NOT run go test)
- Modify: web/src/features/admin/LicensingTab.tsx:28
- Modify: LICENSING.md

  • [ ] Step 1: Add semantic to the canonical module set

In go/internal/platform/config/config.go, change line 60:

var KnownModules = []string{"correspondence", "watermarking", "ai", "esign", "semantic"}

This single change flows through ModuleList(), /me enabled_modules, requireModule, and licensegen's unknownModules() automatically (all iterate config.KnownModules).

  • [ ] Step 2: Update the licensing test fixture (edit, never run)

In go/internal/httpapi/licensing_test.go find the expected-disabled module list (around line 77; it currently reads []string{"ai", "esign", "watermarking"} or similar — a core-only license disables every known premium module). Add "semantic" so the list stays in sync with KnownModules. For example if it reads:

want := []string{"ai", "esign", "watermarking"}

change it to (keep alphabetical to match the test's sort, if it sorts):

want := []string{"ai", "esign", "semantic", "watermarking"}

Read the surrounding assertion first and match its exact slice contents/order. Do not run go test — this edit only keeps the fixture honest for whenever tests run in CI.

  • [ ] Step 3: Add semantic to the UI's known-modules list

In web/src/features/admin/LicensingTab.tsx line 28:

const KNOWN_MODULES = ['correspondence', 'watermarking', 'ai', 'esign', 'semantic']
  • [ ] Step 4: Document the module in LICENSING.md

Find the prose/table that enumerates the premium modules (correspondence / watermarking / ai / esign) and add a row/line for semantic — "Semantic folder suggestion (AI smart-filing): pgvector embedding index over documents; suggests the best folder at upload time." Keep the format identical to the existing entries.

  • [ ] Step 5: Verify build/typecheck
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit

Expected: both clean. (No deploy yet — the demo license still lacks semantic, so /me won't show it until Task 1.2.)

  • [ ] Step 6: Upload the changed .md and commit
curl -F "file=@/home/efran/remote-development/obscura/LICENSING.md" https://x056.think.val.id/upload
cd /home/efran/remote-development/obscura
git add go/internal/platform/config/config.go go/internal/httpapi/licensing_test.go web/src/features/admin/LicensingTab.tsx LICENSING.md
git commit -m "feat(licensing): register the semantic premium module"

Give the user the upload URL.


Task 1.2: Regenerate the demo license to include semantic + verify on deploy

Files:
- Regenerate: deploy/secrets/obscura.license.json (gitignored — not committed)

Depends on Task 1.1 Step 1: licensegen rejects unknown modules, so semantic must already be in KnownModules or this fatals with "unknown module(s) [semantic]".

  • [ ] Step 1: Regenerate the signed demo license with the dev key

The current demo payload is {"customer":"Obscura Demo (dev)","modules":["correspondence","watermarking","ai","esign"],"seats":100,"expiry":"2099-12-31T23:59:59Z"}. Reproduce it with semantic added (the license is canonical — regenerate, never hand-edit the base64):

cd /home/efran/remote-development/obscura/go && go run ./cmd/licensegen \
  -private-key "$(cat ../deploy/secrets/license_dev_ed25519.key)" \
  -customer "Obscura Demo (dev)" \
  -modules correspondence,watermarking,ai,esign,semantic \
  -seats 100 \
  -expiry 2099-12-31 \
  -out ../deploy/secrets/obscura.license.json

Expected stderr: licensegen: signing with public key 3tVBsf3z3mdBKcdPSoV85w4BlZccBORIN8P89Y+9XWo= (the dev key). The file is written world-readable (0644) so the distroless nonroot UID 65532 can read it.

  • [ ] Step 2: Deploy and assert the module set
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build

Then run the post-deploy assertion from the discipline block. Expected: ['ai', 'correspondence', 'esign', 'semantic', 'watermarking']. Confirm the demo is otherwise intact (log in to http://localhost:8091, browse a folder).

  • [ ] Step 3: Commit (no license file — it is gitignored)

Nothing to commit here (the only artifact, deploy/secrets/obscura.license.json, is gitignored). Record in the task notes that the demo license was regenerated. If git status shows it as untracked-but-ignored, leave it.


Task 1.3: Define the Embedder port

Files:
- Create: go/internal/ai/app/embed.go

  • [ ] Step 1: Write the port
package app

import "context"

// EmbedKind selects the text role for asymmetric embedding models (the e5 family),
// which prepend "query:" or "passage:" before encoding. Symmetric models and the mock
// ignore it.
type EmbedKind string

const (
    EmbedQuery   EmbedKind = "query"
    EmbedPassage EmbedKind = "passage"
)

// EmbedInfo identifies the active embedding backend and its output dimension. Dim MUST
// equal the pgvector column dimension the embeddings are stored in — a mismatch is a
// runtime insert error, not a compile error.
type EmbedInfo struct {
    Provider string
    Model    string
    Dim      int
}

// Embedder is the swappable embedding backend, mirroring ChatProvider. Implemented by the
// fastembed sidecar and OpenAI-compatible adapters, and a deterministic mock for tests/dev.
// Embed encodes a batch and returns exactly one vector per input text, each of length
// Info().Dim.
type Embedder interface {
    Embed(ctx context.Context, kind EmbedKind, texts []string) ([][]float32, error)
    Info() EmbedInfo
}
  • [ ] Step 2: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

Expected: clean.

  • [ ] Step 3: Commit
git add go/internal/ai/app/embed.go
git commit -m "feat(ai): add Embedder port mirroring ChatProvider"

Task 1.4: Mock embedder + SelectEmbedder factory

Files:
- Create: go/internal/ai/adapters/embed_mock.go
- Create: go/internal/ai/adapters/embed_select.go

  • [ ] Step 1: Write the deterministic mock
package adapters

import (
    "context"
    "crypto/sha256"
    "math"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
)

// MockEmbedder produces deterministic, L2-normalized pseudo-embeddings from a hash of each
// input. Stable (so tests/dev are reproducible) but non-semantic — it is NOT meaningful for
// real ranking, so the demo runs the fastembed sidecar instead.
type MockEmbedder struct {
    Dim int
}

// NewMockEmbedder builds a mock of the given dimension (falls back to 384).
func NewMockEmbedder(dim int) *MockEmbedder {
    if dim <= 0 {
        dim = 384
    }
    return &MockEmbedder{Dim: dim}
}

// Embed returns one deterministic unit vector per text. kind is folded into the hash so the
// query and passage forms of the same text differ slightly, mirroring an asymmetric model.
func (m *MockEmbedder) Embed(_ context.Context, kind app.EmbedKind, texts []string) ([][]float32, error) {
    out := make([][]float32, len(texts))
    for i, t := range texts {
        out[i] = hashVector(string(kind)+"\x00"+t, m.Dim)
    }
    return out, nil
}

// Info identifies the mock backend.
func (m *MockEmbedder) Info() app.EmbedInfo {
    return app.EmbedInfo{Provider: "mock", Model: "mock", Dim: m.Dim}
}

// hashVector expands a seed string into a deterministic L2-normalized vector of width dim by
// hashing (seed||counter) blocks and mapping bytes to [-1,1).
func hashVector(seed string, dim int) []float32 {
    v := make([]float32, dim)
    var counter byte
    idx := 0
    for idx < dim {
        h := sha256.Sum256(append([]byte(seed), counter))
        for _, b := range h {
            if idx >= dim {
                break
            }
            v[idx] = (float32(b) / 127.5) - 1.0
            idx++
        }
        counter++
    }
    var norm float64
    for _, x := range v {
        norm += float64(x) * float64(x)
    }
    norm = math.Sqrt(norm)
    if norm == 0 {
        return v
    }
    for i := range v {
        v[i] = float32(float64(v[i]) / norm)
    }
    return v
}

var _ app.Embedder = (*MockEmbedder)(nil)
  • [ ] Step 2: Write the factory (Phase-1 form: only mock exists)
package adapters

import "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"

// SelectEmbedder returns an Embedder for the given config, mirroring SelectProvider. Unknown
// or unset providers fall back to the deterministic mock so the seam compiles and runs with
// no sidecar. The "sidecar" and "openai" cases are added in Phase 2.
func SelectEmbedder(provider, sidecarURL, openAIBaseURL, openAIKey, model string, dim int) app.Embedder {
    switch provider {
    default:
        return NewMockEmbedder(dim)
    }
}

Note: a switch with only a default is intentional — Phase 2 Task 2.3 adds case "sidecar" / case "openai". go vet does not flag this.

  • [ ] Step 3: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

Expected: clean.

  • [ ] Step 4: Commit
git add go/internal/ai/adapters/embed_mock.go go/internal/ai/adapters/embed_select.go
git commit -m "feat(ai): add deterministic mock embedder + SelectEmbedder factory"

Task 1.5: EmbedConfig

Files:
- Modify: go/internal/platform/config/config.go (struct field at line 34 region; new struct near the AIConfig at line 170; Validate at line 234 region)

  • [ ] Step 1: Add the Embed field to Config

In the Config struct, after AI AIConfig (line 34) add:

    AI           AIConfig
    Embed        EmbedConfig
  • [ ] Step 2: Add the EmbedConfig struct

Immediately after the AIConfig struct (after line 175) add:

// EmbedConfig selects the embedding backend for the semantic module (pgvector index).
// Provider "mock" (the default) needs no service and is non-semantic; "sidecar" calls the
// local FastAPI embed-sidecar (air-gapped, model baked into the image); "openai" calls an
// OpenAI-compatible /v1/embeddings endpoint. Dim MUST equal the vector(N) column dimension
// in the embeddings migration (384 for multilingual-e5-small) — switching to a model of a
// different dimension requires a re-embed + a dim migration, NOT just an env change.
// SidecarURL defaults to the host-mapped port for `go run` outside compose (mirroring how
// GotenbergURL defaults to localhost:33000); compose overrides it to the in-network address.
type EmbedConfig struct {
    Provider      string `env:"EMBED_PROVIDER" envDefault:"mock"`
    SidecarURL    string `env:"EMBED_SIDECAR_URL" envDefault:"http://localhost:38000"`
    Model         string `env:"EMBED_MODEL" envDefault:"intfloat/multilingual-e5-small"`
    Dim           int    `env:"EMBED_DIM" envDefault:"384"`
    OpenAIBaseURL string `env:"EMBED_OPENAI_BASE_URL"`
    OpenAIKey     string `env:"EMBED_OPENAI_API_KEY"`
}
  • [ ] Step 3: Validate the provider whitelist (fail-closed for openai)

In Validate(), after the ESignProvider switch / Mekari guard and before if c.DatabaseURL == "" (around line 245), add:

    switch c.Embed.Provider {
    case "mock", "sidecar", "openai":
    default:
        return fmt.Errorf("config: invalid EMBED_PROVIDER %q (want mock|sidecar|openai)", c.Embed.Provider)
    }
    if c.Embed.Provider == "openai" && c.Embed.OpenAIKey == "" {
        return fmt.Errorf("config: EMBED_PROVIDER=openai requires EMBED_OPENAI_API_KEY")
    }
    if c.Embed.Dim <= 0 {
        return fmt.Errorf("config: EMBED_DIM must be positive")
    }
  • [ ] Step 4: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

Expected: clean. (Embed is parsed automatically by env.ParseAs[Config](); no manual registration.)

  • [ ] Step 5: Commit
git add go/internal/platform/config/config.go
git commit -m "feat(config): add EmbedConfig (EMBED_PROVIDER/URL/MODEL/DIM) with validation"

Phase 1 done: semantic is a licensed module (demo shows it), the Embedder seam exists with a mock + factory + config. No embeddings are produced yet.


Phase 2 — Sidecar + adapters + pgvector migration + embed pipeline + backfill

Goal: real embeddings get written for documents (best-effort on text-land + a backfill sweep), gated on semantic. Still no suggest endpoint.

Task 2.1: The embed-sidecar FastAPI service (fastembed, e5-small, air-gapped)

Files:
- Create: deploy/embed-sidecar/app.py
- Create: deploy/embed-sidecar/requirements.txt
- Create: deploy/Dockerfile.embed

  • [ ] Step 1: Write requirements.txt

deploy/embed-sidecar/requirements.txt:

fastembed>=0.3,<1.0
fastapi>=0.110,<1.0
uvicorn[standard]>=0.29,<1.0
  • [ ] Step 2: Write the FastAPI app

deploy/embed-sidecar/app.py:

import os

from fastapi import FastAPI
from fastembed import TextEmbedding
from pydantic import BaseModel

MODEL_NAME = os.environ.get("EMBED_MODEL", "intfloat/multilingual-e5-small")
CACHE_DIR = os.environ.get("EMBED_CACHE_DIR", "/app/.fastembed_cache")

app = FastAPI()

# The model is baked into the image at build time (see Dockerfile.embed), so this loads
# from the local cache with no network access — air-gap safe.
_model = TextEmbedding(model_name=MODEL_NAME, cache_dir=CACHE_DIR)
# Probe the output dimension once so /healthz and /embed can report it.
_DIM = len(next(iter(_model.embed(["dimension probe"]))))


class EmbedRequest(BaseModel):
    texts: list[str]
    kind: str = "passage"  # "query" | "passage"


@app.get("/healthz")
def healthz():
    return {"status": "ok", "model": MODEL_NAME, "dim": _DIM}


@app.post("/embed")
def embed(req: EmbedRequest):
    # e5 retrieval convention: prefix queries with "query: " and documents with "passage: ".
    prefix = "query: " if req.kind == "query" else "passage: "
    prefixed = [prefix + (t or "") for t in req.texts]
    vectors = [v.tolist() for v in _model.embed(prefixed)]
    return {"vectors": vectors, "model": MODEL_NAME, "dim": _DIM}
  • [ ] Step 3: Write Dockerfile.embed (bakes the model for air-gap)

deploy/Dockerfile.embed:

# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app

COPY deploy/embed-sidecar/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# Bake the ONNX model into the image at build time so the runtime never reaches the internet
# (on-prem / air-gapped first). The cache dir matches EMBED_CACHE_DIR used by app.py.
ARG EMBED_MODEL=intfloat/multilingual-e5-small
ENV EMBED_MODEL=${EMBED_MODEL} EMBED_CACHE_DIR=/app/.fastembed_cache
RUN python -c "from fastembed import TextEmbedding; TextEmbedding(model_name='${EMBED_MODEL}', cache_dir='/app/.fastembed_cache')"

COPY deploy/embed-sidecar/ ./
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

The compose build context is the repo root (context: ..), so COPY paths are repo-root-relative (deploy/embed-sidecar/...), exactly like Dockerfile.web.

  • [ ] Step 4: Build the image in isolation to confirm the model resolves
cd /home/efran/remote-development/obscura
docker build -f deploy/Dockerfile.embed -t obscura-embed-sidecar:plancheck .

Expected: build succeeds; the RUN python -c ... line downloads + caches the model. If intfloat/multilingual-e5-small is NOT in this fastembed version's catalogue (the bake step errors with an unsupported-model message), fall back to another 384-dim model fastembed ships — try BAAI/bge-small-en-v1.5 (English-only, 384) — by setting --build-arg EMBED_MODEL=... AND updating the EMBED_MODEL default in compose (Task 2.2) + config (Task 1.5) to match; keep EMBED_DIM=384. Record the chosen model in the commit message.

  • [ ] Step 5: Smoke the container locally (optional but recommended)
docker run --rm -p 38000:8000 --name embed-plancheck -d obscura-embed-sidecar:plancheck
sleep 8
curl -s localhost:38000/healthz
curl -s -XPOST localhost:38000/embed -H 'content-type: application/json' \
  -d '{"texts":["kontrak kerja sama vendor"],"kind":"passage"}' | python3 -c 'import sys,json;d=json.load(sys.stdin);print("dim",d["dim"],"n",len(d["vectors"]),"len0",len(d["vectors"][0]))'
docker stop embed-plancheck

Expected: /healthz{"status":"ok","model":"intfloat/multilingual-e5-small","dim":384}; embed → dim 384 n 1 len0 384.

  • [ ] Step 6: Commit
git add deploy/embed-sidecar/ deploy/Dockerfile.embed
git commit -m "feat(embed-sidecar): fastembed e5-small FastAPI sidecar (air-gapped, 384-dim)"

Task 2.2: Wire the sidecar into compose + EMBED_* env

Files:
- Modify: deploy/docker-compose.yml

  • [ ] Step 1: Add the embed-sidecar service

After the gotenberg service block (after line 35) and before the obscura service, add:

  # CPU-only embedding sidecar (FastAPI + fastembed ONNX multilingual-e5-small, 384-dim).
  # The model is baked into the image (air-gapped: no boot-time download). Reached by the
  # backend over the compose network as http://embed-sidecar:8000 (container port), mirroring
  # gotenberg. Only used when the `semantic` module is licensed.
  embed-sidecar:
    build:
      context: ..
      dockerfile: deploy/Dockerfile.embed
    healthcheck:
      # slim python has no curl — use a urllib one-liner.
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"]
      interval: 10s
      timeout: 5s
      retries: 12
    ports: ["38000:8000"]
  • [ ] Step 2: Add EMBED_* to the obscura environment block

In the obscura service environment: block, right after the GOTENBERG_URL line (line 69), add:

      # Semantic module embedding backend. Defaults to the local sidecar so the DEMO shows
      # real (non-mock) suggestions; override EMBED_PROVIDER=mock for an unlicensed/air-gapped
      # build with no sidecar. EMBED_DIM MUST match the vector(384) migration + the model.
      EMBED_PROVIDER: "${EMBED_PROVIDER:-sidecar}"
      EMBED_SIDECAR_URL: "http://embed-sidecar:8000"
      EMBED_MODEL: "${EMBED_MODEL:-intfloat/multilingual-e5-small}"
      EMBED_DIM: "${EMBED_DIM:-384}"
      EMBED_OPENAI_BASE_URL: "${EMBED_OPENAI_BASE_URL:-}"
      EMBED_OPENAI_API_KEY: "${EMBED_OPENAI_API_KEY:-}"
  • [ ] Step 3: Add a (soft) dependency so the sidecar starts with the stack

In the obscura service depends_on: block (lines 43-47), add:

      embed-sidecar:
        condition: service_started

Use service_started, NOT service_healthy: embed calls are best-effort (the embed-on-upload hook and the backfill sweep both tolerate a sidecar that is still loading), and the demo must boot even if the model is mid-load. The healthcheck still exists for operator visibility.

  • [ ] Step 4: Deploy the full stack + assert intact
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build
docker compose -f deploy/docker-compose.yml ps embed-sidecar
curl -s localhost:38000/healthz

Then the post-deploy /me enabled_modules assertion (expect the 5 modules) + log in to confirm the demo is intact.

  • [ ] Step 5: Commit
git add deploy/docker-compose.yml
git commit -m "feat(deploy): run embed-sidecar + EMBED_* config in the compose stack"

Task 2.3: Sidecar + OpenAI embedder adapters + extend the factory

Files:
- Create: go/internal/ai/adapters/embed_sidecar.go
- Create: go/internal/ai/adapters/embed_openai.go
- Modify: go/internal/ai/adapters/embed_select.go

  • [ ] Step 1: Write the sidecar adapter (truncate already exists in this package, used by openai.go)

go/internal/ai/adapters/embed_sidecar.go:

package adapters

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "strings"
    "time"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
)

// SidecarEmbedder calls the local embed-sidecar (FastAPI + fastembed) over the compose
// network. The sidecar bakes the model into its image (air-gapped) and applies the e5
// query:/passage: prefixes based on kind.
type SidecarEmbedder struct {
    baseURL string
    model   string
    dim     int
    http    *http.Client
}

// NewSidecarEmbedder constructs the sidecar client.
func NewSidecarEmbedder(baseURL, model string, dim int) *SidecarEmbedder {
    return &SidecarEmbedder{
        baseURL: strings.TrimRight(baseURL, "/"),
        model:   model,
        dim:     dim,
        http:    &http.Client{Timeout: 60 * time.Second},
    }
}

// Embed POSTs the batch to the sidecar and returns one vector per text.
func (e *SidecarEmbedder) Embed(ctx context.Context, kind app.EmbedKind, texts []string) ([][]float32, error) {
    if len(texts) == 0 {
        return nil, nil
    }
    raw, _ := json.Marshal(map[string]any{"texts": texts, "kind": string(kind)})
    httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/embed", bytes.NewReader(raw))
    if err != nil {
        return nil, err
    }
    httpReq.Header.Set("content-type", "application/json")
    resp, err := e.http.Do(httpReq)
    if err != nil {
        return nil, fmt.Errorf("embed sidecar request: %w", err)
    }
    defer resp.Body.Close()
    rb, _ := io.ReadAll(resp.Body)
    if resp.StatusCode/100 != 2 {
        return nil, fmt.Errorf("embed sidecar %d: %s", resp.StatusCode, truncate(string(rb), 300))
    }
    var out struct {
        Vectors [][]float32 `json:"vectors"`
        Model   string      `json:"model"`
        Dim     int         `json:"dim"`
    }
    if err := json.Unmarshal(rb, &out); err != nil {
        return nil, fmt.Errorf("embed sidecar decode: %w", err)
    }
    if len(out.Vectors) != len(texts) {
        return nil, fmt.Errorf("embed sidecar: expected %d vectors, got %d", len(texts), len(out.Vectors))
    }
    return out.Vectors, nil
}

// Info identifies the sidecar backend.
func (e *SidecarEmbedder) Info() app.EmbedInfo {
    return app.EmbedInfo{Provider: "sidecar", Model: e.model, Dim: e.dim}
}

var _ app.Embedder = (*SidecarEmbedder)(nil)
  • [ ] Step 2: Write the OpenAI embedder adapter

go/internal/ai/adapters/embed_openai.go:

package adapters

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "strings"
    "time"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
)

// OpenAIEmbedder calls an OpenAI-compatible /v1/embeddings endpoint (OpenAI itself or a
// self-hosted gateway) — the connected/SaaS path. OpenAI embedding models are symmetric, so
// kind is ignored.
type OpenAIEmbedder struct {
    baseURL string
    apiKey  string
    model   string
    dim     int
    http    *http.Client
}

// NewOpenAIEmbedder constructs the embeddings client. An empty baseURL defaults to the public
// API; an empty model defaults to text-embedding-3-small.
func NewOpenAIEmbedder(baseURL, apiKey, model string, dim int) *OpenAIEmbedder {
    if baseURL == "" {
        baseURL = "https://api.openai.com"
    }
    if model == "" {
        model = "text-embedding-3-small"
    }
    return &OpenAIEmbedder{
        baseURL: strings.TrimRight(baseURL, "/"),
        apiKey:  apiKey,
        model:   model,
        dim:     dim,
        http:    &http.Client{Timeout: 60 * time.Second},
    }
}

// Embed POSTs the batch and returns one vector per text, index-ordered.
func (e *OpenAIEmbedder) Embed(ctx context.Context, _ app.EmbedKind, texts []string) ([][]float32, error) {
    if len(texts) == 0 {
        return nil, nil
    }
    raw, _ := json.Marshal(map[string]any{"model": e.model, "input": texts})
    httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/v1/embeddings", bytes.NewReader(raw))
    if err != nil {
        return nil, err
    }
    httpReq.Header.Set("authorization", "Bearer "+e.apiKey)
    httpReq.Header.Set("content-type", "application/json")
    resp, err := e.http.Do(httpReq)
    if err != nil {
        return nil, fmt.Errorf("openai embeddings request: %w", err)
    }
    defer resp.Body.Close()
    rb, _ := io.ReadAll(resp.Body)
    if resp.StatusCode/100 != 2 {
        return nil, fmt.Errorf("openai embeddings %d: %s", resp.StatusCode, truncate(string(rb), 300))
    }
    var out struct {
        Data []struct {
            Embedding []float32 `json:"embedding"`
            Index     int       `json:"index"`
        } `json:"data"`
    }
    if err := json.Unmarshal(rb, &out); err != nil {
        return nil, fmt.Errorf("openai embeddings decode: %w", err)
    }
    if len(out.Data) != len(texts) {
        return nil, fmt.Errorf("openai embeddings: expected %d vectors, got %d", len(texts), len(out.Data))
    }
    vecs := make([][]float32, len(texts))
    for _, d := range out.Data {
        if d.Index >= 0 && d.Index < len(vecs) {
            vecs[d.Index] = d.Embedding
        }
    }
    return vecs, nil
}

// Info identifies the OpenAI embeddings backend.
func (e *OpenAIEmbedder) Info() app.EmbedInfo {
    return app.EmbedInfo{Provider: "openai", Model: e.model, Dim: e.dim}
}

var _ app.Embedder = (*OpenAIEmbedder)(nil)
  • [ ] Step 3: Extend SelectEmbedder

Replace the body of SelectEmbedder in go/internal/ai/adapters/embed_select.go:

func SelectEmbedder(provider, sidecarURL, openAIBaseURL, openAIKey, model string, dim int) app.Embedder {
    switch provider {
    case "sidecar":
        return NewSidecarEmbedder(sidecarURL, model, dim)
    case "openai":
        return NewOpenAIEmbedder(openAIBaseURL, openAIKey, model, dim)
    default:
        return NewMockEmbedder(dim)
    }
}
  • [ ] Step 4: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

Expected: clean.

  • [ ] Step 5: Commit
git add go/internal/ai/adapters/embed_sidecar.go go/internal/ai/adapters/embed_openai.go go/internal/ai/adapters/embed_select.go
git commit -m "feat(ai): sidecar + openai embedder adapters; wire into SelectEmbedder"

Task 2.4: pgvector migration — document_embeddings

Files:
- Create: go/migrations/00071_pgvector_embeddings.sql

  • [ ] Step 1: Write the migration (documents.id is uuid; ON DELETE CASCADE so purges clean up automatically)

go/migrations/00071_pgvector_embeddings.sql:

-- +goose Up
-- Semantic module: a pgvector embedding per document for folder-suggestion (and later
-- semantic search). The vector(384) dimension matches multilingual-e5-small / EMBED_DIM;
-- switching to a model of a different dimension requires a re-embed + a dim migration.
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE document_embeddings (
    document_id uuid PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE,
    version     int NOT NULL,
    model       text NOT NULL,
    embedding   vector(384) NOT NULL,
    updated_at  timestamptz NOT NULL DEFAULT now()
);

-- HNSW index for cosine-distance ANN search over document embeddings.
CREATE INDEX document_embeddings_ann ON document_embeddings USING hnsw (embedding vector_cosine_ops);

-- +goose Down
DROP TABLE IF EXISTS document_embeddings;
-- Leave the `vector` extension installed; later objects may depend on it.
  • [ ] Step 2: Apply by deploying (migrations run on boot via MIGRATE_ON_BOOT=true)
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura
docker compose -f deploy/docker-compose.yml logs --tail=40 obscura | grep -i "migrat\|error"

Then confirm the table exists:

docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "\d document_embeddings"

Expected: the table prints with an embedding | vector(384) column and the document_embeddings_ann hnsw index. Run the /me module assertion + demo-intact check.

  • [ ] Step 3: Commit
git add go/migrations/00071_pgvector_embeddings.sql
git commit -m "feat(db): pgvector extension + document_embeddings table (00071)"

Task 2.5: The semantic application context (store + service: embed pipeline)

Files:
- Create: go/internal/semantic/app/ports.go
- Create: go/internal/semantic/app/service.go
- Create: go/internal/semantic/adapters/vector.go
- Create: go/internal/semantic/adapters/pg.go

  • [ ] Step 1: Write the ports

go/internal/semantic/app/ports.go:

// Package app is the semantic context: a pgvector embedding index over documents that powers
// folder suggestion (and, later, semantic search). It embeds documents via an ai/app.Embedder,
// stores vectors through a Store, and reads document text + the caller's write-accessible
// folders through a DocSource (satisfied by the dms Service). Everything no-ops when the
// `semantic` module is unlicensed (the Enabled predicate fails closed).
package app

import (
    "context"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)

// Store is the pgvector persistence port (document_embeddings, and in Phase 4
// folder_description_embeddings).
type Store interface {
    // UpsertDocEmbedding inserts or replaces a document's embedding (keyed by document_id).
    UpsertDocEmbedding(ctx context.Context, docID string, version int, model string, vec []float32) error
    // DocIDsNeedingEmbedding returns live documents (current_version > 0) whose embedding is
    // missing or stale (different model or older version than current), newest first, capped
    // at limit — the backfill working set.
    DocIDsNeedingEmbedding(ctx context.Context, model string, limit int) ([]string, error)
}

// DocSource is the narrow slice of the dms Service the semantic context needs (declared here
// so semantic never imports the dms domain).
type DocSource interface {
    // DocumentEmbedSource returns the title, extracted content_text, and current version for a
    // document. kernel.ErrNotFound if absent.
    DocumentEmbedSource(ctx context.Context, id string) (title, text string, version int, err error)
    // WritableFolderIDs returns the ids of folders the subjects can write to (access_mode >= 2),
    // or all live folders when bypass is true.
    WritableFolderIDs(ctx context.Context, subjects []kernel.Subject, bypass bool) ([]string, error)
}
  • [ ] Step 2: Write the vector text codec (dependency-light: encode/parse pgvector's [a,b,c] text form; no new module)

go/internal/semantic/adapters/vector.go:

package adapters

import (
    "fmt"
    "strconv"
    "strings"
)

// formatVector renders a []float32 as pgvector's text input form, e.g. "[0.1,0.2,0.3]". Bound
// as a text param and cast ::vector in SQL — avoids pulling in a pgvector codec dependency.
func formatVector(v []float32) string {
    var b strings.Builder
    b.Grow(len(v)*8 + 2)
    b.WriteByte('[')
    for i, x := range v {
        if i > 0 {
            b.WriteByte(',')
        }
        b.WriteString(strconv.FormatFloat(float64(x), 'f', -1, 32))
    }
    b.WriteByte(']')
    return b.String()
}

// parseVector parses pgvector's text output form ("[0.1,0.2,...]") into a []float32.
func parseVector(s string) ([]float32, error) {
    s = strings.TrimSpace(s)
    s = strings.TrimPrefix(s, "[")
    s = strings.TrimSuffix(s, "]")
    if s == "" {
        return nil, nil
    }
    parts := strings.Split(s, ",")
    v := make([]float32, len(parts))
    for i, p := range parts {
        f, err := strconv.ParseFloat(strings.TrimSpace(p), 32)
        if err != nil {
            return nil, fmt.Errorf("parse vector element %d: %w", i, err)
        }
        v[i] = float32(f)
    }
    return v, nil
}
  • [ ] Step 3: Write the pgvector Store (mirror the existing pgx pool usage; pass docID strings directly into uuid columns exactly like dms/adapters/content_pg.go does)

go/internal/semantic/adapters/pg.go:

package adapters

import (
    "context"
    "fmt"

    "github.com/jackc/pgx/v5/pgxpool"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/app"
)

// Store is the pgvector-backed persistence adapter for the semantic context.
type Store struct {
    db *pgxpool.Pool
}

// NewStore builds the Store over the shared pgx pool.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }

// UpsertDocEmbedding inserts or replaces a document's embedding row.
func (s *Store) UpsertDocEmbedding(ctx context.Context, docID string, version int, model string, vec []float32) error {
    const q = `
INSERT INTO document_embeddings (document_id, version, model, embedding, updated_at)
VALUES ($1, $2, $3, $4::vector, now())
ON CONFLICT (document_id) DO UPDATE
SET version = EXCLUDED.version, model = EXCLUDED.model, embedding = EXCLUDED.embedding, updated_at = now()`
    if _, err := s.db.Exec(ctx, q, docID, version, model, formatVector(vec)); err != nil {
        return fmt.Errorf("upsert doc embedding: %w", err)
    }
    return nil
}

// DocIDsNeedingEmbedding returns live documents whose embedding is missing or stale.
func (s *Store) DocIDsNeedingEmbedding(ctx context.Context, model string, limit int) ([]string, error) {
    const q = `
SELECT d.id::text
FROM documents d
LEFT JOIN document_embeddings e ON e.document_id = d.id
WHERE d.deleted_at IS NULL
  AND d.current_version > 0
  AND (e.document_id IS NULL OR e.model <> $1 OR e.version <> d.current_version)
ORDER BY d.created_at DESC
LIMIT $2`
    rows, err := s.db.Query(ctx, q, model, limit)
    if err != nil {
        return nil, fmt.Errorf("list docs needing embedding: %w", err)
    }
    defer rows.Close()
    var ids []string
    for rows.Next() {
        var id string
        if err := rows.Scan(&id); err != nil {
            return nil, err
        }
        ids = append(ids, id)
    }
    return ids, rows.Err()
}

var _ app.Store = (*Store)(nil)

documents.deleted_at is added by a later migration than 00006; it is referenced by existing queries (AllFolderIDs, ListExpired), so the column exists.

  • [ ] Step 4: Write the Service (embed pipeline; suggest is added in Phase 3)

go/internal/semantic/app/service.go:

package app

import (
    "context"
    "errors"
    "log/slog"
    "sync"
    "time"

    aiapp "github.com/Virtue-Digital-Indonesia/obscura/internal/ai/app"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)

// embedCharLimit bounds the text sent to the embedder per document. multilingual-e5-small has
// a ~512-token window (the sidecar truncates too); this caps the request payload.
const embedCharLimit = 2000

// Service owns the embedding pipeline + folder suggestion. enabled fails closed: until the
// composition root wires it to the live license, it reports false (no embeddings written).
type Service struct {
    store    Store
    embedder aiapp.Embedder
    docs     DocSource
    model    string
    dim      int
    logger   *slog.Logger

    mu      sync.RWMutex
    enabled func() bool
}

// NewService builds the semantic Service. The enabled predicate defaults to "always false"
// (fail-closed) until SetEnabled is called with the live license check.
func NewService(store Store, embedder aiapp.Embedder, docs DocSource, model string, dim int, logger *slog.Logger) *Service {
    if logger == nil {
        logger = slog.Default()
    }
    return &Service{
        store:    store,
        embedder: embedder,
        docs:     docs,
        model:    model,
        dim:      dim,
        logger:   logger,
        enabled:  func() bool { return false },
    }
}

// SetEnabled installs the live "is the semantic module licensed" predicate (read from the
// hot-swappable license). Called once by the composition root after the HTTP server exists.
func (s *Service) SetEnabled(fn func() bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    if fn != nil {
        s.enabled = fn
    }
}

// Enabled reports whether the semantic module is currently licensed.
func (s *Service) Enabled() bool {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return s.enabled()
}

// OnContentText is the embed-on-upload hook the dms Service calls (best-effort) after a
// document's content_text lands. It detaches from the request and embeds asynchronously so it
// never blocks the upload; a no-op when the module is unlicensed.
func (s *Service) OnContentText(docID string) {
    if !s.Enabled() {
        return
    }
    go func() {
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        if err := s.EmbedDocument(ctx, docID); err != nil {
            s.logger.Warn("semantic: embed-on-upload failed", "doc", docID, "err", err)
        }
    }()
}

// EmbedDocument embeds one document's (title + extracted text) and upserts the vector. Returns
// nil (skips) when the document has no embeddable text yet.
func (s *Service) EmbedDocument(ctx context.Context, docID string) error {
    title, text, version, err := s.docs.DocumentEmbedSource(ctx, docID)
    if err != nil {
        if errors.Is(err, kernel.ErrNotFound) {
            return nil
        }
        return err
    }
    body := buildEmbedText(title, text, "")
    if body == "" {
        return nil
    }
    vecs, err := s.embedder.Embed(ctx, aiapp.EmbedPassage, []string{body})
    if err != nil {
        return err
    }
    if len(vecs) != 1 || len(vecs[0]) != s.dim {
        s.logger.Warn("semantic: unexpected embedding shape; skipping", "doc", docID, "want_dim", s.dim)
        return nil
    }
    return s.store.UpsertDocEmbedding(ctx, docID, version, s.model, vecs[0])
}

// RunBackfill embeds up to limit documents whose embedding is missing or stale. Each document
// is best-effort (a per-doc error is logged, not fatal). Returns the number of candidates it
// processed. A no-op (0, nil) when unlicensed.
func (s *Service) RunBackfill(ctx context.Context, limit int) (int, error) {
    if !s.Enabled() {
        return 0, nil
    }
    ids, err := s.store.DocIDsNeedingEmbedding(ctx, s.model, limit)
    if err != nil {
        return 0, err
    }
    for _, id := range ids {
        if err := s.EmbedDocument(ctx, id); err != nil {
            s.logger.Warn("semantic: backfill embed failed", "doc", id, "err", err)
        }
    }
    return len(ids), nil
}

// buildEmbedText joins the signals into the text to embed, clipped to embedCharLimit.
func buildEmbedText(title, text, filename string) string {
    parts := make([]byte, 0, embedCharLimit+64)
    add := func(s string) {
        if s == "" {
            return
        }
        if len(parts) > 0 {
            parts = append(parts, '\n')
        }
        parts = append(parts, s...)
    }
    add(title)
    add(filename)
    add(text)
    if len(parts) > embedCharLimit {
        parts = parts[:embedCharLimit]
    }
    return string(parts)
}
  • [ ] Step 5: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

Expected: clean. (The Service compiles standalone; it is wired in Task 2.7.)

  • [ ] Step 6: Commit
git add go/internal/semantic/
git commit -m "feat(semantic): pgvector store + embed pipeline service (embed-on-upload + backfill)"

Task 2.6: dms additions — writable folders, embed source, and the embed hook

Files:
- Modify: go/internal/dms/app/service.go (Repository port near lines 182-185; SetContentText at 1572; new Service methods)
- Modify: go/internal/dms/app/acl.go (Service wrapper near EffectiveDocumentAccess at 385)
- Modify: go/internal/dms/adapters/acl_pg.go (new WritableFolderIDs SQL near EffectiveReadAccess at 304)

  • [ ] Step 1: Add WritableFolderIDs to the Repository port

In go/internal/dms/app/service.go, in the Repository interface near the folder-ACL methods (after EffectiveReadAccess, line 185), add:

    // WritableFolderIDs returns the ids of folders any of subjects can write to (folder_acl_read
    // access_mode >= AccessReadWrite), excluding soft-deleted folders. bypass=true returns every
    // live folder id; empty subjects returns nil.
    WritableFolderIDs(ctx context.Context, subjects []kernel.Subject, bypass bool) ([]string, error)
  • [ ] Step 2: Implement the SQL in the Store

In go/internal/dms/adapters/acl_pg.go, after EffectiveReadAccess (line 304), add. (Mirror the existing unnest($1::text[],$2::text[]) subject-join idiom; the folder_acl_read projection already stores the per-subject tier as access_mode.)

// WritableFolderIDs returns the live folders any of subjects can write to (access_mode >= 2).
// bypass returns every live folder id (delegates to AllFolderIDs); empty subjects returns nil.
func (s *Store) WritableFolderIDs(ctx context.Context, subjects []kernel.Subject, bypass bool) ([]string, error) {
    if bypass {
        return s.AllFolderIDs(ctx)
    }
    if len(subjects) == 0 {
        return nil, nil
    }
    kinds := make([]string, len(subjects))
    ids := make([]string, len(subjects))
    for i, sub := range subjects {
        kinds[i] = string(sub.Kind)
        ids[i] = sub.ID
    }
    const q = `
SELECT DISTINCT r.folder_id::text
FROM folder_acl_read r
JOIN unnest($1::text[], $2::text[]) AS subj(kind, id)
  ON r.subject_kind = subj.kind AND r.subject_id = subj.id
JOIN folders f ON f.id = r.folder_id AND f.deleted_at IS NULL
WHERE r.access_mode >= 2`
    rows, err := s.db.Query(ctx, q, kinds, ids)
    if err != nil {
        return nil, fmt.Errorf("writable folder ids: %w", err)
    }
    defer rows.Close()
    var out []string
    for rows.Next() {
        var id string
        if err := rows.Scan(&id); err != nil {
            return nil, err
        }
        out = append(out, id)
    }
    return out, rows.Err()
}

Check the exact pool field name + string(sub.Kind) usage against the existing EffectiveReadAccess impl in this file and match it (e.g. if subjects are passed as kinds/ids slices built differently, mirror that). fmt is already imported in this file (it builds errors); if not, add it.

  • [ ] Step 3: Add the Service wrappers (WritableFolderIDs + DocumentEmbedSource)

In go/internal/dms/app/acl.go, next to EffectiveDocumentAccess (line 385), add:

// WritableFolderIDs returns the ids of folders the subjects can write to (Contributor+), or
// all live folders when bypass is true. Used by the semantic suggest flow to bound candidate
// folders to where the caller may actually file.
func (s *Service) WritableFolderIDs(ctx context.Context, subjects []kernel.Subject, bypass bool) ([]string, error) {
    return s.repo.WritableFolderIDs(ctx, subjects, bypass)
}

In go/internal/dms/app/service.go, near DocumentText (line 748), add:

// DocumentEmbedSource returns the inputs the semantic embedding pipeline needs for a document
// — its title, extracted content_text, and current version — without exposing the dms domain
// type. kernel.ErrNotFound if the document is absent.
func (s *Service) DocumentEmbedSource(ctx context.Context, id string) (title, text string, version int, err error) {
    doc, err := s.repo.GetDocument(ctx, id)
    if err != nil {
        return "", "", 0, err
    }
    text, err = s.repo.GetContentText(ctx, id)
    if err != nil {
        return "", "", 0, err
    }
    return doc.Title, text, doc.CurrentVersion, nil
}

Confirm domain.Document exposes CurrentVersion (it maps documents.current_version); if the field is named differently, use that name.

  • [ ] Step 4: Add the embed hook field + setter + fire it in SetContentText

In go/internal/dms/app/service.go, find the Service struct definition (near the top, after the package docs). Add a field:

    // embedHook, when set, is invoked (best-effort, non-fatal) after content_text is
    // (re)written, so an out-of-context indexer (the semantic embedding pipeline) can react to
    // freshly-extracted text. nil = no hook (core build / unlicensed). The hook owns its own
    // goroutine + context.
    embedHook func(docID string)

Add a setter near the constructor:

// SetEmbedHook installs the post-content-text hook (the semantic embedding trigger). Idempotent;
// nil clears it.
func (s *Service) SetEmbedHook(fn func(docID string)) { s.embedHook = fn }

In SetContentText (line 1572), after the successful s.repo.SetContentText(...) call, fire the hook. Change:

    return s.repo.SetContentText(ctx, docID, text)

to:

    if err := s.repo.SetContentText(ctx, docID, text); err != nil {
        return err
    }
    if s.embedHook != nil {
        s.embedHook(docID)
    }
    return nil
  • [ ] Step 5: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

Expected: clean.

  • [ ] Step 6: Commit
git add go/internal/dms/app/service.go go/internal/dms/app/acl.go go/internal/dms/adapters/acl_pg.go
git commit -m "feat(dms): WritableFolderIDs, DocumentEmbedSource, and content-text embed hook"

Task 2.7: Wire the semantic service + backfill job in the composition root

Files:
- Modify: go/cmd/obscura-server/wire.go (AI wiring at 331; Deps at 341; after api is built at 379; scheduler registration at 264-327)
- Modify: go/cmd/obscura-server/jobs.go (new runEmbedBackfill)

  • [ ] Step 1: Add the backfill job body

In go/cmd/obscura-server/jobs.go, add (mirrors the disposition sweep's page-cap-logging shape):

// embedBackfillPageSize bounds how many documents one backfill sweep embeds; logged when hit so
// older un-embedded documents are never silently starved.
const embedBackfillPageSize = 200

// runEmbedBackfill embeds documents whose semantic embedding is missing or stale (the safety net
// behind the best-effort embed-on-upload hook, and the catch-up when `semantic` is first
// licensed). A no-op when the module is unlicensed. Idempotent; safe at every boot.
func runEmbedBackfill(ctx context.Context, semantic *semanticapp.Service, logger *slog.Logger) error {
    n, err := semantic.RunBackfill(ctx, embedBackfillPageSize)
    if err != nil {
        return err
    }
    if n == embedBackfillPageSize {
        logger.Warn("semantic embed backfill hit its page cap; older documents may be deferred this run", "cap", embedBackfillPageSize)
    }
    return nil
}

Add the import to jobs.go:

    semanticapp "github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/app"
  • [ ] Step 2: Build the embedder + semantic store/service near the AI wiring

In go/cmd/obscura-server/wire.go, right after the aiSvc := ... line (331-332), add:

    embedder := aiadapters.SelectEmbedder(cfg.Embed.Provider, cfg.Embed.SidecarURL, cfg.Embed.OpenAIBaseURL, cfg.Embed.OpenAIKey, cfg.Embed.Model, cfg.Embed.Dim)
    logger.Info("embed provider", "provider", embedder.Info().Provider, "model", embedder.Info().Model, "dim", embedder.Info().Dim)
    semanticSvc := semanticapp.NewService(
        semanticadapters.NewStore(database),
        embedder,
        dmsSvc, // satisfies semanticapp.DocSource (DocumentEmbedSource + WritableFolderIDs)
        cfg.Embed.Model,
        cfg.Embed.Dim,
        logger,
    )

Add the imports near the other aliased context imports (top of wire.go, by aiadapters/aiapp):

    semanticadapters "github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/adapters"
    semanticapp "github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/app"

database is the *pgxpool.Pool already used for the other stores (e.g. aiSvc/correspondence wiring); confirm its variable name in this file and reuse it.

  • [ ] Step 3: Inject Semantic into the HTTP Deps

In the httpapi.Deps{...} literal (line 341), after Ai: aiSvc, add:

        Semantic:       semanticSvc,

(The Deps.Semantic field + Server.semantic field + NewServer copy are added in Phase 3 Task 3.2; until then this line will not compile, so either do Task 3.2's Deps/Server edits together with this step, or temporarily omit this single line and add it in 3.2. Recommended: add the Deps.Semantic field now as part of this step — edit server.go Deps struct (after Ai *aiapp.Service, line 74) to add Semantic *semanticapp.Service, the Server struct (after ai, line 106) to add semantic *semanticapp.Service, and NewServer (after ai: d.Ai,, line 149) to add semantic: d.Semantic,, plus the semanticapp import in server.go. Then this injection compiles.)

  • [ ] Step 4: Wire the live license predicate + the dms hook, after api is built

After the api := httpapi.NewServer(...) call completes (after line 379), add:

    // The semantic pipeline reads the LIVE (hot-swappable) license so a license up/downgrade
    // takes effect without a restart, mirroring esign's ModuleEnabled use. Fail-closed until set.
    semanticSvc.SetEnabled(func() bool { return api.ModuleEnabled("semantic") })
    // Embed each document when its extracted text lands (best-effort/async; no-op when unlicensed).
    dmsSvc.SetEmbedHook(semanticSvc.OnContentText)
  • [ ] Step 5: Register + trigger the backfill sweep

The scheduler is registered + EnsureRegistered at lines 264-321, before api exists. Register the embed backfill AFTER api/semanticSvc.SetEnabled (Step 4), then re-run EnsureRegistered (idempotent upsert) and trigger it once:

    schedulerSvc.Register("semantic.embed_backfill", 1*time.Hour, func(ctx context.Context) error {
        return runEmbedBackfill(ctx, semanticSvc, logger)
    })
    if err := schedulerSvc.EnsureRegistered(ctx); err != nil {
        return fmt.Errorf("scheduler register (semantic): %w", err)
    }
    // Catch up immediately on a fresh deploy / first-time licensing (no-op when unlicensed).
    if err := schedulerSvc.TriggerNow(ctx, "semantic.embed_backfill"); err != nil {
        logger.Warn("could not trigger initial embed backfill", "err", err)
    }
  • [ ] Step 6: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

Expected: clean.

  • [ ] Step 7: Commit
git add go/cmd/obscura-server/wire.go go/cmd/obscura-server/jobs.go go/internal/httpapi/server.go
git commit -m "feat(semantic): wire embedder + service, license-gated embed hook + backfill sweep"

Task 2.8: Deploy + e2e — embeddings actually get written (and only when licensed)

Files: none (verification only)

  • [ ] Step 1: Deploy + module assertion
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build

Run the /me enabled_modules assertion (expect the 5 modules) + confirm demo intact + curl -s localhost:38000/healthz → ok.

  • [ ] Step 2: Create a doc, set its content text, confirm an embedding row appears
TOKEN=$(curl -s -XPOST localhost:38080/api/v1/auth/dev-login -H 'content-type: application/json' -d '{"email":"admin@obscura.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
# create a document (root folder)
DOC=$(curl -s -XPOST localhost:38080/api/v1/documents -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"title":"PLAN-EMBED-TEST kontrak vendor"}' | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
echo "doc=$DOC"
# land extracted text (fires the embed hook)
curl -s -XPUT "localhost:38080/api/v1/documents/$DOC/content-text" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"text":"Perjanjian kerja sama pengadaan barang dan jasa antara perusahaan dan vendor pihak ketiga."}' -o /dev/null -w "%{http_code}\n"
sleep 4
docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "SELECT document_id, version, model, vector_dims(embedding) FROM document_embeddings WHERE document_id='$DOC';"

Expected: one row, model = intfloat/multilingual-e5-small, vector_dims = 384. (Adjust the create-document body to whatever POST /documents requires — check CreateDocument/useCreateDocument; classification may default. If the create needs folder_id/classification, add them.)

  • [ ] Step 3: Backfill sweep covers a pre-existing doc

Confirm the boot TriggerNow ran (or trigger again): within ~35s of boot the semantic.embed_backfill task runs (30s RunDue tick). Re-query document_embeddings count vs documents count (live, current_version>0) to confirm pre-existing demo docs that have content_text got embedded:

docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "SELECT (SELECT count(*) FROM document_embeddings) AS embedded, (SELECT count(*) FROM documents WHERE deleted_at IS NULL AND current_version>0 AND content_text<>'') AS have_text;"

Expected: embedded grows toward have_text over subsequent sweeps. (Docs with empty content_text are intentionally not embedded in v1.)

  • [ ] Step 4: Fail-closed check — unlicensed ⇒ no embeddings
EMBED_PROVIDER=mock docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build embed-sidecar obscura >/dev/null 2>&1 || true
# Temporarily install a core-only license to prove the gate (regenerate WITHOUT semantic),
# create a doc + set content-text, and assert NO new embedding row appears, then restore the
# 5-module demo license and redeploy.

Do this carefully and restore the demo license afterward (Task 1.2 Step 1 command, with semantic back in). The point: with semantic absent from the license, semanticSvc.Enabled() is false, so OnContentText and RunBackfill no-op — no document_embeddings rows for the new doc. Verify, then restore + redeploy + re-assert the 5 modules. (If a full license-swap is heavy, it is acceptable to instead reason from code that Enabled() gates both paths and skip the live downgrade — but note that in the task log.)

  • [ ] Step 5: Clean up test docs

Delete the PLAN-EMBED-TEST document(s) you created (move to trash + purge, or via the UI). Confirm the demo is back to its baseline.

  • [ ] Step 6: Commit (if any verification scratch files were created, remove them; otherwise nothing to commit)

No code changes in this task; record the e2e results in the task notes.

Phase 2 done: documents embed best-effort on text-land + via the backfill sweep, written to pgvector, strictly gated on semantic.


Phase 3 — Folder centroids + suggest endpoint + the upload chip

Goal: POST /api/v1/semantic/suggest-folder ranks the caller's writable folders; NewDocumentModal shows a one-click suggested-folder chip. The demo shows real suggestions after this phase.

Task 3.1: Folder centroids + SuggestFolders ranking

Files:
- Modify: go/internal/semantic/adapters/pg.go (add FolderCentroids)
- Modify: go/internal/semantic/app/ports.go (add FolderCentroids to Store)
- Modify: go/internal/semantic/app/service.go (add SuggestQuery, Suggestion, SuggestFolders, cosine)

  • [ ] Step 1: Add the centroid type + Store method to the port

In go/internal/semantic/app/ports.go, add the type and extend Store:

// FolderCentroid is a folder's id, materialized path, and the centroid (mean) of its member
// documents' embeddings.
type FolderCentroid struct {
    FolderID string
    Path     string
    Centroid []float32
}

Add to the Store interface:

    // FolderCentroids returns avg(embedding) per folder over live member documents, restricted
    // to folderIDs. Folders in folderIDs with no embedded members are omitted.
    FolderCentroids(ctx context.Context, folderIDs []string) ([]FolderCentroid, error)
  • [ ] Step 2: Implement FolderCentroids (pgvector ships the avg(vector) aggregate; cast the centroid to text and parse it — same codec as the upsert)

In go/internal/semantic/adapters/pg.go:

// FolderCentroids returns the mean embedding per folder over its live member documents,
// restricted to folderIDs. Computed on the fly (v1); materializing is the documented scale path.
func (s *Store) FolderCentroids(ctx context.Context, folderIDs []string) ([]app.FolderCentroid, error) {
    if len(folderIDs) == 0 {
        return nil, nil
    }
    const q = `
SELECT f.id::text, f.path, avg(e.embedding)::text
FROM document_embeddings e
JOIN documents d ON d.id = e.document_id AND d.deleted_at IS NULL
JOIN folders f ON f.id = d.folder_id AND f.deleted_at IS NULL
WHERE d.folder_id::text = ANY($1::text[])
GROUP BY f.id, f.path`
    rows, err := s.db.Query(ctx, q, folderIDs)
    if err != nil {
        return nil, fmt.Errorf("folder centroids: %w", err)
    }
    defer rows.Close()
    var out []app.FolderCentroid
    for rows.Next() {
        var id, path, vecText string
        if err := rows.Scan(&id, &path, &vecText); err != nil {
            return nil, err
        }
        vec, perr := parseVector(vecText)
        if perr != nil {
            return nil, perr
        }
        out = append(out, app.FolderCentroid{FolderID: id, Path: path, Centroid: vec})
    }
    return out, rows.Err()
}
  • [ ] Step 3: Add the suggest types + ranking to the Service

In go/internal/semantic/app/service.go, add (also add "math" and "sort" to the imports):

// SuggestQuery is the input to a folder suggestion: any of title/text/filename (at least one).
type SuggestQuery struct {
    Title    string
    Text     string
    Filename string
}

// Suggestion is one ranked folder. Score is cosine similarity in [-1,1] (clamped to >= 0 for
// display); higher is better.
type Suggestion struct {
    FolderID string
    Path     string
    Score    float64
}

// SuggestFolders ranks the caller's write-accessible folders by cosine similarity of the query
// to each folder's member-document centroid, returning the top k (default 3). An empty index
// or no writable folders yields an empty slice (the UI then shows nothing). The route gates on
// requireModule("semantic"); this method assumes the caller is already authorized.
func (s *Service) SuggestFolders(ctx context.Context, subjects []kernel.Subject, bypass bool, q SuggestQuery, k int) ([]Suggestion, error) {
    if k <= 0 {
        k = 3
    }
    folderIDs, err := s.docs.WritableFolderIDs(ctx, subjects, bypass)
    if err != nil {
        return nil, err
    }
    if len(folderIDs) == 0 {
        return nil, nil
    }
    body := buildEmbedText(q.Title, q.Text, q.Filename)
    if body == "" {
        return nil, nil
    }
    vecs, err := s.embedder.Embed(ctx, aiapp.EmbedQuery, []string{body})
    if err != nil {
        return nil, err
    }
    if len(vecs) != 1 || len(vecs[0]) != s.dim {
        return nil, nil
    }
    query := vecs[0]
    centroids, err := s.store.FolderCentroids(ctx, folderIDs)
    if err != nil {
        return nil, err
    }
    out := make([]Suggestion, 0, len(centroids))
    for _, c := range centroids {
        score := cosine(query, c.Centroid)
        if score < 0 {
            score = 0
        }
        out = append(out, Suggestion{FolderID: c.FolderID, Path: c.Path, Score: score})
    }
    sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
    if len(out) > k {
        out = out[:k]
    }
    return out, nil
}

// cosine returns the cosine similarity of two equal-length vectors (0 if degenerate).
func cosine(a, b []float32) float64 {
    if len(a) != len(b) || len(a) == 0 {
        return 0
    }
    var dot, na, nb float64
    for i := range a {
        dot += float64(a[i]) * float64(b[i])
        na += float64(a[i]) * float64(a[i])
        nb += float64(b[i]) * float64(b[i])
    }
    if na == 0 || nb == 0 {
        return 0
    }
    return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
  • [ ] Step 4: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

Expected: clean.

  • [ ] Step 5: Commit
git add go/internal/semantic/
git commit -m "feat(semantic): folder centroids + cosine SuggestFolders ranking"

Task 3.2: Suggest HTTP endpoint + route (and finish the Deps/Server wiring)

Files:
- Create: go/internal/httpapi/handlers_semantic.go
- Modify: go/internal/httpapi/server.go (Deps field 74; Server field 106; NewServer 149; route at 283; semanticapp import) — if not already done in Task 2.7 Step 3, do it here.

  • [ ] Step 1: Ensure Deps.Semantic / Server.semantic / NewServer are wired (from Task 2.7 Step 3; if you deferred it, add now)

server.go Deps struct — after Ai *aiapp.Service (line 74):

    Semantic       *semanticapp.Service

server.go Server struct — after ai *aiapp.Service (line 106):

    semantic       *semanticapp.Service

NewServer — after ai: d.Ai, (line 149):

        semantic:       d.Semantic,

Add the import to server.go:

    semanticapp "github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/app"
  • [ ] Step 2: Write the handler (mirror handlers_ai.go: PrincipalFrom, s.isContentAdmin, json.NewDecoder, writeJSON, writeProblem)

go/internal/httpapi/handlers_semantic.go:

package httpapi

import (
    "encoding/json"
    "net/http"
    "strings"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
    semanticapp "github.com/Virtue-Digital-Indonesia/obscura/internal/semantic/app"
)

// SemanticSuggestFolder ranks the caller's write-accessible folders by semantic similarity of
// the supplied {title, text, filename} to each folder's contents. Gated by
// requireModule("semantic"). Returns the top-K (default 3) as {folder_id, path, score}; an
// empty index or no writable folders yields an empty list.
func (s *Server) SemanticSuggestFolder(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    var body struct {
        Title    string `json:"title"`
        Text     string `json:"text"`
        Filename string `json:"filename"`
        K        int    `json:"k"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_json", Message: "invalid request body"})
        return
    }
    if strings.TrimSpace(body.Title) == "" && strings.TrimSpace(body.Text) == "" && strings.TrimSpace(body.Filename) == "" {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "semantic.query_required", Message: "a title, text, or filename is required"})
        return
    }
    sugs, err := s.semantic.SuggestFolders(
        r.Context(),
        p.Subjects(),
        s.isContentAdmin(r.Context(), p),
        semanticapp.SuggestQuery{Title: body.Title, Text: body.Text, Filename: body.Filename},
        body.K,
    )
    if err != nil {
        writeProblem(w, err)
        return
    }
    out := make([]map[string]any, 0, len(sugs))
    for _, sg := range sugs {
        out = append(out, map[string]any{"folder_id": sg.FolderID, "path": sg.Path, "score": sg.Score})
    }
    writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
}

Confirm the helper names against handlers_ai.go: PrincipalFrom (used by nav handlers), writeJSON, writeProblem. If PrincipalFrom has a different exact name in this codebase, mirror what handlers_dms_nav.go:ListFolders uses.

  • [ ] Step 3: Register the route (top-level protected group, next to the non-doc AI routes at server.go:283)

After line 283 (r.With(s.requireModule("ai")).Post("/ai/classify", s.Classify)), add:

                // Semantic folder suggestion (premium "semantic" module): rank the caller's
                // write-accessible folders for an about-to-be-filed document. 403
                // module.not_licensed when unlicensed; hidden in the UI likewise.
                r.With(s.requireModule("semantic")).Post("/semantic/suggest-folder", s.SemanticSuggestFolder)
  • [ ] Step 4: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

Expected: clean.

  • [ ] Step 5: Commit
git add go/internal/httpapi/handlers_semantic.go go/internal/httpapi/server.go
git commit -m "feat(httpapi): POST /semantic/suggest-folder gated by requireModule(semantic)"

Task 3.3: OpenAPI + frontend chip

Files:
- Modify: api/openapi.yaml (add the path + schemas)
- Regenerate: web/src/api/schema.ts (npm run gen:api)
- Create: web/src/api/semantic.ts
- Create: web/src/features/semantic/i18n.ts
- Modify: web/src/i18n/locales/en.ts, web/src/i18n/locales/id.ts
- Modify: web/src/features/documents/NewDocumentModal.tsx
- Modify: web/src/features/documents/DocumentsPage.tsx

  • [ ] Step 1: Add the OpenAPI path

In api/openapi.yaml, add under paths: (match the file's existing style; find a sibling like /ai/classify for the exact request/response shape conventions used):

  /semantic/suggest-folder:
    post:
      tags: [semantic]
      summary: Suggest folders for an about-to-be-filed document (premium "semantic" module)
      operationId: suggestFolder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                text: { type: string }
                filename: { type: string }
                k: { type: integer }
      responses:
        '200':
          description: Ranked folder suggestions (possibly empty)
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestions:
                    type: array
                    items:
                      type: object
                      properties:
                        folder_id: { type: string }
                        path: { type: string }
                        score: { type: number }
        '403':
          description: The semantic module is not licensed in this deployment
  • [ ] Step 2: Regenerate the typed client
cd /home/efran/remote-development/obscura/web && npm run gen:api

Confirm web/src/api/schema.ts now contains /semantic/suggest-folder.

  • [ ] Step 3: Write the data hook (mirror useDocAiClassify in web/src/api/ai.ts:45-56, incl. its toError helper)

web/src/api/semantic.ts:

import { useMutation } from '@tanstack/react-query'
import { api } from './client'

function toError(e: unknown): Error {
  const p = e as { detail?: string; title?: string } | undefined
  return new Error(p?.detail || p?.title || 'Request failed')
}

export interface FolderSuggestion {
  folderId: string
  path: string
  score: number
}

export interface SuggestFolderInput {
  title?: string
  text?: string
  filename?: string
}

export function useSuggestFolder() {
  return useMutation<FolderSuggestion[], Error, SuggestFolderInput>({
    mutationFn: async (input) => {
      const { data, error } = await api.POST('/api/v1/semantic/suggest-folder', {
        body: { title: input.title, text: input.text, filename: input.filename },
      })
      if (error) throw toError(error)
      return (data?.suggestions ?? []).map((s) => ({
        folderId: s.folder_id ?? '',
        path: s.path ?? '',
        score: s.score ?? 0,
      }))
    },
  })
}

If the generated path constant differs (e.g. no /api/v1 prefix in paths), match exactly what the other api.POST(...) calls in web/src/api/ai.ts use.

  • [ ] Step 4: Add the i18n slice

web/src/features/semantic/i18n.ts:

export const en = {
  semantic: {
    suggestTitle: 'Suggested folder',
    suggest: 'Suggest a folder',
    suggesting: 'Finding the best folder…',
    useFolder: 'Use this folder',
    match: '{{pct}}% match',
    none: 'No suggestion available',
    error: 'Could not suggest a folder',
  },
}

export const id: typeof en = {
  semantic: {
    suggestTitle: 'Folder yang disarankan',
    suggest: 'Sarankan folder',
    suggesting: 'Mencari folder terbaik…',
    useFolder: 'Gunakan folder ini',
    match: '{{pct}}% cocok',
    none: 'Tidak ada saran',
    error: 'Tidak dapat menyarankan folder',
  },
}

In web/src/i18n/locales/en.ts: add import { en as semanticEn } from '@/features/semantic/i18n' near the aiEn import (line 13), and spread ...semanticEn next to ...aiEn (line 717). Do the mirror in id.ts (semanticId, ...semanticId). The id: typeof en constraint keeps shapes identical (tsc enforces it).

  • [ ] Step 5: Add the chip to NewDocumentModal (gate on moduleEnabled(useMe().data?.enabledModules, 'semantic'); route the accepted folder out via NewDocumentInput.folderId)

In web/src/features/documents/NewDocumentModal.tsx:

  1. Imports:
import { useMe } from '@/api/me'
import { moduleEnabled } from '@/lib/nav'
import { useSuggestFolder } from '@/api/semantic'
import { Tag, InlineLoading } from '@carbon/react'
import { useTranslation } from 'react-i18next'
  1. Extend NewDocumentInput (line 16) with the optional accepted folder:
  folderId?: string | null
  1. In the component (near line 58-60):
  const me = useMe()
  const semanticEnabled = moduleEnabled(me.data?.enabledModules, 'semantic')
  const suggest = useSuggestFolder()
  const { t } = useTranslation()
  const [pickedFolderId, setPickedFolderId] = useState<string | null>(null)
  const [pickedPath, setPickedPath] = useState<string>('')
  1. Reset in the open-effect (lines 73-82): add setPickedFolderId(null); setPickedPath(''); suggest.reset().
  2. Render the chip block — only when semanticEnabled and a file/title exists — as a new <div className="newdoc__field"> (matching the existing fields at lines 136/147/156), placed after the file/title inputs:
{semanticEnabled && (
  <div className="newdoc__field">
    <Button
      kind="ghost"
      size="sm"
      disabled={suggest.isPending}
      onClick={() =>
        suggest.mutate(
          // No extracted text exists at create time (extraction is async, post-upload), so the
          // query is driven by the title + filename. See the "documented limitations" note.
          { title, filename: file?.name },
          {
            onSuccess: (rows) => {
              if (rows.length > 0) {
                setPickedFolderId(rows[0].folderId)
                setPickedPath(rows[0].path)
              }
            },
          },
        )
      }
    >
      {t('semantic.suggest')}
    </Button>
    {suggest.isPending && <InlineLoading description={t('semantic.suggesting')} />}
    {suggest.isError && <div className="newdoc__hint">{t('semantic.error')}</div>}
    {pickedFolderId && (
      <Tag type="blue" filter onClose={() => { setPickedFolderId(null); setPickedPath('') }}>
        {t('semantic.suggestTitle')}: {pickedPath}
      </Tag>
    )}
  </div>
)}
  1. Include the accepted folder in the onSubmit payload (line ~108): add folderId: pickedFolderId to the NewDocumentInput object passed to onSubmit.

The title / file / mode variables already exist in the modal state (NewDocumentInput fields). The Button is already imported (it renders the submit/cancel actions). The chip uses a Carbon <Tag> with filter+onClose (the dismissible-tag pattern used elsewhere, e.g. RequestSignatureModal).

  • [ ] Step 6: Honor the accepted folder in DocumentsPage

In web/src/features/documents/DocumentsPage.tsx, submitNewDoc (lines 90-116): destructure folderId from the input and prefer it over currentFolderId:

const submitNewDoc = async ({ title, classification, docType, mode, formatId, customVars, file, folderId }: NewDocumentInput) => {
  const targetFolderId = folderId ?? currentFolderId
  // ... pass targetFolderId into createDocument.mutateAsync(...) at line 94 in place of currentFolderId
}
  • [ ] Step 7: Verify
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build

Expected: clean (i18n en/id parity enforced by tsc).

  • [ ] Step 8: Commit
cd /home/efran/remote-development/obscura
git add api/openapi.yaml web/src/api/schema.ts web/src/api/semantic.ts web/src/features/semantic/ web/src/i18n/locales/en.ts web/src/i18n/locales/id.ts web/src/features/documents/NewDocumentModal.tsx web/src/features/documents/DocumentsPage.tsx
git commit -m "feat(web): suggested-folder chip in NewDocumentModal (semantic-gated) + OpenAPI"

Task 3.4: Deploy + e2e — suggestion ranks the right folder; hidden when unlicensed

Files: none (verification only)

  • [ ] Step 1: Deploy + module assertion (full stack; expect 5 modules, demo intact, sidecar healthy).

  • [ ] Step 2: Seed two distinct folders, embed their contents, and suggest

TOKEN=$(curl -s -XPOST localhost:38080/api/v1/auth/dev-login -H 'content-type: application/json' -d '{"email":"admin@obscura.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
# create two folders (match the POST /folders body the app uses)
FA=$(curl -s -XPOST localhost:38080/api/v1/folders -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"name":"PLAN-Kontrak"}' | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
FB=$(curl -s -XPOST localhost:38080/api/v1/folders -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"name":"PLAN-SDM"}' | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
# file a contract-y doc in FA and an HR-y doc in FB, each with content-text (fires embedding)
DA=$(curl -s -XPOST localhost:38080/api/v1/documents -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d "{\"title\":\"PLAN kontrak A\",\"folder_id\":\"$FA\"}" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
curl -s -XPUT "localhost:38080/api/v1/documents/$DA/content-text" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"text":"Perjanjian kontrak pengadaan barang jasa vendor pemasok harga termin pembayaran."}' -o /dev/null
DB=$(curl -s -XPOST localhost:38080/api/v1/documents -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d "{\"title\":\"PLAN SDM B\",\"folder_id\":\"$FB\"}" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
curl -s -XPUT "localhost:38080/api/v1/documents/$DB/content-text" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"text":"Surat keputusan pengangkatan karyawan cuti tahunan penggajian sumber daya manusia."}' -o /dev/null
sleep 5
# suggest a NEW contract-like upload — expect PLAN-Kontrak (FA) as the top folder
curl -s -XPOST localhost:38080/api/v1/semantic/suggest-folder -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"title":"kontrak baru","text":"perjanjian pengadaan vendor termin pembayaran"}' | python3 -m json.tool

Expected: suggestions[0].path is the PLAN-Kontrak folder, with score clearly above the SDM folder's. (Real semantic ordering depends on the sidecar; with EMBED_PROVIDER=mock the ordering is meaningless — this e2e requires the real sidecar, which the demo runs.)

  • [ ] Step 3: Unlicensed ⇒ 403 / hidden

Reason from code (route is requireModule("semantic")) or do a temporary core-only license swap (as in Task 2.8 Step 4) and confirm:

curl -s -o /dev/null -w "%{http_code}\n" -XPOST localhost:38080/api/v1/semantic/suggest-folder -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"title":"x"}'

Expected with semantic unlicensed: 403. Restore the 5-module demo license + redeploy + re-assert afterward.

  • [ ] Step 4: UI smoke

Open http://localhost:8091, start a new document (upload), confirm the "Suggest a folder" affordance renders and accepting the chip pre-selects the folder for creation. Then create with the suggestion and confirm the doc lands in that folder.

  • [ ] Step 5: Clean up all PLAN-* test folders/docs; confirm the demo is back to baseline.

Phase 3 done: end-to-end semantic folder suggestion, gated, demonstrable in the demo.


Phase 4 — Optional admin folder descriptions (blended into the centroid)

Goal: admins can set a folder "purpose" description; when semantic is licensed it is embedded and blended into the folder's profile, sharpening suggestions for new/empty folders.

Task 4.1: Migration — folders.description + folder_description_embeddings

Files:
- Create: go/migrations/00072_folder_description.sql

  • [ ] Step 1: Write the migration (folders.id is uuid)

go/migrations/00072_folder_description.sql:

-- +goose Up
-- Optional admin-set folder purpose ("what this folder is for"). Harmless core metadata; its
-- USE (embedding it to sharpen semantic folder suggestions) is gated on the `semantic` module.
ALTER TABLE folders ADD COLUMN description text NOT NULL DEFAULT '';

-- The embedding of a folder's description (semantic module). Its own table so a description
-- change re-embeds without touching member-document embeddings. vector(384) matches EMBED_DIM.
CREATE TABLE folder_description_embeddings (
    folder_id  uuid PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE,
    embedding  vector(384) NOT NULL,
    updated_at timestamptz NOT NULL DEFAULT now()
);

-- +goose Down
DROP TABLE IF EXISTS folder_description_embeddings;
ALTER TABLE folders DROP COLUMN IF EXISTS description;
  • [ ] Step 2: Apply (deploy obscura) + confirm
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura
docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "\d folder_description_embeddings" -c "\d folders"

Expected: the new table + folders.description column. Module assertion + demo intact.

  • [ ] Step 3: Commit
git add go/migrations/00072_folder_description.sql
git commit -m "feat(db): folders.description + folder_description_embeddings (00072)"

Task 4.2: dms — carry + set the folder description

Files:
- Modify: go/internal/dms/domain/document.go (Folder.Description)
- Modify: go/internal/dms/app/service.go (Repository SetFolderDescription; Service SetFolderDescription)
- Modify: go/internal/dms/adapters/pg.go (folderColumns includes description; SetFolderDescription SQL; GetFolder/ListFolders scan the new column)

  • [ ] Step 1: Add the domain field

In go/internal/dms/domain/document.go, add Description string to the Folder struct (line 29 region).

  • [ ] Step 2: Repository + Service method

Add to the Repository interface (folders region, near RenameFolderTree):

    // SetFolderDescription sets folders.description. kernel.ErrNotFound if absent.
    SetFolderDescription(ctx context.Context, folderID, description string) error

Add a Service method (near other folder mutators):

// SetFolderDescription sets a folder's admin purpose description. Returns a hook signal so the
// caller (composition root) can re-embed it when the semantic module is licensed.
func (s *Service) SetFolderDescription(ctx context.Context, folderID, description string) error {
    if folderID == "" {
        return &kernel.Error{Kind: kernel.ErrValidation, Code: "dms.folder.id_required", Message: "folder id is required"}
    }
    if err := s.repo.SetFolderDescription(ctx, folderID, description); err != nil {
        return err
    }
    if s.folderDescHook != nil {
        s.folderDescHook(folderID, description)
    }
    return nil
}

Add a folderDescHook func(folderID, description string) field to the Service struct + a SetFolderDescHook setter (mirror the embedHook pattern from Task 2.6 Step 4).

  • [ ] Step 3: Adapter SQL + scans

In go/internal/dms/adapters/pg.go:
- Add description to folderColumns (line 56 region) and to every Folder row-scan (GetFolder, ListFolders) — append &f.Description to the scan targets in the same order as the column list. (Find all places that scan a domain.Folder and update them, or the scan will misalign — this is the main risk; grep for folderColumns usage.)
- Add:

// SetFolderDescription sets folders.description.
func (s *Store) SetFolderDescription(ctx context.Context, folderID, description string) error {
    ct, err := s.db.Exec(ctx, `UPDATE folders SET description = $2 WHERE id = $1 AND deleted_at IS NULL`, folderID, description)
    if err != nil {
        return fmt.Errorf("set folder description: %w", err)
    }
    if ct.RowsAffected() == 0 {
        return kernel.ErrNotFound
    }
    return nil
}

Confirm kernel.ErrNotFound is the sentinel used in this file (or the &kernel.Error{...} form) and match the existing convention.

  • [ ] Step 4: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

Expected: clean.

  • [ ] Step 5: Commit
git add go/internal/dms/
git commit -m "feat(dms): folder description column, setter, and change hook"

Task 4.3: semantic — embed the description + blend into the centroid

Files:
- Modify: go/internal/semantic/app/ports.go (Store: upsert/delete/get description embeddings)
- Modify: go/internal/semantic/adapters/pg.go (the three SQL methods)
- Modify: go/internal/semantic/app/service.go (EmbedFolderDescription; blend in SuggestFolders)
- Modify: go/cmd/obscura-server/wire.go (set the dms folder-desc hook)

  • [ ] Step 1: Extend the Store port

Add to Store:

    // UpsertFolderDescEmbedding inserts/replaces a folder description's embedding.
    UpsertFolderDescEmbedding(ctx context.Context, folderID string, vec []float32) error
    // DeleteFolderDescEmbedding removes a folder description embedding (empty description).
    DeleteFolderDescEmbedding(ctx context.Context, folderID string) error
    // FolderDescEmbeddings returns the description embedding for each of folderIDs that has one.
    FolderDescEmbeddings(ctx context.Context, folderIDs []string) (map[string][]float32, error)
  • [ ] Step 2: Implement them

In go/internal/semantic/adapters/pg.go:

func (s *Store) UpsertFolderDescEmbedding(ctx context.Context, folderID string, vec []float32) error {
    const q = `
INSERT INTO folder_description_embeddings (folder_id, embedding, updated_at)
VALUES ($1, $2::vector, now())
ON CONFLICT (folder_id) DO UPDATE SET embedding = EXCLUDED.embedding, updated_at = now()`
    if _, err := s.db.Exec(ctx, q, folderID, formatVector(vec)); err != nil {
        return fmt.Errorf("upsert folder desc embedding: %w", err)
    }
    return nil
}

func (s *Store) DeleteFolderDescEmbedding(ctx context.Context, folderID string) error {
    if _, err := s.db.Exec(ctx, `DELETE FROM folder_description_embeddings WHERE folder_id = $1`, folderID); err != nil {
        return fmt.Errorf("delete folder desc embedding: %w", err)
    }
    return nil
}

func (s *Store) FolderDescEmbeddings(ctx context.Context, folderIDs []string) (map[string][]float32, error) {
    if len(folderIDs) == 0 {
        return map[string][]float32{}, nil
    }
    rows, err := s.db.Query(ctx, `SELECT folder_id::text, embedding::text FROM folder_description_embeddings WHERE folder_id::text = ANY($1::text[])`, folderIDs)
    if err != nil {
        return nil, fmt.Errorf("folder desc embeddings: %w", err)
    }
    defer rows.Close()
    out := make(map[string][]float32)
    for rows.Next() {
        var id, vecText string
        if err := rows.Scan(&id, &vecText); err != nil {
            return nil, err
        }
        v, perr := parseVector(vecText)
        if perr != nil {
            return nil, perr
        }
        out[id] = v
    }
    return out, rows.Err()
}
  • [ ] Step 3: EmbedFolderDescription on the Service
// EmbedFolderDescription (re)embeds a folder's admin description, or removes the embedding when
// the description is blank. A no-op when unlicensed. The composition root wires this to the dms
// folder-description change hook.
func (s *Service) EmbedFolderDescription(folderID, description string) {
    if !s.Enabled() {
        return
    }
    go func() {
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        if strings.TrimSpace(description) == "" {
            if err := s.store.DeleteFolderDescEmbedding(ctx, folderID); err != nil {
                s.logger.Warn("semantic: delete folder desc embedding failed", "folder", folderID, "err", err)
            }
            return
        }
        vecs, err := s.embedder.Embed(ctx, aiapp.EmbedPassage, []string{clipEmbed(description)})
        if err != nil || len(vecs) != 1 || len(vecs[0]) != s.dim {
            s.logger.Warn("semantic: embed folder desc failed", "folder", folderID, "err", err)
            return
        }
        if err := s.store.UpsertFolderDescEmbedding(ctx, folderID, vecs[0]); err != nil {
            s.logger.Warn("semantic: upsert folder desc embedding failed", "folder", folderID, "err", err)
        }
    }()
}

// clipEmbed clips text to embedCharLimit for embedding.
func clipEmbed(t string) string {
    if len(t) > embedCharLimit {
        return t[:embedCharLimit]
    }
    return t
}

Add "strings" to the service imports.

  • [ ] Step 4: Blend the description embedding into the ranking

In SuggestFolders, after fetching centroids, also fetch description embeddings and blend (average member-centroid + description embedding when both exist; otherwise use whichever exists). Replace the centroid loop:

    descs, err := s.store.FolderDescEmbeddings(ctx, folderIDs)
    if err != nil {
        return nil, err
    }
    // A folder can be ranked on its member centroid, its description embedding, or the blend.
    type prof struct {
        path string
        vec  []float32
    }
    profiles := make(map[string]prof, len(centroids))
    for _, c := range centroids {
        profiles[c.FolderID] = prof{path: c.Path, vec: c.Centroid}
    }
    for fid, dvec := range descs {
        if p, ok := profiles[fid]; ok {
            profiles[fid] = prof{path: p.path, vec: averageVectors(p.vec, dvec)}
        }
        // description-only folders (no embedded members) need their path; fetched lazily below.
    }
    out := make([]Suggestion, 0, len(profiles))
    for fid, p := range profiles {
        score := cosine(query, p.vec)
        if score < 0 {
            score = 0
        }
        out = append(out, Suggestion{FolderID: fid, Path: p.path, Score: score})
    }

Add the blend helper:

// averageVectors returns the element-wise mean of two equal-length vectors (or the non-empty
// one if the other is empty / mismatched).
func averageVectors(a, b []float32) []float32 {
    if len(a) == 0 {
        return b
    }
    if len(b) == 0 || len(a) != len(b) {
        return a
    }
    out := make([]float32, len(a))
    for i := range a {
        out[i] = (a[i] + b[i]) / 2
    }
    return out
}

Scope note (YAGNI): a description-only folder with no embedded members won't appear in centroids, so it has no path here. Ranking such empty folders requires also returning their path from a description-embedding query. For v1 keep the blend limited to folders that already have member documents (the common case), and leave "rank empty folders by description alone" as a documented follow-up — OR extend FolderDescEmbeddings to also return the folder path and seed profiles for description-only folders. Pick one and state it in the commit; the simplest correct v1 is members-only blend.

  • [ ] Step 5: Wire the dms folder-desc hook in wire.go

After dmsSvc.SetEmbedHook(...) (Task 2.7 Step 4), add:

    dmsSvc.SetFolderDescHook(semanticSvc.EmbedFolderDescription)
  • [ ] Step 6: Verify
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

Expected: clean.

  • [ ] Step 7: Commit
git add go/internal/semantic/ go/cmd/obscura-server/wire.go
git commit -m "feat(semantic): embed folder descriptions and blend into suggestion ranking"

Task 4.4: Folder description — endpoint + admin/edit UI

Files:
- Modify: go/internal/httpapi/server.go (route PUT /folders/{id}/description, gated folder.write) + a handler (in handlers_dms_nav.go or a small new handler)
- Modify: api/openapi.yaml (+ npm run gen:api)
- Modify: the folder edit/admin UI (the existing rename-folder affordance) + i18n

  • [ ] Step 1: Backend handler + route

Add a handler (mirror RenameFolder in handlers_dms_nav.go): decode {description}, call s.dms.SetFolderDescription(ctx, chi.URLParam(r,"id"), body.Description), writeJSON 200. Register near the folder routes (server.go:293 region):

                r.With(s.requirePerm("folder.write")).Put("/folders/{id}/description", s.SetFolderDescription)

The description field itself is harmless core metadata (settable regardless of semantic); only its embedding use is gated (the hook no-ops when unlicensed). So the route is NOT requireModule("semantic").

  • [ ] Step 2: OpenAPI + regen

Add PUT /folders/{id}/description (request {description: string}, 200) to api/openapi.yaml; cd web && npm run gen:api.

  • [ ] Step 3: UI — in the folder edit affordance (the rename modal / folder settings), add a "Folder purpose" textarea bound to a useSetFolderDescription mutation (mirror the rename hook in web/src/api/documents.ts). Show a hint that the description improves AI suggestions ONLY when moduleEnabled(me.data?.enabledModules, 'semantic'); the field itself always shows for folder admins. Add i18n keys to web/src/features/semantic/i18n.ts (and the id mirror): descLabel, descHint, descSave.

  • [ ] Step 4: Verify

cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build

Expected: both clean.

  • [ ] Step 5: Commit
cd /home/efran/remote-development/obscura
git add go/internal/httpapi/ api/openapi.yaml web/src/
git commit -m "feat(folders): admin folder-purpose description (UI + endpoint)"

Task 4.5: Deploy + e2e — a description sharpens suggestions

Files: none (verification only)

  • [ ] Step 1: Deploy + module assertion (5 modules, demo intact).

  • [ ] Step 2: Set a description on an empty/sparse folder, confirm it is embedded + influences ranking

TOKEN=$(curl -s -XPOST localhost:38080/api/v1/auth/dev-login -H 'content-type: application/json' -d '{"email":"admin@obscura.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
FC=$(curl -s -XPOST localhost:38080/api/v1/folders -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"name":"PLAN-Hukum"}' | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
# give it a purpose + at least one member doc so it appears in centroids
curl -s -XPUT "localhost:38080/api/v1/folders/$FC/description" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"description":"Dokumen hukum, perjanjian, dan kepatuhan regulasi perusahaan."}' -o /dev/null -w "%{http_code}\n"
sleep 4
docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "SELECT folder_id, vector_dims(embedding) FROM folder_description_embeddings WHERE folder_id='$FC';"

Expected: a folder_description_embeddings row with vector_dims = 384. Then run a suggest-folder query whose text matches the description and confirm the folder ranks appropriately (needs ≥1 embedded member doc in the folder per the v1 members-only blend scope note).

  • [ ] Step 3: Clear the description ⇒ embedding row removed
curl -s -XPUT "localhost:38080/api/v1/folders/$FC/description" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"description":""}' -o /dev/null
sleep 3
docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -c "SELECT count(*) FROM folder_description_embeddings WHERE folder_id='$FC';"

Expected: 0.

  • [ ] Step 4: Clean up all PLAN-* test folders/docs; confirm the demo is back to baseline.

Phase 4 done — feature complete.


Final review (after all phases)

  • [ ] Dispatch a final whole-implementation code review (subagent) over the full diff: confirm fail-closed gating on every path (embed hook, backfill, suggest endpoint, description embedding); confirm the demo license carries all 5 modules and /me reflects them; confirm no go test was run; confirm go build ./... && go vet ./... and cd web && npx tsc --noEmit && npx vite build are clean; confirm all PLAN-* test artifacts are cleaned up.
  • [ ] Use superpowers:finishing-a-development-branch (the work is on main per repo discipline; do NOT push unless the user asks).
  • [ ] Upload every .md created/modified this session to https://x056.think.val.id/upload and give the user the URLs.

Notes / documented limitations (carry into commits where relevant)

  • No in-process raw-bytes→text extractor exists (extraction is deferred to an external Python sidecar that PUTs /content-text). So the suggest endpoint embeds the supplied {title, text, filename} — for a brand-new upload with no client-side text, the suggestion is driven by title+filename. Server-side ad-hoc extraction of uploaded bytes is a documented follow-up (would require an extract endpoint on the extractor sidecar).
  • Centroids are computed on the fly (SQL avg() per request). Materializing them (a folder_embeddings table refreshed on a sweep) is the documented scale path; not built (YAGNI for the single-node on-prem target).
  • pgvector dimension is fixed at 384. Switching embedding models to a different dimension requires a re-embed + a vector(N) dim migration — not a runtime toggle.
  • Mock embedder is non-semantic. Deployments running EMBED_PROVIDER=mock get gated, stored, but meaningless vectors; the demo runs the real sidecar.
  • Phase 4 v1 blends descriptions for folders that already have member documents. Ranking a description-only (no members) empty folder is a documented follow-up.