diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 83e681d06..3e30d455e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -92,6 +92,9 @@ jobs:
- name: Test
run: make test
+ - name: Test build without vector backends
+ run: go test -tags fts5 -run '^TestDocumentVectorStubKeepsStatusAvailableAndBuildActionable$' -count=1 ./cmd/msgvault/cmd
+
- name: Check OpenAPI artifacts
run: make openapi-check
@@ -503,6 +506,7 @@ jobs:
POSTGRES_USER: msgvault_test
POSTGRES_PASSWORD: msgvault_test
POSTGRES_DB: msgvault_test
+ POSTGRES_INITDB_ARGS: "-c max_locks_per_transaction=256"
ports:
- 5432:5432
options: >-
diff --git a/Makefile b/Makefile
index 08ab87361..8443c0328 100644
--- a/Makefile
+++ b/Makefile
@@ -45,7 +45,7 @@ PG_TEST_TAGS := fts5 sqlite_vec pgvector
# in both configurations, so test-pg-both runs just these in the shipped-build
# configuration. Verified by `make pg-shipped-only-check`, which re-derives the
# closure from `go list`.
-PG_SHIPPED_ONLY_PKGS := ./cmd/msgvault ./cmd/msgvault/cmd ./internal/api ./internal/mcp ./internal/scheduler ./internal/store ./internal/vector/chunkmatch ./internal/vector/embed ./internal/vector/hybrid ./internal/vector/pgvector ./scripts/contextual-retrieval-eval
+PG_SHIPPED_ONLY_PKGS := ./cmd/msgvault ./cmd/msgvault/cmd ./internal/api ./internal/mcp ./internal/scheduler ./internal/store ./internal/vector/chunkmatch ./internal/vector/document ./internal/vector/embed ./internal/vector/hybrid ./internal/vector/pgvector ./scripts/contextual-retrieval-eval
OPENAPI_ARTIFACTS := api/openapi.yaml pkg/client/openapi.yaml pkg/client/generated
WEB_INSTALL_STAMP := web/node_modules/.msgvault-install-stamp
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 113063d6e..d9bccc67a 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -2608,6 +2608,8 @@ components:
DocumentSearchResponse:
additionalProperties: true
properties:
+ effective_mode:
+ type: string
next_cursor:
type: string
results:
@@ -2621,6 +2623,11 @@ components:
type: integer
truncated:
type: boolean
+ vector_generation_fingerprint:
+ type: string
+ vector_generation_id:
+ format: int64
+ type: integer
required:
- results
- revision
@@ -2652,6 +2659,9 @@ components:
first_unit_index:
format: int64
type: integer
+ fusion_score:
+ format: double
+ type: number
heading_path:
items:
type: string
@@ -2667,6 +2677,9 @@ components:
last_unit_index:
format: int64
type: integer
+ lexical_rank:
+ format: int64
+ type: integer
matched_signals:
items:
type: string
@@ -2697,6 +2710,12 @@ components:
rank:
format: int64
type: integer
+ semantic_rank:
+ format: int64
+ type: integer
+ semantic_score:
+ format: double
+ type: number
source_id:
format: int64
type: integer
@@ -2706,6 +2725,20 @@ components:
type: string
truncated:
type: boolean
+ vector_dimension:
+ format: int64
+ type: integer
+ vector_embedding_profile:
+ type: string
+ vector_generation_fingerprint:
+ type: string
+ vector_generation_id:
+ format: int64
+ type: integer
+ vector_model:
+ type: string
+ vector_token:
+ type: string
required:
- attachment_id
- message_id
@@ -2729,6 +2762,255 @@ components:
- truncated
- rank
type: object
+ DocumentVectorConsent:
+ additionalProperties: true
+ properties:
+ consented_at:
+ format: date-time
+ type: string
+ dimension:
+ format: int64
+ type: integer
+ egress_fingerprint:
+ type: string
+ embedding_profile:
+ type: string
+ fingerprint:
+ type: string
+ model:
+ type: string
+ purpose:
+ type: string
+ target_extraction_profile_id:
+ type: string
+ required:
+ - consented_at
+ - egress_fingerprint
+ - purpose
+ - fingerprint
+ - target_extraction_profile_id
+ - embedding_profile
+ - model
+ - dimension
+ type: object
+ DocumentVectorCoverage:
+ additionalProperties: true
+ properties:
+ ready:
+ format: int64
+ type: integer
+ required:
+ format: int64
+ type: integer
+ required:
+ - required
+ - ready
+ type: object
+ DocumentVectorFailureDiagnostic:
+ additionalProperties: true
+ properties:
+ attempt_count:
+ format: int64
+ type: integer
+ error_code:
+ type: string
+ next_retry_at:
+ format: date-time
+ type: string
+ terminal:
+ type: boolean
+ token:
+ type: string
+ required:
+ - token
+ - attempt_count
+ - terminal
+ - error_code
+ type: object
+ DocumentVectorGeneration:
+ additionalProperties: true
+ properties:
+ activated_at:
+ format: date-time
+ type: string
+ created_at:
+ format: date-time
+ type: string
+ dimension:
+ format: int64
+ type: integer
+ embedding_profile:
+ type: string
+ fingerprint:
+ type: string
+ id:
+ format: int64
+ type: integer
+ model:
+ type: string
+ retired_at:
+ format: date-time
+ type: string
+ state:
+ type: string
+ target_extraction_profile_id:
+ type: string
+ required:
+ - id
+ - state
+ - created_at
+ - fingerprint
+ - target_extraction_profile_id
+ - embedding_profile
+ - model
+ - dimension
+ type: object
+ DocumentVectorGenerationSpec:
+ additionalProperties: true
+ properties:
+ dimension:
+ format: int64
+ type: integer
+ embedding_profile:
+ type: string
+ fingerprint:
+ type: string
+ model:
+ type: string
+ target_extraction_profile_id:
+ type: string
+ required:
+ - fingerprint
+ - target_extraction_profile_id
+ - embedding_profile
+ - model
+ - dimension
+ type: object
+ DocumentVectorGenerationStatus:
+ additionalProperties: true
+ properties:
+ blocked:
+ type: boolean
+ cleanup_pending:
+ format: int64
+ type: integer
+ failure_after_generation_id:
+ format: int64
+ type: integer
+ failure_after_token:
+ type: string
+ failures:
+ items:
+ $ref: "#/components/schemas/DocumentVectorFailureDiagnostic"
+ type:
+ - array
+ - "null"
+ failures_exhausted:
+ type: boolean
+ generation_id:
+ format: int64
+ type: integer
+ pending:
+ format: int64
+ type: integer
+ ready_live:
+ format: int64
+ type: integer
+ retryable:
+ format: int64
+ type: integer
+ stale_obsolete:
+ format: int64
+ type: integer
+ state:
+ type: string
+ terminal:
+ format: int64
+ type: integer
+ required:
+ - generation_id
+ - state
+ - blocked
+ - pending
+ - retryable
+ - terminal
+ - ready_live
+ - stale_obsolete
+ - cleanup_pending
+ - failures
+ - failures_exhausted
+ type: object
+ DocumentVectorOperationsResponse:
+ additionalProperties: true
+ properties:
+ configured:
+ type: boolean
+ enabled:
+ type: boolean
+ scheduled_registration_requires_restart:
+ type: boolean
+ status:
+ $ref: "#/components/schemas/DocumentVectorOperationsStatus"
+ required:
+ - enabled
+ - configured
+ type: object
+ DocumentVectorOperationsStatus:
+ additionalProperties: true
+ properties:
+ active:
+ $ref: "#/components/schemas/DocumentVectorGeneration"
+ building:
+ $ref: "#/components/schemas/DocumentVectorGeneration"
+ configured_document_egress_fingerprint:
+ type: string
+ configured_query_egress_fingerprint:
+ type: string
+ configured_spec:
+ $ref: "#/components/schemas/DocumentVectorGenerationSpec"
+ coverage:
+ $ref: "#/components/schemas/DocumentVectorCoverage"
+ document_consent:
+ $ref: "#/components/schemas/DocumentVectorConsent"
+ query_consent:
+ $ref: "#/components/schemas/DocumentVectorConsent"
+ selected:
+ $ref: "#/components/schemas/DocumentVectorGenerationStatus"
+ usage:
+ $ref: "#/components/schemas/DocumentVectorProviderUsage"
+ required:
+ - configured_spec
+ - configured_document_egress_fingerprint
+ - configured_query_egress_fingerprint
+ - usage
+ type: object
+ DocumentVectorProviderUsage:
+ additionalProperties: true
+ properties:
+ fingerprint:
+ type: string
+ provider_calls:
+ format: int64
+ type: integer
+ provider_chunks:
+ format: int64
+ type: integer
+ provider_documents:
+ format: int64
+ type: integer
+ provider_input_chars:
+ format: int64
+ type: integer
+ updated_at:
+ format: date-time
+ type: string
+ required:
+ - fingerprint
+ - provider_calls
+ - provider_documents
+ - provider_chunks
+ - provider_input_chars
+ type: object
DomainContextSummaryHTTPResponse:
additionalProperties: true
properties:
@@ -13972,6 +14254,17 @@ paths:
name: cursor
schema:
type: string
+ - description: "Search mode: lexical (default and auto); semantic/hybrid send the query to the embedding provider"
+ in: query
+ name: mode
+ schema:
+ type: string
+ - description: "Maximum candidates (default/max: lexical 10000; semantic/hybrid 100/1000)"
+ in: query
+ name: candidate_limit
+ schema:
+ format: int64
+ type: integer
responses:
"200":
content:
@@ -14099,6 +14392,69 @@ paths:
summary: Get extracted document index status
tags:
- API
+ /api/v1/documents/vectors/status:
+ get:
+ operationId: getDocumentVectorStatus
+ parameters:
+ - description: Generation whose bounded failures to inspect
+ in: query
+ name: generation_id
+ schema:
+ format: int64
+ type: integer
+ - description: Stable failure cursor token
+ in: query
+ name: after_token
+ schema:
+ type: string
+ - description: Maximum failure diagnostics (default 20, max 1000)
+ in: query
+ name: limit
+ schema:
+ format: int64
+ type: integer
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DocumentVectorOperationsResponse"
+ description: OK
+ "400":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ description: Error
+ "403":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ description: Error
+ "429":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ description: Error
+ "503":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ description: Error
+ default:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ description: Error
+ security:
+ - apiKey: []
+ summary: Get document vector generation, consent, usage, and failure status
+ tags:
+ - API
/api/v1/domains/search:
post:
operationId: searchDomains
diff --git a/cmd/msgvault/cmd/add_synctech_sms_drive.go b/cmd/msgvault/cmd/add_synctech_sms_drive.go
index c405bb276..eddd8afb7 100644
--- a/cmd/msgvault/cmd/add_synctech_sms_drive.go
+++ b/cmd/msgvault/cmd/add_synctech_sms_drive.go
@@ -138,7 +138,7 @@ func runConfiguredSynctechSMSSourceWithStoreDriveClient(ctx context.Context, st
}
var err error
switch src.Backend {
- case "", "local":
+ case "", localValue:
if src.Path == "" {
return fmt.Errorf("synctech-sms source %q path is required for local backend", src.Name)
}
diff --git a/cmd/msgvault/cmd/add_synctech_sms_drive_test.go b/cmd/msgvault/cmd/add_synctech_sms_drive_test.go
index ae3312e58..04b08d4dc 100644
--- a/cmd/msgvault/cmd/add_synctech_sms_drive_test.go
+++ b/cmd/msgvault/cmd/add_synctech_sms_drive_test.go
@@ -318,7 +318,7 @@ func TestRunConfiguredSynctechSMSSourceLeavesManualSyncMessagesUnstamped(t *test
src := config.SynctechSMSSource{
Name: "pixel-local",
- Backend: "local",
+ Backend: localValue,
Path: importDir,
OwnerPhone: "+15550000001",
IncludeSMS: true,
@@ -354,7 +354,7 @@ func TestConfiguredSynctechSMSCompletesAfterImport(t *testing.T) {
`), 0o600), "write sms fixture")
src := synctechDriveTestSource()
- src.Backend = "local"
+ src.Backend = localValue
src.Path = xmlPath
err := runConfiguredSynctechSMSSourceWithStore(context.Background(), f.Store, src)
diff --git a/cmd/msgvault/cmd/backup.go b/cmd/msgvault/cmd/backup.go
index 5db7e8975..31201c82b 100644
--- a/cmd/msgvault/cmd/backup.go
+++ b/cmd/msgvault/cmd/backup.go
@@ -265,13 +265,33 @@ func runBackupRestore(cmd *cobra.Command, args []string) error {
PackedContent: backupRestorePackedContentTarget(looseAttachments),
TargetCoordinator: targetCoordinatorOption,
AuxiliaryTarget: backupapp.NewDocumentAuxiliaryTarget(),
+ BeforePublication: backupapp.InvalidateRestoredDocumentVectors,
})
if err != nil {
return fmt.Errorf("restoring snapshot: %w", err)
}
+ if backupRestoreOverwrite {
+ if err := removeRestoredSQLiteVectorBackend(backupRestoreTarget); err != nil {
+ return err
+ }
+ }
return printBackupRestoreSummary(cmd.OutOrStdout(), backupRestoreTarget, res, looseAttachments)
}
+func removeRestoredSQLiteVectorBackend(target string) error {
+ root, err := os.OpenRoot(target)
+ if err != nil {
+ return fmt.Errorf("backup restore: open restored target for vector reset: %w", err)
+ }
+ defer func() { _ = root.Close() }()
+ for _, name := range []string{"vectors.db", "vectors.db-wal", "vectors.db-shm", "vectors.db-journal"} {
+ if err := root.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("backup restore: remove excluded vector backend %s: %w", name, err)
+ }
+ }
+ return nil
+}
+
func backupRestorePackedContentTarget(loose bool) backup.PackedContentTarget {
limits := packstore.DefaultLimits()
if loose {
diff --git a/cmd/msgvault/cmd/backup_test.go b/cmd/msgvault/cmd/backup_test.go
index 3f6684707..da151cb6f 100644
--- a/cmd/msgvault/cmd/backup_test.go
+++ b/cmd/msgvault/cmd/backup_test.go
@@ -152,6 +152,31 @@ func TestRunBackupRestorePackedDefaultAndExplicitLooseCleanup(t *testing.T) {
require.NoError(os.WriteFile(loosePath, content, 0o600))
require.NoError(st.UpsertAttachment(messageID, "restore-cli.bin", "application/octet-stream",
hash[:2]+"/"+hash, hash, len(content)))
+ profileFingerprint := strings.Repeat("d", 64)
+ profile := store.DocumentExtractionProfile{
+ ID: "profile-" + profileFingerprint, Fingerprint: profileFingerprint,
+ Provider: "synthetic", Endpoint: "https://documents.example.test/v1",
+ Region: localValue, Model: "extract-test", RetentionPosture: "standard",
+ TrainingPosture: "opted-out", AllowedMediaTypes: []string{"application/pdf"},
+ PolicyJSON: []byte(`{"policy":1}`),
+ }
+ _, err = st.EnsureDocumentExtractionProfile(t.Context(), profile)
+ require.NoError(err)
+ var vectorGenerationID int64
+ require.NoError(st.DB().QueryRow(st.Rebind(`
+ INSERT INTO document_vector_generations
+ (fingerprint, target_extraction_profile_id, embedding_profile, model, dimension, state)
+ VALUES (?, ?, 'vector.embeddings', 'embed-test', 3, 'active') RETURNING id`),
+ strings.Repeat("e", 64), profile.ID).Scan(&vectorGenerationID))
+ _, err = st.DB().Exec(st.Rebind(`
+ INSERT INTO document_vector_publications
+ (generation_id, extraction_id, extraction_profile_id, canonical_blob_hash,
+ extraction_input_key, chunk_id, chunk_key, chunk_checksum, source_sequence,
+ token, state, created_at, updated_at)
+ VALUES (?, 'extraction-restore', ?, ?, 'input-restore', 1, 'chunk-restore', ?, 1,
+ 'token-restore', 'ready', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`),
+ vectorGenerationID, profile.ID, strings.Repeat("f", 64), strings.Repeat("a", 64))
+ require.NoError(err)
layout, err := packstore.NewLayout(attachmentsDir, packstore.LayoutOptions{
Staging: packstore.StagingSameDirectory,
})
@@ -208,8 +233,13 @@ func TestRunBackupRestorePackedDefaultAndExplicitLooseCleanup(t *testing.T) {
assert.Contains(packedOutput.String(), "page and blob hashes verified; manifest stats match")
assert.NotContains(packedOutput.String(), "SQLite integrity_check")
assertRestoredCLIBlob(t, backupRestoreTarget, hash, content, true)
+ assertRestoredDocumentVectorsInvalidated(t, backupRestoreTarget)
backupRestoreTarget = filepath.Join(t.TempDir(), "loose-target")
+ require.NoError(os.MkdirAll(backupRestoreTarget, 0o700))
+ staleVectorPath := filepath.Join(backupRestoreTarget, "vectors.db")
+ require.NoError(os.WriteFile(staleVectorPath, []byte("stale derived vectors"), 0o600))
+ backupRestoreOverwrite = true
backupRestoreLooseAttachments = true
backupRestoreIntegrityCheck = true
var looseOutput bytes.Buffer
@@ -220,6 +250,8 @@ func TestRunBackupRestorePackedDefaultAndExplicitLooseCleanup(t *testing.T) {
assert.Contains(looseOutput.String(), "Pack metadata cleared")
assert.Contains(looseOutput.String(), "SQLite integrity_check ok")
assertRestoredCLIBlob(t, backupRestoreTarget, hash, content, false)
+ assertRestoredDocumentVectorsInvalidated(t, backupRestoreTarget)
+ assert.NoFileExists(staleVectorPath, "overwrite restore removes the excluded derived vector backend")
}
func TestRunBackupRestoreIntoNonexistentConfiguredDataDir(t *testing.T) {
@@ -514,6 +546,21 @@ func assertRestoredCLIBlob(t *testing.T, target, hash string, want []byte, packe
assert.Equal(want, got)
}
+func assertRestoredDocumentVectorsInvalidated(t *testing.T, target string) {
+ t.Helper()
+ require := require.New(t)
+ assert := assert.New(t)
+ restored, err := store.OpenForTest(filepath.Join(target, "msgvault.db"))
+ require.NoError(err)
+ defer func() { require.NoError(restored.Close()) }()
+ active, err := restored.GetActiveDocumentVectorGeneration(t.Context())
+ require.NoError(err)
+ assert.Nil(active)
+ var publications int64
+ require.NoError(restored.DB().QueryRow(`SELECT COUNT(*) FROM document_vector_publications`).Scan(&publications))
+ assert.Zero(publications)
+}
+
func TestPrintBackupRestoreSummaryReportsPackedMixedAndLooseLayouts(t *testing.T) {
tests := []struct {
name string
diff --git a/cmd/msgvault/cmd/constants.go b/cmd/msgvault/cmd/constants.go
index 4a9fd4878..b3eec414b 100644
--- a/cmd/msgvault/cmd/constants.go
+++ b/cmd/msgvault/cmd/constants.go
@@ -42,6 +42,11 @@ const cmdUseList = "list"
// cmdUseResume is the shared Cobra use/name for resume subcommands.
const cmdUseResume = "resume"
+// cmdUseConsent is the shared Cobra use/name for consent subcommands.
+const cmdUseConsent = "consent"
+
+const localValue = "local"
+
// outputFormatJSON is the "json" value accepted by the --format flag.
const outputFormatJSON = "json"
diff --git a/cmd/msgvault/cmd/documents.go b/cmd/msgvault/cmd/documents.go
index ea81445c3..4a6c1952a 100644
--- a/cmd/msgvault/cmd/documents.go
+++ b/cmd/msgvault/cmd/documents.go
@@ -23,6 +23,7 @@ import (
"go.kenn.io/msgvault/internal/personscope"
personresolver "go.kenn.io/msgvault/internal/personscope/resolver"
"go.kenn.io/msgvault/internal/store"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
)
const (
@@ -90,6 +91,7 @@ type documentsCommandDeps struct {
openStore func() (*store.Store, func(), error)
openAttachments func(*store.Store) (documentindex.DocumentAttachmentOpener, func() error, error)
openReadClient func(context.Context) (documentReadClient, func(), error)
+ runDocumentVector func(context.Context, *store.Store, int64, int) (vectordocument.ReconcileResult, error)
}
type documentReadClient interface {
@@ -110,6 +112,7 @@ func defaultDocumentsCommandDeps() documentsCommandDeps {
validateProbeFixtures: mistral.ValidateProbeFixtures,
runCapabilityProbe: mistral.RunCapabilityProbe,
openStore: openWritableStoreAndInit,
+ runDocumentVector: runConfiguredDocumentVectorGeneration,
openAttachments: openDocumentAttachments,
openReadClient: func(ctx context.Context) (documentReadClient, func(), error) {
client, _, err := OpenHTTPStore(ctx)
@@ -135,6 +138,7 @@ func newDocumentsCmd(deps documentsCommandDeps) *cobra.Command {
parent.AddCommand(newRetryDocumentCmd(deps))
parent.AddCommand(newRetireDocumentProfileCmd(deps))
parent.AddCommand(newPurgeDocumentDerivedCmd(deps))
+ parent.AddCommand(newDocumentVectorsCmd(deps))
return parent
}
@@ -194,6 +198,8 @@ func newSearchDocumentsCmd(deps documentsCommandDeps) *cobra.Command {
command.Flags().StringVar(&beforeValue, "before", "", "Only messages before YYYY-MM-DD or RFC3339")
command.Flags().IntVarP(&request.PageSize, "limit", "n", 20, "Maximum results to return")
command.Flags().StringVar(&request.Cursor, "cursor", "", "Opaque cursor from the previous page")
+ command.Flags().StringVar(&request.SearchMode, "mode", "lexical", "Search mode: lexical (default and auto); semantic/hybrid send the query to the embedding provider")
+ command.Flags().IntVar(&request.CandidateLimit, "candidate-limit", 0, "Maximum candidates (default/max: lexical 10000; semantic/hybrid 100/1000)")
command.Flags().BoolVar(&jsonOutput, flagJSON, false, "Output structured JSON")
return command
}
@@ -1081,6 +1087,13 @@ func (c localDocumentReadClient) SearchDocuments(
}
request.Person = &resolved.Scope
}
+ mode, err := vectordocument.ParseSearchMode(request.SearchMode)
+ if err != nil {
+ return store.DocumentSearchResponse{}, fmt.Errorf("%w: %w", store.ErrDocumentSearchInvalidRequest, err)
+ }
+ if mode == vectordocument.SearchModeSemantic || mode == vectordocument.SearchModeHybrid {
+ return store.DocumentSearchResponse{}, vectordocument.ErrSemanticSearchUnavailable
+ }
if err := reconcileDocumentOccurrencesForSearch(ctx, c.store); err != nil {
return store.DocumentSearchResponse{}, err
}
diff --git a/cmd/msgvault/cmd/documents_routing_test.go b/cmd/msgvault/cmd/documents_routing_test.go
index 39afd19e3..bf9a829b8 100644
--- a/cmd/msgvault/cmd/documents_routing_test.go
+++ b/cmd/msgvault/cmd/documents_routing_test.go
@@ -9,6 +9,51 @@ import (
"go.kenn.io/msgvault/internal/documentindex"
)
+func TestDocumentVectorCommandsRouteWithConfiguredRemote(t *testing.T) {
+ const (
+ apiKeyEnv = "MSGVAULT_VECTOR_TEST_KEY"
+ apiKey = "synthetic-vector-key"
+ )
+ tests := []struct {
+ name string
+ args []string
+ wantArgs []string
+ wantAPIKey bool
+ }{
+ {name: "consent", args: []string{"documents", "vectors", "consent", "--yes"}, wantArgs: []string{"documents", "vectors", "consent", "--yes"}},
+ {name: "build", args: []string{"documents", "vectors", "build", "--limit", "5"}, wantArgs: []string{"documents", "vectors", "build", "--limit=5"}, wantAPIKey: true},
+ {name: "resume", args: []string{"documents", "vectors", "resume", "--generation-id", "7", "--limit", "5"}, wantArgs: []string{"documents", "vectors", "resume", "--generation-id=7", "--limit=5"}, wantAPIKey: true},
+ {name: "retry", args: []string{"documents", "vectors", "retry", "--generation-id", "7"}, wantArgs: []string{"documents", "vectors", "retry", "--generation-id=7"}},
+ {name: "rebuild", args: []string{"documents", "vectors", "rebuild", "--generation-id", "7", "--yes"}, wantArgs: []string{"documents", "vectors", "rebuild", "--generation-id=7", "--yes"}, wantAPIKey: true},
+ {name: "retire", args: []string{"documents", "vectors", "retire", "--generation-id", "7", "--yes"}, wantArgs: []string{"documents", "vectors", "retire", "--generation-id=7", "--yes"}},
+ {name: "status", args: []string{"documents", "vectors", "status", "--json"}, wantArgs: []string{"documents", "vectors", "status", "--json"}},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ assert := assert.New(t)
+ server, requests := newDaemonCLIRunnerTestServer(t, func(req daemonCLIRunTestRequest) {
+ assert.Equal(test.wantArgs, req.Args)
+ if test.wantAPIKey {
+ assert.Equal(map[string]string{apiKeyEnv: apiKey}, req.Env)
+ } else {
+ assert.Empty(req.Env)
+ }
+ }, `{"type":"complete"}`)
+ configureRemoteDaemonForTest(t, server.URL)
+ cfg.Vector.Embeddings.APIKeyEnv = apiKeyEnv
+ t.Setenv(apiKeyEnv, apiKey)
+
+ root := &cobra.Command{Use: "msgvault"}
+ root.AddCommand(newDocumentsCmd(documentsCommandDeps{}))
+ root.SetArgs(test.args)
+
+ require.NoError(t, root.ExecuteContext(t.Context()))
+ assert.Equal(1, int(requests.Load()))
+ })
+ }
+}
+
func TestDocumentMutationsRouteSafelyWithConfiguredRemote(t *testing.T) {
const (
apiKeyEnv = "MSGVAULT_DOCUMENT_TEST_KEY"
diff --git a/cmd/msgvault/cmd/documents_test.go b/cmd/msgvault/cmd/documents_test.go
index 2858eeeca..8bf48944c 100644
--- a/cmd/msgvault/cmd/documents_test.go
+++ b/cmd/msgvault/cmd/documents_test.go
@@ -26,6 +26,7 @@ import (
"go.kenn.io/msgvault/internal/scheduler"
"go.kenn.io/msgvault/internal/store"
"go.kenn.io/msgvault/internal/testutil/storetest"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
)
func TestProbeMistralCommandWritesCompleteSanitizedManifest(t *testing.T) {
@@ -406,6 +407,16 @@ func TestDocumentsSearchDoesNotRegisterUnconsentedJournalConsumer(t *testing.T)
require.ErrorIs(err, store.ErrAttachmentChangeConsumerMissing)
}
+func TestDocumentsSearchLocalExplicitSemanticNeverMasqueradesAsLexical(t *testing.T) {
+ fixture := storetest.New(t)
+ command := newDocumentsCmd(documentsCommandDeps{
+ openStore: func() (*store.Store, func(), error) { return fixture.Store, func() {}, nil },
+ })
+ command.SetArgs([]string{"search", "evidence", "--mode", "semantic", "--candidate-limit", "25"})
+ err := command.ExecuteContext(t.Context())
+ require.ErrorIs(t, err, vectordocument.ErrSemanticSearchUnavailable)
+}
+
func TestDocumentsSearchUsesConfiguredReadClient(t *testing.T) {
assert := assert.New(t)
openStoreCalled := false
@@ -416,6 +427,7 @@ func TestDocumentsSearchUsesConfiguredReadClient(t *testing.T) {
request store.DocumentSearchRequest,
) (store.DocumentSearchResponse, error) {
assert.Equal("damage report", request.Query)
+ assert.Zero(request.CandidateLimit)
assert.Equal(int64(40), request.PersonID)
assert.Equal([]personscope.Direction{personscope.FromPerson, personscope.Group}, request.Directions)
require.NotNil(t, request.After)
diff --git a/cmd/msgvault/cmd/documents_vector.go b/cmd/msgvault/cmd/documents_vector.go
new file mode 100644
index 000000000..0158cff6b
--- /dev/null
+++ b/cmd/msgvault/cmd/documents_vector.go
@@ -0,0 +1,460 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/url"
+ "time"
+
+ "github.com/spf13/cobra"
+ "go.kenn.io/msgvault/internal/store"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
+)
+
+const (
+ defaultDocumentVectorOperationLimit = 100
+ documentVectorsSubcommand = "vectors"
+)
+
+func newDocumentVectorsCmd(deps documentsCommandDeps) *cobra.Command {
+ command := &cobra.Command{Use: documentVectorsSubcommand, Short: "Manage document attachment vectors"}
+ command.AddCommand(
+ newDocumentVectorConsentCmd(deps),
+ newDocumentVectorBuildCmd(deps, false),
+ newDocumentVectorBuildCmd(deps, true),
+ newDocumentVectorRetryCmd(deps),
+ newDocumentVectorRebuildCmd(deps),
+ newDocumentVectorRetireCmd(deps),
+ newDocumentVectorStatusCmd(deps),
+ )
+ return command
+}
+
+func desiredDocumentVectorSpec(ctx context.Context, st *store.Store) (store.DocumentVectorGenerationSpec, error) {
+ if cfg == nil || !cfg.Vector.Enabled || !cfg.Attachments.Documents.Index.Embeddings.Enabled {
+ return store.DocumentVectorGenerationSpec{}, errors.New("document embeddings are disabled; enable [vector] and [attachments.documents.index.embeddings]")
+ }
+ return configuredDocumentVectorSpec(ctx, st)
+}
+
+func configuredDocumentVectorSpec(ctx context.Context, st *store.Store) (store.DocumentVectorGenerationSpec, error) {
+ if cfg == nil || !cfg.Attachments.Documents.Index.Embeddings.Enabled {
+ return store.DocumentVectorGenerationSpec{}, errors.New("document embeddings are not configured")
+ }
+ if err := cfg.Vector.Embeddings.Validate(); err != nil {
+ return store.DocumentVectorGenerationSpec{}, fmt.Errorf("document embedding config: %w", err)
+ }
+ if err := cfg.Vector.Validate(); err != nil {
+ return store.DocumentVectorGenerationSpec{}, fmt.Errorf("vector config: %w", err)
+ }
+ target, err := st.GetDocumentVectorTargetProfileID(ctx)
+ if err != nil {
+ return store.DocumentVectorGenerationSpec{}, err
+ }
+ fingerprint, err := vectordocument.Fingerprint(target, cfg.Vector)
+ if err != nil {
+ return store.DocumentVectorGenerationSpec{}, err
+ }
+ return store.DocumentVectorGenerationSpec{
+ Fingerprint: fingerprint,
+ TargetExtractionProfileID: target,
+ EmbeddingProfile: cfg.Attachments.Documents.Index.Embeddings.Profile,
+ Model: cfg.Vector.Embeddings.Model,
+ Dimension: cfg.Vector.Embeddings.Dimension,
+ }, nil
+}
+
+func configuredDocumentVectorConsentSpec(spec store.DocumentVectorGenerationSpec) (store.DocumentVectorConsentSpec, error) {
+ egressFingerprint, err := vectordocument.EgressFingerprint(spec.TargetExtractionProfileID, cfg.Vector)
+ if err != nil {
+ return store.DocumentVectorConsentSpec{}, err
+ }
+ return store.DocumentVectorConsentSpec{
+ DocumentVectorGenerationSpec: spec,
+ EgressFingerprint: egressFingerprint,
+ Purpose: "document_embedding",
+ }, nil
+}
+
+func configuredDocumentVectorQueryConsentSpec(spec store.DocumentVectorGenerationSpec) (store.DocumentVectorConsentSpec, error) {
+ egressFingerprint, err := vectordocument.QueryEgressFingerprint(spec.TargetExtractionProfileID, cfg.Vector)
+ if err != nil {
+ return store.DocumentVectorConsentSpec{}, err
+ }
+ return store.DocumentVectorConsentSpec{
+ DocumentVectorGenerationSpec: spec,
+ EgressFingerprint: egressFingerprint,
+ Purpose: "query_embedding",
+ }, nil
+}
+
+func withDocumentVectorStore(deps documentsCommandDeps, fn func(*store.Store) error) error {
+ if deps.openStore == nil {
+ return errors.New("document vector ledger is unavailable")
+ }
+ st, cleanup, err := deps.openStore()
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ return fn(st)
+}
+
+func runDocumentVectorCommandHTTP(command *cobra.Command, args []string, forwardEmbeddingKey bool) error {
+ if forwardEmbeddingKey {
+ return runDaemonCLICommandHTTPFromCobraWithEnv(command, args, embeddingsForwardEnv())
+ }
+ return runDaemonCLICommandHTTPFromCobra(command, args)
+}
+
+func newDocumentVectorConsentCmd(deps documentsCommandDeps) *cobra.Command {
+ var yes bool
+ var purpose string
+ command := &cobra.Command{
+ Use: cmdUseConsent,
+ Short: "Consent to hosted embedding for the configured document policy",
+ Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, args []string) error {
+ if !isDaemonCLISubprocess() {
+ return runDocumentVectorCommandHTTP(command, args, false)
+ }
+ return withDocumentVectorStore(deps, func(st *store.Store) error {
+ spec, err := configuredDocumentVectorSpec(command.Context(), st)
+ if err != nil {
+ return err
+ }
+ var consentSpec store.DocumentVectorConsentSpec
+ switch purpose {
+ case "documents":
+ consentSpec, err = configuredDocumentVectorConsentSpec(spec)
+ case "queries":
+ consentSpec, err = configuredDocumentVectorQueryConsentSpec(spec)
+ default:
+ return errors.New("document vector consent purpose must be documents or queries")
+ }
+ if err != nil {
+ return err
+ }
+ printDocumentVectorConsentDisclosure(command.OutOrStdout(), consentSpec)
+ if !yes {
+ return errors.New("hosted document embedding consent requires --yes after reviewing the provider disclosure")
+ }
+ consent, _, err := st.RecordDocumentVectorConsent(command.Context(), consentSpec, time.Now())
+ if err != nil {
+ return err
+ }
+ _, _ = fmt.Fprintf(command.OutOrStdout(), "Recorded consent for document vector egress fingerprint %s. Restart the daemon to enable scheduled document vector work.\n", consent.EgressFingerprint)
+ return nil
+ })
+ },
+ }
+ command.Flags().BoolVar(&yes, "yes", false, "Confirm hosted document embedding consent")
+ command.Flags().StringVar(&purpose, "purpose", "documents", "Plaintext class to consent: documents or queries")
+ return command
+}
+
+func printDocumentVectorConsentDisclosure(w io.Writer, spec store.DocumentVectorConsentSpec) {
+ authentication := "no authentication environment variable configured"
+ if cfg.Vector.Embeddings.APIKeyEnv != "" {
+ authentication = "environment variable " + cfg.Vector.Embeddings.APIKeyEnv
+ }
+ _, _ = fmt.Fprintln(w, "Hosted document embedding disclosure:")
+ _, _ = fmt.Fprintf(w, "Corpus fingerprint: %s\n", spec.Fingerprint)
+ _, _ = fmt.Fprintf(w, "Egress fingerprint: %s\n", spec.EgressFingerprint)
+ _, _ = fmt.Fprintf(w, "Destination: %s\n", documentVectorConsentEndpoint(cfg.Vector.Embeddings.Endpoint))
+ _, _ = fmt.Fprintf(w, "Authentication: %s\n", authentication)
+ _, _ = fmt.Fprintf(w, "API format: %s\n", cfg.Vector.Embeddings.EffectiveAPIFormat())
+ _, _ = fmt.Fprintf(w, "Model: %s\n", spec.Model)
+ _, _ = fmt.Fprintf(w, "Dimension: %d\n", spec.Dimension)
+ _, _ = fmt.Fprintf(w, "Maximum input: %d characters\n", cfg.Vector.Embeddings.MaxInputChars)
+ if spec.Purpose == "query_embedding" {
+ _, _ = fmt.Fprintln(w, "Explicit semantic or hybrid document searches will send query text to the configured destination.")
+ } else {
+ _, _ = fmt.Fprintln(w, "Docbank-prepared normalized attachment document inputs will be sent to the configured destination for embedding.")
+ }
+}
+
+func documentVectorConsentEndpoint(endpoint string) string {
+ parsed, err := url.Parse(endpoint)
+ if err != nil {
+ return "[configured endpoint omitted: invalid URL]"
+ }
+ parsed.User = nil
+ parsed.RawQuery = ""
+ parsed.ForceQuery = false
+ parsed.Fragment = ""
+ return parsed.String()
+}
+
+func newDocumentVectorBuildCmd(deps documentsCommandDeps, resume bool) *cobra.Command {
+ name, short := documentBuildSubcommand, "Build the configured document vector generation"
+ if resume {
+ name, short = cmdUseResume, "Resume a building generation or retired-generation cleanup"
+ }
+ var generationID int64
+ var limit int
+ command := &cobra.Command{
+ Use: name, Short: short, Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, args []string) error {
+ if !isDaemonCLISubprocess() {
+ return runDocumentVectorCommandHTTP(command, args, true)
+ }
+ return withDocumentVectorStore(deps, func(st *store.Store) error {
+ return st.WithDocumentVectorOperationLock(command.Context(), func() error {
+ if resume {
+ generation, err := st.GetDocumentVectorGeneration(command.Context(), generationID)
+ if err != nil {
+ return err
+ }
+ if generation.State == store.DocumentVectorGenerationRetired {
+ return runDocumentVectorCommand(command, deps, st, generationID, limit)
+ }
+ }
+ spec, err := desiredDocumentVectorSpec(command.Context(), st)
+ if err != nil {
+ return err
+ }
+ if err := requireDocumentVectorConsent(command.Context(), st, spec); err != nil {
+ return err
+ }
+ if resume {
+ generation, err := st.GetDocumentVectorGeneration(command.Context(), generationID)
+ if err != nil {
+ return err
+ }
+ if generation.State != store.DocumentVectorGenerationBuilding || generation.DocumentVectorGenerationSpec != spec {
+ return store.ErrDocumentVectorInvalidGenerationState
+ }
+ } else {
+ generation, _, err := st.EnsureDocumentVectorGeneration(command.Context(), spec)
+ if err != nil {
+ return err
+ }
+ if generation.State != store.DocumentVectorGenerationBuilding {
+ return errors.New("the configured generation is already active; use documents vectors rebuild for coverage drift")
+ }
+ generationID = generation.ID
+ }
+ return runDocumentVectorCommand(command, deps, st, generationID, limit)
+ })
+ })
+ },
+ }
+ command.Flags().IntVarP(&limit, "limit", "n", defaultDocumentVectorOperationLimit, "Maximum chunks and cleanup tokens to process (1-1000)")
+ if resume {
+ command.Flags().Int64Var(&generationID, "generation-id", 0, "Building or retired generation to resume")
+ _ = command.MarkFlagRequired("generation-id")
+ }
+ return command
+}
+
+func requireDocumentVectorConsent(ctx context.Context, st *store.Store, spec store.DocumentVectorGenerationSpec) error {
+ consented, err := hasDocumentVectorConsent(ctx, st, spec)
+ if err != nil {
+ return err
+ }
+ if !consented {
+ return errors.New("exact document vector policy is not consented; run `msgvault documents vectors consent --yes`")
+ }
+ return nil
+}
+
+func hasDocumentVectorConsent(ctx context.Context, st *store.Store, spec store.DocumentVectorGenerationSpec) (bool, error) {
+ consentSpec, err := configuredDocumentVectorConsentSpec(spec)
+ if err != nil {
+ return false, err
+ }
+ consent, err := st.GetDocumentVectorConsent(ctx, consentSpec.EgressFingerprint)
+ if err != nil {
+ return false, err
+ }
+ return consent != nil && consent.DocumentVectorConsentSpec == consentSpec, nil
+}
+
+func runDocumentVectorCommand(command *cobra.Command, deps documentsCommandDeps, st *store.Store, generationID int64, limit int) error {
+ if limit < 1 || limit > 1000 {
+ return errors.New("document vector operation limit must be between 1 and 1000")
+ }
+ if deps.runDocumentVector == nil {
+ return errors.New("document vector backend is unavailable in this binary")
+ }
+ result, err := deps.runDocumentVector(command.Context(), st, generationID, limit)
+ if encodeErr := json.NewEncoder(command.OutOrStdout()).Encode(result); encodeErr != nil {
+ return errors.Join(err, encodeErr)
+ }
+ return err
+}
+
+func newDocumentVectorRetryCmd(deps documentsCommandDeps) *cobra.Command {
+ var generationID int64
+ var afterToken string
+ var limit int
+ command := &cobra.Command{Use: "retry", Short: "Reset current failed publications for retry", Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, args []string) error {
+ if !isDaemonCLISubprocess() {
+ return runDocumentVectorCommandHTTP(command, args, false)
+ }
+ return withDocumentVectorStore(deps, func(st *store.Store) error {
+ return st.WithDocumentVectorOperationLock(command.Context(), func() error {
+ result, err := st.ResetDocumentVectorFailures(command.Context(), generationID, afterToken, limit, time.Now())
+ if err != nil {
+ return err
+ }
+ return json.NewEncoder(command.OutOrStdout()).Encode(result)
+ })
+ })
+ }}
+ command.Flags().Int64Var(&generationID, "generation-id", 0, "Generation whose failures should be reset")
+ command.Flags().StringVar(&afterToken, "after-token", "", "Stable retry cursor token")
+ command.Flags().IntVarP(&limit, "limit", "n", defaultDocumentVectorOperationLimit, "Maximum failures to scan (1-1000)")
+ _ = command.MarkFlagRequired("generation-id")
+ return command
+}
+
+func newDocumentVectorRebuildCmd(deps documentsCommandDeps) *cobra.Command {
+ var activeID int64
+ var limit int
+ var yes bool
+ command := &cobra.Command{Use: "rebuild", Short: "Build a fresh generation while the active generation remains searchable", Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, args []string) error {
+ if !isDaemonCLISubprocess() {
+ return runDocumentVectorCommandHTTP(command, args, true)
+ }
+ if !yes {
+ return errors.New("document vector rebuild requires --yes")
+ }
+ return withDocumentVectorStore(deps, func(st *store.Store) error {
+ return st.WithDocumentVectorOperationLock(command.Context(), func() error {
+ spec, err := desiredDocumentVectorSpec(command.Context(), st)
+ if err != nil {
+ return err
+ }
+ if err := requireDocumentVectorConsent(command.Context(), st, spec); err != nil {
+ return err
+ }
+ generation, err := st.StartDocumentVectorRebuild(command.Context(), activeID, spec, time.Now())
+ if err != nil {
+ return err
+ }
+ return runDocumentVectorCommand(command, deps, st, generation.ID, limit)
+ })
+ })
+ }}
+ command.Flags().Int64Var(&activeID, "generation-id", 0, "Active generation being replaced")
+ command.Flags().IntVarP(&limit, "limit", "n", defaultDocumentVectorOperationLimit, "Maximum chunks and cleanup tokens to process (1-1000)")
+ command.Flags().BoolVar(&yes, "yes", false, "Confirm the rebuild")
+ _ = command.MarkFlagRequired("generation-id")
+ return command
+}
+
+func newDocumentVectorRetireCmd(deps documentsCommandDeps) *cobra.Command {
+ var generationID int64
+ var yes bool
+ command := &cobra.Command{Use: cliEmbeddingsOperationRetire, Short: "Retire a document vector generation without deleting its backend ledger", Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, args []string) error {
+ if !isDaemonCLISubprocess() {
+ return runDocumentVectorCommandHTTP(command, args, false)
+ }
+ if !yes {
+ return errors.New("document vector retirement requires --yes")
+ }
+ return withDocumentVectorStore(deps, func(st *store.Store) error {
+ return st.WithDocumentVectorOperationLock(command.Context(), func() error {
+ retired, err := st.RetireDocumentVectorGeneration(command.Context(), generationID, time.Now())
+ if err != nil {
+ return err
+ }
+ _, _ = fmt.Fprintf(command.OutOrStdout(), "retired=%t generation_id=%d; backend cleanup will resume when vector operations next run\n", retired, generationID)
+ return nil
+ })
+ })
+ }}
+ command.Flags().Int64Var(&generationID, "generation-id", 0, "Generation to retire")
+ command.Flags().BoolVar(&yes, "yes", false, "Confirm retirement")
+ _ = command.MarkFlagRequired("generation-id")
+ return command
+}
+
+func newDocumentVectorStatusCmd(deps documentsCommandDeps) *cobra.Command {
+ var generationID int64
+ var afterToken string
+ var limit int
+ var jsonOutput bool
+ command := &cobra.Command{Use: statusValue, Short: "Inspect document vector generations, consent, usage, and failures", Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, args []string) error {
+ if !isDaemonCLISubprocess() {
+ return runDocumentVectorCommandHTTP(command, args, false)
+ }
+ if cfg == nil || !cfg.Vector.Enabled || !cfg.Attachments.Documents.Index.Embeddings.Enabled {
+ if jsonOutput {
+ return json.NewEncoder(command.OutOrStdout()).Encode(map[string]bool{"enabled": false})
+ }
+ _, _ = fmt.Fprintln(command.OutOrStdout(), "document_vectors=disabled")
+ return nil
+ }
+ return withDocumentVectorStore(deps, func(st *store.Store) error {
+ spec, err := desiredDocumentVectorSpec(command.Context(), st)
+ if errors.Is(err, store.ErrDocumentVectorInvalidGenerationState) {
+ if jsonOutput {
+ return json.NewEncoder(command.OutOrStdout()).Encode(map[string]bool{"enabled": true, "configured": false})
+ }
+ _, _ = fmt.Fprintln(command.OutOrStdout(), "document_vectors=enabled configured=false")
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ documentConsentSpec, err := configuredDocumentVectorConsentSpec(spec)
+ if err != nil {
+ return err
+ }
+ queryConsentSpec, err := configuredDocumentVectorQueryConsentSpec(spec)
+ if err != nil {
+ return err
+ }
+ status, err := st.GetDocumentVectorOperationsStatus(command.Context(), spec, documentConsentSpec.EgressFingerprint, queryConsentSpec.EgressFingerprint, generationID, afterToken, limit)
+ if err != nil {
+ return err
+ }
+ if jsonOutput {
+ return json.NewEncoder(command.OutOrStdout()).Encode(struct {
+ Enabled bool `json:"enabled"`
+ Configured bool `json:"configured"`
+ Status store.DocumentVectorOperationsStatus `json:"status"`
+ }{Enabled: true, Configured: true, Status: status})
+ }
+ _, _ = fmt.Fprintf(command.OutOrStdout(), "configured_fingerprint=%s document_consented=%t query_consented=%t provider_calls=%d provider_documents=%d provider_chunks=%d provider_input_chars=%d\n", status.ConfiguredSpec.Fingerprint, status.DocumentConsent != nil, status.QueryConsent != nil, status.Usage.ProviderCalls, status.Usage.ProviderDocuments, status.Usage.ProviderChunks, status.Usage.ProviderInputChars)
+ if status.Active != nil {
+ _, _ = fmt.Fprintf(command.OutOrStdout(), "active_generation=%d state=%s\n", status.Active.ID, status.Active.State)
+ } else {
+ _, _ = fmt.Fprintln(command.OutOrStdout(), "active_generation=none")
+ }
+ if status.Building != nil {
+ _, _ = fmt.Fprintf(command.OutOrStdout(), "building_generation=%d state=%s\n", status.Building.ID, status.Building.State)
+ } else {
+ _, _ = fmt.Fprintln(command.OutOrStdout(), "building_generation=none")
+ }
+ if status.Selected != nil {
+ _, _ = fmt.Fprintf(command.OutOrStdout(), "selected_generation=%d state=%s blocked=%t pending=%d retryable=%d terminal=%d ready_live=%d obsolete=%d cleanup_pending=%d\n",
+ status.Selected.GenerationID, status.Selected.State, status.Selected.Blocked, status.Selected.Pending,
+ status.Selected.Retryable, status.Selected.Terminal, status.Selected.ReadyLive,
+ status.Selected.Obsolete, status.Selected.CleanupPending)
+ }
+ if status.Coverage != nil {
+ _, _ = fmt.Fprintf(command.OutOrStdout(), "coverage_required=%d coverage_ready=%d\n", status.Coverage.Required, status.Coverage.Ready)
+ }
+ if status.DocumentConsent != nil || status.QueryConsent != nil {
+ _, _ = fmt.Fprintln(command.OutOrStdout(), "scheduled_registration=restart-required-after-new-consent")
+ }
+ return nil
+ })
+ }}
+ command.Flags().Int64Var(&generationID, "generation-id", 0, "Generation whose bounded failures to inspect")
+ command.Flags().StringVar(&afterToken, "after-token", "", "Stable failure cursor token")
+ command.Flags().IntVarP(&limit, "limit", "n", 20, "Maximum failure diagnostics (1-1000)")
+ command.Flags().BoolVar(&jsonOutput, flagJSON, false, "Output structured JSON")
+ return command
+}
diff --git a/cmd/msgvault/cmd/documents_vector_cleanup_sqlitevec_test.go b/cmd/msgvault/cmd/documents_vector_cleanup_sqlitevec_test.go
new file mode 100644
index 000000000..9e7dfeb6e
--- /dev/null
+++ b/cmd/msgvault/cmd/documents_vector_cleanup_sqlitevec_test.go
@@ -0,0 +1,63 @@
+//go:build sqlite_vec
+
+package cmd
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
+ "go.kenn.io/msgvault/internal/vector/sqlitevec"
+)
+
+func TestRunConfiguredDocumentVectorGenerationCleansRetiredWhenEmbeddingsDisabled(t *testing.T) {
+ fixture, spec := documentVectorCommandFixture(t)
+ vectorPath := filepath.Join(t.TempDir(), "vectors.db")
+ cfg.Vector.DBPath = vectorPath
+
+ generation, _, err := fixture.Store.EnsureDocumentVectorGeneration(t.Context(), spec)
+ require.NoError(t, err)
+ token := strings.Repeat("9", 64)
+ backend, err := sqlitevec.Open(t.Context(), sqlitevec.Options{
+ Path: vectorPath, Dimension: spec.Dimension,
+ })
+ require.NoError(t, err)
+ require.NoError(t, backend.DocumentBackend().PutUnpublished(t.Context(), vectordocument.GenerationID(generation.ID), spec.Dimension, []vectordocument.Embedding{{
+ Token: token, Vector: []float32{1, 0, 0},
+ }}))
+ require.NoError(t, backend.Close())
+
+ _, err = fixture.Store.DB().Exec(fixture.Store.Rebind(`
+ INSERT INTO document_vector_publications
+ (generation_id, extraction_id, extraction_profile_id, canonical_blob_hash,
+ extraction_input_key, chunk_id, chunk_key, chunk_checksum, source_sequence, token, state)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ready')`), generation.ID,
+ "disabled-cleanup-extraction", spec.TargetExtractionProfileID, strings.Repeat("a", 64),
+ "original", 1, "disabled-cleanup-chunk", "disabled-cleanup-checksum", 1, token)
+ require.NoError(t, err)
+ retired, err := fixture.Store.RetireDocumentVectorGeneration(t.Context(), generation.ID, time.Now())
+ require.NoError(t, err)
+ require.True(t, retired)
+ cfg.Vector.Enabled = false
+
+ result, err := runConfiguredDocumentVectorGeneration(t.Context(), fixture.Store, generation.ID, 1)
+
+ require.NoError(t, err)
+ assert.True(t, result.Purged)
+ assert.True(t, result.Converged)
+ _, err = fixture.Store.GetDocumentVectorGeneration(t.Context(), generation.ID)
+ require.ErrorContains(t, err, "not found")
+
+ backend, err = sqlitevec.Open(t.Context(), sqlitevec.Options{Path: vectorPath})
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = backend.Close() })
+ var remaining int
+ require.NoError(t, backend.DB().QueryRow(
+ `SELECT COUNT(*) FROM document_vector_embeddings WHERE token = ?`, token,
+ ).Scan(&remaining))
+ assert.Zero(t, remaining)
+}
diff --git a/cmd/msgvault/cmd/documents_vector_runtime.go b/cmd/msgvault/cmd/documents_vector_runtime.go
new file mode 100644
index 000000000..d473bb749
--- /dev/null
+++ b/cmd/msgvault/cmd/documents_vector_runtime.go
@@ -0,0 +1,221 @@
+//go:build sqlite_vec || pgvector
+
+package cmd
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "time"
+
+ "github.com/google/uuid"
+ "go.kenn.io/msgvault/internal/store"
+ "go.kenn.io/msgvault/internal/vector"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
+ "go.kenn.io/msgvault/internal/vector/pgvector"
+ "go.kenn.io/msgvault/internal/vector/sqlitevec"
+)
+
+func nextDocumentVectorWorkerOwner() string {
+ return "document-vector-" + uuid.NewString()
+}
+
+type checkpointingDocumentVectorWorker struct {
+ worker vectordocument.WorkerRunner
+ checkpointer documentVectorBuildCheckpointer
+ fingerprint string
+ now func() time.Time
+}
+
+type documentVectorBuildCheckpointer interface {
+ CheckpointDocumentVectorBuildForFingerprint(ctx context.Context, generationID int64, fingerprint string, afterChunkID int64, exhausted bool, delta store.DocumentVectorUsageDelta, now time.Time) error
+}
+
+func (w checkpointingDocumentVectorWorker) Run(ctx context.Context, generationID vectordocument.GenerationID, limit int) (vectordocument.RunResult, error) {
+ result, runErr := w.worker.Run(ctx, generationID, limit)
+ if !result.Exhausted && result.AfterGenerationID == 0 {
+ return result, runErr
+ }
+ delta := store.DocumentVectorUsageDelta{
+ ProviderCalls: int64(result.ProviderCalls), ProviderDocuments: int64(result.ProviderDocuments),
+ ProviderChunks: int64(result.ProviderChunks), ProviderInputChars: int64(result.ProviderInputChars),
+ }
+ checkpointErr := w.checkpointer.CheckpointDocumentVectorBuildForFingerprint(
+ ctx, int64(generationID), w.fingerprint, result.AfterChunkID, result.Exhausted, delta, w.now(),
+ )
+ return result, errors.Join(runErr, checkpointErr)
+}
+
+func runConfiguredDocumentVectorGeneration(ctx context.Context, st *store.Store, generationID int64, limit int) (vectordocument.ReconcileResult, error) {
+ if limit < 1 || limit > 1000 {
+ return vectordocument.ReconcileResult{}, errors.New("document vector operation limit must be between 1 and 1000")
+ }
+ generation, err := st.GetDocumentVectorGeneration(ctx, generationID)
+ if err != nil {
+ return vectordocument.ReconcileResult{}, err
+ }
+ if generation.State == store.DocumentVectorGenerationRetired {
+ backend, closeBackend, err := openDocumentVectorCleanupBackend(ctx, st, cfg.DatabaseDSN())
+ if err != nil {
+ return vectordocument.ReconcileResult{}, err
+ }
+ defer func() { _ = closeBackend() }()
+ reconciler := vectordocument.NewReconciler(vectordocument.ReconcilerDeps{
+ Ledger: st, Backend: backend, Now: func() time.Time { return time.Now().UTC() },
+ })
+ return reconciler.Run(ctx, vectordocument.GenerationID(generationID), limit)
+ }
+ vf, err := setupVectorFeatures(ctx, st, cfg.DatabaseDSN(), false)
+ if err != nil {
+ return vectordocument.ReconcileResult{}, err
+ }
+ if vf == nil || vf.DocumentBackend == nil || vf.SemanticClient == nil {
+ return vectordocument.ReconcileResult{}, errors.New("document vector runtime is unavailable")
+ }
+ defer func() { _ = vf.Close() }()
+ return runDocumentVectorWithFeatures(ctx, st, vf, generationID, limit)
+}
+
+func openDocumentVectorCleanupBackend(ctx context.Context, st *store.Store, mainPath string) (vectordocument.Backend, func() error, error) {
+ if store.IsPostgresURL(mainPath) {
+ backend, err := pgvector.DocumentBackendForDB(st.DB())
+ if err != nil {
+ return nil, nil, fmt.Errorf("open pgvector document cleanup backend: %w", err)
+ }
+ return backend, func() error { return nil }, nil
+ }
+ vectorPath := cfg.Vector.DBPath
+ if vectorPath == "" {
+ vectorPath = filepath.Join(cfg.Data.DataDir, "vectors.db")
+ }
+ backend, err := sqlitevec.Open(ctx, sqlitevec.Options{Path: vectorPath})
+ if err != nil {
+ return nil, nil, fmt.Errorf("open document cleanup backend: %w", err)
+ }
+ return backend.DocumentBackend(), backend.Close, nil
+}
+
+func runDocumentVectorWithFeatures(ctx context.Context, st *store.Store, vf *vectorFeatures, generationID int64, limit int) (vectordocument.ReconcileResult, error) {
+ limit = min(limit, max(1, vf.Cfg.Embeddings.BatchSize))
+ generation, err := st.GetDocumentVectorGeneration(ctx, generationID)
+ if err != nil {
+ return vectordocument.ReconcileResult{}, err
+ }
+ cursor, err := st.GetDocumentVectorBuildCursor(ctx, generationID)
+ if err != nil {
+ return vectordocument.ReconcileResult{}, err
+ }
+ now := func() time.Time { return time.Now().UTC() }
+ var afterGenerationID vectordocument.GenerationID
+ if cursor > 0 {
+ afterGenerationID = vectordocument.GenerationID(generationID)
+ }
+ worker := vectordocument.NewWorker(vectordocument.WorkerDeps{
+ Ledger: st, Provider: vf.SemanticClient, Backend: vf.DocumentBackend,
+ Owner: nextDocumentVectorWorkerOwner(), Dimension: generation.Dimension,
+ MaxInputChars: cfg.Vector.Embeddings.MaxInputChars,
+ ContextualDocuments: cfg.Vector.Embeddings.EffectiveAPIFormat() == vector.APIFormatVoyageContextual,
+ LeaseDuration: 2 * time.Minute, HeartbeatInterval: 20 * time.Second,
+ RetryDelay: time.Minute, MaxAttempts: 5,
+ AfterGenerationID: afterGenerationID, AfterChunkID: cursor, Now: now,
+ })
+ checkpointed := checkpointingDocumentVectorWorker{
+ worker: worker, checkpointer: st, fingerprint: generation.Fingerprint, now: now,
+ }
+ reconciler := vectordocument.NewReconciler(vectordocument.ReconcilerDeps{
+ Ledger: st, Worker: checkpointed, Backend: vf.DocumentBackend, Now: now,
+ })
+ return reconciler.Run(ctx, vectordocument.GenerationID(generationID), limit)
+}
+
+func runScheduledDocumentVectorGeneration(ctx context.Context, st *store.Store, vf *vectorFeatures, limit int) error {
+ return st.WithDocumentVectorOperationLock(ctx, func() error {
+ return runScheduledDocumentVectorGenerationLocked(ctx, st, vf, limit)
+ })
+}
+
+func runScheduledDocumentVectorGenerationLocked(ctx context.Context, st *store.Store, vf *vectorFeatures, limit int) error {
+ retired, err := st.GetOldestRetiredDocumentVectorGeneration(ctx)
+ if err != nil {
+ return err
+ }
+ if retired != nil {
+ reconciler := vectordocument.NewReconciler(vectordocument.ReconcilerDeps{
+ Ledger: st, Backend: vf.DocumentBackend, Now: func() time.Time { return time.Now().UTC() },
+ })
+ _, err := reconciler.Run(ctx, vectordocument.GenerationID(retired.ID), limit)
+ return err
+ }
+ if vf.SemanticClient == nil {
+ return nil
+ }
+ spec, err := desiredDocumentVectorSpec(ctx, st)
+ if errors.Is(err, store.ErrDocumentVectorInvalidGenerationState) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ consented, err := hasDocumentVectorConsent(ctx, st, spec)
+ if err != nil {
+ return err
+ }
+ if !consented {
+ return nil
+ }
+ building, err := st.GetBuildingDocumentVectorGeneration(ctx)
+ if err != nil {
+ return err
+ }
+ if building != nil && building.DocumentVectorGenerationSpec != spec {
+ retired, retireErr := st.RetireDocumentVectorGeneration(ctx, building.ID, time.Now())
+ if retireErr != nil {
+ return retireErr
+ }
+ if !retired {
+ return store.ErrDocumentVectorInvalidGenerationState
+ }
+ // Reconcile only the obsolete generation this pass. The next bounded
+ // run creates the desired generation without exceeding one cleanup page.
+ _, reconcileErr := runDocumentVectorWithFeatures(ctx, st, vf, building.ID, limit)
+ return reconcileErr
+ }
+ if building == nil {
+ active, err := st.GetActiveDocumentVectorGeneration(ctx)
+ if err != nil {
+ return err
+ }
+ switch {
+ case active == nil || active.DocumentVectorGenerationSpec != spec:
+ generation, _, ensureErr := st.EnsureDocumentVectorGeneration(ctx, spec)
+ if ensureErr != nil {
+ return ensureErr
+ }
+ building = &generation
+ default:
+ status, statusErr := st.GetDocumentVectorGenerationStatus(ctx, active.ID, "", limit)
+ if statusErr != nil {
+ return statusErr
+ }
+ if status.CleanupPending > 0 {
+ _, reconcileErr := runDocumentVectorWithFeatures(ctx, st, vf, active.ID, limit)
+ return reconcileErr
+ }
+ coverage, coverageErr := st.GetDocumentVectorCoverage(ctx, active.ID)
+ if coverageErr != nil {
+ return coverageErr
+ }
+ if coverage.Complete() {
+ return nil
+ }
+ generation, rebuildErr := st.StartDocumentVectorRebuild(ctx, active.ID, spec, time.Now())
+ if rebuildErr != nil {
+ return rebuildErr
+ }
+ building = &generation
+ }
+ }
+ _, err = runDocumentVectorWithFeatures(ctx, st, vf, building.ID, limit)
+ return err
+}
diff --git a/cmd/msgvault/cmd/documents_vector_stub.go b/cmd/msgvault/cmd/documents_vector_stub.go
new file mode 100644
index 000000000..35c493de8
--- /dev/null
+++ b/cmd/msgvault/cmd/documents_vector_stub.go
@@ -0,0 +1,19 @@
+//go:build !sqlite_vec && !pgvector
+
+package cmd
+
+import (
+ "context"
+ "errors"
+
+ "go.kenn.io/msgvault/internal/store"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
+)
+
+func runConfiguredDocumentVectorGeneration(context.Context, *store.Store, int64, int) (vectordocument.ReconcileResult, error) {
+ return vectordocument.ReconcileResult{}, errors.New("document vector backend is unavailable: rebuild with sqlite_vec or pgvector support")
+}
+
+func runScheduledDocumentVectorGeneration(context.Context, *store.Store, *vectorFeatures, int) error {
+ return errors.New("document vector backend is unavailable: rebuild with sqlite_vec or pgvector support")
+}
diff --git a/cmd/msgvault/cmd/documents_vector_stub_test.go b/cmd/msgvault/cmd/documents_vector_stub_test.go
new file mode 100644
index 000000000..d685cbcb2
--- /dev/null
+++ b/cmd/msgvault/cmd/documents_vector_stub_test.go
@@ -0,0 +1,30 @@
+//go:build !sqlite_vec && !pgvector
+
+package cmd
+
+import (
+ "bytes"
+ "os"
+ "strconv"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.kenn.io/msgvault/internal/config"
+)
+
+func TestDocumentVectorStubKeepsStatusAvailableAndBuildActionable(t *testing.T) {
+ previous := cfg
+ t.Cleanup(func() { cfg = previous })
+ t.Setenv(daemonCLISubprocessEnv, strconv.Itoa(os.Getppid()))
+ cfg = config.NewDefaultConfig()
+ status := newDocumentsCmd(documentsCommandDeps{})
+ var output bytes.Buffer
+ status.SetOut(&output)
+ status.SetArgs([]string{documentVectorsSubcommand, statusValue, "--json"})
+ require.NoError(t, status.ExecuteContext(t.Context()))
+ assert.JSONEq(t, `{"enabled":false}`, output.String())
+
+ _, err := runConfiguredDocumentVectorGeneration(t.Context(), nil, 1, 1)
+ require.ErrorContains(t, err, "rebuild with sqlite_vec or pgvector support")
+}
diff --git a/cmd/msgvault/cmd/documents_vector_test.go b/cmd/msgvault/cmd/documents_vector_test.go
new file mode 100644
index 000000000..42482c179
--- /dev/null
+++ b/cmd/msgvault/cmd/documents_vector_test.go
@@ -0,0 +1,572 @@
+//go:build sqlite_vec || pgvector
+
+package cmd
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.kenn.io/msgvault/internal/config"
+ "go.kenn.io/msgvault/internal/store"
+ "go.kenn.io/msgvault/internal/testutil/storetest"
+ "go.kenn.io/msgvault/internal/vector"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
+)
+
+func TestDocumentVectorLedgerCommandsNeverOpenRuntime(t *testing.T) {
+ fixture, spec := documentVectorCommandFixture(t)
+ t.Setenv("SYNTHETIC_EMBEDDING_KEY", "secret-that-must-not-print")
+ runtimeCalls := 0
+ deps := documentsCommandDeps{
+ openStore: func() (*store.Store, func(), error) { return fixture.Store, func() {}, nil },
+ runDocumentVector: func(context.Context, *store.Store, int64, int) (vectordocument.ReconcileResult, error) {
+ runtimeCalls++
+ return vectordocument.ReconcileResult{}, nil
+ },
+ }
+
+ unconfirmed := newDocumentsCmd(deps)
+ var unconfirmedOutput bytes.Buffer
+ unconfirmed.SetOut(&unconfirmedOutput)
+ unconfirmed.SetArgs([]string{documentVectorsSubcommand, "consent"})
+ require.ErrorContains(t, unconfirmed.ExecuteContext(t.Context()), "--yes")
+ assert.Contains(t, unconfirmedOutput.String(), "Hosted document embedding disclosure:")
+ assert.Contains(t, unconfirmedOutput.String(), "Destination: https://embeddings.example.test/v1")
+ assert.Contains(t, unconfirmedOutput.String(), "Authentication: environment variable SYNTHETIC_EMBEDDING_KEY")
+ assert.Contains(t, unconfirmedOutput.String(), "API format: openai")
+ assert.Contains(t, unconfirmedOutput.String(), "Model: embed-test")
+ assert.Contains(t, unconfirmedOutput.String(), "Dimension: 3")
+ assert.Contains(t, unconfirmedOutput.String(), "Maximum input: 4096 characters")
+ assert.Contains(t, unconfirmedOutput.String(), "Docbank-prepared normalized attachment document inputs will be sent")
+ assert.NotContains(t, unconfirmedOutput.String(), "Explicit semantic or hybrid document searches")
+ assert.NotContains(t, unconfirmedOutput.String(), "secret-that-must-not-print")
+ consentSpec, err := configuredDocumentVectorConsentSpec(spec)
+ require.NoError(t, err)
+ unconfirmedConsent, err := fixture.Store.GetDocumentVectorConsent(t.Context(), consentSpec.EgressFingerprint)
+ require.NoError(t, err)
+ assert.Nil(t, unconfirmedConsent)
+
+ consent := newDocumentsCmd(deps)
+ var consentOutput bytes.Buffer
+ consent.SetOut(&consentOutput)
+ consent.SetArgs([]string{documentVectorsSubcommand, "consent", "--yes"})
+ require.NoError(t, consent.ExecuteContext(t.Context()))
+ assert.Contains(t, consentOutput.String(), "Hosted document embedding disclosure:")
+ assert.Contains(t, consentOutput.String(), "Recorded consent for document vector egress fingerprint "+consentSpec.EgressFingerprint)
+ assert.NotContains(t, consentOutput.String(), "secret-that-must-not-print")
+ recorded, err := fixture.Store.GetDocumentVectorConsent(t.Context(), consentSpec.EgressFingerprint)
+ require.NoError(t, err)
+ require.NotNil(t, recorded)
+ assert.Equal(t, spec, recorded.DocumentVectorGenerationSpec)
+ assert.Equal(t, "document_embedding", recorded.Purpose)
+
+ queryConsentSpec, err := configuredDocumentVectorQueryConsentSpec(spec)
+ require.NoError(t, err)
+ assert.NotEqual(t, consentSpec.EgressFingerprint, queryConsentSpec.EgressFingerprint)
+ queryConsent := newDocumentsCmd(deps)
+ var queryConsentOutput bytes.Buffer
+ queryConsent.SetOut(&queryConsentOutput)
+ queryConsent.SetArgs([]string{documentVectorsSubcommand, "consent", "--purpose", "queries", "--yes"})
+ require.NoError(t, queryConsent.ExecuteContext(t.Context()))
+ assert.Contains(t, queryConsentOutput.String(), "Explicit semantic or hybrid document searches will send query text")
+ recordedQuery, err := fixture.Store.GetDocumentVectorConsent(t.Context(), queryConsentSpec.EgressFingerprint)
+ require.NoError(t, err)
+ require.NotNil(t, recordedQuery)
+ assert.Equal(t, "query_embedding", recordedQuery.Purpose)
+
+ consentedEndpoint := cfg.Vector.Embeddings.Endpoint
+ cfg.Vector.Embeddings.Endpoint = "https://hosted.example.test/v1"
+ changedConsentSpec, err := configuredDocumentVectorConsentSpec(spec)
+ require.NoError(t, err)
+ assert.NotEqual(t, consentSpec.EgressFingerprint, changedConsentSpec.EgressFingerprint)
+ require.ErrorContains(t, requireDocumentVectorConsent(t.Context(), fixture.Store, spec), "not consented")
+ cfg.Vector.Embeddings.Endpoint = consentedEndpoint
+ require.NoError(t, requireDocumentVectorConsent(t.Context(), fixture.Store, spec))
+
+ generation, _, err := fixture.Store.EnsureDocumentVectorGeneration(t.Context(), spec)
+ require.NoError(t, err)
+ status := newDocumentsCmd(deps)
+ var statusOutput bytes.Buffer
+ status.SetOut(&statusOutput)
+ status.SetArgs([]string{documentVectorsSubcommand, statusValue})
+ require.NoError(t, status.ExecuteContext(t.Context()))
+ assert.Contains(t, statusOutput.String(), "building_generation=")
+ assert.Contains(t, statusOutput.String(), "state=building")
+ assert.Contains(t, statusOutput.String(), "pending=0 retryable=0 terminal=0 ready_live=0 obsolete=0 cleanup_pending=0")
+ assert.Contains(t, statusOutput.String(), "coverage_required=0 coverage_ready=0")
+
+ retry := newDocumentsCmd(deps)
+ retry.SetOut(&bytes.Buffer{})
+ retry.SetArgs([]string{documentVectorsSubcommand, "retry", "--generation-id", "999", "--limit", "1"})
+ require.Error(t, retry.ExecuteContext(t.Context()))
+
+ retire := newDocumentsCmd(deps)
+ retire.SetOut(&bytes.Buffer{})
+ retire.SetArgs([]string{documentVectorsSubcommand, cliEmbeddingsOperationRetire, "--generation-id", fmtInt64(generation.ID), "--yes"})
+ require.NoError(t, retire.ExecuteContext(t.Context()))
+ assert.Zero(t, runtimeCalls)
+}
+
+func TestDocumentVectorStatusWorksWhenEmbeddingsAreDisabled(t *testing.T) {
+ markDaemonCLISubprocessForTest(t)
+ previous := cfg
+ t.Cleanup(func() { cfg = previous })
+ cfg = config.NewDefaultConfig()
+ runtimeCalls := 0
+ command := newDocumentsCmd(documentsCommandDeps{
+ runDocumentVector: func(context.Context, *store.Store, int64, int) (vectordocument.ReconcileResult, error) {
+ runtimeCalls++
+ return vectordocument.ReconcileResult{}, nil
+ },
+ })
+ var output bytes.Buffer
+ command.SetOut(&output)
+ command.SetArgs([]string{documentVectorsSubcommand, statusValue, "--json"})
+ require.NoError(t, command.ExecuteContext(t.Context()))
+ assert.JSONEq(t, `{"enabled":false}`, output.String())
+ assert.Zero(t, runtimeCalls)
+}
+
+func TestConfiguredDocumentVectorSpecRejectsInvalidDisabledEmbeddingPolicy(t *testing.T) {
+ fixture, _ := documentVectorCommandFixture(t)
+ base := *cfg
+ tests := []struct {
+ name string
+ mutate func(*config.Config)
+ wantErr string
+ }{
+ {
+ name: "endpoint",
+ mutate: func(c *config.Config) {
+ c.Vector.Embeddings.Endpoint = "::not-a-url"
+ },
+ wantErr: "endpoint",
+ },
+ {
+ name: "dimension",
+ mutate: func(c *config.Config) {
+ c.Vector.Embeddings.Dimension = 0
+ },
+ wantErr: "dimension",
+ },
+ {
+ name: "input limit",
+ mutate: func(c *config.Config) {
+ c.Vector.Embeddings.MaxInputChars = -1
+ },
+ wantErr: "max_input_chars",
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ changed := base
+ changed.Vector.Enabled = false
+ test.mutate(&changed)
+ cfg = &changed
+ var err error
+ require.NotPanics(t, func() {
+ _, err = configuredDocumentVectorSpec(t.Context(), fixture.Store)
+ })
+ require.ErrorContains(t, err, test.wantErr)
+ })
+ }
+}
+
+func TestDocumentVectorStatusWorksBeforeExtractionTargetExists(t *testing.T) {
+ markDaemonCLISubprocessForTest(t)
+ previous := cfg
+ t.Cleanup(func() { cfg = previous })
+ cfg = config.NewDefaultConfig()
+ cfg.Vector.Enabled = true
+ cfg.Vector.Embeddings.Endpoint = "https://embeddings.example.test/v1"
+ cfg.Vector.Embeddings.Model = "embed-test"
+ cfg.Vector.Embeddings.Dimension = 3
+ cfg.Attachments.Documents.Index.Embeddings.Enabled = true
+ fixture := storetest.New(t)
+ command := newDocumentsCmd(documentsCommandDeps{
+ openStore: func() (*store.Store, func(), error) { return fixture.Store, func() {}, nil },
+ })
+ var output bytes.Buffer
+ command.SetOut(&output)
+ command.SetArgs([]string{documentVectorsSubcommand, statusValue, "--json"})
+ require.NoError(t, command.ExecuteContext(t.Context()))
+ assert.JSONEq(t, `{"enabled":true,"configured":false}`, output.String())
+}
+
+func TestDocumentVectorProviderCommandsUseRuntimeAndValidateBounds(t *testing.T) {
+ fixture, spec := documentVectorCommandFixture(t)
+ consentSpec, err := configuredDocumentVectorConsentSpec(spec)
+ require.NoError(t, err)
+ _, _, err = fixture.Store.RecordDocumentVectorConsent(t.Context(), consentSpec, time.Now())
+ require.NoError(t, err)
+ var calls []int64
+ deps := documentsCommandDeps{
+ openStore: func() (*store.Store, func(), error) { return fixture.Store, func() {}, nil },
+ runDocumentVector: func(_ context.Context, _ *store.Store, generationID int64, _ int) (vectordocument.ReconcileResult, error) {
+ calls = append(calls, generationID)
+ return vectordocument.ReconcileResult{}, nil
+ },
+ }
+
+ invalid := newDocumentsCmd(deps)
+ invalid.SetArgs([]string{documentVectorsSubcommand, documentBuildSubcommand, "--limit", "0"})
+ require.ErrorContains(t, invalid.ExecuteContext(t.Context()), "limit")
+ assert.Empty(t, calls)
+
+ build := newDocumentsCmd(deps)
+ build.SetOut(&bytes.Buffer{})
+ build.SetArgs([]string{documentVectorsSubcommand, documentBuildSubcommand, "--limit", "1"})
+ require.NoError(t, build.ExecuteContext(t.Context()))
+ require.Len(t, calls, 1)
+ building, err := fixture.Store.GetBuildingDocumentVectorGeneration(t.Context())
+ require.NoError(t, err)
+ require.NotNil(t, building)
+
+ resume := newDocumentsCmd(deps)
+ resume.SetOut(&bytes.Buffer{})
+ resume.SetArgs([]string{documentVectorsSubcommand, cmdUseResume, "--generation-id", fmtInt64(building.ID), "--limit", "1"})
+ require.NoError(t, resume.ExecuteContext(t.Context()))
+ assert.Len(t, calls, 2)
+
+ require.NoError(t, fixture.Store.ActivateDocumentVectorGeneration(t.Context(), building.ID, time.Now()))
+ rebuild := newDocumentsCmd(deps)
+ rebuild.SetOut(&bytes.Buffer{})
+ rebuild.SetArgs([]string{documentVectorsSubcommand, "rebuild", "--generation-id", fmtInt64(building.ID), "--limit", "1", "--yes"})
+ require.NoError(t, rebuild.ExecuteContext(t.Context()))
+ assert.Len(t, calls, 3)
+}
+
+func TestDocumentVectorResumeRunsBoundedCleanupForRetiredGeneration(t *testing.T) {
+ fixture, spec := documentVectorCommandFixture(t)
+ generation, _, err := fixture.Store.EnsureDocumentVectorGeneration(t.Context(), spec)
+ require.NoError(t, err)
+ token := strings.Repeat("8", 64)
+ _, err = fixture.Store.DB().Exec(fixture.Store.Rebind(`
+ INSERT INTO document_vector_publications
+ (generation_id, extraction_id, extraction_profile_id, canonical_blob_hash,
+ extraction_input_key, chunk_id, chunk_key, chunk_checksum, source_sequence, token, state)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ready')`), generation.ID,
+ "manual-cleanup-extraction", spec.TargetExtractionProfileID, strings.Repeat("a", 64),
+ "original", 1, "manual-cleanup-chunk", "manual-cleanup-checksum", 1, token)
+ require.NoError(t, err)
+ retired, err := fixture.Store.RetireDocumentVectorGeneration(t.Context(), generation.ID, time.Now())
+ require.NoError(t, err)
+ require.True(t, retired)
+
+ backend := &commandDocumentVectorBackend{}
+ deps := documentsCommandDeps{
+ openStore: func() (*store.Store, func(), error) { return fixture.Store, func() {}, nil },
+ runDocumentVector: func(ctx context.Context, st *store.Store, generationID int64, limit int) (vectordocument.ReconcileResult, error) {
+ return runDocumentVectorWithFeatures(ctx, st, &vectorFeatures{
+ DocumentBackend: backend,
+ Cfg: cfg.Vector,
+ }, generationID, limit)
+ },
+ }
+ command := newDocumentsCmd(deps)
+ var output bytes.Buffer
+ command.SetOut(&output)
+ command.SetArgs([]string{
+ documentVectorsSubcommand, cmdUseResume,
+ "--generation-id", fmtInt64(generation.ID), "--limit", "1",
+ })
+
+ require.NoError(t, command.ExecuteContext(t.Context()))
+ var result vectordocument.ReconcileResult
+ require.NoError(t, json.Unmarshal(output.Bytes(), &result))
+ assert.True(t, result.Purged)
+ assert.True(t, result.Converged)
+ assert.Equal(t, [][]string{{token}}, backend.deletes)
+ _, err = fixture.Store.GetDocumentVectorGeneration(t.Context(), generation.ID)
+ require.ErrorContains(t, err, "not found")
+}
+
+func TestDocumentVectorWorkerCheckpointsPartialErrorResult(t *testing.T) {
+ wantErr := errors.New("provider partial failure")
+ runResult := vectordocument.RunResult{
+ ProviderCalls: 1, ProviderDocuments: 2, ProviderChunks: 3, ProviderInputChars: 44,
+ AfterGenerationID: 7, AfterChunkID: 81,
+ }
+ worker := checkpointingDocumentVectorWorker{
+ worker: fakeDocumentVectorWorkerRunner{result: runResult, err: wantErr},
+ checkpointer: &fakeDocumentVectorCheckpointer{},
+ fingerprint: strings.Repeat("a", 64),
+ now: func() time.Time { return time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) },
+ }
+ result, err := worker.Run(t.Context(), 7, 10)
+ require.ErrorIs(t, err, wantErr)
+ assert.Equal(t, runResult, result)
+ checkpoint, ok := worker.checkpointer.(*fakeDocumentVectorCheckpointer)
+ require.True(t, ok)
+ require.Len(t, checkpoint.calls, 1)
+ assert.Equal(t, strings.Repeat("a", 64), checkpoint.calls[0].fingerprint)
+ assert.Equal(t, int64(81), checkpoint.calls[0].afterChunkID)
+ assert.False(t, checkpoint.calls[0].exhausted)
+ assert.Equal(t, store.DocumentVectorUsageDelta{
+ ProviderCalls: 1, ProviderDocuments: 2, ProviderChunks: 3, ProviderInputChars: 44,
+ }, checkpoint.calls[0].delta)
+}
+
+func TestDocumentVectorWorkerOwnersAreUniqueWithinOneProcess(t *testing.T) {
+ first := nextDocumentVectorWorkerOwner()
+ second := nextDocumentVectorWorkerOwner()
+ assert.NotEqual(t, first, second)
+ assert.True(t, strings.HasPrefix(first, "document-vector-"))
+ _, err := uuid.Parse(strings.TrimPrefix(first, "document-vector-"))
+ require.NoError(t, err)
+}
+
+type fakeDocumentVectorWorkerRunner struct {
+ result vectordocument.RunResult
+ err error
+}
+
+func (r fakeDocumentVectorWorkerRunner) Run(context.Context, vectordocument.GenerationID, int) (vectordocument.RunResult, error) {
+ return r.result, r.err
+}
+
+type fakeDocumentVectorCheckpoint struct {
+ afterChunkID int64
+ exhausted bool
+ fingerprint string
+ delta store.DocumentVectorUsageDelta
+}
+
+type fakeDocumentVectorCheckpointer struct {
+ calls []fakeDocumentVectorCheckpoint
+}
+
+func (c *fakeDocumentVectorCheckpointer) CheckpointDocumentVectorBuildForFingerprint(_ context.Context, _ int64, fingerprint string, afterChunkID int64, exhausted bool, delta store.DocumentVectorUsageDelta, _ time.Time) error {
+ c.calls = append(c.calls, fakeDocumentVectorCheckpoint{
+ fingerprint: fingerprint, afterChunkID: afterChunkID, exhausted: exhausted, delta: delta,
+ })
+ return nil
+}
+
+func TestScheduledDocumentVectorRotationRetiresObsoleteBuildingBeforeDesiredBuild(t *testing.T) {
+ fixture, desired := documentVectorCommandFixture(t)
+ consentSpec, err := configuredDocumentVectorConsentSpec(desired)
+ require.NoError(t, err)
+ _, _, err = fixture.Store.RecordDocumentVectorConsent(t.Context(), consentSpec, time.Now())
+ require.NoError(t, err)
+ activeSpec := desired
+ activeSpec.Fingerprint = strings.Repeat("1", 64)
+ active, _, err := fixture.Store.EnsureDocumentVectorGeneration(t.Context(), activeSpec)
+ require.NoError(t, err)
+ require.NoError(t, fixture.Store.ActivateDocumentVectorGeneration(t.Context(), active.ID, time.Now()))
+ obsoleteSpec := desired
+ obsoleteSpec.Fingerprint = strings.Repeat("2", 64)
+ obsolete, _, err := fixture.Store.EnsureDocumentVectorGeneration(t.Context(), obsoleteSpec)
+ require.NoError(t, err)
+ for index := range 3 {
+ _, err = fixture.Store.DB().Exec(fixture.Store.Rebind(`
+ INSERT INTO document_vector_publications
+ (generation_id, extraction_id, extraction_profile_id, canonical_blob_hash,
+ extraction_input_key, chunk_id, chunk_key, chunk_checksum, source_sequence, token, state)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ready')`), obsolete.ID,
+ fmt.Sprintf("obsolete-%d", index), desired.TargetExtractionProfileID, strings.Repeat("a", 64),
+ "original", index+1, fmt.Sprintf("chunk-%d", index), fmt.Sprintf("checksum-%d", index),
+ 1, fmt.Sprintf("%064x", index+1))
+ require.NoError(t, err)
+ }
+ client := &commandDocumentSemanticClient{}
+ backend := &commandDocumentVectorBackend{}
+ vf := &vectorFeatures{
+ DocumentBackend: backend, SemanticClient: client, Cfg: cfg.Vector,
+ }
+
+ require.NoError(t, runScheduledDocumentVectorGeneration(t.Context(), fixture.Store, vf, 2))
+ stillActive, err := fixture.Store.GetActiveDocumentVectorGeneration(t.Context())
+ require.NoError(t, err)
+ require.NotNil(t, stillActive)
+ assert.Equal(t, active.ID, stillActive.ID)
+ retired, err := fixture.Store.GetDocumentVectorGeneration(t.Context(), obsolete.ID)
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentVectorGenerationRetired, retired.State)
+ require.Len(t, backend.deletes, 1)
+ assert.Len(t, backend.deletes[0], 2)
+
+ require.NoError(t, runScheduledDocumentVectorGeneration(t.Context(), fixture.Store, vf, 2))
+ _, err = fixture.Store.GetDocumentVectorGeneration(t.Context(), obsolete.ID)
+ require.ErrorContains(t, err, "not found")
+ require.Len(t, backend.deletes, 2)
+ assert.Len(t, backend.deletes[1], 1)
+ stillActive, err = fixture.Store.GetActiveDocumentVectorGeneration(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, active.ID, stillActive.ID)
+
+ require.NoError(t, runScheduledDocumentVectorGeneration(t.Context(), fixture.Store, vf, 2))
+ newActive, err := fixture.Store.GetActiveDocumentVectorGeneration(t.Context())
+ require.NoError(t, err)
+ require.NotNil(t, newActive)
+ assert.Equal(t, desired, newActive.DocumentVectorGenerationSpec)
+ assert.Zero(t, client.documentCalls)
+}
+
+func TestScheduledDocumentVectorCleansRetiredWithoutConsentOrProvider(t *testing.T) {
+ fixture, spec := documentVectorCommandFixture(t)
+ generation, _, err := fixture.Store.EnsureDocumentVectorGeneration(t.Context(), spec)
+ require.NoError(t, err)
+ token := strings.Repeat("9", 64)
+ _, err = fixture.Store.DB().Exec(fixture.Store.Rebind(`
+ INSERT INTO document_vector_publications
+ (generation_id, extraction_id, extraction_profile_id, canonical_blob_hash,
+ extraction_input_key, chunk_id, chunk_key, chunk_checksum, source_sequence, token, state)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ready')`), generation.ID,
+ "retired-extraction", spec.TargetExtractionProfileID, strings.Repeat("a", 64),
+ "original", 1, "retired-chunk", "retired-checksum", 1, token)
+ require.NoError(t, err)
+ retired, err := fixture.Store.RetireDocumentVectorGeneration(t.Context(), generation.ID, time.Now())
+ require.NoError(t, err)
+ require.True(t, retired)
+ backend := &commandDocumentVectorBackend{}
+ vf := &vectorFeatures{DocumentBackend: backend, Cfg: cfg.Vector}
+
+ require.NoError(t, runScheduledDocumentVectorGeneration(t.Context(), fixture.Store, vf, 10))
+
+ assert.Equal(t, [][]string{{token}}, backend.deletes)
+ _, err = fixture.Store.GetDocumentVectorGeneration(t.Context(), generation.ID)
+ require.ErrorContains(t, err, "not found")
+ require.NoError(t, runScheduledDocumentVectorGeneration(t.Context(), fixture.Store, vf, 10))
+}
+
+func TestScheduledDocumentVectorObservesConsentRecordedAfterRuntimeInitialization(t *testing.T) {
+ fixture, spec := documentVectorCommandFixture(t)
+ vf := &vectorFeatures{
+ DocumentBackend: &commandDocumentVectorBackend{},
+ SemanticClient: &commandDocumentSemanticClient{},
+ Cfg: cfg.Vector,
+ }
+
+ require.NoError(t, runScheduledDocumentVectorGeneration(t.Context(), fixture.Store, vf, 10))
+ active, err := fixture.Store.GetActiveDocumentVectorGeneration(t.Context())
+ require.NoError(t, err)
+ assert.Nil(t, active)
+
+ consentSpec, err := configuredDocumentVectorConsentSpec(spec)
+ require.NoError(t, err)
+ _, _, err = fixture.Store.RecordDocumentVectorConsent(t.Context(), consentSpec, time.Now())
+ require.NoError(t, err)
+
+ require.NoError(t, runScheduledDocumentVectorGeneration(t.Context(), fixture.Store, vf, 10))
+ active, err = fixture.Store.GetActiveDocumentVectorGeneration(t.Context())
+ require.NoError(t, err)
+ require.NotNil(t, active)
+ assert.Equal(t, spec, active.DocumentVectorGenerationSpec)
+}
+
+func TestScheduledDocumentVectorCleansObsoleteActiveTokensAfterCoverageIsComplete(t *testing.T) {
+ fixture, desired := documentVectorCommandFixture(t)
+ consentSpec, err := configuredDocumentVectorConsentSpec(desired)
+ require.NoError(t, err)
+ _, _, err = fixture.Store.RecordDocumentVectorConsent(t.Context(), consentSpec, time.Now())
+ require.NoError(t, err)
+ active, _, err := fixture.Store.EnsureDocumentVectorGeneration(t.Context(), desired)
+ require.NoError(t, err)
+ require.NoError(t, fixture.Store.ActivateDocumentVectorGeneration(t.Context(), active.ID, time.Now()))
+ token := strings.Repeat("c", 64)
+ _, err = fixture.Store.DB().Exec(fixture.Store.Rebind(`
+ INSERT INTO document_vector_publications
+ (generation_id, extraction_id, extraction_profile_id, canonical_blob_hash,
+ extraction_input_key, chunk_id, chunk_key, chunk_checksum, source_sequence, token, state)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ready')`), active.ID,
+ "deleted-extraction", desired.TargetExtractionProfileID, strings.Repeat("d", 64),
+ "original", 1, "deleted-chunk", "deleted-checksum", 1, token)
+ require.NoError(t, err)
+ coverage, err := fixture.Store.GetDocumentVectorCoverage(t.Context(), active.ID)
+ require.NoError(t, err)
+ assert.True(t, coverage.Complete())
+ status, err := fixture.Store.GetDocumentVectorGenerationStatus(t.Context(), active.ID, "", 10)
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), status.CleanupPending)
+
+ client := &commandDocumentSemanticClient{}
+ backend := &commandDocumentVectorBackend{}
+ vf := &vectorFeatures{
+ DocumentBackend: backend, SemanticClient: client, Cfg: cfg.Vector,
+ }
+ require.NoError(t, runScheduledDocumentVectorGeneration(t.Context(), fixture.Store, vf, 10))
+ require.Equal(t, [][]string{{token}}, backend.deletes)
+ assert.Zero(t, client.documentCalls)
+ status, err = fixture.Store.GetDocumentVectorGenerationStatus(t.Context(), active.ID, "", 10)
+ require.NoError(t, err)
+ assert.Zero(t, status.CleanupPending)
+ var publications int
+ require.NoError(t, fixture.Store.DB().QueryRow(fixture.Store.Rebind(
+ `SELECT COUNT(*) FROM document_vector_publications WHERE generation_id = ?`), active.ID).Scan(&publications))
+ assert.Zero(t, publications)
+
+ require.NoError(t, runScheduledDocumentVectorGeneration(t.Context(), fixture.Store, vf, 10))
+ assert.Len(t, backend.deletes, 1, "a converged replay does not re-delete finalized tokens")
+ assert.Zero(t, client.documentCalls)
+}
+
+type commandDocumentSemanticClient struct{ documentCalls int }
+
+func (*commandDocumentSemanticClient) EmbedQuery(context.Context, string) ([]float32, error) {
+ return []float32{1, 0, 0}, nil
+}
+
+func (c *commandDocumentSemanticClient) EmbedDocuments(context.Context, []vector.DocumentInput) ([][][]float32, error) {
+ c.documentCalls++
+ return nil, nil
+}
+
+type commandDocumentVectorBackend struct{ deletes [][]string }
+
+func (*commandDocumentVectorBackend) PutUnpublished(context.Context, vectordocument.GenerationID, int, []vectordocument.Embedding) error {
+ return nil
+}
+
+func (b *commandDocumentVectorBackend) DeleteTokens(_ context.Context, _ vectordocument.GenerationID, tokens []string) error {
+ b.deletes = append(b.deletes, append([]string(nil), tokens...))
+ return nil
+}
+
+func (*commandDocumentVectorBackend) Search(context.Context, vectordocument.GenerationID, int, []float32, int) ([]vectordocument.Hit, error) {
+ return nil, nil
+}
+
+func documentVectorCommandFixture(t *testing.T) (*storetest.Fixture, store.DocumentVectorGenerationSpec) {
+ t.Helper()
+ markDaemonCLISubprocessForTest(t)
+ previous := cfg
+ t.Cleanup(func() { cfg = previous })
+ c := config.NewDefaultConfig()
+ c.Vector.Enabled = true
+ c.Vector.Embeddings.Endpoint = "https://embeddings.example.test/v1"
+ c.Vector.Embeddings.APIKeyEnv = "SYNTHETIC_EMBEDDING_KEY"
+ c.Vector.Embeddings.Model = "embed-test"
+ c.Vector.Embeddings.Dimension = 3
+ c.Vector.Embeddings.MaxInputChars = 4096
+ c.Attachments.Documents.Index.Embeddings.Enabled = true
+ c.Attachments.Documents.Index.Embeddings.Profile = "vector.embeddings"
+ cfg = c
+ fixture := storetest.New(t)
+ fingerprint := strings.Repeat("7", 64)
+ profile := store.DocumentExtractionProfile{
+ ID: "profile-" + fingerprint, Fingerprint: fingerprint, Provider: "synthetic",
+ Endpoint: "https://documents.example.test/v1", Region: localValue, Model: "extract-test",
+ RetentionPosture: "standard", TrainingPosture: "opted-out",
+ AllowedMediaTypes: []string{"application/pdf"}, PolicyJSON: []byte(`{"policy":1}`),
+ }
+ _, err := fixture.Store.EnsureDocumentExtractionProfile(t.Context(), profile)
+ require.NoError(t, err)
+ _, err = fixture.Store.DB().Exec(fixture.Store.Rebind(`UPDATE document_index_state SET target_profile_id = ? WHERE singleton = 1`), profile.ID)
+ require.NoError(t, err)
+ spec, err := desiredDocumentVectorSpec(t.Context(), fixture.Store)
+ require.NoError(t, err)
+ return fixture, spec
+}
+
+func fmtInt64(value int64) string { return strconv.FormatInt(value, 10) }
diff --git a/cmd/msgvault/cmd/embed_vector_test.go b/cmd/msgvault/cmd/embed_vector_test.go
index 1dcaee372..f96fcdad0 100644
--- a/cmd/msgvault/cmd/embed_vector_test.go
+++ b/cmd/msgvault/cmd/embed_vector_test.go
@@ -8,9 +8,12 @@ import (
"database/sql"
"errors"
"fmt"
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"strings"
+ "sync/atomic"
"testing"
"time"
@@ -282,7 +285,9 @@ func TestActivateBuiltGeneration_ContextualLifecycleErrorsDoNotActivateAnotherGe
})
}
-func setupVectorFeaturesFixture(t *testing.T, apiFormat vector.EmbeddingAPIFormat, readOnly bool) *vectorFeatures {
+func setupVectorFeaturesFixture(
+ t *testing.T, apiFormat vector.EmbeddingAPIFormat, readOnly bool, mutate ...func(*config.Config),
+) *vectorFeatures {
t.Helper()
dir := t.TempDir()
mainPath := filepath.Join(dir, "msgvault.db")
@@ -294,15 +299,41 @@ func setupVectorFeaturesFixture(t *testing.T, apiFormat vector.EmbeddingAPIForma
c.Vector.Embeddings.Model = "text-embedding-test"
c.Vector.Embeddings.Dimension = 4
c.Vector.Embeddings.APIFormat = apiFormat
+ c.Attachments.Documents.Index.Embeddings.Enabled = true
+ c.Attachments.Documents.Index.Embeddings.Profile = "vector.embeddings"
if apiFormat == vector.APIFormatVoyageContextual {
c.Vector.Embeddings.Model = "voyage-context-4"
}
+ for _, apply := range mutate {
+ apply(c)
+ }
withTestConfig(t, c)
s, err := store.Open(mainPath)
require.NoError(t, err)
t.Cleanup(func() { _ = s.Close() })
require.NoError(t, s.InitSchema())
+ fingerprint := strings.Repeat("d", 64)
+ profile := store.DocumentExtractionProfile{
+ ID: "profile-" + fingerprint, Fingerprint: fingerprint, Provider: "synthetic",
+ Endpoint: "https://documents.example.test/v1", Region: localValue, Model: "extract-test",
+ RetentionPosture: "standard", TrainingPosture: "opted-out",
+ AllowedMediaTypes: []string{"application/pdf"}, PolicyJSON: []byte(`{"policy":1}`),
+ }
+ _, err = s.EnsureDocumentExtractionProfile(t.Context(), profile)
+ require.NoError(t, err)
+ _, err = s.DB().Exec(s.Rebind(`UPDATE document_index_state SET target_profile_id = ? WHERE singleton = 1`), profile.ID)
+ require.NoError(t, err)
+ spec, err := configuredDocumentVectorSpec(t.Context(), s)
+ require.NoError(t, err)
+ documentConsent, err := configuredDocumentVectorConsentSpec(spec)
+ require.NoError(t, err)
+ queryConsent, err := configuredDocumentVectorQueryConsentSpec(spec)
+ require.NoError(t, err)
+ for _, consentSpec := range []store.DocumentVectorConsentSpec{documentConsent, queryConsent} {
+ _, _, err = s.RecordDocumentVectorConsent(t.Context(), consentSpec, time.Now())
+ require.NoError(t, err)
+ }
vf, err := setupVectorFeatures(t.Context(), s, mainPath, readOnly)
require.NoError(t, err)
@@ -311,6 +342,35 @@ func setupVectorFeaturesFixture(t *testing.T, apiFormat vector.EmbeddingAPIForma
return vf
}
+func TestSetupVectorFeaturesDocumentClientRejectsCrossOriginRedirects(t *testing.T) {
+ for _, apiFormat := range []vector.EmbeddingAPIFormat{vector.APIFormatOpenAI, vector.APIFormatVoyageContextual} {
+ t.Run(string(apiFormat), func(t *testing.T) {
+ var targetRequests atomic.Int32
+ target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ targetRequests.Add(1)
+ }))
+ t.Cleanup(target.Close)
+ origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Location", target.URL+"/outside-consent")
+ w.WriteHeader(http.StatusTemporaryRedirect)
+ }))
+ t.Cleanup(origin.Close)
+
+ vf := setupVectorFeaturesFixture(t, apiFormat, false, func(c *config.Config) {
+ c.Vector.Embeddings.Endpoint = origin.URL
+ })
+ _, err := vf.SemanticClient.EmbedDocuments(t.Context(), []vector.DocumentInput{{
+ Chunks: []string{"private attachment text"},
+ }})
+
+ require.ErrorIs(t, err, embed.ErrEmbeddingProviderRedirect)
+ _, err = vf.DocumentQueryClient.EmbedQuery(t.Context(), "private query text")
+ require.ErrorIs(t, err, embed.ErrEmbeddingProviderRedirect)
+ assert.Zero(t, targetRequests.Load(), "attachment text must not reach the redirect target")
+ })
+ }
+}
+
func TestSetupVectorFeatures_SelectsRunnerByAPIFormat(t *testing.T) {
t.Run("implicit OpenAI", func(t *testing.T) {
vf := setupVectorFeaturesFixture(t, "", false)
diff --git a/cmd/msgvault/cmd/import_imessage.go b/cmd/msgvault/cmd/import_imessage.go
index f6f67de8f..dd50aa0c3 100644
--- a/cmd/msgvault/cmd/import_imessage.go
+++ b/cmd/msgvault/cmd/import_imessage.go
@@ -356,7 +356,7 @@ func resolveImessageSource(s *store.Store) (*store.Source, error) {
if err == nil && len(sources) > 0 {
var best *store.Source
for _, src := range sources {
- if src.Identifier != "local" {
+ if src.Identifier != localValue {
if best == nil || src.ID > best.ID {
best = src
}
@@ -367,7 +367,7 @@ func resolveImessageSource(s *store.Store) (*store.Source, error) {
}
return sources[0], nil
}
- return s.GetOrCreateSource("apple_messages", "local")
+ return s.GetOrCreateSource("apple_messages", localValue)
}
func init() {
diff --git a/cmd/msgvault/cmd/import_imessage_test.go b/cmd/msgvault/cmd/import_imessage_test.go
index 0a192ea93..71fe1a2cd 100644
--- a/cmd/msgvault/cmd/import_imessage_test.go
+++ b/cmd/msgvault/cmd/import_imessage_test.go
@@ -42,14 +42,14 @@ func TestResolveImessageSource(t *testing.T) {
{
name: "no existing sources — creates local",
seedSources: nil,
- wantIdentifier: "local",
+ wantIdentifier: localValue,
},
{
name: "only local exists — reuses local",
seedSources: []struct{ sourceType, identifier string }{
- {"apple_messages", "local"},
+ {"apple_messages", localValue},
},
- wantIdentifier: "local",
+ wantIdentifier: localValue,
},
{
name: "only legacy exists — reuses legacy",
@@ -61,7 +61,7 @@ func TestResolveImessageSource(t *testing.T) {
{
name: "both legacy and local — prefers legacy",
seedSources: []struct{ sourceType, identifier string }{
- {"apple_messages", "local"},
+ {"apple_messages", localValue},
{"apple_messages", "+15551234567"},
},
wantIdentifier: "+15551234567",
@@ -69,7 +69,7 @@ func TestResolveImessageSource(t *testing.T) {
{
name: "multiple legacy — picks first non-local",
seedSources: []struct{ sourceType, identifier string }{
- {"apple_messages", "local"},
+ {"apple_messages", localValue},
{"apple_messages", "alice@icloud.com"},
{"apple_messages", "+15551234567"},
},
diff --git a/cmd/msgvault/cmd/person_provider.go b/cmd/msgvault/cmd/person_provider.go
index 0a4483269..41a7a7798 100644
--- a/cmd/msgvault/cmd/person_provider.go
+++ b/cmd/msgvault/cmd/person_provider.go
@@ -165,7 +165,7 @@ func newPersonProviderConsentCommand(deps personProviderCommandDeps) *cobra.Comm
var jsonOutput bool
var semanticEmbeddings bool
command := &cobra.Command{
- Use: "consent",
+ Use: cmdUseConsent,
Short: "Consent to the exact people inference policy",
Args: cobra.NoArgs,
RunE: func(command *cobra.Command, args []string) error {
diff --git a/cmd/msgvault/cmd/root.go b/cmd/msgvault/cmd/root.go
index b57ca7d6c..bc8aa5a60 100644
--- a/cmd/msgvault/cmd/root.go
+++ b/cmd/msgvault/cmd/root.go
@@ -654,7 +654,7 @@ func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: ~/.msgvault/config.toml)")
rootCmd.PersistentFlags().StringVar(&homeDir, "home", "", "home directory (overrides MSGVAULT_HOME)")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output (implies --log-level=debug)")
- rootCmd.PersistentFlags().BoolVar(&useLocal, "local", false, "use local daemon instead of configured remote")
+ rootCmd.PersistentFlags().BoolVar(&useLocal, localValue, false, "use local daemon instead of configured remote")
rootCmd.PersistentFlags().StringVar(&logFile, "log-file", "",
"override log file path (default: /logs/msgvault-YYYY-MM-DD.log)")
rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "",
diff --git a/cmd/msgvault/cmd/serve.go b/cmd/msgvault/cmd/serve.go
index 2afbaf14f..4581bd12b 100644
--- a/cmd/msgvault/cmd/serve.go
+++ b/cmd/msgvault/cmd/serve.go
@@ -1163,6 +1163,7 @@ var _ api.ChangedMessageLister = (*storeAPIAdapter)(nil)
var _ api.ArchiveIdentifier = (*storeAPIAdapter)(nil)
var _ api.DocumentSearchStore = (*storeAPIAdapter)(nil)
var _ api.DocumentStatusStore = (*storeAPIAdapter)(nil)
+var _ api.DocumentVectorStatusStore = (*storeAPIAdapter)(nil)
var _ api.ActivityStore = (*storeAPIAdapter)(nil)
func (a *storeAPIAdapter) ContactStateContext(
@@ -1282,6 +1283,24 @@ func (a *storeAPIAdapter) CountIncompleteDocumentExtractionRebuild(
)
}
+func (a *storeAPIAdapter) GetDocumentVectorTargetProfileID(ctx context.Context) (string, error) {
+ return a.store.GetDocumentVectorTargetProfileID(ctx)
+}
+
+func (a *storeAPIAdapter) GetDocumentVectorOperationsStatus(
+ ctx context.Context,
+ configured store.DocumentVectorGenerationSpec,
+ documentEgressFingerprint, queryEgressFingerprint string,
+ generationID int64,
+ afterToken string,
+ limit int,
+) (store.DocumentVectorOperationsStatus, error) {
+ return a.store.GetDocumentVectorOperationsStatus(
+ ctx, configured, documentEgressFingerprint, queryEgressFingerprint,
+ generationID, afterToken, limit,
+ )
+}
+
func (a *storeAPIAdapter) GetStats() (*api.StoreStats, error) {
return a.store.GetStats()
}
diff --git a/cmd/msgvault/cmd/serve_vector.go b/cmd/msgvault/cmd/serve_vector.go
index 40863aabb..e087e01e0 100644
--- a/cmd/msgvault/cmd/serve_vector.go
+++ b/cmd/msgvault/cmd/serve_vector.go
@@ -19,6 +19,7 @@ import (
"go.kenn.io/msgvault/internal/scheduler"
"go.kenn.io/msgvault/internal/store"
"go.kenn.io/msgvault/internal/vector"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
"go.kenn.io/msgvault/internal/vector/embed"
"go.kenn.io/msgvault/internal/vector/hybrid"
"go.kenn.io/msgvault/internal/vector/personsearch"
@@ -30,11 +31,13 @@ import (
const contextualDocumentUTF8Limit = 100_000
type embeddingRuntime struct {
- Runner scheduler.EmbedRunner
- QueryClient hybrid.EmbeddingClient
- PersonQueryClient personsearch.QueryEmbedder
- Convergence scheduler.ConvergenceChecker
- PersonGate vector.SemanticPersonEmbeddingGate
+ Runner scheduler.EmbedRunner
+ QueryClient hybrid.EmbeddingClient
+ PersonQueryClient personsearch.QueryEmbedder
+ Convergence scheduler.ConvergenceChecker
+ PersonGate vector.SemanticPersonEmbeddingGate
+ SemanticClient embed.SemanticClient
+ QuerySemanticClient embed.SemanticClient
}
type embeddingRuntimeDeps struct {
@@ -48,6 +51,8 @@ type embeddingRuntimeDeps struct {
Progress func(embed.ProgressReport)
Log *slog.Logger
PersonGate vector.SemanticPersonEmbeddingGate
+ DocumentGate embed.BeforeRequestFunc
+ QueryGate embed.BeforeRequestFunc
}
type legacyConvergenceChecker struct {
@@ -245,6 +250,14 @@ func newEmbeddingRuntime(vectorCfg vector.Config, deps embeddingRuntimeDeps) (*e
Timeout: vectorCfg.Embeddings.Timeout, MaxRetries: vectorCfg.Embeddings.MaxRetries,
}
messageClient := embed.NewClient(clientConfig)
+ documentClientConfig := clientConfig
+ documentClientConfig.BeforeRequest = deps.DocumentGate
+ documentClientConfig.RejectRedirects = true
+ documentClient := embed.NewClient(documentClientConfig)
+ queryClientConfig := clientConfig
+ queryClientConfig.BeforeRequest = deps.QueryGate
+ queryClientConfig.RejectRedirects = true
+ queryClient := embed.NewClient(queryClientConfig)
clientConfig.BeforeRequest = personGate.Check
personClient := embed.NewClient(clientConfig)
messageWorker := embed.NewWorker(embed.WorkerDeps{
@@ -263,7 +276,8 @@ func newEmbeddingRuntime(vectorCfg vector.Config, deps embeddingRuntimeDeps) (*e
worker := embed.NewGenerationWorker(messageWorker, personWorker)
return &embeddingRuntime{
Runner: worker, QueryClient: messageClient, PersonQueryClient: personClient,
- Convergence: checker, PersonGate: personGate,
+ Convergence: checker, PersonGate: personGate, SemanticClient: documentClient,
+ QuerySemanticClient: queryClient,
}, nil
case vector.APIFormatVoyageContextual:
if vectorCfg.Embeddings.Model != "voyage-context-4" {
@@ -282,6 +296,14 @@ func newEmbeddingRuntime(vectorCfg vector.Config, deps embeddingRuntimeDeps) (*e
MaxChunks: 16_000, MaxUTF8Bytes: contextualDocumentUTF8Limit},
}
messageClient := embed.NewVoyageClient(clientConfig)
+ documentClientConfig := clientConfig
+ documentClientConfig.BeforeRequest = deps.DocumentGate
+ documentClientConfig.RejectRedirects = true
+ documentClient := embed.NewVoyageClient(documentClientConfig)
+ queryClientConfig := clientConfig
+ queryClientConfig.BeforeRequest = deps.QueryGate
+ queryClientConfig.RejectRedirects = true
+ queryClient := embed.NewVoyageClient(queryClientConfig)
clientConfig.BeforeRequest = personGate.Check
personClient := embed.NewVoyageClient(clientConfig)
policy := embed.AssemblyPolicy{
@@ -304,7 +326,8 @@ func newEmbeddingRuntime(vectorCfg vector.Config, deps embeddingRuntimeDeps) (*e
worker := embed.NewGenerationWorker(messageWorker, personWorker)
return &embeddingRuntime{
Runner: worker, QueryClient: messageClient, PersonQueryClient: personClient,
- Convergence: checker, PersonGate: personGate,
+ Convergence: checker, PersonGate: personGate, SemanticClient: documentClient,
+ QuerySemanticClient: queryClient,
}, nil
default:
return nil, fmt.Errorf("unsupported embedding api format %q", vectorCfg.Embeddings.APIFormat)
@@ -439,9 +462,10 @@ func setupVectorFeatures(ctx context.Context, mainStore *store.Store, mainPath s
}
var (
- backend vector.Backend
- vectorsDB *sql.DB
- closeFn func() error
+ backend vector.Backend
+ documentBackend vectordocument.Backend
+ vectorsDB *sql.DB
+ closeFn func() error
)
if store.IsPostgresURL(mainPath) {
// Same database handle as the main store: pgvector embeddings
@@ -466,6 +490,7 @@ func setupVectorFeatures(ctx context.Context, mainStore *store.Store, mainPath s
return nil, fmt.Errorf("open pgvector backend: %w", err)
}
backend = pgb
+ documentBackend = pgb.DocumentBackend()
vectorsDB = pgb.DB()
closeFn = pgb.Close
} else {
@@ -492,11 +517,14 @@ func setupVectorFeatures(ctx context.Context, mainStore *store.Store, mainPath s
return nil, fmt.Errorf("open vectors.db: %w", err)
}
backend = sb
+ documentBackend = sb.DocumentBackend()
vectorsDB = sb.DB()
closeFn = sb.Close
}
- features := &vectorFeatures{Backend: backend, Cfg: vecCfg, Close: closeFn}
+ features := &vectorFeatures{
+ Backend: backend, DocumentBackend: documentBackend, Cfg: vecCfg, Close: closeFn,
+ }
if vecCfg.Enabled {
personGate := vector.NewPinnedExactSemanticPersonEmbeddingGate(
vecCfg, currentSemanticPersonVectorConfigSource(), mainStore,
@@ -504,7 +532,9 @@ func setupVectorFeatures(ctx context.Context, mainStore *store.Store, mainPath s
runtime, err := newEmbeddingRuntime(vecCfg, embeddingRuntimeDeps{
Backend: backend, VectorsDB: vectorsDB, MainDB: mainDB, Store: mainStore,
Rebind: dialect.Rebind, LastModifiedExpr: lastModifiedExpr, Log: logger,
- PersonGate: personGate,
+ PersonGate: personGate,
+ DocumentGate: documentVectorRequestGate(mainStore, vecCfg, "document_embedding"),
+ QueryGate: documentVectorRequestGate(mainStore, vecCfg, "query_embedding"),
})
if err != nil {
_ = closeFn()
@@ -512,6 +542,8 @@ func setupVectorFeatures(ctx context.Context, mainStore *store.Store, mainPath s
}
features.Runner = runtime.Runner
features.Convergence = runtime.Convergence
+ features.SemanticClient = runtime.SemanticClient
+ features.DocumentQueryClient = runtime.QuerySemanticClient
features.HybridEngine = hybrid.NewEngine(backend, mainDB, runtime.QueryClient, hybrid.Config{
ExpectedFingerprint: vecCfg.GenerationFingerprint(),
RRFK: vecCfg.Search.RRFK,
@@ -542,6 +574,45 @@ func setupVectorFeatures(ctx context.Context, mainStore *store.Store, mainPath s
Gate: runtime.PersonGate,
},
)
+ if cfg.Attachments.Documents.Index.Embeddings.Enabled {
+ target, targetErr := mainStore.GetDocumentVectorTargetProfileID(ctx)
+ if targetErr != nil && !errors.Is(targetErr, store.ErrDocumentVectorInvalidGenerationState) {
+ _ = closeFn()
+ return nil, fmt.Errorf("read document vector target profile: %w", targetErr)
+ }
+ if targetErr == nil {
+ generationFingerprint, fingerprintErr := vectordocument.Fingerprint(target, vecCfg)
+ if fingerprintErr != nil {
+ _ = closeFn()
+ return nil, fingerprintErr
+ }
+ desired := store.DocumentVectorGenerationSpec{
+ Fingerprint: generationFingerprint,
+ TargetExtractionProfileID: target,
+ EmbeddingProfile: "vector.embeddings",
+ Model: vecCfg.Embeddings.Model,
+ Dimension: vecCfg.Embeddings.Dimension,
+ }
+ queryEgressFingerprint, queryErr := vectordocument.QueryEgressFingerprint(target, vecCfg)
+ if queryErr != nil {
+ _ = closeFn()
+ return nil, queryErr
+ }
+ queryConsent, queryErr := mainStore.GetDocumentVectorConsent(ctx, queryEgressFingerprint)
+ if queryErr != nil {
+ _ = closeFn()
+ return nil, fmt.Errorf("read document query consent: %w", queryErr)
+ }
+ if queryConsent != nil && queryConsent.DocumentVectorGenerationSpec == desired &&
+ queryConsent.EgressFingerprint == queryEgressFingerprint &&
+ queryConsent.Purpose == "query_embedding" {
+ features.DocumentSearch = vectordocument.NewSearchService(vectordocument.SearchDeps{
+ Ledger: mainStore, Embedder: runtime.QuerySemanticClient, Backend: documentBackend,
+ ExpectedFingerprint: desired.Fingerprint,
+ })
+ }
+ }
+ }
}
if vecCfg.Multimodal.Enabled && !readOnly {
if len(openers) == 0 || openers[0] == nil {
@@ -567,6 +638,45 @@ func setupVectorFeatures(ctx context.Context, mainStore *store.Store, mainPath s
return features, nil
}
+func documentVectorRequestGate(st *store.Store, vectorCfg vector.Config, purpose string) embed.BeforeRequestFunc {
+ return func(ctx context.Context) error {
+ target, err := st.GetDocumentVectorTargetProfileID(ctx)
+ if err != nil {
+ return err
+ }
+ generationFingerprint, err := vectordocument.Fingerprint(target, vectorCfg)
+ if err != nil {
+ return err
+ }
+ spec := store.DocumentVectorGenerationSpec{
+ Fingerprint: generationFingerprint, TargetExtractionProfileID: target,
+ EmbeddingProfile: "vector.embeddings", Model: vectorCfg.Embeddings.Model,
+ Dimension: vectorCfg.Embeddings.Dimension,
+ }
+ var fingerprint string
+ switch purpose {
+ case "document_embedding":
+ fingerprint, err = vectordocument.EgressFingerprint(target, vectorCfg)
+ case "query_embedding":
+ fingerprint, err = vectordocument.QueryEgressFingerprint(target, vectorCfg)
+ default:
+ return errors.New("document vector request purpose is invalid")
+ }
+ if err != nil {
+ return err
+ }
+ consent, err := st.GetDocumentVectorConsent(ctx, fingerprint)
+ if err != nil {
+ return err
+ }
+ if consent == nil || consent.DocumentVectorGenerationSpec != spec ||
+ consent.EgressFingerprint != fingerprint || consent.Purpose != purpose {
+ return errors.New("exact document vector egress is not consented")
+ }
+ return nil
+ }
+}
+
func newVisualRuntime(ctx context.Context, vecCfg vector.Config, mainStore *store.Store, backend vector.Backend, opener visual.StreamOpener) (*visualFeatures, error) {
fingerprint := vecCfg.MultimodalGenerationFingerprint()
var visualBackend visual.Backend
diff --git a/cmd/msgvault/cmd/serve_vector_init.go b/cmd/msgvault/cmd/serve_vector_init.go
index 4d903ba3b..4055fbac9 100644
--- a/cmd/msgvault/cmd/serve_vector_init.go
+++ b/cmd/msgvault/cmd/serve_vector_init.go
@@ -141,6 +141,7 @@ func startVectorInit(
apiServer.SetVectorFeatures(
vf.HybridEngine, vf.PersonSearchEngine, vf.Backend, vf.Cfg,
)
+ apiServer.SetDocumentSearchService(vf.DocumentSearch)
// Preflight drift detection: vector-search requests re-resolve the
// durable account scope (throttled) so drift latches index_stale
// even on daemons whose embed job never runs (empty cron,
@@ -217,6 +218,9 @@ func startVectorInit(
logger.Error("register multimodal job failed", "error", err)
}
}
+ if err := registerDocumentVectorJob(sched, vf, s); err != nil {
+ logger.Error("register document vector job failed", "error", err)
+ }
logger.Info("daemon startup step complete", "step", "init_vector_backend")
}()
return h
@@ -465,6 +469,32 @@ type embedJobRegistrar interface {
SetEmbedJob(job *scheduler.EmbedJob, schedule string, runAfterSync bool) error
}
+type documentVectorJobRegistrar interface {
+ SetDocumentVectorJob(job func(context.Context) error, schedule string, runAfterSync bool) error
+}
+
+func registerDocumentVectorJob(sched documentVectorJobRegistrar, vf *vectorFeatures, st *store.Store) error {
+ if vf == nil || vf.DocumentBackend == nil {
+ return nil
+ }
+ limit := vf.Cfg.Embeddings.BatchSize
+ if limit < 1 {
+ limit = defaultDocumentVectorOperationLimit
+ }
+ if limit > 1000 {
+ limit = 1000
+ }
+ job := func(ctx context.Context) error {
+ return runScheduledDocumentVectorGeneration(ctx, st, vf, limit)
+ }
+ if err := sched.SetDocumentVectorJob(job, cfg.Vector.Embed.Schedule.Cron, cfg.Vector.Embed.Schedule.RunAfterSync); err != nil {
+ return fmt.Errorf("register document vector job: %w", err)
+ }
+ logger.Info("document vectors scheduled", "cron", cfg.Vector.Embed.Schedule.Cron,
+ "run_after_sync", cfg.Vector.Embed.Schedule.RunAfterSync)
+ return nil
+}
+
func registerEmbedJob(sched embedJobRegistrar, vf *vectorFeatures, s *store.Store, apiServer *api.Server) error {
embedJob := newSchedulerEmbedJob(vf, s)
embedJob.ResolveBuildScope = func() (vector.BuildScope, error) {
diff --git a/cmd/msgvault/cmd/serve_vector_init_test.go b/cmd/msgvault/cmd/serve_vector_init_test.go
index 59d2e6a11..c015ca03a 100644
--- a/cmd/msgvault/cmd/serve_vector_init_test.go
+++ b/cmd/msgvault/cmd/serve_vector_init_test.go
@@ -18,6 +18,7 @@ import (
"go.kenn.io/msgvault/internal/store"
"go.kenn.io/msgvault/internal/testutil"
"go.kenn.io/msgvault/internal/vector"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
"go.kenn.io/msgvault/internal/vector/embed"
"go.kenn.io/msgvault/internal/vector/personsearch"
"go.kenn.io/msgvault/internal/vector/visual"
@@ -213,6 +214,148 @@ func TestStartVectorInitInstallsFeaturesOnSuccess(t *testing.T) {
assertions.True(closed, "CloseFeatures must close the opened backend")
}
+type registeredDocumentVectorJobCapture struct {
+ calls int
+ job func(context.Context) error
+ schedule string
+ runAfterSync bool
+}
+
+func (c *registeredDocumentVectorJobCapture) SetDocumentVectorJob(
+ job func(context.Context) error, schedule string, runAfterSync bool,
+) error {
+ c.calls++
+ c.job = job
+ c.schedule = schedule
+ c.runAfterSync = runAfterSync
+ return nil
+}
+
+type startupDocumentSearchLedger struct{}
+
+func (startupDocumentSearchLedger) SearchDocuments(context.Context, store.DocumentSearchRequest) (store.DocumentSearchResponse, error) {
+ return store.DocumentSearchResponse{}, nil
+}
+
+func (startupDocumentSearchLedger) GetDocumentIndexRevision(context.Context) (int64, error) {
+ return 1, nil
+}
+
+func (startupDocumentSearchLedger) GetActiveDocumentVectorGeneration(context.Context) (*store.DocumentVectorGeneration, error) {
+ return &store.DocumentVectorGeneration{
+ ID: 1,
+ Fingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ Dimension: 3, TargetExtractionProfileID: "profile-a",
+ }, nil
+}
+
+func (startupDocumentSearchLedger) GetDocumentVectorTargetProfileID(context.Context) (string, error) {
+ return "profile-a", nil
+}
+
+func (startupDocumentSearchLedger) ResolveDocumentVectorSearchOccurrences(
+ context.Context, int64, []store.DocumentVectorSearchHit, store.DocumentSearchRequest, int,
+) ([]store.DocumentSearchResult, bool, error) {
+ return nil, false, nil
+}
+
+type startupDocumentSemanticClient struct{}
+
+func (startupDocumentSemanticClient) EmbedQuery(context.Context, string) ([]float32, error) {
+ return []float32{1, 0, 0}, nil
+}
+
+func (startupDocumentSemanticClient) EmbedDocuments(context.Context, []vector.DocumentInput) ([][][]float32, error) {
+ return nil, nil
+}
+
+type startupDocumentBackend struct{}
+
+func (startupDocumentBackend) PutUnpublished(context.Context, vectordocument.GenerationID, int, []vectordocument.Embedding) error {
+ return nil
+}
+
+func (startupDocumentBackend) DeleteTokens(context.Context, vectordocument.GenerationID, []string) error {
+ return nil
+}
+
+func (startupDocumentBackend) Search(context.Context, vectordocument.GenerationID, int, []float32, int) ([]vectordocument.Hit, error) {
+ return nil, nil
+}
+
+func (startupDocumentBackend) SearchPage(context.Context, vectordocument.GenerationID, int, []float32, string, int) (vectordocument.HitPage, error) {
+ return vectordocument.HitPage{Exhausted: true}, nil
+}
+
+func TestRegisterDocumentVectorJobRequiresBackend(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ c := config.NewDefaultConfig()
+ c.Vector.Embed.Schedule.Cron = "*/7 * * * *"
+ c.Vector.Embed.Schedule.RunAfterSync = true
+ withTestConfig(t, c)
+ available := &vectorFeatures{DocumentBackend: startupDocumentBackend{}, Cfg: c.Vector}
+
+ capture := ®isteredDocumentVectorJobCapture{}
+ requirements.NoError(registerDocumentVectorJob(capture, available, nil))
+ assertions.Equal(1, capture.calls)
+ assertions.NotNil(capture.job)
+ assertions.Equal("*/7 * * * *", capture.schedule)
+ assertions.True(capture.runAfterSync)
+
+ for name, features := range map[string]*vectorFeatures{
+ "disabled": nil,
+ "nil backend": {Cfg: c.Vector},
+ } {
+ t.Run(name, func(t *testing.T) {
+ unregistered := ®isteredDocumentVectorJobCapture{}
+ require.NoError(t, registerDocumentVectorJob(unregistered, features, nil))
+ assert.Zero(t, unregistered.calls)
+ })
+ }
+}
+
+func TestStartVectorInitInstallsOnlyConsentedDocumentSearch(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ service *vectordocument.SearchService
+ wantStatus int
+ wantPayload string
+ }{
+ {name: "unconsented", wantStatus: http.StatusServiceUnavailable, wantPayload: "semantic_search_unavailable"},
+ {name: "consented", service: vectordocument.NewSearchService(vectordocument.SearchDeps{
+ Ledger: startupDocumentSearchLedger{}, Embedder: startupDocumentSemanticClient{}, Backend: startupDocumentBackend{},
+ }), wantStatus: http.StatusOK, wantPayload: `"effective_mode":"semantic"`},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ c := config.NewDefaultConfig()
+ c.Vector.Enabled = true
+ withTestConfig(t, c)
+ overrideSetupVectorFeatures(t, func(context.Context, *store.Store, string, bool) (*vectorFeatures, error) {
+ return &vectorFeatures{
+ Backend: &fakeCmdVectorBackend{}, DocumentSearch: test.service,
+ DocumentBackend: startupDocumentBackend{}, SemanticClient: startupDocumentSemanticClient{},
+ Cfg: c.Vector, Close: func() error { return nil },
+ }, nil
+ })
+ mainStore := testutil.NewTestStore(t)
+ srv := api.NewServerWithOptions(api.ServerOptions{
+ Config: c, Store: &storeAPIAdapter{store: mainStore}, Logger: slog.New(slog.DiscardHandler),
+ VectorStatus: api.VectorStatusInitializing,
+ })
+ h := startVectorInit(t.Context(), mainStore, "/tmp/msgvault.db", nil, srv, scheduler.New(nil))
+ require.True(t, h.WaitTimeout(5*time.Second))
+
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/documents/search?q=bounded&mode=semantic&candidate_limit=10", nil)
+ response := httptest.NewRecorder()
+ srv.Router().ServeHTTP(response, request)
+ assert.Equal(t, test.wantStatus, response.Code, response.Body.String())
+ assert.Contains(t, response.Body.String(), test.wantPayload)
+ h.CloseFeatures()
+ })
+ }
+}
+
func TestStartVectorInitFlagsStaleIndex(t *testing.T) {
c := config.NewDefaultConfig()
c.Vector.Enabled = true
diff --git a/cmd/msgvault/cmd/store_adapter_test.go b/cmd/msgvault/cmd/store_adapter_test.go
index bb2cf0642..6e9a69347 100644
--- a/cmd/msgvault/cmd/store_adapter_test.go
+++ b/cmd/msgvault/cmd/store_adapter_test.go
@@ -45,6 +45,25 @@ func TestStoreAPIAdapterServesAttributeDefinitions(t *testing.T) {
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
}
+func TestStoreAPIAdapterServesDocumentVectorStatus(t *testing.T) {
+ st := testutil.NewTestStore(t)
+ c := config.NewDefaultConfig()
+ c.Vector.Enabled = true
+ c.Attachments.Documents.Index.Embeddings.Enabled = true
+ srv := api.NewServerWithOptions(api.ServerOptions{
+ Config: c,
+ Store: &storeAPIAdapter{store: st},
+ Logger: slog.New(slog.DiscardHandler),
+ })
+
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/documents/vectors/status", nil)
+ response := httptest.NewRecorder()
+ srv.Router().ServeHTTP(response, request)
+
+ require.Equal(t, http.StatusOK, response.Code, response.Body.String())
+ require.JSONEq(t, `{"enabled":true,"configured":false}`, response.Body.String())
+}
+
var _ api.CtxMessageStore = (*storeAPIAdapter)(nil)
var _ api.MessageIdentityStore = (*storeAPIAdapter)(nil)
var _ api.MeetingImporter = (*storeAPIAdapter)(nil)
diff --git a/cmd/msgvault/cmd/tui.go b/cmd/msgvault/cmd/tui.go
index f956b52ba..7a6559f0f 100644
--- a/cmd/msgvault/cmd/tui.go
+++ b/cmd/msgvault/cmd/tui.go
@@ -279,7 +279,7 @@ func openTUIBackend(ctx context.Context) (*tuiBackend, error) {
func init() {
rootCmd.AddCommand(tuiCmd)
- tuiCmd.Flags().BoolVar(&forceLocalTUI, "local", false, "Use the local daemon instead of the configured remote server")
+ tuiCmd.Flags().BoolVar(&forceLocalTUI, localValue, false, "Use the local daemon instead of the configured remote server")
tuiCmd.Flags().BoolVar(&deprecatedTUIForceSQL, "force-sql", false, "Deprecated in 0.17.0: set [analytics].engine = \"sql\" in config.toml")
tuiCmd.Flags().BoolVar(&deprecatedTUISkipCacheBuild, "no-cache-build", false, "Deprecated in 0.17.0: set [analytics].auto_build_cache = false in config.toml")
tuiCmd.Flags().BoolVar(&deprecatedTUINoSQLiteScanner, "no-sqlite-scanner", false, "Deprecated in 0.17.0: cache engine selection is daemon-managed")
diff --git a/cmd/msgvault/cmd/vector_features.go b/cmd/msgvault/cmd/vector_features.go
index b9472f41d..5ffa89ae2 100644
--- a/cmd/msgvault/cmd/vector_features.go
+++ b/cmd/msgvault/cmd/vector_features.go
@@ -6,6 +6,8 @@ import (
"go.kenn.io/msgvault/internal/scheduler"
"go.kenn.io/msgvault/internal/store"
"go.kenn.io/msgvault/internal/vector"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
+ "go.kenn.io/msgvault/internal/vector/embed"
"go.kenn.io/msgvault/internal/vector/hybrid"
"go.kenn.io/msgvault/internal/vector/personsearch"
"go.kenn.io/msgvault/internal/vector/visual"
@@ -42,11 +44,15 @@ type vectorFeatures struct {
HybridEngine *hybrid.Engine
// PersonSearchEngine shares Backend and Cfg with HybridEngine, but searches
// only the person-owned corpus through a separately gated provider client.
- PersonSearchEngine *personsearch.Engine
- Runner scheduler.EmbedRunner
- Convergence scheduler.ConvergenceChecker
- Cfg vector.Config
- Visual *visualFeatures
+ PersonSearchEngine *personsearch.Engine
+ Runner scheduler.EmbedRunner
+ Convergence scheduler.ConvergenceChecker
+ DocumentBackend vectordocument.Backend
+ DocumentSearch *vectordocument.SearchService
+ SemanticClient embed.SemanticClient
+ DocumentQueryClient embed.SemanticClient
+ Cfg vector.Config
+ Visual *visualFeatures
// Close releases the backend's resources: on SQLite it closes the
// vectors.db handle (so WAL checkpoints complete); on PostgreSQL it is
// a no-op because the pgvector backend shares the main store's handle,
diff --git a/go.mod b/go.mod
index f3933071f..323ad18ee 100644
--- a/go.mod
+++ b/go.mod
@@ -39,7 +39,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
- go.kenn.io/docbank v0.13.1-0.20260820025223-11af138586a2
+ go.kenn.io/docbank v0.14.0
go.kenn.io/kit v0.18.1
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
diff --git a/go.sum b/go.sum
index 7a231f2ca..b011e175c 100644
--- a/go.sum
+++ b/go.sum
@@ -297,8 +297,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
-go.kenn.io/docbank v0.13.1-0.20260820025223-11af138586a2 h1:WatIwVvxbCGe/bqbRCo+sLdgYcN7yHn7VIaKxlAU7L8=
-go.kenn.io/docbank v0.13.1-0.20260820025223-11af138586a2/go.mod h1:FhKONSj2UYowwhgK2Lq3pIkYiIF00fSQt7PDBqqUbNc=
+go.kenn.io/docbank v0.14.0 h1:qzkCnFIAKucpZIvblMouEV948MyjX+p49F+pqPtB5bk=
+go.kenn.io/docbank v0.14.0/go.mod h1:sd4cATphlFCUU/Ea6VlB+LnFKFt18mbPvkDijhG42h4=
go.kenn.io/kit v0.18.1 h1:dHEwtnXApwzbhqgvkj48Ylvdhes9QeWm3Q2wf9hq6Jc=
go.kenn.io/kit v0.18.1/go.mod h1:SXD5SAxpYlH65wmBnfNqr5Za3NVFzKWJph+Sobx/ZMA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
diff --git a/internal/api/cli_allowlist_documents_test.go b/internal/api/cli_allowlist_documents_test.go
index ce9f02b38..db265bae4 100644
--- a/internal/api/cli_allowlist_documents_test.go
+++ b/internal/api/cli_allowlist_documents_test.go
@@ -20,4 +20,16 @@ func TestCLIRunCommandAllowedDocumentMutations(t *testing.T) {
assert.False(t, cliRunCommandAllowed([]string{"documents", subcommand}))
})
}
+
+ for _, subcommand := range []string{"build", "consent", "rebuild", "resume", "retire", "retry", "status"} {
+ t.Run("vectors/"+subcommand, func(t *testing.T) {
+ assert.True(t, cliRunCommandAllowed([]string{"documents", "vectors", subcommand}))
+ })
+ }
+ for _, args := range [][]string{
+ {"documents", "vectors"},
+ {"documents", "vectors", "unknown"},
+ } {
+ assert.False(t, cliRunCommandAllowed(args))
+ }
}
diff --git a/internal/api/cli_handlers.go b/internal/api/cli_handlers.go
index 25ef15879..1f6c04832 100644
--- a/internal/api/cli_handlers.go
+++ b/internal/api/cli_handlers.go
@@ -1426,6 +1426,17 @@ func cliRunCommandAllowed(args []string) bool {
if len(args) < 2 {
return false
}
+ if args[1] == "vectors" {
+ if len(args) < 3 {
+ return false
+ }
+ switch args[2] {
+ case "build", "consent", "rebuild", "resume", "retire", "retry", "status":
+ return true
+ default:
+ return false
+ }
+ }
switch args[1] {
case "build", "consent-mistral", "purge-derived", "resume", "retire", "retry":
return true
diff --git a/internal/api/document_search.go b/internal/api/document_search.go
index a404d9c83..d4a6f4e80 100644
--- a/internal/api/document_search.go
+++ b/internal/api/document_search.go
@@ -3,6 +3,7 @@ package api
import (
"context"
"errors"
+ "fmt"
"net/http"
"strings"
@@ -10,6 +11,7 @@ import (
"go.kenn.io/msgvault/internal/personscope"
"go.kenn.io/msgvault/internal/personscope/resolver"
"go.kenn.io/msgvault/internal/store"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
)
const (
@@ -48,6 +50,18 @@ type DocumentStatusStore interface {
) (int64, error)
}
+type DocumentVectorStatusStore interface {
+ GetDocumentVectorTargetProfileID(ctx context.Context) (string, error)
+ GetDocumentVectorOperationsStatus(ctx context.Context, configured store.DocumentVectorGenerationSpec, documentEgressFingerprint, queryEgressFingerprint string, generationID int64, afterToken string, limit int) (store.DocumentVectorOperationsStatus, error)
+}
+
+type DocumentVectorOperationsResponse struct {
+ Enabled bool `json:"enabled"`
+ Configured bool `json:"configured"`
+ ScheduledRegistrationRequiresRestart bool `json:"scheduled_registration_requires_restart,omitempty"`
+ Status *store.DocumentVectorOperationsStatus `json:"status,omitempty"`
+}
+
type documentOccurrenceStatusReconciler interface {
ReconcileDocumentOccurrences(ctx context.Context) error
}
@@ -70,6 +84,82 @@ func (s *Server) registerDocumentSearchRoute(api huma.API) {
http.StatusBadRequest, http.StatusForbidden, http.StatusTooManyRequests,
http.StatusServiceUnavailable,
)
+ registerAPIV1RawHumaJSONRouteWithErrors[DocumentVectorOperationsResponse](
+ api, "getDocumentVectorStatus", http.MethodGet, "/documents/vectors/status",
+ "Get document vector generation, consent, usage, and failure status",
+ s.documentSearchGuard("document vector status", s.handleDocumentVectorStatus),
+ http.StatusBadRequest, http.StatusForbidden, http.StatusTooManyRequests,
+ http.StatusServiceUnavailable,
+ )
+}
+
+func (s *Server) handleDocumentVectorStatus(w http.ResponseWriter, r *http.Request) {
+ if !s.cfg.Vector.Enabled || !s.cfg.Attachments.Documents.Index.Embeddings.Enabled {
+ writeJSON(w, http.StatusOK, DocumentVectorOperationsResponse{Enabled: false})
+ return
+ }
+ statusStore, ok := s.store.(DocumentVectorStatusStore)
+ if !ok {
+ writeError(w, http.StatusServiceUnavailable, "document_vector_status_unavailable", "Document vector status is unavailable")
+ return
+ }
+ generationID, _, err := queryInt64(r, "generation_id")
+ if err != nil {
+ s.rejectBadParam(w, err)
+ return
+ }
+ limit, found, err := queryInt(r, "limit")
+ if err != nil {
+ s.rejectBadParam(w, err)
+ return
+ }
+ if !found {
+ limit = 20
+ }
+ if limit < 1 || limit > 1000 {
+ s.rejectBadParam(w, errors.New("limit must be between 1 and 1000"))
+ return
+ }
+ target, err := statusStore.GetDocumentVectorTargetProfileID(r.Context())
+ if errors.Is(err, store.ErrDocumentVectorInvalidGenerationState) {
+ writeJSON(w, http.StatusOK, DocumentVectorOperationsResponse{Enabled: true, Configured: false})
+ return
+ }
+ if err != nil {
+ s.writeDocumentSearchError(w, err)
+ return
+ }
+ generationFingerprint, err := vectordocument.Fingerprint(target, s.cfg.Vector)
+ if err != nil {
+ s.writeDocumentSearchError(w, err)
+ return
+ }
+ spec := store.DocumentVectorGenerationSpec{
+ Fingerprint: generationFingerprint, TargetExtractionProfileID: target,
+ EmbeddingProfile: s.cfg.Attachments.Documents.Index.Embeddings.Profile,
+ Model: s.cfg.Vector.Embeddings.Model, Dimension: s.cfg.Vector.Embeddings.Dimension,
+ }
+ documentEgressFingerprint, err := vectordocument.EgressFingerprint(target, s.cfg.Vector)
+ if err != nil {
+ s.writeDocumentSearchError(w, err)
+ return
+ }
+ queryEgressFingerprint, err := vectordocument.QueryEgressFingerprint(target, s.cfg.Vector)
+ if err != nil {
+ s.writeDocumentSearchError(w, err)
+ return
+ }
+ status, err := statusStore.GetDocumentVectorOperationsStatus(r.Context(), spec, documentEgressFingerprint, queryEgressFingerprint, generationID, r.URL.Query().Get("after_token"), limit)
+ if err != nil {
+ s.writeDocumentSearchError(w, err)
+ return
+ }
+ s.documentSearchMu.RLock()
+ restartRequired := status.QueryConsent != nil && s.documentSearch == nil
+ s.documentSearchMu.RUnlock()
+ writeJSON(w, http.StatusOK, DocumentVectorOperationsResponse{
+ Enabled: true, Configured: true, ScheduledRegistrationRequiresRestart: restartRequired, Status: &status,
+ })
}
// documentSearchGuard protects the document reads that reconcile attachment
@@ -189,7 +279,23 @@ func (s *Server) handleDocumentSearch(w http.ResponseWriter, r *http.Request) {
}
request.Person = &resolved.Scope
}
- response, err := searcher.SearchDocuments(r.Context(), request)
+ s.documentSearchMu.RLock()
+ service := s.documentSearch
+ s.documentSearchMu.RUnlock()
+ var response store.DocumentSearchResponse
+ if service != nil {
+ if reconciler, ok := s.store.(documentOccurrenceStatusReconciler); ok {
+ if err := reconciler.ReconcileDocumentOccurrences(r.Context()); err != nil {
+ s.writeDocumentSearchError(w, err)
+ return
+ }
+ }
+ response, err = service.Search(r.Context(), request)
+ } else if request.SearchMode == string(vectordocument.SearchModeSemantic) || request.SearchMode == string(vectordocument.SearchModeHybrid) {
+ err = vectordocument.ErrSemanticSearchUnavailable
+ } else {
+ response, err = searcher.SearchDocuments(r.Context(), request)
+ }
if err != nil {
s.writeDocumentSearchError(w, err)
return
@@ -211,6 +317,9 @@ func (s *Server) writeDocumentSearchError(w http.ResponseWriter, err error) {
case errors.Is(err, store.ErrDocumentSearchUnavailable):
writeError(w, http.StatusServiceUnavailable, "document_search_unavailable",
"Document search requires full-text search support")
+ case errors.Is(err, vectordocument.ErrSemanticSearchUnavailable):
+ writeError(w, http.StatusServiceUnavailable, "semantic_search_unavailable",
+ "Semantic document search is unavailable")
default:
s.logger.Error("document search failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Document search failed")
@@ -219,8 +328,15 @@ func (s *Server) writeDocumentSearchError(w http.ResponseWriter, err error) {
func parseDocumentSearchRequest(r *http.Request) (store.DocumentSearchRequest, error) {
request := store.DocumentSearchRequest{
- Query: r.URL.Query().Get("q"),
- Cursor: r.URL.Query().Get("cursor"),
+ Query: r.URL.Query().Get("q"), Cursor: r.URL.Query().Get("cursor"),
+ SearchMode: r.URL.Query().Get("mode"),
+ }
+ if request.SearchMode != "" {
+ mode, err := vectordocument.ParseSearchMode(request.SearchMode)
+ if err != nil {
+ return request, err
+ }
+ request.SearchMode = string(mode)
}
var err error
request.SourceIDs, _, err = queryInt64s(r, "source_id")
@@ -235,6 +351,18 @@ func parseDocumentSearchRequest(r *http.Request) (store.DocumentSearchRequest, e
if request.PageSize, _, err = queryInt(r, "limit"); err != nil {
return request, err
}
+ var candidateFound bool
+ if request.CandidateLimit, candidateFound, err = queryInt(r, "candidate_limit"); err != nil {
+ return request, err
+ }
+ maxCandidateLimit := store.MaxLexicalDocumentSearchCandidateLimit
+ if request.SearchMode == string(vectordocument.SearchModeSemantic) ||
+ request.SearchMode == string(vectordocument.SearchModeHybrid) {
+ maxCandidateLimit = store.MaxDocumentSearchCandidateLimit
+ }
+ if candidateFound && (request.CandidateLimit < 1 || request.CandidateLimit > maxCandidateLimit) {
+ return request, fmt.Errorf("candidate_limit must be between 1 and %d for this mode", maxCandidateLimit)
+ }
if request.AttachmentID, _, err = queryInt64(r, "attachment_id"); err != nil {
return request, err
}
@@ -284,3 +412,12 @@ func parseDocumentSearchRequest(r *http.Request) (store.DocumentSearchRequest, e
}
return request, nil
}
+
+// SetDocumentSearchService installs semantic document retrieval after the
+// optional vector runtime is ready. Before installation auto/lexical remain
+// available and explicit semantic/hybrid requests return a stable 503.
+func (s *Server) SetDocumentSearchService(service *vectordocument.SearchService) {
+ s.documentSearchMu.Lock()
+ s.documentSearch = service
+ s.documentSearchMu.Unlock()
+}
diff --git a/internal/api/document_search_test.go b/internal/api/document_search_test.go
index 52ec9fc42..e0ba05ac2 100644
--- a/internal/api/document_search_test.go
+++ b/internal/api/document_search_test.go
@@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"log/slog"
+ "maps"
"net/http"
"net/http/httptest"
+ "slices"
"testing"
"time"
@@ -31,6 +33,8 @@ func TestDocumentSearchHTTPPreservesDedicatedContract(t *testing.T) {
assert.Equal(int64(41), request.AttachmentID)
assert.Equal(int64(42), request.MessageID)
assert.Equal(7, request.PageSize)
+ assert.Equal("lexical", request.SearchMode)
+ assert.Equal(1001, request.CandidateLimit)
assert.Equal("opaque", request.Cursor)
return store.DocumentSearchResponse{
Revision: 12, NextCursor: "next",
@@ -41,7 +45,7 @@ func TestDocumentSearchHTTPPreservesDedicatedContract(t *testing.T) {
}, nil
}
request := httptest.NewRequest(http.MethodGet,
- "/api/v1/documents/search?q=shipping+damage&source_id=4,9&message_type=email,mms&attachment_id=41&message_id=42&limit=7&cursor=opaque",
+ "/api/v1/documents/search?q=shipping+damage&source_id=4,9&message_type=email,mms&attachment_id=41&message_id=42&limit=7&cursor=opaque&mode=lexical&candidate_limit=1001",
nil)
response := httptest.NewRecorder()
server.Router().ServeHTTP(response, request)
@@ -80,6 +84,21 @@ func TestDocumentSearchHTTPResolvesDurablePersonScope(t *testing.T) {
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
}
+func TestDocumentSearchHTTPExplicitSemanticUnavailableDoesNotFallBack(t *testing.T) {
+ server, catalog := newTestServerWithMockStore(t)
+ calls := 0
+ catalog.documentSearchFunc = func(context.Context, store.DocumentSearchRequest) (store.DocumentSearchResponse, error) {
+ calls++
+ return store.DocumentSearchResponse{}, nil
+ }
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/documents/search?q=evidence&mode=semantic&candidate_limit=25", nil)
+ response := httptest.NewRecorder()
+ server.Router().ServeHTTP(response, request)
+ assert.Equal(t, http.StatusServiceUnavailable, response.Code)
+ assert.Contains(t, response.Body.String(), "semantic_search_unavailable")
+ assert.Zero(t, calls)
+}
+
func TestDocumentSearchHTTPMapsCursorRevisionConflict(t *testing.T) {
server, catalog := newTestServerWithMockStore(t)
catalog.documentSearchFunc = func(
@@ -230,10 +249,37 @@ func TestOpenAPIDocumentSearchParameters(t *testing.T) {
names = append(names, parameter.Name)
}
assert.ElementsMatch(t,
- []string{"q", "source_id", "message_type", "attachment_id", "message_id", "person_id", "participant_id", "direction", "after", "before", "limit", "cursor"},
+ []string{"q", "source_id", "message_type", "attachment_id", "message_id", "person_id", "participant_id", "direction", "after", "before", "limit", "cursor", "mode", "candidate_limit"},
names)
}
+func TestDocumentVectorStatusHTTPIsUsefulBeforeTargetConfiguration(t *testing.T) {
+ fixture := storetest.New(t)
+ c := config.NewDefaultConfig()
+ c.Vector.Enabled = true
+ c.Attachments.Documents.Index.Embeddings.Enabled = true
+ server := NewServerWithOptions(ServerOptions{Config: c, Store: fixture.Store, Logger: slog.New(slog.DiscardHandler)})
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/documents/vectors/status", nil)
+ response := httptest.NewRecorder()
+ server.Router().ServeHTTP(response, request)
+ require.Equal(t, http.StatusOK, response.Code, response.Body.String())
+ assert.JSONEq(t, `{"enabled":true,"configured":false}`, response.Body.String())
+}
+
+func TestOpenAPIDocumentVectorStatusUsesSnakeCaseCoverage(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ document := OpenAPIDocument()
+ operation := document.Paths["/api/v1/documents/vectors/status"].Get
+ requirements.NotNil(operation)
+ coverage := document.Components.Schemas.Map()["DocumentVectorCoverage"]
+ requirements.NotNil(coverage)
+ assertions.ElementsMatch([]string{"required", "ready"}, slices.Collect(maps.Keys(coverage.Properties)))
+ encoded, err := json.Marshal(store.DocumentVectorCoverage{Required: 4, Ready: 3})
+ requirements.NoError(err)
+ assertions.JSONEq(`{"required":4,"ready":3}`, string(encoded))
+}
+
func TestDocumentStatusHTTPPreservesScopedContract(t *testing.T) {
assert := assert.New(t)
server, catalog := newTestServerWithMockStore(t)
diff --git a/internal/api/routes.go b/internal/api/routes.go
index 32ae829ec..39a91e938 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -632,6 +632,8 @@ func rawRouteParameters(operationID string) []*huma.Param {
queryStringParam("before", "Only messages before an RFC3339 or YYYY-MM-DD date", false),
queryIntegerParam(limitParam, "Maximum results to return (default 20, max 100)"),
queryStringParam("cursor", "Opaque cursor from the previous document search page", false),
+ queryStringParam("mode", "Search mode: lexical (default and auto); semantic/hybrid send the query to the embedding provider", false),
+ queryIntegerParam("candidate_limit", "Maximum candidates (default/max: lexical 10000; semantic/hybrid 100/1000)"),
}
case "getDocumentIndexStatus":
mediaTypes := queryRefArrayParam("media_type", "Allowed document media types")
@@ -642,6 +644,12 @@ func rawRouteParameters(operationID string) []*huma.Param {
mediaTypes,
queryRefArrayParam("message_type", "Allowed message types"),
}
+ case "getDocumentVectorStatus":
+ return []*huma.Param{
+ queryIntegerParam("generation_id", "Generation whose bounded failures to inspect"),
+ queryStringParam("after_token", "Stable failure cursor token", false),
+ queryIntegerParam("limit", "Maximum failure diagnostics (default 20, max 1000)"),
+ }
case "getCLIMessage", "getCLIMessageRaw":
return []*huma.Param{queryStringParam("id", "Message numeric ID or source message ID", true)}
case "getCLIAttachment":
diff --git a/internal/api/server.go b/internal/api/server.go
index 5073d2bb6..6ddd089a7 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -31,6 +31,7 @@ import (
"go.kenn.io/msgvault/internal/taskclient"
"go.kenn.io/msgvault/internal/tasklinks"
"go.kenn.io/msgvault/internal/vector"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
"go.kenn.io/msgvault/internal/vector/hybrid"
"go.kenn.io/msgvault/internal/vector/visual"
webapp "go.kenn.io/msgvault/internal/web"
@@ -338,6 +339,8 @@ type Server struct {
// the ordinary refresh would flip straight back to ready. Only a
// successful reinit (SetVectorFeatures) clears the latch.
vectorStaleLatch bool
+ documentSearchMu sync.RWMutex
+ documentSearch *vectordocument.SearchService
// vectorScopeCheck re-resolves the durable embedding scope on the
// vector-search preflight path (throttled by vectorScopeNextCheck) so
// drift is detected even when no embed job ever runs. Wired by the
diff --git a/internal/backupapp/restore.go b/internal/backupapp/restore.go
new file mode 100644
index 000000000..ca7c4f649
--- /dev/null
+++ b/internal/backupapp/restore.go
@@ -0,0 +1,70 @@
+package backupapp
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+
+ "go.kenn.io/kit/backup"
+ "go.kenn.io/msgvault/internal/sqliteutil"
+)
+
+// InvalidateRestoredDocumentVectors removes document-vector authority because vectors.db
+// is deliberately excluded from snapshots. Consent and historical provider
+// usage remain valid archive records; the next vector run rebuilds the derived
+// generation from normalized document evidence.
+func InvalidateRestoredDocumentVectors(ctx context.Context, target backup.RestorePublicationTarget) (err error) {
+ if target.DBPath == "" {
+ return errors.New("backupapp: restored database path is required")
+ }
+ db, err := sql.Open(sqliteutil.DriverName(), target.DBPath)
+ if err != nil {
+ return fmt.Errorf("backupapp: open restored database: %w", err)
+ }
+ db.SetMaxOpenConns(1)
+ defer func() {
+ if closeErr := db.Close(); closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("backupapp: close restored database: %w", closeErr))
+ }
+ }()
+ for _, statement := range []string{
+ `PRAGMA busy_timeout = 30000`,
+ `PRAGMA foreign_keys = ON`,
+ `PRAGMA journal_mode = DELETE`,
+ } {
+ if _, err := db.ExecContext(ctx, statement); err != nil {
+ return fmt.Errorf("backupapp: configure restored database: %w", err)
+ }
+ }
+
+ var vectorAuthorityExists bool
+ if err := db.QueryRowContext(ctx, `SELECT EXISTS (
+ SELECT 1 FROM sqlite_master
+ WHERE type = 'table' AND name = 'document_vector_generations'
+ )`).Scan(&vectorAuthorityExists); err != nil {
+ return fmt.Errorf("backupapp: inspect restored document vector authority: %w", err)
+ }
+ if !vectorAuthorityExists {
+ return nil
+ }
+
+ tx, err := db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("backupapp: begin restored document vector invalidation: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ for _, statement := range []string{
+ `DELETE FROM document_vector_publications`,
+ `DELETE FROM document_vector_build_progress`,
+ `DELETE FROM document_vector_generations`,
+ } {
+ if _, err := tx.ExecContext(ctx, statement); err != nil {
+ return fmt.Errorf("backupapp: invalidate restored document vectors: %w", err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("backupapp: commit restored document vector invalidation: %w", err)
+ }
+ return nil
+}
diff --git a/internal/config/documentindex_config_test.go b/internal/config/documentindex_config_test.go
index 74dd103f4..1b206e0cc 100644
--- a/internal/config/documentindex_config_test.go
+++ b/internal/config/documentindex_config_test.go
@@ -42,6 +42,10 @@ message_types = ["EMAIL", "chat", "email"]
[attachments.documents.index]
lexical = true
store_chunk_text = true
+
+[attachments.documents.index.embeddings]
+enabled = true
+profile = "vector.embeddings"
`)
require.NoError(t, os.WriteFile(path, content, 0o600))
@@ -57,6 +61,38 @@ store_chunk_text = true
assert.Equal([]string{"chat", "email"}, documents.Scope.MessageTypes)
assert.True(documents.LexicalEnabled())
assert.True(documents.StoresChunkText())
+ assert.True(documents.Index.Embeddings.Enabled)
+ assert.Equal("vector.embeddings", documents.Index.Embeddings.Profile)
+}
+
+func TestLoadRejectsUnknownDocumentEmbeddingProfile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.toml")
+ content := []byte(`
+[attachments.documents.index.embeddings]
+enabled = true
+profile = "other.embeddings"
+`)
+ require.NoError(t, os.WriteFile(path, content, 0o600))
+
+ _, err := Load(path, "")
+ assert.ErrorContains(t, err, "profile must be \"vector.embeddings\"")
+}
+
+func TestLoadDefaultsOmittedEnabledDocumentEmbeddingProfile(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.toml")
+ requirements.NoError(os.WriteFile(path, []byte(`
+[attachments.documents.index.embeddings]
+enabled = true
+`), 0o600))
+
+ loaded, err := Load(path, "")
+ requirements.NoError(err)
+ assertions.True(loaded.Attachments.Documents.Index.Embeddings.Enabled)
+ assertions.Equal("vector.embeddings", loaded.Attachments.Documents.Index.Embeddings.Profile)
}
func TestLoadRejectsInvalidDocumentAttachmentConfigWithoutResolvingKey(t *testing.T) {
diff --git a/internal/daemonclient/documents.go b/internal/daemonclient/documents.go
index 99ba19311..d3ca9e3c3 100644
--- a/internal/daemonclient/documents.go
+++ b/internal/daemonclient/documents.go
@@ -26,6 +26,7 @@ func (c *Client) SearchDocuments(
After: optionalTimeRFC3339(request.After),
Before: optionalTimeRFC3339(request.Before),
Limit: optionalPositiveInt64(request.PageSize), Cursor: optionalString(request.Cursor),
+ Mode: optionalString(request.SearchMode), CandidateLimit: optionalPositiveInt64(request.CandidateLimit),
},
})
})
@@ -66,8 +67,10 @@ func documentSearchFromGenerated(response *generated.DocumentSearchResponse) sto
return store.DocumentSearchResponse{Results: []store.DocumentSearchResult{}}
}
result := store.DocumentSearchResponse{
- Revision: response.Revision,
- Results: make([]store.DocumentSearchResult, len(response.Results)),
+ Revision: response.Revision, EffectiveMode: stringValue(response.EffectiveMode),
+ VectorGenerationID: int64Value(response.VectorGenerationID),
+ VectorGenerationFingerprint: stringValue(response.VectorGenerationFingerprint),
+ Results: make([]store.DocumentSearchResult, len(response.Results)),
}
if response.Truncated != nil {
result.Truncated = *response.Truncated
@@ -90,6 +93,12 @@ func documentSearchFromGenerated(response *generated.DocumentSearchResponse) sto
ProfileID: row.ProfileID, ExtractionID: row.ExtractionID,
Provider: row.Provider, Model: row.Model, MatchedSignals: row.MatchedSignals,
Truncated: row.Truncated, Rank: int(row.Rank),
+ LexicalRank: int(int64Value(row.LexicalRank)), SemanticRank: int(int64Value(row.SemanticRank)),
+ SemanticScore: float64Value(row.SemanticScore), FusionScore: float64Value(row.FusionScore),
+ VectorToken: stringValue(row.VectorToken), VectorGenerationID: int64Value(row.VectorGenerationID),
+ VectorGenerationFingerprint: stringValue(row.VectorGenerationFingerprint),
+ VectorEmbeddingProfile: stringValue(row.VectorEmbeddingProfile), VectorModel: stringValue(row.VectorModel),
+ VectorDimension: int(int64Value(row.VectorDimension)),
}
if row.PersonProvenance != nil {
result.Results[index].PersonProvenance = &personscope.Provenance{
@@ -119,6 +128,13 @@ func documentDirectionStrings(directions []personscope.Direction) []string {
return result
}
+func float64Value(value *float64) float64 {
+ if value == nil {
+ return 0
+ }
+ return *value
+}
+
func documentIndexStatusFromGenerated(
response *generated.DocumentIndexStatusResponse,
) store.DocumentIndexStatusResponse {
diff --git a/internal/daemonclient/documents_test.go b/internal/daemonclient/documents_test.go
index 10af68f78..a85371dd0 100644
--- a/internal/daemonclient/documents_test.go
+++ b/internal/daemonclient/documents_test.go
@@ -27,21 +27,28 @@ func TestSearchDocumentsUsesGeneratedDaemonContract(t *testing.T) {
assert.Equal("2026-08-01T00:00:00Z", r.URL.Query().Get("after"))
assert.Equal("2026-08-20T00:00:00Z", r.URL.Query().Get("before"))
assert.Equal("5", r.URL.Query().Get("limit"))
+ assert.Equal("hybrid", r.URL.Query().Get("mode"))
+ assert.Equal("88", r.URL.Query().Get("candidate_limit"))
w.Header().Set("Content-Type", "application/json")
occurredAt := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC)
assert.NoError(json.NewEncoder(w).Encode(store.DocumentSearchResponse{
- Revision: 9, Truncated: true,
+ Revision: 9, Truncated: true, EffectiveMode: "hybrid",
+ VectorGenerationID: 31, VectorGenerationFingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Results: []store.DocumentSearchResult{{
AttachmentID: 22, MessageID: 23, ConversationID: 24, SourceID: 3,
SourceMessageID: "synthetic-message", OccurredAt: &occurredAt,
OccurrenceKey: "occurrence", CanonicalBlobHash: "hash", ChunkKey: "chunk",
Filename: "claim.docx", Excerpt: "damaged carton", ProfileID: "profile",
ExtractionID: "extraction", Provider: "mistral", Model: "ocr",
- MatchedSignals: []string{"content"}, Rank: 1,
+ MatchedSignals: []string{"content", "semantic"}, Rank: 1,
PersonProvenance: &personscope.Provenance{
ParticipantIDs: []int64{4}, Roles: []personscope.Role{personscope.RoleFrom},
Directions: []personscope.Direction{personscope.FromPerson},
},
+ LexicalRank: 2, SemanticRank: 1, SemanticScore: 0.875, FusionScore: 0.032,
+ VectorToken: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ VectorGenerationID: 31, VectorGenerationFingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ VectorEmbeddingProfile: "vector.embeddings", VectorModel: "embed-test", VectorDimension: 3,
}},
}))
}))
@@ -54,19 +61,34 @@ func TestSearchDocumentsUsesGeneratedDaemonContract(t *testing.T) {
Query: "damaged carton", SourceIDs: []int64{3, 7}, AttachmentID: 22, PageSize: 5,
PersonID: 40, Directions: []personscope.Direction{personscope.FromPerson, personscope.Group},
After: &after, Before: &before,
+ SearchMode: "hybrid", CandidateLimit: 88,
})
require.NoError(err)
assert.Equal(int64(9), response.Revision)
assert.True(response.Truncated)
+ assert.Equal("hybrid", response.EffectiveMode)
+ assert.Equal(int64(31), response.VectorGenerationID)
+ assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", response.VectorGenerationFingerprint)
require.Len(response.Results, 1)
- assert.Equal("claim.docx", response.Results[0].Filename)
- assert.Equal(int64(24), response.Results[0].ConversationID)
- assert.Equal("synthetic-message", response.Results[0].SourceMessageID)
- require.NotNil(response.Results[0].OccurredAt)
- assert.Equal(time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC), *response.Results[0].OccurredAt)
- assert.Equal([]string{"content"}, response.Results[0].MatchedSignals)
- require.NotNil(response.Results[0].PersonProvenance)
- assert.Equal([]personscope.Role{personscope.RoleFrom}, response.Results[0].PersonProvenance.Roles)
+ row := response.Results[0]
+ assert.Equal("claim.docx", row.Filename)
+ assert.Equal(int64(24), row.ConversationID)
+ assert.Equal("synthetic-message", row.SourceMessageID)
+ require.NotNil(row.OccurredAt)
+ assert.Equal(time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC), *row.OccurredAt)
+ assert.Equal([]string{"content", "semantic"}, row.MatchedSignals)
+ assert.Equal(2, row.LexicalRank)
+ assert.Equal(1, row.SemanticRank)
+ assert.InDelta(0.875, row.SemanticScore, 0.0001)
+ assert.InDelta(0.032, row.FusionScore, 0.0001)
+ assert.Equal("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", row.VectorToken)
+ assert.Equal(int64(31), row.VectorGenerationID)
+ assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", row.VectorGenerationFingerprint)
+ assert.Equal("vector.embeddings", row.VectorEmbeddingProfile)
+ assert.Equal("embed-test", row.VectorModel)
+ assert.Equal(3, row.VectorDimension)
+ require.NotNil(row.PersonProvenance)
+ assert.Equal([]personscope.Role{personscope.RoleFrom}, row.PersonProvenance.Roles)
}
func TestDocumentIndexStatusUsesGeneratedDaemonContract(t *testing.T) {
diff --git a/internal/documentindex/config.go b/internal/documentindex/config.go
index 2e7ee7447..746aafbb5 100644
--- a/internal/documentindex/config.go
+++ b/internal/documentindex/config.go
@@ -278,8 +278,8 @@ func (c *DocumentsConfig) Validate() error {
if !c.LexicalEnabled() || !c.StoresChunkText() {
return errors.New("attachments.documents.index.lexical and store_chunk_text must both be true until document vector publication lands")
}
- if c.Index.Embeddings.Enabled {
- return errors.New("attachments.documents.index.embeddings.enabled is not available until document vector publication lands")
+ if c.Index.Embeddings.Enabled && c.Index.Embeddings.Profile != "vector.embeddings" {
+ return fmt.Errorf("attachments.documents.index.embeddings.profile must be %q when embeddings are enabled", "vector.embeddings")
}
if slices.Contains(c.Scope.MessageTypes, "") {
return errors.New("attachments.documents.scope.message_types contains an empty value")
diff --git a/internal/documentindex/config_test.go b/internal/documentindex/config_test.go
index 686ceaa4f..4654f2a4a 100644
--- a/internal/documentindex/config_test.go
+++ b/internal/documentindex/config_test.go
@@ -51,6 +51,24 @@ func TestDocumentsConfigRejectsUnavailableIndexOptOut(t *testing.T) {
assert.False(t, config.StoresChunkText())
}
+func TestDocumentsConfigAllowsNamedEmbeddingProfileWithLexicalFallback(t *testing.T) {
+ config := DefaultDocumentsConfig()
+ config.Index.Embeddings.Enabled = true
+ config.Index.Embeddings.Profile = "vector.embeddings"
+
+ require.NoError(t, config.Validate())
+ assert.True(t, config.LexicalEnabled())
+ assert.True(t, config.StoresChunkText())
+}
+
+func TestDocumentsConfigRejectsUnknownEmbeddingProfile(t *testing.T) {
+ config := DefaultDocumentsConfig()
+ config.Index.Embeddings.Enabled = true
+ config.Index.Embeddings.Profile = "other.embeddings"
+
+ require.ErrorContains(t, config.Validate(), "profile must be \"vector.embeddings\"")
+}
+
func TestDocumentsConfigRejectsUnsafePolicy(t *testing.T) {
tests := []struct {
name string
@@ -74,7 +92,10 @@ func TestDocumentsConfigRejectsUnsafePolicy(t *testing.T) {
{name: "training", mutate: func(c *DocumentsConfig) { c.TrainingPosture = "never" }, want: "training_posture"},
{name: "lexical without text", mutate: func(c *DocumentsConfig) { disabled := false; c.Index.StoreChunkText = &disabled }, want: "must both be true"},
{name: "stored text without lexical", mutate: func(c *DocumentsConfig) { disabled := false; c.Index.Lexical = &disabled }, want: "must both be true"},
- {name: "premature vectors", mutate: func(c *DocumentsConfig) { c.Index.Embeddings.Enabled = true }, want: "not available"},
+ {name: "unknown embedding profile", mutate: func(c *DocumentsConfig) {
+ c.Index.Embeddings.Enabled = true
+ c.Index.Embeddings.Profile = "other.embeddings"
+ }, want: "profile must be \"vector.embeddings\""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -155,6 +176,32 @@ func TestDocumentsProfileFingerprintIsDeterministicAndPolicyBound(t *testing.T)
assert.NotEqual(first, seventh)
}
+func TestDocumentsProfileFingerprintExcludesEmbeddingOptIn(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ config := DefaultDocumentsConfig()
+ config.RetentionPosture = RetentionZDR
+ config.TrainingPosture = TrainingOptedOut
+ policy, err := config.MistralPolicy()
+ requirements.NoError(err)
+ manifest := testCapabilityManifest(t, policy)
+
+ before, err := config.ProfileFingerprint(manifest, []string{"application/pdf"})
+ requirements.NoError(err)
+ beforePolicy, err := config.ProfilePolicyJSON(manifest, []string{"application/pdf"})
+ requirements.NoError(err)
+
+ config.Index.Embeddings.Enabled = true
+ config.Index.Embeddings.Profile = "vector.embeddings"
+ after, err := config.ProfileFingerprint(manifest, []string{"application/pdf"})
+ requirements.NoError(err)
+ afterPolicy, err := config.ProfilePolicyJSON(manifest, []string{"application/pdf"})
+ requirements.NoError(err)
+
+ assertions.Equal(before, after)
+ assertions.Equal(string(beforePolicy), string(afterPolicy))
+}
+
func TestDocumentsProfilePolicyJSONRemainsByteStable(t *testing.T) {
require := require.New(t)
assert := assert.New(t)
@@ -169,7 +216,7 @@ func TestDocumentsProfilePolicyJSONRemainsByteStable(t *testing.T) {
policyJSON, err := config.ProfilePolicyJSON(manifest, []string{"text/csv", "application/pdf", "text/csv"})
require.NoError(err)
- expected := `{"version":1,"provider":"mistral","endpoint":"https://api.eu.mistral.ai/v1/ocr","model":"mistral-ocr-4-0","retention":"zdr","training":"opted-out","max_file_bytes":52428800,"max_pages_per_document":500,"max_response_bytes":67108864,"max_normalized_chars":25000000,"max_spool_bytes":536870912,"min_free_space_bytes":1073741824,"request_timeout_nanos":300000000000,"max_retries":3,"max_pages_per_run":10000,"max_estimated_cost_usd_per_run":50,"message_types":["chat","email"],"allowed_media_types":["application/pdf","text/csv"],"document_policy_fingerprint":"2614ee7d1bcac019ca6a7b78147be1954af97ebb22600ce0c22a36384f0813cd","lexical":true,"store_chunk_text":true,"extract_header":true,"extract_footer":true,"normalization_version":2,"max_unit_chars":1000000,"max_source_unit_bytes":4000000,"max_metadata_source_bytes":65536,"max_link_chars":2048,"max_chunk_runes":4000,"chunk_overlap":200,"max_chunks":20000}`
+ expected := `{"version":1,"provider":"mistral","endpoint":"https://api.eu.mistral.ai/v1/ocr","model":"mistral-ocr-4-0","retention":"zdr","training":"opted-out","max_file_bytes":52428800,"max_pages_per_document":500,"max_response_bytes":67108864,"max_normalized_chars":25000000,"max_spool_bytes":536870912,"min_free_space_bytes":1073741824,"request_timeout_nanos":300000000000,"max_retries":3,"max_pages_per_run":10000,"max_estimated_cost_usd_per_run":50,"message_types":["chat","email"],"allowed_media_types":["application/pdf","text/csv"],"document_policy_fingerprint":"466816abfedf47e64d25db7b38262b15d3a52077fd0856340fedd0227b600843","lexical":true,"store_chunk_text":true,"extract_header":true,"extract_footer":true,"normalization_version":3,"max_unit_chars":1000000,"max_source_unit_bytes":4000000,"max_metadata_source_bytes":65536,"max_link_chars":2048,"max_chunk_runes":4000,"chunk_overlap":200,"max_chunks":20000}`
assert.JSONEq(expected, string(policyJSON))
fingerprint, err := config.ProfileFingerprint(manifest, []string{"application/pdf", "text/csv"})
diff --git a/internal/documentindex/worker.go b/internal/documentindex/worker.go
index e6b98de8c..c170cc4a1 100644
--- a/internal/documentindex/worker.go
+++ b/internal/documentindex/worker.go
@@ -400,6 +400,8 @@ func publicationFromNormalized(
LeaseOwner: claim.LeaseOwner, LeaseFence: claim.LeaseFence,
ReturnedModel: providerResult.ReturnedModel, ProviderBytes: providerResult.ProviderBytes,
UnitsProcessed: providerResult.UnitsProcessed, ManifestChecksum: normalized.Checksum,
+ NormalizationVersion: normalized.PolicyVersion, DocumentFamily: normalized.Family,
+ UnitKind: normalized.UnitKind, NormalizedTruncated: normalized.Truncated,
RequestCount: providerResult.Metrics.Requests, RetryCount: providerResult.Metrics.Retries,
ProviderLatencyMS: requestLatencyMillis(providerResult.Metrics.Latency),
Units: make([]store.DocumentPublishedUnit, len(normalized.Units)),
@@ -410,6 +412,7 @@ func publicationFromNormalized(
Index: unit.Index, Kind: unit.Kind, Text: unit.Text, Header: unit.Header, Footer: unit.Footer,
Width: unit.Dimensions.Width, Height: unit.Dimensions.Height, DPI: unit.Dimensions.DPI,
Checksum: unit.Checksum, CharCount: unit.CharCount, Truncated: unit.Truncated,
+ HeadingMarks: unit.HeadingMarks,
}
}
for i, chunk := range normalized.Chunks {
diff --git a/internal/mcp/catalog.go b/internal/mcp/catalog.go
index 14cd56192..05f0d1260 100644
--- a/internal/mcp/catalog.go
+++ b/internal/mcp/catalog.go
@@ -677,6 +677,9 @@ func findSimilarMessagesDefinition(_ *handlers) toolDefinition {
func searchDocumentsDefinition(_ *handlers) toolDefinition {
limit := boundedIntegerSchema("Maximum results to return (default 20, max 100)", 1, 100)
limit.Default = json.RawMessage("20")
+ mode := stringSchema("Search mode: lexical (default and auto); semantic/hybrid send the query to the embedding provider",
+ "auto", "lexical", "semantic", "hybrid")
+ mode.Default = json.RawMessage(`"lexical"`)
direction := stringSchema("How the owning message relates to the person",
"from_person", "to_person", "group")
definition := readDefinition(
@@ -700,10 +703,12 @@ func searchDocumentsDefinition(_ *handlers) toolDefinition {
Type: "array", Description: "Optional union of from_person, to_person, and group; requires a person reference",
Items: direction,
},
- toolArgAfter: stringSchema("Only messages on or after YYYY-MM-DD"),
- toolArgBefore: stringSchema("Only messages before YYYY-MM-DD"),
- toolArgLimit: limit,
- toolArgCursor: stringSchema("Opaque cursor from the previous page"),
+ toolArgAfter: stringSchema("Only messages on or after YYYY-MM-DD"),
+ toolArgBefore: stringSchema("Only messages before YYYY-MM-DD"),
+ toolArgLimit: limit,
+ toolArgCursor: stringSchema("Opaque cursor from the previous page"),
+ toolArgMode: mode,
+ "candidate_limit": boundedIntegerSchema("Maximum candidates (default/max: lexical 10000; semantic/hybrid 100/1000)", 1, store.MaxLexicalDocumentSearchCandidateLimit),
}, toolArgQuery),
outputSchemaFor[store.DocumentSearchResponse](),
(*handlers).searchDocuments,
diff --git a/internal/mcp/catalog_test.go b/internal/mcp/catalog_test.go
index ea8ede187..cc36f1fe8 100644
--- a/internal/mcp/catalog_test.go
+++ b/internal/mcp/catalog_test.go
@@ -317,7 +317,7 @@ func TestCatalogSchemas(t *testing.T) {
}
if shape.document {
checks.Equal(
- []string{"after", "attachment_id", "before", "cursor", "directions", "limit", "message_id", "message_types", "participant_id", "person_id", "query", "source_ids"},
+ []string{"after", "attachment_id", "before", "candidate_limit", "cursor", "directions", "limit", "message_id", "message_types", "mode", "participant_id", "person_id", "query", "source_ids"},
toolPropertyNames(t, byName[ToolSearchDocuments]),
)
}
diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go
index 122d2e564..0be15bbad 100644
--- a/internal/mcp/handlers.go
+++ b/internal/mcp/handlers.go
@@ -23,6 +23,7 @@ import (
"go.kenn.io/msgvault/internal/store"
"go.kenn.io/msgvault/internal/vector"
"go.kenn.io/msgvault/internal/vector/chunkmatch"
+ vectordocument "go.kenn.io/msgvault/internal/vector/document"
"go.kenn.io/msgvault/internal/vector/embed"
"go.kenn.io/msgvault/internal/vector/hybrid"
"go.kenn.io/msgvault/internal/vector/visual"
@@ -761,11 +762,39 @@ func (h *handlers) searchDocuments(ctx context.Context, req toolRequest) (*toolR
limit = int(parsedLimit)
}
cursor, _ := args[toolArgCursor].(string)
+ mode, _ := args[toolArgMode].(string)
+ parsedMode := vectordocument.SearchModeAuto
+ if mode != "" {
+ parsed, parseErr := vectordocument.ParseSearchMode(mode)
+ if parseErr != nil {
+ return toolErrorResult(parseErr.Error()), nil
+ }
+ parsedMode = parsed
+ mode = string(parsed)
+ }
+ candidateLimit := 0
+ if _, found := args["candidate_limit"]; found {
+ parsed, parseErr := positiveInt64Arg(args, "candidate_limit")
+ if parseErr != nil {
+ return toolErrorResult(parseErr.Error()), nil
+ }
+ maxCandidateLimit := store.MaxLexicalDocumentSearchCandidateLimit
+ if parsedMode == vectordocument.SearchModeSemantic || parsedMode == vectordocument.SearchModeHybrid {
+ maxCandidateLimit = store.MaxDocumentSearchCandidateLimit
+ }
+ if parsed > int64(maxCandidateLimit) {
+ return toolErrorResult(fmt.Sprintf(
+ "candidate_limit must be an integer between 1 and %d for this mode", maxCandidateLimit,
+ )), nil
+ }
+ candidateLimit = int(parsed)
+ }
response, err := h.documentSearcher.SearchDocuments(ctx, store.DocumentSearchRequest{
Query: queryText, SourceIDs: sourceIDs, MessageTypes: messageTypes,
AttachmentID: attachmentID, MessageID: messageID,
PersonID: personID, ParticipantID: participantID, Directions: directions,
After: after, Before: before, PageSize: limit, Cursor: cursor,
+ SearchMode: mode, CandidateLimit: candidateLimit,
})
if err != nil {
return toolErrorResult(fmt.Sprintf("document search failed: %v", err)), nil
diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go
index 347c386b7..ecb6e998c 100644
--- a/internal/mcp/server_test.go
+++ b/internal/mcp/server_test.go
@@ -427,6 +427,7 @@ func TestSearchDocumentAttachmentsPreservesScopeAndProvenance(t *testing.T) {
"directions": []any{"from_person", "group"},
"after": "2026-08-01", "before": "2026-08-20",
"limit": float64(5), "cursor": "opaque",
+ "mode": "lexical", "candidate_limit": float64(1001),
})
assert.Equal("carton damage", searcher.request.Query)
assert.Equal([]int64{3, 7}, searcher.request.SourceIDs)
@@ -439,6 +440,8 @@ func TestSearchDocumentAttachmentsPreservesScopeAndProvenance(t *testing.T) {
require.NotNil(searcher.request.Before)
assert.Equal(5, searcher.request.PageSize)
assert.Equal("opaque", searcher.request.Cursor)
+ assert.Equal("lexical", searcher.request.SearchMode)
+ assert.Equal(1001, searcher.request.CandidateLimit)
require.Len(response.Results, 1)
assert.Equal("inspection.xlsx", response.Results[0].Filename)
assert.Equal("mistral", response.Results[0].Provider)
@@ -456,6 +459,16 @@ func TestSearchDocumentAttachmentsRejectsOutOfRangeExactID(t *testing.T) {
assert.Empty(t, searcher.request.Query, "an invalid exact filter must never be silently dropped")
}
+func TestSearchDocumentAttachmentsRejectsSemanticCandidateLimitAboveBound(t *testing.T) {
+ searcher := &recordingDocumentSearcher{}
+ h := &handlers{documentSearcher: searcher}
+ result := runToolExpectError(t, ToolSearchDocuments, h.searchDocuments, map[string]any{
+ "query": "carton damage", "mode": "semantic", "candidate_limit": float64(1001),
+ })
+ assert.Contains(t, resultText(t, result), "candidate_limit must be an integer between 1 and 1000")
+ assert.Empty(t, searcher.request.Query)
+}
+
func TestSearchRejectsInvalidQueryBeforeDispatch(t *testing.T) {
queries := []struct {
name string
diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go
index 4ada2551c..2c95d054f 100644
--- a/internal/scheduler/scheduler.go
+++ b/internal/scheduler/scheduler.go
@@ -89,13 +89,17 @@ type Scheduler struct {
// Embed job state (optional). Set via SetEmbedJob; cron.EntryID 0
// may be valid, so embedEntrySet tracks whether an entry exists.
- embedJob *EmbedJob
- embedEntry cron.EntryID
- embedEntrySet bool
- runEmbedAfterSync bool
- visualPostSync func(context.Context) error
- visualPostRunning bool
- visualPostPending bool
+ embedJob *EmbedJob
+ embedEntry cron.EntryID
+ embedEntrySet bool
+ runEmbedAfterSync bool
+ visualPostSync func(context.Context) error
+ visualPostRunning bool
+ visualPostPending bool
+ documentVectorJob func(context.Context) error
+ documentVectorEntry cron.EntryID
+ documentVectorEntrySet bool
+ runDocumentVectorAfterSync bool
ctx context.Context // cancelled on Stop
cancel context.CancelFunc // cancels ctx
@@ -318,6 +322,50 @@ func (s *Scheduler) SetEmbedJob(job *EmbedJob, schedule string, runAfterSync boo
return nil
}
+// SetDocumentVectorJob installs the bounded document-vector convergence job
+// on the same cron/post-sync policy used by message embeddings.
+func (s *Scheduler) SetDocumentVectorJob(job func(context.Context) error, schedule string, runAfterSync bool) error {
+ if job != nil && schedule != "" {
+ if err := ValidateCronExpr(schedule); err != nil {
+ return fmt.Errorf("invalid document vector cron expression %q: %w", schedule, err)
+ }
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.documentVectorEntrySet {
+ s.cron.Remove(s.documentVectorEntry)
+ s.documentVectorEntrySet = false
+ }
+ s.documentVectorJob = job
+ s.runDocumentVectorAfterSync = runAfterSync && job != nil
+ if job == nil || schedule == "" {
+ return nil
+ }
+ entry, err := s.cron.AddFunc(schedule, func() {
+ if s.isStopped() {
+ return
+ }
+ done, ok := s.beginWork()
+ if !ok {
+ return
+ }
+ defer done()
+ runCtx, endRun := s.jobContext()
+ defer endRun()
+ if runErr := job(runCtx); runErr != nil {
+ s.logger.Error("scheduled document vector reconciliation failed", "error", runErr)
+ }
+ })
+ if err != nil {
+ s.documentVectorJob = nil
+ s.runDocumentVectorAfterSync = false
+ return fmt.Errorf("register document vector cron: %w", err)
+ }
+ s.documentVectorEntry = entry
+ s.documentVectorEntrySet = true
+ return nil
+}
+
// isStopped reports s.stopped under a read lock. Used by cron
// callbacks that only need to abort on shutdown.
func (s *Scheduler) isStopped() bool {
@@ -446,6 +494,19 @@ func (s *Scheduler) runSync(email string) {
postSync.Run(embedCtx)
endEmbed()
}
+ var documentVectorPostSync func(context.Context) error
+ s.mu.RLock()
+ if s.runDocumentVectorAfterSync && s.documentVectorJob != nil && !s.stopped {
+ documentVectorPostSync = s.documentVectorJob
+ }
+ s.mu.RUnlock()
+ if documentVectorPostSync != nil {
+ documentCtx, endDocument := s.jobContext()
+ if documentErr := documentVectorPostSync(documentCtx); documentErr != nil {
+ s.logger.Error("post-sync document vector reconciliation failed", "error", documentErr)
+ }
+ endDocument()
+ }
s.startVisualPostSync()
}
diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go
index 0c1c0b817..2cb33d4cb 100644
--- a/internal/scheduler/scheduler_test.go
+++ b/internal/scheduler/scheduler_test.go
@@ -1831,6 +1831,70 @@ func TestEmbedJob_Run_NilSafe(t *testing.T) {
// ---------- SetEmbedJob tests ----------
+func TestSchedulerSetDocumentVectorJobUsesEmbeddingSchedulePolicy(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ s := New(func(context.Context, string) error { return nil })
+ t.Cleanup(func() { <-s.Stop().Done() })
+ called := 0
+ requirements.NoError(s.SetDocumentVectorJob(func(context.Context) error {
+ called++
+ return nil
+ }, "*/5 * * * *", true))
+ assertions.True(s.documentVectorEntrySet)
+ assertions.True(s.runDocumentVectorAfterSync)
+ requirements.ErrorContains(s.SetDocumentVectorJob(func(context.Context) error { return nil }, "invalid", false), "invalid")
+ assertions.True(s.documentVectorEntrySet, "invalid replacement preserves the prior job")
+ assertions.Zero(called)
+}
+
+func TestSchedulerDocumentVectorJobRunsOnlyAfterSuccessfulSync(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ syncErr error
+ wantRuns int64
+ }{
+ {name: "successful sync", wantRuns: 1},
+ {name: "failed sync", syncErr: errors.New("sync failed")},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ syncDone := make(chan struct{})
+ s := New(func(context.Context, string) error {
+ close(syncDone)
+ return test.syncErr
+ })
+ var runs atomic.Int64
+ documentDone := make(chan struct{}, 1)
+ requirements.NoError(s.SetDocumentVectorJob(func(context.Context) error {
+ runs.Add(1)
+ documentDone <- struct{}{}
+ return nil
+ }, "", true))
+ requirements.NoError(s.AddAccount("test@example.test", "0 0 1 1 *"))
+ s.Start()
+ t.Cleanup(func() { <-s.Stop().Done() })
+ requirements.NoError(s.TriggerSync("test@example.test"))
+ select {
+ case <-syncDone:
+ case <-time.After(time.Second):
+ requirements.Fail("sync did not complete")
+ }
+ if test.wantRuns == 1 {
+ select {
+ case <-documentDone:
+ case <-time.After(time.Second):
+ requirements.Fail("document vector job did not run")
+ }
+ } else {
+ assertions.Never(func() bool { return runs.Load() != 0 }, 100*time.Millisecond, 5*time.Millisecond)
+ }
+ assertions.Equal(test.wantRuns, runs.Load())
+ })
+ }
+}
+
func TestScheduler_SetEmbedJob_AddsCronEntry(t *testing.T) {
require := require.New(t)
assert := assert.New(t)
diff --git a/internal/store/dialect_pg.go b/internal/store/dialect_pg.go
index ebbd2f443..33303cb5e 100644
--- a/internal/store/dialect_pg.go
+++ b/internal/store/dialect_pg.go
@@ -659,6 +659,11 @@ func (d *PostgreSQLDialect) LegacyColumnMigrations() []ColumnMigration {
{`ALTER TABLE document_extractions ADD COLUMN IF NOT EXISTS request_count INTEGER NOT NULL DEFAULT 0 CHECK (request_count >= 0)`, "document_extractions.request_count"},
{`ALTER TABLE document_extractions ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0 AND retry_count <= request_count)`, "document_extractions.retry_count"},
{`ALTER TABLE document_extractions ADD COLUMN IF NOT EXISTS provider_latency_ms BIGINT NOT NULL DEFAULT 0 CHECK (provider_latency_ms >= 0)`, "document_extractions.provider_latency_ms"},
+ {`ALTER TABLE document_extractions ADD COLUMN IF NOT EXISTS normalization_version INTEGER`, "document_extractions.normalization_version"},
+ {`ALTER TABLE document_extractions ADD COLUMN IF NOT EXISTS document_family TEXT`, "document_extractions.document_family"},
+ {`ALTER TABLE document_extractions ADD COLUMN IF NOT EXISTS unit_kind TEXT`, "document_extractions.unit_kind"},
+ {`ALTER TABLE document_extractions ADD COLUMN IF NOT EXISTS normalized_truncated BOOLEAN NOT NULL DEFAULT FALSE`, "document_extractions.normalized_truncated"},
+ {`ALTER TABLE document_units ADD COLUMN IF NOT EXISTS heading_marks JSONB NOT NULL DEFAULT '[]'::jsonb`, "document_units.heading_marks"},
{`ALTER TABLE document_index_state ADD COLUMN IF NOT EXISTS target_profile_id TEXT`, "document_index_state.target_profile_id"},
{`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS attachment_state TEXT`, "attachments.attachment_state"},
{`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS attachment_skip_reason TEXT`, "attachments.attachment_skip_reason"},
diff --git a/internal/store/dialect_sqlite.go b/internal/store/dialect_sqlite.go
index c545a6e4a..4a43322e1 100644
--- a/internal/store/dialect_sqlite.go
+++ b/internal/store/dialect_sqlite.go
@@ -1614,6 +1614,11 @@ func (d *SQLiteDialect) LegacyColumnMigrations() []ColumnMigration {
{`ALTER TABLE document_extractions ADD COLUMN request_count INTEGER NOT NULL DEFAULT 0 CHECK (request_count >= 0)`, "document_extractions.request_count"},
{`ALTER TABLE document_extractions ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0 AND retry_count <= request_count)`, "document_extractions.retry_count"},
{`ALTER TABLE document_extractions ADD COLUMN provider_latency_ms INTEGER NOT NULL DEFAULT 0 CHECK (provider_latency_ms >= 0)`, "document_extractions.provider_latency_ms"},
+ {`ALTER TABLE document_extractions ADD COLUMN normalization_version INTEGER`, "document_extractions.normalization_version"},
+ {`ALTER TABLE document_extractions ADD COLUMN document_family TEXT`, "document_extractions.document_family"},
+ {`ALTER TABLE document_extractions ADD COLUMN unit_kind TEXT`, "document_extractions.unit_kind"},
+ {`ALTER TABLE document_extractions ADD COLUMN normalized_truncated BOOLEAN NOT NULL DEFAULT FALSE`, "document_extractions.normalized_truncated"},
+ {`ALTER TABLE document_units ADD COLUMN heading_marks JSON NOT NULL DEFAULT '[]'`, "document_units.heading_marks"},
{`ALTER TABLE document_index_state ADD COLUMN target_profile_id TEXT`, "document_index_state.target_profile_id"},
{`ALTER TABLE attachments ADD COLUMN attachment_state TEXT`, "attachments.attachment_state"},
{`ALTER TABLE attachments ADD COLUMN attachment_skip_reason TEXT`, "attachments.attachment_skip_reason"},
diff --git a/internal/store/document_normalized.go b/internal/store/document_normalized.go
new file mode 100644
index 000000000..a5a200b3e
--- /dev/null
+++ b/internal/store/document_normalized.go
@@ -0,0 +1,169 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "go.kenn.io/docbank/document"
+)
+
+var ErrDocumentNormalizedIdentityUnavailable = errors.New("document normalized identity is unavailable")
+
+func currentDocumentNormalizationVersion() (int, error) {
+ policy, err := document.NewNormalizePolicy(1)
+ if err != nil {
+ return 0, fmt.Errorf("load current document normalization policy: %w", err)
+ }
+ return policy.Identity().Version, nil
+}
+
+func validateDocumentNormalizedIdentity(version int, family, unitKind string) error {
+ currentVersion, err := currentDocumentNormalizationVersion()
+ if err != nil {
+ return err
+ }
+ if version != currentVersion || strings.TrimSpace(family) == "" || strings.TrimSpace(unitKind) == "" {
+ return fmt.Errorf("%w: require normalization version %d, document family, and unit kind",
+ ErrDocumentNormalizedIdentityUnavailable, currentVersion)
+ }
+ return nil
+}
+
+func documentNormalizedIdentityRebuildError(subject string, cause error) error {
+ return fmt.Errorf("%s: %w; run `msgvault documents build --full-rebuild --capabilities PATH --yes` before document vector consent or build",
+ subject, cause)
+}
+
+// LoadNormalizedDocument reconstructs the complete immutable Docbank evidence
+// published for one extraction. The stored v3 identities are validated before
+// the document can be used to prepare provider inputs.
+func (s *Store) LoadNormalizedDocument(ctx context.Context, extractionID string) (document.NormalizedDocument, error) {
+ if extractionID == "" {
+ return document.NormalizedDocument{}, errors.New("document extraction id is required")
+ }
+ var normalized document.NormalizedDocument
+ var normalizationVersion sql.NullInt64
+ var documentFamily, unitKind sql.NullString
+ err := s.db.QueryRowContext(ctx, s.Rebind(`
+ SELECT normalization_version, document_family, unit_kind,
+ manifest_checksum, normalized_truncated
+ FROM document_extractions WHERE id = ? AND state = 'ready'`), extractionID).Scan(
+ &normalizationVersion, &documentFamily, &unitKind,
+ &normalized.Checksum, &normalized.Truncated,
+ )
+ if errors.Is(err, sql.ErrNoRows) {
+ return document.NormalizedDocument{}, fmt.Errorf("ready document extraction %q not found", extractionID)
+ }
+ if err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("read normalized document identity: %w", err)
+ }
+ if !normalizationVersion.Valid || !documentFamily.Valid || !unitKind.Valid {
+ return document.NormalizedDocument{}, documentNormalizedIdentityRebuildError(
+ fmt.Sprintf("document extraction %q", extractionID), ErrDocumentNormalizedIdentityUnavailable,
+ )
+ }
+ normalized.PolicyVersion = int(normalizationVersion.Int64)
+ normalized.Family = documentFamily.String
+ normalized.UnitKind = unitKind.String
+ if err := validateDocumentNormalizedIdentity(normalized.PolicyVersion, normalized.Family, normalized.UnitKind); err != nil {
+ return document.NormalizedDocument{}, documentNormalizedIdentityRebuildError(
+ fmt.Sprintf("document extraction %q", extractionID), err,
+ )
+ }
+ unitRows, err := s.db.QueryContext(ctx, s.Rebind(`
+ SELECT unit_index, unit_kind, text, COALESCE(header_text, ''), COALESCE(footer_text, ''),
+ COALESCE(width, 0), COALESCE(height, 0), COALESCE(dpi, 0), checksum,
+ char_count, truncated, CAST(heading_marks AS TEXT)
+ FROM document_units WHERE extraction_id = ? ORDER BY unit_index`), extractionID)
+ if err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("read normalized document units: %w", err)
+ }
+ for unitRows.Next() {
+ var unit document.NormalizedUnit
+ var headingMarks string
+ if err := unitRows.Scan(&unit.Index, &unit.Kind, &unit.Text, &unit.Header, &unit.Footer,
+ &unit.Dimensions.Width, &unit.Dimensions.Height, &unit.Dimensions.DPI,
+ &unit.Checksum, &unit.CharCount, &unit.Truncated, &headingMarks); err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("scan normalized document unit: %w", err)
+ }
+ unit.SourceKey = fmt.Sprintf("%s:%06d", normalized.UnitKind, unit.Index)
+ if err := json.Unmarshal([]byte(headingMarks), &unit.HeadingMarks); err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("decode normalized document unit headings: %w", err)
+ }
+ normalized.Units = append(normalized.Units, unit)
+ }
+ if err := unitRows.Err(); err != nil {
+ _ = unitRows.Close()
+ return document.NormalizedDocument{}, fmt.Errorf("iterate normalized document units: %w", err)
+ }
+ if err := unitRows.Close(); err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("close normalized document units: %w", err)
+ }
+
+ chunkRows, err := s.db.QueryContext(ctx, s.Rebind(`
+ SELECT chunk_key, ordinal, text, CAST(heading_path AS TEXT), checksum,
+ char_count, truncated
+ FROM document_chunks WHERE extraction_id = ? ORDER BY ordinal`), extractionID)
+ if err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("read normalized document chunks: %w", err)
+ }
+ for chunkRows.Next() {
+ var chunk document.Chunk
+ var headingPath string
+ if err := chunkRows.Scan(&chunk.Key, &chunk.Ordinal, &chunk.Text, &headingPath,
+ &chunk.Checksum, &chunk.CharCount, &chunk.Truncated); err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("scan normalized document chunk: %w", err)
+ }
+ if err := json.Unmarshal([]byte(headingPath), &chunk.HeadingPath); err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("decode normalized document chunk headings: %w", err)
+ }
+ normalized.Chunks = append(normalized.Chunks, chunk)
+ }
+ if err := chunkRows.Err(); err != nil {
+ _ = chunkRows.Close()
+ return document.NormalizedDocument{}, fmt.Errorf("iterate normalized document chunks: %w", err)
+ }
+ if err := chunkRows.Close(); err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("close normalized document chunks: %w", err)
+ }
+ chunkIndexes := make(map[string]int, len(normalized.Chunks))
+ for index := range normalized.Chunks {
+ chunkIndexes[normalized.Chunks[index].Key] = index
+ }
+ spanRows, err := s.db.QueryContext(ctx, s.Rebind(`
+ SELECT chunk_key, unit_index, start_char, end_char
+ FROM document_chunk_spans
+ WHERE extraction_id = ? ORDER BY chunk_key, span_ordinal`), extractionID)
+ if err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("read normalized document chunk spans: %w", err)
+ }
+ for spanRows.Next() {
+ var chunkKey string
+ var span document.ChunkSpan
+ if err := spanRows.Scan(&chunkKey, &span.UnitIndex, &span.CharStart, &span.CharEnd); err != nil {
+ _ = spanRows.Close()
+ return document.NormalizedDocument{}, fmt.Errorf("scan normalized document chunk span: %w", err)
+ }
+ index, ok := chunkIndexes[chunkKey]
+ if !ok {
+ _ = spanRows.Close()
+ return document.NormalizedDocument{}, fmt.Errorf("normalized document chunk span references unknown chunk %q", chunkKey)
+ }
+ normalized.Chunks[index].Spans = append(normalized.Chunks[index].Spans, span)
+ }
+ if err := spanRows.Err(); err != nil {
+ _ = spanRows.Close()
+ return document.NormalizedDocument{}, fmt.Errorf("iterate normalized document chunk spans: %w", err)
+ }
+ if err := spanRows.Close(); err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("close normalized document chunk spans: %w", err)
+ }
+ if err := document.ValidateNormalizedDocument(normalized); err != nil {
+ return document.NormalizedDocument{}, fmt.Errorf("validate stored normalized document: %w", err)
+ }
+ return normalized, nil
+}
diff --git a/internal/store/document_publication.go b/internal/store/document_publication.go
index 871aa89b2..dabfb3b55 100644
--- a/internal/store/document_publication.go
+++ b/internal/store/document_publication.go
@@ -8,6 +8,8 @@ import (
"fmt"
"time"
"unicode/utf8"
+
+ "go.kenn.io/docbank/document"
)
var (
@@ -39,17 +41,18 @@ type DocumentExtractionClaim struct {
}
type DocumentPublishedUnit struct {
- Index int
- Kind string
- Text string
- Header string
- Footer string
- Width int
- Height int
- DPI int
- Checksum string
- CharCount int
- Truncated bool
+ Index int
+ Kind string
+ Text string
+ Header string
+ Footer string
+ Width int
+ Height int
+ DPI int
+ Checksum string
+ CharCount int
+ Truncated bool
+ HeadingMarks []document.HeadingMark
}
type DocumentPublishedSpan struct {
@@ -92,6 +95,10 @@ type DocumentExtractionPublication struct {
RetryCount int
ProviderLatencyMS int64
ManifestChecksum string
+ NormalizationVersion int
+ DocumentFamily string
+ UnitKind string
+ NormalizedTruncated bool
Units []DocumentPublishedUnit
Chunks []DocumentPublishedChunk
}
@@ -439,15 +446,19 @@ func (s *Store) PublishDocumentExtraction(
return fmt.Errorf("clear staged document units: %w", err)
}
for _, unit := range publication.Units {
+ headingMarks, err := json.Marshal(unit.HeadingMarks)
+ if err != nil {
+ return fmt.Errorf("encode document unit heading marks: %w", err)
+ }
if _, err := q.Exec(`
INSERT INTO document_units
(extraction_id, unit_index, unit_kind, text, header_text,
- footer_text, width, height, dpi, checksum, char_count, truncated)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ footer_text, width, height, dpi, checksum, char_count, truncated, heading_marks)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, `+s.dialect.JSONBindExpr()+`)`,
publication.ExtractionID, unit.Index, unit.Kind, unit.Text,
nullIfEmpty(unit.Header), nullIfEmpty(unit.Footer), nullIfZero(int64(unit.Width)),
nullIfZero(int64(unit.Height)), nullIfZero(int64(unit.DPI)), unit.Checksum,
- unit.CharCount, unit.Truncated,
+ unit.CharCount, unit.Truncated, string(headingMarks),
); err != nil {
return fmt.Errorf("publish document unit %d: %w", unit.Index, err)
}
@@ -492,13 +503,16 @@ func (s *Store) PublishDocumentExtraction(
SET state = 'ready', lease_owner = NULL, lease_until = NULL,
provider_bytes = ?, units_processed = ?, returned_model = ?,
request_count = ?, retry_count = ?, provider_latency_ms = ?,
- manifest_checksum = ?, source_sequence = ?,
+ manifest_checksum = ?, normalization_version = ?, document_family = ?,
+ unit_kind = ?, normalized_truncated = ?, source_sequence = ?,
updated_at = `+s.dialect.Now()+`, published_at = `+s.dialect.Now()+`
WHERE id = ? AND profile_id = ? AND canonical_blob_hash = ?
AND extraction_input_key = ? AND state = 'staging'`,
providerBytes, publication.UnitsProcessed, publication.ReturnedModel,
publication.RequestCount, publication.RetryCount, publication.ProviderLatencyMS,
- publication.ManifestChecksum, sourceSequence, publication.ExtractionID,
+ publication.ManifestChecksum, publication.NormalizationVersion,
+ publication.DocumentFamily, publication.UnitKind, publication.NormalizedTruncated,
+ sourceSequence, publication.ExtractionID,
publication.ProfileID, publication.CanonicalBlobHash,
publication.ExtractionInputKey,
)
@@ -578,6 +592,11 @@ func validateDocumentPublication(publication DocumentExtractionPublication) erro
publication.RetryCount > publication.RequestCount || publication.ProviderLatencyMS < 0 {
return errors.New("document extraction publication has invalid provider request accounting")
}
+ if err := validateDocumentNormalizedIdentity(
+ publication.NormalizationVersion, publication.DocumentFamily, publication.UnitKind,
+ ); err != nil {
+ return fmt.Errorf("document extraction publication has invalid normalized identity: %w", err)
+ }
for index, unit := range publication.Units {
if unit.Index != index || unit.Kind == "" || !utf8.ValidString(unit.Text) ||
unit.CharCount != utf8.RuneCountInString(unit.Text) || !validLowerSHA256(unit.Checksum) ||
diff --git a/internal/store/document_publication_test.go b/internal/store/document_publication_test.go
index 696f79efa..5ded92c36 100644
--- a/internal/store/document_publication_test.go
+++ b/internal/store/document_publication_test.go
@@ -7,10 +7,70 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ docbankdocument "go.kenn.io/docbank/document"
"go.kenn.io/msgvault/internal/store"
"go.kenn.io/msgvault/internal/testutil/storetest"
)
+func TestDocumentExtractionPublicationRoundTripsNormalizedV3Identity(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f := storetest.New(t)
+ profile, hash := seedDocumentPublicationAuthority(t, f)
+ claim, err := f.Store.ClaimDocumentExtraction(t.Context(), documentClaimInputForHash(t, f, store.DocumentExtractionClaimInput{
+ ExtractionID: "extraction-normalized-v3", ProfileID: profile.ID,
+ CanonicalBlobHash: hash, ExtractionInputKey: "original",
+ LeaseOwner: "worker-normalized-v3", LeaseUntil: time.Now().UTC().Add(10 * time.Minute),
+ LocalBytes: 128, SourceSequence: 1,
+ }))
+ require.NoError(err)
+ policy, err := docbankdocument.NewNormalizePolicy(10_000)
+ require.NoError(err)
+ normalized, err := docbankdocument.NormalizeDocument(docbankdocument.SourceDocument{
+ Family: "pdf", UnitKind: "page", Units: []docbankdocument.SourceUnit{{
+ Index: 0, Markdown: "# Evidence\n\n" + strings.Repeat("stored identity evidence ", 300),
+ }},
+ }, policy)
+ require.NoError(err)
+ require.Greater(len(normalized.Chunks), 1)
+ publication := publicationFor(t, claim, normalized.Chunks[0].Text, normalized.Chunks[0].Checksum)
+ publication.ManifestChecksum = normalized.Checksum
+ publication.NormalizationVersion = normalized.PolicyVersion
+ publication.DocumentFamily = normalized.Family
+ publication.UnitKind = normalized.UnitKind
+ publication.NormalizedTruncated = normalized.Truncated
+ publication.Units[0] = store.DocumentPublishedUnit{
+ Index: 0, Kind: normalized.Units[0].Kind, Text: normalized.Units[0].Text,
+ Header: normalized.Units[0].Header, Footer: normalized.Units[0].Footer,
+ Width: normalized.Units[0].Dimensions.Width, Height: normalized.Units[0].Dimensions.Height,
+ DPI: normalized.Units[0].Dimensions.DPI, Checksum: normalized.Units[0].Checksum,
+ CharCount: normalized.Units[0].CharCount, Truncated: normalized.Units[0].Truncated,
+ HeadingMarks: normalized.Units[0].HeadingMarks,
+ }
+ publication.Chunks = make([]store.DocumentPublishedChunk, len(normalized.Chunks))
+ for index, chunk := range normalized.Chunks {
+ publication.Chunks[index] = store.DocumentPublishedChunk{
+ Key: chunk.Key, Ordinal: chunk.Ordinal, Text: chunk.Text,
+ HeadingPath: chunk.HeadingPath, Checksum: chunk.Checksum,
+ CharCount: chunk.CharCount, Truncated: chunk.Truncated,
+ Spans: []store.DocumentPublishedSpan{{
+ UnitIndex: chunk.Spans[0].UnitIndex,
+ CharStart: chunk.Spans[0].CharStart,
+ CharEnd: chunk.Spans[0].CharEnd,
+ }},
+ }
+ }
+ require.NoError(f.Store.PublishDocumentExtraction(t.Context(), publication))
+
+ logs := captureAttachmentQueryLogs(t)
+ loaded, err := f.Store.LoadNormalizedDocument(t.Context(), claim.ExtractionID)
+
+ require.NoError(err)
+ assert.Equal(normalized, loaded)
+ assert.Equal(1, strings.Count(logs.String(), "FROM document_chunk_spans"),
+ "normalized document loading must fetch every chunk span in one query")
+}
+
func TestDocumentExtractionPublicationKeepsOldHeadUntilAtomicSwitch(t *testing.T) {
require := require.New(t)
assert := assert.New(t)
@@ -42,7 +102,7 @@ func TestDocumentExtractionPublicationKeepsOldHeadUntilAtomicSwitch(t *testing.T
wrongFence.LeaseFence++
err = f.Store.RenewDocumentExtractionClaim(t.Context(), wrongFence, time.Now().UTC().Add(15*time.Minute))
require.ErrorIs(err, store.ErrDocumentExtractionFenceLost)
- require.NoError(f.Store.PublishDocumentExtraction(t.Context(), publicationFor(
+ require.NoError(f.Store.PublishDocumentExtraction(t.Context(), publicationFor(t,
firstClaim, "old searchable quasar evidence", strings.Repeat("d", 64),
)))
assert.Equal(1, documentFTSMatchCount(t, f.Store, "quasar"))
@@ -58,7 +118,7 @@ func TestDocumentExtractionPublicationKeepsOldHeadUntilAtomicSwitch(t *testing.T
assert.Equal([]string{"old searchable quasar evidence"}, currentDocumentTexts(t, f, profile.ID, hash),
"a staging replacement must not hide the ready head")
- require.NoError(f.Store.PublishDocumentExtraction(t.Context(), publicationFor(
+ require.NoError(f.Store.PublishDocumentExtraction(t.Context(), publicationFor(t,
secondClaim, "new searchable nebula evidence", strings.Repeat("e", 64),
)))
assert.Equal([]string{"new searchable nebula evidence"}, currentDocumentTexts(t, f, profile.ID, hash))
@@ -82,7 +142,7 @@ func TestDocumentExtractionPublicationRejectsInvalidSpanBeforeMutation(t *testin
LocalBytes: 128, SourceSequence: 1,
}))
require.NoError(err)
- publication := publicationFor(claim, "short text", strings.Repeat("f", 64))
+ publication := publicationFor(t, claim, "short text", strings.Repeat("f", 64))
publication.Chunks[0].Spans[0].CharEnd = 100
require.ErrorContains(f.Store.PublishDocumentExtraction(t.Context(), publication), "span 0 is invalid")
@@ -96,6 +156,31 @@ func TestDocumentExtractionPublicationRejectsInvalidSpanBeforeMutation(t *testin
assert.Equal(t, "staging", state)
}
+func TestDocumentExtractionPublicationRequiresNormalizedIdentity(t *testing.T) {
+ requirements := require.New(t)
+ f := storetest.New(t)
+ profile, hash := seedDocumentPublicationAuthority(t, f)
+ claim, err := f.Store.ClaimDocumentExtraction(t.Context(), documentClaimInputForHash(t, f, store.DocumentExtractionClaimInput{
+ ExtractionID: "extraction-missing-normalized-identity", ProfileID: profile.ID,
+ CanonicalBlobHash: hash, ExtractionInputKey: "original",
+ LeaseOwner: "worker-missing-normalized-identity", LeaseUntil: time.Now().UTC().Add(10 * time.Minute),
+ LocalBytes: 128, SourceSequence: 1,
+ }))
+ requirements.NoError(err)
+
+ for name, clearIdentity := range map[string]func(*store.DocumentExtractionPublication){
+ "normalization version": func(publication *store.DocumentExtractionPublication) { publication.NormalizationVersion = 0 },
+ "document family": func(publication *store.DocumentExtractionPublication) { publication.DocumentFamily = "" },
+ "unit kind": func(publication *store.DocumentExtractionPublication) { publication.UnitKind = "" },
+ } {
+ t.Run(name, func(t *testing.T) {
+ publication := publicationFor(t, claim, "normalized identity", strings.Repeat("f", 64))
+ clearIdentity(&publication)
+ require.ErrorContains(t, f.Store.PublishDocumentExtraction(t.Context(), publication), "normalized identity")
+ })
+ }
+}
+
func TestDocumentExtractionClaimRequiresAuthoritativeRoleProvenance(t *testing.T) {
tests := []struct {
name string
@@ -170,7 +255,7 @@ func TestDocumentExtractionPublicationRechecksClaimedOccurrenceScope(t *testing.
_, err = f.Store.DB().Exec(f.Store.Rebind(test.update), claim.OccurrenceAttachmentID)
require.NoError(err)
- err = f.Store.PublishDocumentExtraction(t.Context(), publicationFor(
+ err = f.Store.PublishDocumentExtraction(t.Context(), publicationFor(t,
claim, "must not publish", strings.Repeat("f", 64),
))
require.ErrorContains(err, "claimed occurrence is no longer eligible")
@@ -194,7 +279,7 @@ func TestDocumentExtractionPublicationAcceptsTrustedHashlessCASAlias(t *testing.
claim, err := f.Store.ClaimDocumentExtraction(t.Context(), input)
require.NoError(err)
- require.NoError(f.Store.PublishDocumentExtraction(t.Context(), publicationFor(
+ require.NoError(f.Store.PublishDocumentExtraction(t.Context(), publicationFor(t,
claim, "hashless alias evidence", strings.Repeat("a", 64),
)))
}
@@ -406,10 +491,14 @@ func seedDocumentPublicationAuthority(
}
func publicationFor(
+ t *testing.T,
claim store.DocumentExtractionClaim,
text string,
checksum string,
) store.DocumentExtractionPublication {
+ t.Helper()
+ policy, err := docbankdocument.NewNormalizePolicy(1)
+ require.NoError(t, err)
return store.DocumentExtractionPublication{
ExtractionID: claim.ExtractionID, ProfileID: claim.ProfileID,
CanonicalBlobHash: claim.CanonicalBlobHash, ExtractionInputKey: claim.ExtractionInputKey,
@@ -419,7 +508,8 @@ func publicationFor(
LeaseOwner: claim.LeaseOwner, LeaseFence: claim.LeaseFence,
ReturnedModel: "mistral-ocr-4-0", UnitsProcessed: 1,
RequestCount: 1, ProviderLatencyMS: 25,
- ManifestChecksum: strings.Repeat("c", 64),
+ ManifestChecksum: strings.Repeat("c", 64), NormalizationVersion: policy.Identity().Version,
+ DocumentFamily: "pdf", UnitKind: "page",
Units: []store.DocumentPublishedUnit{{
Index: 0, Kind: "page", Text: text, Checksum: checksum, CharCount: len([]rune(text)),
}},
diff --git a/internal/store/document_search.go b/internal/store/document_search.go
index e8a6b58b7..66047cc86 100644
--- a/internal/store/document_search.go
+++ b/internal/store/document_search.go
@@ -17,18 +17,22 @@ import (
"unicode"
"unicode/utf8"
+ docembedding "go.kenn.io/docbank/document/embedding"
"go.kenn.io/msgvault/internal/personscope"
)
const (
- documentSearchCursorVersion = 2
- documentSearchRRFConstant = 60.0
- maxDocumentSearchQueryBytes = 1_024
- maxDocumentSearchTerms = 20
- maxDocumentSearchPageSize = 100
- maxDocumentSearchOffset = 10_000
- maxDocumentSearchCandidates = 10_000
- maxDocumentSearchExcerpt = 320
+ documentSearchCursorVersion = 2
+ documentSearchRRFConstant = float64(docembedding.DefaultReciprocalRankConstant)
+ DefaultDocumentSearchCandidateLimit = docembedding.DefaultCandidateLimit
+ MaxDocumentSearchCandidateLimit = docembedding.MaxCandidateLimit
+ DefaultLexicalDocumentSearchCandidateLimit = 10_000
+ MaxLexicalDocumentSearchCandidateLimit = 10_000
+ maxDocumentSearchQueryBytes = 1_024
+ maxDocumentSearchTerms = 20
+ maxDocumentSearchPageSize = 100
+ maxDocumentSearchOffset = 10_000
+ maxDocumentSearchExcerpt = 320
)
var (
@@ -39,58 +43,81 @@ var (
)
type DocumentSearchRequest struct {
- Query string
- SourceIDs []int64
- MessageTypes []string
- AttachmentID int64
- MessageID int64
- PageSize int
- Cursor string
- After *time.Time
- Before *time.Time
- PersonID int64
- ParticipantID int64
- Directions []personscope.Direction
- Person *personscope.Scope
+ Query string `json:"query"`
+ SourceIDs []int64 `json:"source_ids,omitempty"`
+ MessageTypes []string `json:"message_types,omitempty"`
+ AttachmentID int64 `json:"attachment_id,omitempty"`
+ MessageID int64 `json:"message_id,omitempty"`
+ PageSize int `json:"page_size"`
+ Cursor string `json:"cursor,omitempty"`
+ SearchMode string `json:"search_mode,omitempty"`
+ CandidateLimit int `json:"candidate_limit,omitempty"`
+ After *time.Time `json:"after,omitempty"`
+ Before *time.Time `json:"before,omitempty"`
+ PersonID int64 `json:"person_id,omitempty"`
+ ParticipantID int64 `json:"participant_id,omitempty"`
+ Directions []personscope.Direction `json:"directions,omitempty"`
+ Person *personscope.Scope `json:"person,omitempty"`
}
type DocumentSearchResponse struct {
- Results []DocumentSearchResult `json:"results"`
- NextCursor string `json:"next_cursor,omitempty"`
- Revision int64 `json:"revision"`
- Truncated bool `json:"truncated,omitempty"`
+ Results []DocumentSearchResult `json:"results"`
+ NextCursor string `json:"next_cursor,omitempty"`
+ Revision int64 `json:"revision"`
+ Truncated bool `json:"truncated,omitempty"`
+ EffectiveMode string `json:"effective_mode,omitempty"`
+ VectorGenerationID int64 `json:"vector_generation_id,omitempty"`
+ VectorGenerationFingerprint string `json:"vector_generation_fingerprint,omitempty"`
}
type DocumentSearchResult struct {
- AttachmentID int64 `json:"attachment_id"`
- MessageID int64 `json:"message_id"`
- ConversationID int64 `json:"conversation_id"`
- SourceID int64 `json:"source_id"`
- SourceMessageID string `json:"source_message_id,omitempty"`
- OccurredAt *time.Time `json:"occurred_at,omitempty"`
- OccurrenceKey string `json:"occurrence_key"`
- SourcePartKey string `json:"source_part_key,omitempty"`
- Filename string `json:"filename,omitempty"`
- ContainingTitle string `json:"containing_title,omitempty"`
- MIMEType string `json:"mime_type,omitempty"`
- CanonicalBlobHash string `json:"canonical_blob_hash"`
- OtherLiveCopies int `json:"other_live_copies"`
- ChunkKey string `json:"chunk_key"`
- ChunkOrdinal int `json:"chunk_ordinal"`
- HeadingPath []string `json:"heading_path,omitempty"`
- FirstUnitIndex int `json:"first_unit_index"`
- LastUnitIndex int `json:"last_unit_index"`
- Excerpt string `json:"excerpt"`
- HighlightStart int `json:"highlight_start"`
- HighlightEnd int `json:"highlight_end"`
- ProfileID string `json:"profile_id"`
- ExtractionID string `json:"extraction_id"`
- Provider string `json:"provider"`
- Model string `json:"model"`
- MatchedSignals []string `json:"matched_signals"`
- Truncated bool `json:"truncated"`
- Rank int `json:"rank"`
- PersonProvenance *personscope.Provenance `json:"person_provenance,omitempty"`
+ AttachmentID int64 `json:"attachment_id"`
+ MessageID int64 `json:"message_id"`
+ ConversationID int64 `json:"conversation_id"`
+ SourceID int64 `json:"source_id"`
+ SourceMessageID string `json:"source_message_id,omitempty"`
+ OccurredAt *time.Time `json:"occurred_at,omitempty"`
+ OccurrenceKey string `json:"occurrence_key"`
+ SourcePartKey string `json:"source_part_key,omitempty"`
+ Filename string `json:"filename,omitempty"`
+ ContainingTitle string `json:"containing_title,omitempty"`
+ MIMEType string `json:"mime_type,omitempty"`
+ CanonicalBlobHash string `json:"canonical_blob_hash"`
+ OtherLiveCopies int `json:"other_live_copies"`
+ ChunkKey string `json:"chunk_key"`
+ ChunkOrdinal int `json:"chunk_ordinal"`
+ HeadingPath []string `json:"heading_path,omitempty"`
+ FirstUnitIndex int `json:"first_unit_index"`
+ LastUnitIndex int `json:"last_unit_index"`
+ Excerpt string `json:"excerpt"`
+ HighlightStart int `json:"highlight_start"`
+ HighlightEnd int `json:"highlight_end"`
+ ProfileID string `json:"profile_id"`
+ ExtractionID string `json:"extraction_id"`
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ MatchedSignals []string `json:"matched_signals"`
+ Truncated bool `json:"truncated"`
+ Rank int `json:"rank"`
+ PersonProvenance *personscope.Provenance `json:"person_provenance,omitempty"`
+ LexicalRank int `json:"lexical_rank,omitempty"`
+ SemanticRank int `json:"semantic_rank,omitempty"`
+ SemanticScore float64 `json:"semantic_score,omitempty"`
+ FusionScore float64 `json:"fusion_score,omitempty"`
+ VectorToken string `json:"vector_token,omitempty"`
+ VectorGenerationID int64 `json:"vector_generation_id,omitempty"`
+ VectorGenerationFingerprint string `json:"vector_generation_fingerprint,omitempty"`
+ VectorEmbeddingProfile string `json:"vector_embedding_profile,omitempty"`
+ VectorModel string `json:"vector_model,omitempty"`
+ VectorDimension int `json:"vector_dimension,omitempty"`
+}
+
+// DocumentVectorSearchHit is an opaque backend hit presented for authoritative
+// occurrence expansion. Rank is one-based in backend order.
+type DocumentVectorSearchHit struct {
+ Token string
+ Score float64
+ Rank int
}
type documentSearchCursor struct {
@@ -145,7 +172,7 @@ func (s *Store) SearchDocuments(
moreCandidates := contentMore || filenameMore || fusionMore
response := DocumentSearchResponse{
Revision: revision,
- Truncated: moreCandidates && candidateLimit == maxDocumentSearchCandidates,
+ Truncated: moreCandidates,
}
if offset >= len(rows) {
return response, nil
@@ -177,6 +204,160 @@ func (s *Store) SearchDocuments(
return response, nil
}
+// ResolveDocumentVectorSearchOccurrences filters backend hits through the
+// active generation's exact publication snapshot and expands surviving chunks
+// to scoped live attachment occurrences. The limit applies after occurrence
+// deduplication, so duplicate chunks and heavily reused blobs stay bounded.
+func (s *Store) ResolveDocumentVectorSearchOccurrences(
+ ctx context.Context,
+ generationID int64,
+ hits []DocumentVectorSearchHit,
+ request DocumentSearchRequest,
+ limit int,
+) ([]DocumentSearchResult, bool, error) {
+ if generationID <= 0 {
+ return nil, false, fmt.Errorf("%w: vector generation must be positive", ErrDocumentSearchInvalidRequest)
+ }
+ if limit < 1 || limit > maxDocumentVectorCandidateLimit+1 || len(hits) > maxDocumentVectorCandidateLimit {
+ return nil, false, fmt.Errorf("%w: semantic candidate bounds are invalid", ErrDocumentSearchInvalidRequest)
+ }
+ if len(hits) == 0 {
+ return []DocumentSearchResult{}, false, nil
+ }
+ var err error
+ request.SourceIDs, err = sortedUniquePositive(request.SourceIDs)
+ if err != nil {
+ return nil, false, err
+ }
+ request.MessageTypes, err = sortedUniqueNonempty(request.MessageTypes)
+ if err != nil {
+ return nil, false, err
+ }
+ if request.AttachmentID < 0 || request.MessageID < 0 {
+ return nil, false, fmt.Errorf("%w: request scope has invalid bounds", ErrDocumentSearchInvalidRequest)
+ }
+ seenTokens := make(map[string]struct{}, len(hits))
+ values := make([]string, 0, len(hits))
+ args := make([]any, 0, len(hits)*3+6)
+ for _, hit := range hits {
+ if !documentVectorFingerprintPattern.MatchString(hit.Token) || hit.Rank < 1 || math.IsNaN(hit.Score) || math.IsInf(hit.Score, 0) {
+ return nil, false, fmt.Errorf("%w: semantic hit is invalid", ErrDocumentSearchInvalidRequest)
+ }
+ if _, exists := seenTokens[hit.Token]; exists {
+ return nil, false, fmt.Errorf("%w: semantic hit token is duplicated", ErrDocumentSearchInvalidRequest)
+ }
+ seenTokens[hit.Token] = struct{}{}
+ // Explicit parameter casts keep PostgreSQL from inferring a parameter-only
+ // VALUES column as text (which would order rank 10 before rank 2).
+ values = append(values, "(CAST(? AS TEXT), CAST(? AS INTEGER), CAST(? AS DOUBLE PRECISION))")
+ args = append(args, hit.Token, hit.Rank, hit.Score)
+ }
+ conditions, scopeArgs := documentSearchScope(request, "m", "a", "cv")
+ args = append(args, generationID, string(DocumentVectorGenerationActive))
+ args = append(args, scopeArgs...)
+ args = append(args, limit+1)
+ query := `
+ WITH requested(token, semantic_rank, semantic_score) AS (
+ VALUES ` + strings.Join(values, ", ") + `
+ ), ranked AS (
+ SELECT ` + documentSearchRankedSelectColumns + `,
+ v.token AS vector_token,
+ requested.semantic_rank AS semantic_rank,
+ requested.semantic_score AS semantic_score,
+ g.id AS vector_generation_id,
+ g.fingerprint AS vector_generation_fingerprint,
+ g.embedding_profile AS vector_embedding_profile,
+ g.model AS vector_model,
+ g.dimension AS vector_dimension,
+ ROW_NUMBER() OVER (
+ PARTITION BY o.occurrence_key
+ ORDER BY requested.semantic_rank, requested.token
+ ) AS occurrence_rank
+ FROM requested
+ JOIN document_vector_publications v ON v.token = requested.token
+ JOIN document_vector_generations g ON g.id = v.generation_id
+ JOIN document_index_state ds ON ds.singleton = 1
+ JOIN document_extraction_heads h
+ ON h.extraction_id = v.extraction_id
+ AND h.profile_id = v.extraction_profile_id
+ AND h.canonical_blob_hash = v.canonical_blob_hash
+ AND h.extraction_input_key = v.extraction_input_key
+ JOIN document_extractions e ON e.id = h.extraction_id
+ JOIN document_extraction_profiles p ON p.id = h.profile_id
+ JOIN document_provider_consents c ON c.profile_id = p.id
+ JOIN document_chunks dc
+ ON dc.id = v.chunk_id AND dc.extraction_id = v.extraction_id
+ AND dc.chunk_key = v.chunk_key AND dc.checksum = v.chunk_checksum
+ JOIN document_occurrences o ON o.canonical_blob_hash = h.canonical_blob_hash
+ JOIN attachments a ON a.id = o.attachment_id
+ JOIN messages m ON m.id = o.message_id
+ JOIN conversations cv ON cv.id = m.conversation_id
+ WHERE g.id = ? AND g.state = ?
+ AND ds.target_profile_id = g.target_extraction_profile_id
+ AND v.state = 'ready'
+ AND e.source_sequence = v.source_sequence
+ AND h.source_sequence = v.source_sequence
+ AND ` + documentSearchValidity() + conditions + `
+ )
+ SELECT ` + documentSearchOuterColumns + `,
+ vector_token, semantic_rank, semantic_score,
+ vector_generation_id, vector_generation_fingerprint,
+ vector_embedding_profile, vector_model, vector_dimension
+ FROM ranked
+ WHERE occurrence_rank = 1
+ ORDER BY semantic_rank, occurrence_key
+ LIMIT ?`
+ rows, err := s.db.QueryContext(ctx, s.Rebind(query), args...)
+ if err != nil {
+ return nil, false, fmt.Errorf("resolve document vector search occurrences: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ results := make([]DocumentSearchResult, 0, limit+1)
+ for rows.Next() {
+ var result DocumentSearchResult
+ var headingJSON, text string
+ var occurredAt nullableTimestamp
+ if err := rows.Scan(
+ &result.AttachmentID, &result.MessageID, &result.ConversationID, &result.SourceID,
+ &result.SourceMessageID, &occurredAt, &result.OccurrenceKey,
+ &result.SourcePartKey, &result.Filename, &result.ContainingTitle, &result.MIMEType,
+ &result.CanonicalBlobHash, &result.ChunkKey, &result.ChunkOrdinal, &headingJSON,
+ &result.FirstUnitIndex, &result.LastUnitIndex, &text,
+ &result.ProfileID, &result.ExtractionID, &result.Provider, &result.Model,
+ &result.Truncated, &result.VectorToken, &result.SemanticRank, &result.SemanticScore,
+ &result.VectorGenerationID, &result.VectorGenerationFingerprint,
+ &result.VectorEmbeddingProfile, &result.VectorModel, &result.VectorDimension,
+ ); err != nil {
+ return nil, false, fmt.Errorf("scan document vector search occurrence: %w", err)
+ }
+ if occurredAt.Valid {
+ result.OccurredAt = &occurredAt.Time
+ }
+ if err := json.Unmarshal([]byte(headingJSON), &result.HeadingPath); err != nil {
+ return nil, false, fmt.Errorf("decode document vector search heading path: %w", err)
+ }
+ result.Excerpt, result.HighlightStart, result.HighlightEnd = documentSearchExcerpt(text, nil)
+ result.MatchedSignals = []string{"semantic"}
+ results = append(results, result)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, false, fmt.Errorf("iterate document vector search occurrences: %w", err)
+ }
+ truncated := len(results) > limit
+ if truncated {
+ results = results[:limit]
+ }
+ if err := s.populateDocumentLiveCopyCounts(ctx, results); err != nil {
+ return nil, false, err
+ }
+ if request.Person != nil {
+ if err := s.populateDocumentPersonProvenance(ctx, results, *request.Person); err != nil {
+ return nil, false, err
+ }
+ }
+ return results, truncated, nil
+}
+
func (s *Store) prepareDocumentSearch(
ctx context.Context,
request DocumentSearchRequest,
@@ -195,7 +376,11 @@ func (s *Store) prepareDocumentSearch(
if request.PageSize == 0 {
request.PageSize = 20
}
+ if request.CandidateLimit == 0 {
+ request.CandidateLimit = DefaultLexicalDocumentSearchCandidateLimit
+ }
if request.PageSize < 1 || request.PageSize > maxDocumentSearchPageSize ||
+ request.CandidateLimit < 1 || request.CandidateLimit > MaxLexicalDocumentSearchCandidateLimit ||
request.AttachmentID < 0 || request.MessageID < 0 || request.PersonID < 0 || request.ParticipantID < 0 ||
(request.PersonID > 0 && request.ParticipantID > 0) ||
((request.PersonID > 0 || request.ParticipantID > 0) && request.Person == nil) {
@@ -230,7 +415,7 @@ func (s *Store) prepareDocumentSearch(
// RRF ranks are meaningful only for one fixed candidate set. Every page
// therefore evaluates the same bounded set instead of widening it between
// cursors, which could reorder earlier results and cause skips or repeats.
- candidateLimit := maxDocumentSearchCandidates
+ candidateLimit := request.CandidateLimit
if request.Cursor != "" {
cursor, decodeErr := decodeDocumentSearchCursor(request.Cursor)
if decodeErr != nil {
@@ -258,7 +443,7 @@ func (s *Store) searchDocumentContent(
) ([]documentSearchRow, bool, error) {
ftsArg := s.dialect.BuildFTSArg(terms)
conditions, scopeArgs := documentSearchScope(request, "m", "a", "cv")
- validity := documentSearchValidity("p", "c", "h", "o", "a", "m", "ds")
+ validity := documentSearchValidity()
var query string
args := make([]any, 0, len(scopeArgs)+2)
if s.IsPostgreSQL() {
@@ -355,7 +540,7 @@ func (s *Store) searchDocumentFilenames(
JOIN messages m ON m.id = o.message_id
JOIN conversations cv ON cv.id = m.conversation_id
CROSS JOIN document_index_state ds
- WHERE ` + documentSearchValidity("p", "c", "h", "o", "a", "m", "ds") + conditions + `
+ WHERE ` + documentSearchValidity() + conditions + `
ORDER BY LOWER(COALESCE(o.filename, '')), o.occurrence_key
LIMIT ?`
args = append(args, limit+1)
@@ -401,15 +586,17 @@ const documentSearchOuterColumns = `
first_unit_index, last_unit_index, chunk_text,
profile_id, extraction_id, provider, model, truncated`
-func documentSearchValidity(profile, consent, head, occurrence, attachment, message, state string) string {
+func documentSearchValidity() string {
+ const profile, consent, head, attachment, message, state = "p", "c", "h", "a", "m", "ds"
+
return profile + `.enabled = TRUE
AND ` + profile + `.retired_at IS NULL
AND ` + consent + `.profile_fingerprint = ` + profile + `.fingerprint
AND ` + consent + `.retention_posture = ` + profile + `.retention_posture
AND ` + consent + `.training_posture = ` + profile + `.training_posture
- AND ` + occurrence + `.attachment_role = 'standalone'
+ AND o.attachment_role = 'standalone'
AND ` + attachment + `.attachment_role = 'standalone'
- AND ` + occurrence + `.role_source IN ('mime_disposition', 'provider_explicit', 'importer_semantics', 'raw_mime_repair')
+ AND o.role_source IN ('mime_disposition', 'provider_explicit', 'importer_semantics', 'raw_mime_repair')
AND ` + attachment + `.role_source IN ('mime_disposition', 'provider_explicit', 'importer_semantics', 'raw_mime_repair')
AND ` + LiveMessagesWhere(message, true) + `
AND (` + attachment + `.content_hash = ` + head + `.canonical_blob_hash
@@ -593,7 +780,7 @@ func (s *Store) populateDocumentLiveCopyCounts(
JOIN messages m ON m.id = o.message_id
CROSS JOIN document_index_state ds
WHERE h.canonical_blob_hash IN (` + documentPlaceholders(len(hashes)) + `)
- AND ` + documentSearchValidity("p", "c", "h", "o", "a", "m", "ds") + `
+ AND ` + documentSearchValidity() + `
GROUP BY h.canonical_blob_hash`
args := make([]any, len(hashes))
for index := range hashes {
@@ -740,7 +927,7 @@ func decodeDocumentSearchCursor(value string) (documentSearchCursor, error) {
if err := decoder.Decode(&cursor); err != nil || cursor.Version != documentSearchCursorVersion ||
!validLowerSHA256(cursor.RequestHash) || cursor.Revision < 0 ||
cursor.Offset <= 0 || cursor.Offset > maxDocumentSearchOffset ||
- cursor.CandidateLimit < 1 || cursor.CandidateLimit > maxDocumentSearchCandidates {
+ cursor.CandidateLimit < 1 || cursor.CandidateLimit > MaxLexicalDocumentSearchCandidateLimit {
return documentSearchCursor{}, ErrDocumentSearchInvalidCursor
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
diff --git a/internal/store/document_search_test.go b/internal/store/document_search_test.go
index f2fb722af..34649421d 100644
--- a/internal/store/document_search_test.go
+++ b/internal/store/document_search_test.go
@@ -497,13 +497,35 @@ func TestSearchDocumentsAppliesCandidateLimitAfterOccurrenceDeduplication(t *tes
assert.Contains(t, []int64{response.Results[0].AttachmentID, response.Results[1].AttachmentID}, secondAttachmentID)
}
+func TestSearchDocumentsHonorsExplicitCandidateLimit(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ f := storetest.New(t)
+ profile, hash := seedDocumentPublicationAuthority(t, f)
+ publishSearchDocument(t, f, profile, hash, "bounded nebula evidence", "search-bounded")
+ messageID := f.CreateMessage("document-search-bounded-copy")
+ attachmentID := addSearchAttachment(
+ t, f, messageID, hash, "bounded-copy.pdf", "provider:bounded-copy",
+ )
+ _, eligible, err := f.Store.ReconcileDocumentOccurrence(t.Context(), attachmentID, 2)
+ requirements.NoError(err)
+ requirements.True(eligible)
+
+ response, err := f.Store.SearchDocuments(t.Context(), store.DocumentSearchRequest{
+ Query: "nebula", CandidateLimit: 1,
+ })
+ requirements.NoError(err)
+ assertions.Len(response.Results, 1)
+ assertions.True(response.Truncated)
+}
+
func TestSearchDocumentsPaginationUsesStableRankingSet(t *testing.T) {
require := require.New(t)
f := storetest.New(t)
profile, hash := seedDocumentPublicationAuthority(t, f)
publishSearchDocument(t, f, profile, hash, "window nebula evidence", "search-window")
- const copies = 201
+ const copies = 1001
for index := 1; index < copies; index++ {
messageID := f.CreateMessage("document-search-window-" + strconv.Itoa(index))
attachmentID := addSearchAttachment(
@@ -520,7 +542,7 @@ func TestSearchDocumentsPaginationUsesStableRankingSet(t *testing.T) {
nextRank := 1
for {
response, err := f.Store.SearchDocuments(t.Context(), store.DocumentSearchRequest{
- Query: "nebula", PageSize: 10, Cursor: cursor,
+ Query: "nebula", PageSize: 100, Cursor: cursor,
})
require.NoError(err)
for _, result := range response.Results {
@@ -539,6 +561,221 @@ func TestSearchDocumentsPaginationUsesStableRankingSet(t *testing.T) {
require.Len(seen, copies, "pagination must cover the fixed ranked candidate set")
}
+func TestResolveDocumentVectorSearchOccurrencesExpandsAndBoundsAfterOccurrenceDeduplication(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 2)
+ now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
+ claims := readyAllDocumentVectorChunks(t, f, generation, now)
+ require.Len(claims, 2)
+
+ copyMessageID := f.CreateMessage("semantic-search-copy")
+ copyAttachmentID := addSearchAttachment(
+ t, f, copyMessageID, claims[0].CanonicalBlobHash, "semantic-copy.pdf", "provider:semantic-copy",
+ )
+ _, err := f.Store.DB().Exec(f.Store.Rebind(
+ `UPDATE messages SET sent_at = ? WHERE id = ?`), now, copyMessageID)
+ require.NoError(err)
+ _, eligible, err := f.Store.ReconcileDocumentOccurrence(t.Context(), copyAttachmentID, 2)
+ require.NoError(err)
+ require.True(eligible)
+ require.NoError(f.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, now.Add(time.Second)))
+
+ hits := []store.DocumentVectorSearchHit{
+ {Token: claims[0].Token, Score: .8, Rank: 10},
+ {Token: claims[1].Token, Score: .9, Rank: 2},
+ }
+ results, resultsMore, err := f.Store.ResolveDocumentVectorSearchOccurrences(
+ t.Context(), generation.ID, hits, store.DocumentSearchRequest{}, 10,
+ )
+ require.NoError(err)
+ assert.False(resultsMore)
+ require.Len(results, 2, "two chunks must expand to each occurrence, then collapse by occurrence")
+ assert.Less(results[0].OccurrenceKey, results[1].OccurrenceKey)
+ for _, result := range results {
+ assert.Equal(claims[1].Token, result.VectorToken)
+ assert.Equal(2, result.SemanticRank)
+ assert.InDelta(.9, result.SemanticScore, 1e-12)
+ assert.Equal(generation.ID, result.VectorGenerationID)
+ assert.Equal(generation.Fingerprint, result.VectorGenerationFingerprint)
+ assert.Equal(generation.EmbeddingProfile, result.VectorEmbeddingProfile)
+ assert.Equal(generation.Model, result.VectorModel)
+ assert.Equal(generation.Dimension, result.VectorDimension)
+ assert.Equal(claims[1].ChunkKey, result.ChunkKey)
+ assert.Equal(claims[1].ChunkOrdinal, result.ChunkOrdinal)
+ assert.Equal(claims[1].ExtractionID, result.ExtractionID)
+ assert.Equal(claims[1].ExtractionProfileID, result.ProfileID)
+ assert.Equal([]string{"semantic"}, result.MatchedSignals)
+ if result.AttachmentID == copyAttachmentID {
+ require.NotNil(result.OccurredAt)
+ assert.True(now.Equal(*result.OccurredAt))
+ }
+ }
+
+ bounded, boundedMore, err := f.Store.ResolveDocumentVectorSearchOccurrences(
+ t.Context(), generation.ID, hits, store.DocumentSearchRequest{}, 1,
+ )
+ require.NoError(err)
+ require.Len(bounded, 1)
+ assert.True(boundedMore)
+ assert.Equal(results[0].OccurrenceKey, bounded[0].OccurrenceKey)
+
+ scoped, scopedMore, err := f.Store.ResolveDocumentVectorSearchOccurrences(
+ t.Context(), generation.ID, hits, store.DocumentSearchRequest{AttachmentID: copyAttachmentID}, 10,
+ )
+ require.NoError(err)
+ require.Len(scoped, 1)
+ assert.False(scopedMore)
+ assert.Equal(copyAttachmentID, scoped[0].AttachmentID)
+
+ participantID := f.EnsureParticipant("semantic@example.test", "Semantic", "example.test")
+ var originalMessageID int64
+ require.NoError(f.Store.DB().QueryRow(f.Store.Rebind(
+ `SELECT message_id FROM attachments WHERE content_hash = ? AND id <> ?`),
+ claims[0].CanonicalBlobHash, copyAttachmentID).Scan(&originalMessageID))
+ _, err = f.Store.DB().Exec(f.Store.Rebind(
+ `UPDATE messages SET sender_id = ? WHERE id = ?`), participantID, originalMessageID)
+ require.NoError(err)
+ personResults, personMore, err := f.Store.ResolveDocumentVectorSearchOccurrences(
+ t.Context(), generation.ID, hits, store.DocumentSearchRequest{Person: &personscope.Scope{
+ ParticipantIDs: []int64{participantID}, Directions: []personscope.Direction{personscope.FromPerson},
+ }}, 10,
+ )
+ require.NoError(err)
+ require.Len(personResults, 1)
+ assert.False(personMore)
+ assert.Equal(&personscope.Provenance{
+ ParticipantIDs: []int64{participantID}, Roles: []personscope.Role{personscope.RoleFrom},
+ Directions: []personscope.Direction{personscope.FromPerson},
+ }, personResults[0].PersonProvenance)
+}
+
+func TestResolveDocumentVectorSearchOccurrencesBoundsUnicodeExcerpt(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f := storetest.New(t)
+ profile, hash := seedDocumentPublicationAuthority(t, f)
+ publishSearchDocument(t, f, profile, hash, strings.Repeat("界", 400), "semantic-excerpt")
+ generation, _, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{
+ Fingerprint: strings.Repeat("f", 64), TargetExtractionProfileID: profile.ID,
+ EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 3,
+ })
+ require.NoError(err)
+ now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
+ claims := readyAllDocumentVectorChunks(t, f, generation, now)
+ require.Len(claims, 1)
+ require.NoError(f.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, now.Add(time.Second)))
+
+ results, truncated, err := f.Store.ResolveDocumentVectorSearchOccurrences(t.Context(), generation.ID, []store.DocumentVectorSearchHit{
+ {Token: claims[0].Token, Score: .9, Rank: 1},
+ }, store.DocumentSearchRequest{}, 10)
+ require.NoError(err)
+ require.Len(results, 1)
+ assert.False(truncated)
+ assert.Equal(strings.Repeat("界", 320), results[0].Excerpt)
+ assert.Zero(results[0].HighlightStart)
+ assert.Zero(results[0].HighlightEnd)
+}
+
+func TestResolveDocumentVectorSearchOccurrencesHidesStaleAuthority(t *testing.T) {
+ tests := []struct {
+ name string
+ mutate func(*testing.T, *storetest.Fixture, store.DocumentVectorChunkClaim)
+ }{
+ {
+ name: "attachment replacement",
+ mutate: func(t *testing.T, f *storetest.Fixture, claim store.DocumentVectorChunkClaim) {
+ t.Helper()
+ attachmentID := documentVectorAttachmentID(t, f, claim.CanonicalBlobHash)
+ var messageID int64
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(
+ `SELECT message_id FROM attachments WHERE id = ?`), attachmentID).Scan(&messageID))
+ require.NoError(t, f.Store.UpsertAttachmentRecord(t.Context(), messageID, store.AttachmentWrite{
+ Filename: "replacement.pdf", MIMEType: "application/pdf", Size: 128,
+ StoragePath: "ee/" + strings.Repeat("e", 64), ContentHash: strings.Repeat("e", 64),
+ Role: store.AttachmentRoleStandalone, RoleSource: store.AttachmentRoleSourceImporterSemantics,
+ SourcePartKey: "mime:1.2",
+ }))
+ },
+ },
+ {
+ name: "occurrence deletion",
+ mutate: func(t *testing.T, f *storetest.Fixture, claim store.DocumentVectorChunkClaim) {
+ t.Helper()
+ attachmentID := documentVectorAttachmentID(t, f, claim.CanonicalBlobHash)
+ _, err := f.Store.DB().Exec(f.Store.Rebind(`DELETE FROM attachments WHERE id = ?`), attachmentID)
+ require.NoError(t, err)
+ },
+ },
+ {
+ name: "role change",
+ mutate: func(t *testing.T, f *storetest.Fixture, claim store.DocumentVectorChunkClaim) {
+ t.Helper()
+ attachmentID := documentVectorAttachmentID(t, f, claim.CanonicalBlobHash)
+ var messageID int64
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(
+ `SELECT message_id FROM attachments WHERE id = ?`), attachmentID).Scan(&messageID))
+ require.NoError(t, f.Store.UpsertAttachmentRecord(t.Context(), messageID, store.AttachmentWrite{
+ Filename: "inline.pdf", MIMEType: "application/pdf", Size: 128,
+ StoragePath: claim.CanonicalBlobHash[:2] + "/" + claim.CanonicalBlobHash,
+ ContentHash: claim.CanonicalBlobHash, Role: store.AttachmentRoleInline,
+ RoleSource: store.AttachmentRoleSourceMIMEDisposition, SourcePartKey: "mime:1.2",
+ }))
+ },
+ },
+ {
+ name: "message lifecycle deletion",
+ mutate: func(t *testing.T, f *storetest.Fixture, claim store.DocumentVectorChunkClaim) {
+ t.Helper()
+ var messageID int64
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(`
+ SELECT message_id FROM document_occurrences WHERE canonical_blob_hash = ?`),
+ claim.CanonicalBlobHash).Scan(&messageID))
+ _, err := f.Store.DB().Exec(f.Store.Rebind(
+ `UPDATE messages SET deleted_from_source_at = CURRENT_TIMESTAMP WHERE id = ?`), messageID)
+ require.NoError(t, err)
+ },
+ },
+ {
+ name: "target profile rotation",
+ mutate: func(t *testing.T, f *storetest.Fixture, _ store.DocumentVectorChunkClaim) {
+ t.Helper()
+ profile := rotatedDocumentVectorProfile()
+ _, err := f.Store.EnsureDocumentExtractionProfile(t.Context(), profile)
+ require.NoError(t, err)
+ require.NoError(t, f.Store.RecordDocumentProviderConsent(t.Context(), store.DocumentProviderConsent{
+ ProfileID: profile.ID, ProfileFingerprint: profile.Fingerprint,
+ RetentionPosture: profile.RetentionPosture, TrainingPosture: profile.TrainingPosture,
+ }))
+ },
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
+ claims := readyAllDocumentVectorChunks(t, f, generation, now)
+ require.Len(claims, 1)
+ require.NoError(f.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, now.Add(time.Second)))
+ test.mutate(t, f, claims[0])
+
+ results, truncated, err := f.Store.ResolveDocumentVectorSearchOccurrences(t.Context(), generation.ID, []store.DocumentVectorSearchHit{
+ {Token: claims[0].Token, Score: .9, Rank: 1},
+ }, store.DocumentSearchRequest{}, 10)
+ require.NoError(err)
+ assert.Empty(results)
+ assert.False(truncated)
+ var publications int
+ require.NoError(f.Store.DB().QueryRow(f.Store.Rebind(`
+ SELECT COUNT(*) FROM document_vector_publications WHERE generation_id = ? AND token = ?`),
+ generation.ID, claims[0].Token).Scan(&publications))
+ assert.Equal(1, publications, "authority changes hide but do not erase the token ledger")
+ })
+ }
+}
+
func publishSearchDocument(
t *testing.T,
f *storetest.Fixture,
@@ -555,7 +792,7 @@ func publishSearchDocument(
LocalBytes: 128, SourceSequence: 1,
}))
require.NoError(t, err)
- require.NoError(t, f.Store.PublishDocumentExtraction(t.Context(), publicationFor(
+ require.NoError(t, f.Store.PublishDocumentExtraction(t.Context(), publicationFor(t,
claim, text, strings.Repeat("d", 64),
)))
}
@@ -594,7 +831,7 @@ func publishManySearchChunks(
}))
require.NoError(t, err)
text := "crowded nebula evidence"
- publication := publicationFor(claim, text, strings.Repeat("d", 64))
+ publication := publicationFor(t, claim, text, strings.Repeat("d", 64))
publication.Chunks = make([]store.DocumentPublishedChunk, count)
for index := range count {
publication.Chunks[index] = store.DocumentPublishedChunk{
diff --git a/internal/store/document_vector_lock.go b/internal/store/document_vector_lock.go
new file mode 100644
index 000000000..774b4a8e6
--- /dev/null
+++ b/internal/store/document_vector_lock.go
@@ -0,0 +1,52 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "fmt"
+)
+
+const documentVectorOperationLockSQL = `hashtextextended(
+ current_database() || ':' || current_schema() || ':msgvault.document_vectors', 0)`
+
+// WithDocumentVectorOperationLock serializes document-vector writers. SQLite
+// uses a process-local lock because the archive ownership lock only excludes
+// other processes. PostgreSQL uses a schema-scoped advisory lock.
+func (s *Store) WithDocumentVectorOperationLock(ctx context.Context, operation func() error) (retErr error) {
+ if operation == nil {
+ return errors.New("document vector operation is required")
+ }
+ if !s.IsPostgreSQL() {
+ s.documentVectorOperationMu.Lock()
+ defer s.documentVectorOperationMu.Unlock()
+ return operation()
+ }
+ conn, err := s.db.Conn(ctx)
+ if err != nil {
+ return fmt.Errorf("acquire document vector operation connection: %w", err)
+ }
+ locked := false
+ defer func() {
+ if locked {
+ cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), manualTransactionCleanupTimeout)
+ defer cancel()
+ var unlocked bool
+ unlockErr := conn.QueryRowContext(
+ cleanupCtx, `SELECT pg_advisory_unlock(`+documentVectorOperationLockSQL+`)`,
+ ).Scan(&unlocked)
+ if unlockErr != nil {
+ retErr = errors.Join(retErr, fmt.Errorf("release document vector operation lock: %w", unlockErr))
+ } else if !unlocked {
+ retErr = errors.Join(retErr, errors.New("document vector operation lock was not held during release"))
+ }
+ }
+ if closeErr := conn.Close(); closeErr != nil {
+ retErr = errors.Join(retErr, fmt.Errorf("close document vector operation connection: %w", closeErr))
+ }
+ }()
+ if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock(`+documentVectorOperationLockSQL+`)`); err != nil {
+ return fmt.Errorf("acquire document vector operation lock: %w", err)
+ }
+ locked = true
+ return operation()
+}
diff --git a/internal/store/document_vector_operations.go b/internal/store/document_vector_operations.go
new file mode 100644
index 000000000..899177c23
--- /dev/null
+++ b/internal/store/document_vector_operations.go
@@ -0,0 +1,449 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// DocumentVectorConsentSpec binds hosted-processing consent to both the
+// reusable corpus policy and the exact canonical egress destination.
+type DocumentVectorConsentSpec struct {
+ DocumentVectorGenerationSpec
+
+ EgressFingerprint string `json:"egress_fingerprint"`
+ Purpose string `json:"purpose"`
+}
+
+// DocumentVectorConsent records operator consent for one exact egress policy.
+// It never contains credentials or raw provider endpoint data.
+type DocumentVectorConsent struct {
+ DocumentVectorConsentSpec
+
+ ConsentedAt time.Time `json:"consented_at"`
+}
+
+// DocumentVectorUsageDelta is locally observed provider work. It intentionally
+// excludes token usage because the provider contract does not report it.
+type DocumentVectorUsageDelta struct {
+ ProviderCalls int64 `json:"provider_calls"`
+ ProviderDocuments int64 `json:"provider_documents"`
+ ProviderChunks int64 `json:"provider_chunks"`
+ ProviderInputChars int64 `json:"provider_input_chars"`
+}
+
+// DocumentVectorProviderUsage is cumulative observed provider work for an
+// immutable generation fingerprint.
+type DocumentVectorProviderUsage struct {
+ DocumentVectorUsageDelta
+
+ Fingerprint string `json:"fingerprint"`
+ UpdatedAt time.Time `json:"updated_at,omitzero"`
+}
+
+// DocumentVectorOperationsStatus is the bounded operator view. When no
+// generation is requested, failures deterministically come from the building
+// generation first, then the active generation.
+type DocumentVectorOperationsStatus struct {
+ ConfiguredSpec DocumentVectorGenerationSpec `json:"configured_spec"`
+ ConfiguredDocumentEgressFingerprint string `json:"configured_document_egress_fingerprint"`
+ ConfiguredQueryEgressFingerprint string `json:"configured_query_egress_fingerprint"`
+ DocumentConsent *DocumentVectorConsent `json:"document_consent,omitempty"`
+ QueryConsent *DocumentVectorConsent `json:"query_consent,omitempty"`
+ Usage DocumentVectorProviderUsage `json:"usage"`
+ Active *DocumentVectorGeneration `json:"active,omitempty"`
+ Building *DocumentVectorGeneration `json:"building,omitempty"`
+ Selected *DocumentVectorGenerationStatus `json:"selected,omitempty"`
+ Coverage *DocumentVectorCoverage `json:"coverage,omitempty"`
+}
+
+// GetDocumentVectorTargetProfileID returns the configured extraction target
+// without resolving any provider credentials.
+func (s *Store) GetDocumentVectorTargetProfileID(ctx context.Context) (string, error) {
+ var target sql.NullString
+ if err := s.db.QueryRowContext(ctx, `SELECT target_profile_id FROM document_index_state WHERE singleton = 1`).Scan(&target); err != nil {
+ return "", fmt.Errorf("read document vector target profile: %w", err)
+ }
+ if !target.Valid || target.String == "" {
+ return "", ErrDocumentVectorInvalidGenerationState
+ }
+ currentVersion, err := currentDocumentNormalizationVersion()
+ if err != nil {
+ return "", err
+ }
+ var incompatible bool
+ if err := s.db.QueryRowContext(ctx, s.Rebind(`
+ SELECT EXISTS (
+ SELECT 1
+ FROM document_extraction_heads h
+ JOIN document_extractions e ON e.id = h.extraction_id
+ JOIN document_extraction_profiles p ON p.id = h.profile_id
+ JOIN document_provider_consents c ON c.profile_id = p.id
+ JOIN document_index_state ds ON ds.singleton = 1
+ WHERE h.profile_id = ?
+ AND (e.normalization_version IS NULL OR e.normalization_version <> ?
+ OR e.document_family IS NULL OR TRIM(e.document_family) = ''
+ OR e.unit_kind IS NULL OR TRIM(e.unit_kind) = '')
+ AND `+documentVectorLiveAuthoritySQL()+`
+ )`), target.String, currentVersion).Scan(&incompatible); err != nil {
+ return "", fmt.Errorf("check document vector target normalized identity: %w", err)
+ }
+ if incompatible {
+ return "", documentNormalizedIdentityRebuildError(
+ fmt.Sprintf("document vector target profile %q contains an older extraction", target.String),
+ ErrDocumentNormalizedIdentityUnavailable,
+ )
+ }
+ return target.String, nil
+}
+
+func (s *Store) RecordDocumentVectorConsent(ctx context.Context, spec DocumentVectorConsentSpec, now time.Time) (DocumentVectorConsent, bool, error) {
+ if err := validateDocumentVectorGenerationSpec(spec.DocumentVectorGenerationSpec); err != nil {
+ return DocumentVectorConsent{}, false, err
+ }
+ if !documentVectorFingerprintPattern.MatchString(spec.EgressFingerprint) {
+ return DocumentVectorConsent{}, false, errors.New("document vector consent egress fingerprint is invalid")
+ }
+ if spec.Purpose != "document_embedding" && spec.Purpose != "query_embedding" {
+ return DocumentVectorConsent{}, false, errors.New("document vector consent purpose is invalid")
+ }
+ now = normalizeDocumentVectorTime(now)
+ if now.IsZero() {
+ return DocumentVectorConsent{}, false, errors.New("document vector consent time is required")
+ }
+ var consent DocumentVectorConsent
+ var created bool
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ result, err := tx.ExecContext(ctx, s.Rebind(`
+ INSERT INTO document_vector_consents
+ (egress_fingerprint, purpose, generation_fingerprint, target_extraction_profile_id, embedding_profile, model, dimension, consented_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (egress_fingerprint) DO NOTHING`),
+ spec.EgressFingerprint, spec.Purpose, spec.Fingerprint, spec.TargetExtractionProfileID, spec.EmbeddingProfile, spec.Model,
+ spec.Dimension, s.dialect.TimestampParam(now))
+ if err != nil {
+ return fmt.Errorf("record document vector consent: %w", err)
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read document vector consent result: %w", err)
+ }
+ created = rows == 1
+ return tx.QueryRowContext(ctx, s.Rebind(`
+ SELECT egress_fingerprint, purpose, generation_fingerprint, target_extraction_profile_id, embedding_profile, model, dimension, consented_at
+ FROM document_vector_consents WHERE egress_fingerprint = ?`), spec.EgressFingerprint).Scan(
+ &consent.EgressFingerprint, &consent.Purpose, &consent.Fingerprint, &consent.TargetExtractionProfileID, &consent.EmbeddingProfile,
+ &consent.Model, &consent.Dimension, &consent.ConsentedAt)
+ })
+ if err != nil {
+ return DocumentVectorConsent{}, false, err
+ }
+ consent.ConsentedAt = normalizeDocumentVectorTime(consent.ConsentedAt)
+ if consent.DocumentVectorConsentSpec != spec {
+ return DocumentVectorConsent{}, false, fmt.Errorf("document vector consent egress fingerprint %q collides with a different immutable specification", spec.EgressFingerprint)
+ }
+ return consent, created, nil
+}
+
+func (s *Store) GetDocumentVectorConsent(ctx context.Context, egressFingerprint string) (*DocumentVectorConsent, error) {
+ if !documentVectorFingerprintPattern.MatchString(egressFingerprint) {
+ return nil, errors.New("document vector consent egress fingerprint is invalid")
+ }
+ var consent DocumentVectorConsent
+ err := s.db.QueryRowContext(ctx, s.Rebind(`
+ SELECT egress_fingerprint, purpose, generation_fingerprint, target_extraction_profile_id, embedding_profile, model, dimension, consented_at
+ FROM document_vector_consents WHERE egress_fingerprint = ?`), egressFingerprint).Scan(
+ &consent.EgressFingerprint, &consent.Purpose, &consent.Fingerprint, &consent.TargetExtractionProfileID, &consent.EmbeddingProfile,
+ &consent.Model, &consent.Dimension, &consent.ConsentedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil //nolint:nilnil // Absence is a valid optional consent lookup result.
+ }
+ if err != nil {
+ return nil, fmt.Errorf("read document vector consent: %w", err)
+ }
+ consent.ConsentedAt = normalizeDocumentVectorTime(consent.ConsentedAt)
+ return &consent, nil
+}
+
+// CheckpointDocumentVectorBuild atomically advances the durable scan cursor
+// and accumulates the observed provider work returned by one completed worker
+// invocation. It is not provider billing or an exactly-once crash counter.
+func (s *Store) CheckpointDocumentVectorBuild(ctx context.Context, generationID, afterChunkID int64, exhausted bool, delta DocumentVectorUsageDelta, now time.Time) error {
+ return s.checkpointDocumentVectorBuild(ctx, generationID, "", afterChunkID, exhausted, delta, now)
+}
+
+// CheckpointDocumentVectorBuildForFingerprint preserves observed usage even
+// if a concurrently retired generation was purged after Worker.Run returned.
+// The cursor remains generation-scoped and is never written after lifecycle loss.
+func (s *Store) CheckpointDocumentVectorBuildForFingerprint(ctx context.Context, generationID int64, fingerprint string, afterChunkID int64, exhausted bool, delta DocumentVectorUsageDelta, now time.Time) error {
+ if !documentVectorFingerprintPattern.MatchString(fingerprint) {
+ return errors.New("document vector build checkpoint fingerprint is invalid")
+ }
+ return s.checkpointDocumentVectorBuild(ctx, generationID, fingerprint, afterChunkID, exhausted, delta, now)
+}
+
+func (s *Store) checkpointDocumentVectorBuild(ctx context.Context, generationID int64, expectedFingerprint string, afterChunkID int64, exhausted bool, delta DocumentVectorUsageDelta, now time.Time) error {
+ if generationID <= 0 || afterChunkID < 0 || exhausted != (afterChunkID == 0) {
+ return errors.New("document vector build checkpoint is invalid")
+ }
+ if delta.ProviderCalls < 0 || delta.ProviderDocuments < 0 || delta.ProviderChunks < 0 || delta.ProviderInputChars < 0 {
+ return errors.New("document vector provider usage must be nonnegative")
+ }
+ now = normalizeDocumentVectorTime(now)
+ if now.IsZero() {
+ return errors.New("document vector build checkpoint time is required")
+ }
+ var lifecycleErr error
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, currentTarget, found, err := s.lockDocumentVectorGenerationIfExists(q, generationID)
+ if err != nil {
+ return err
+ }
+ fingerprint := expectedFingerprint
+ if found {
+ if err := q.QueryRow(`SELECT fingerprint FROM document_vector_generations WHERE id = ?`, generationID).Scan(&fingerprint); err != nil {
+ return fmt.Errorf("read document vector build fingerprint: %w", err)
+ }
+ if expectedFingerprint != "" && fingerprint != expectedFingerprint {
+ return errors.New("document vector build checkpoint fingerprint does not match generation")
+ }
+ } else if fingerprint == "" {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ if _, err := q.Exec(`
+ INSERT INTO document_vector_provider_usage
+ (fingerprint, provider_calls, provider_documents, provider_chunks, provider_input_chars, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT (fingerprint) DO UPDATE SET
+ provider_calls = document_vector_provider_usage.provider_calls + excluded.provider_calls,
+ provider_documents = document_vector_provider_usage.provider_documents + excluded.provider_documents,
+ provider_chunks = document_vector_provider_usage.provider_chunks + excluded.provider_chunks,
+ provider_input_chars = document_vector_provider_usage.provider_input_chars + excluded.provider_input_chars,
+ updated_at = excluded.updated_at`, fingerprint, delta.ProviderCalls, delta.ProviderDocuments,
+ delta.ProviderChunks, delta.ProviderInputChars, s.dialect.TimestampParam(now)); err != nil {
+ return fmt.Errorf("checkpoint document vector provider usage: %w", err)
+ }
+ if !found || state != DocumentVectorGenerationBuilding || !currentTarget {
+ // The provider work already happened. Commit its observed usage even
+ // when activation, retirement, or target rotation won the lifecycle
+ // race; only the resumable cursor remains building/current scoped.
+ lifecycleErr = ErrDocumentVectorInvalidGenerationState
+ return nil
+ }
+ if exhausted {
+ if _, err := q.Exec(`DELETE FROM document_vector_build_progress WHERE generation_id = ?`, generationID); err != nil {
+ return fmt.Errorf("reset document vector build cursor: %w", err)
+ }
+ return nil
+ }
+ if _, err := q.Exec(`
+ INSERT INTO document_vector_build_progress(generation_id, after_chunk_id, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT (generation_id) DO UPDATE SET after_chunk_id = excluded.after_chunk_id, updated_at = excluded.updated_at`,
+ generationID, afterChunkID, s.dialect.TimestampParam(now)); err != nil {
+ return fmt.Errorf("checkpoint document vector build cursor: %w", err)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ return lifecycleErr
+}
+
+func (s *Store) GetDocumentVectorBuildCursor(ctx context.Context, generationID int64) (int64, error) {
+ if generationID <= 0 {
+ return 0, errors.New("document vector generation id must be positive")
+ }
+ var after int64
+ err := s.db.QueryRowContext(ctx, s.Rebind(`SELECT after_chunk_id FROM document_vector_build_progress WHERE generation_id = ?`), generationID).Scan(&after)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, fmt.Errorf("read document vector build cursor: %w", err)
+ }
+ return after, nil
+}
+
+func (s *Store) GetDocumentVectorProviderUsage(ctx context.Context, fingerprint string) (DocumentVectorProviderUsage, error) {
+ if !documentVectorFingerprintPattern.MatchString(fingerprint) {
+ return DocumentVectorProviderUsage{}, errors.New("document vector usage fingerprint is invalid")
+ }
+ usage := DocumentVectorProviderUsage{Fingerprint: fingerprint}
+ err := s.db.QueryRowContext(ctx, s.Rebind(`
+ SELECT provider_calls, provider_documents, provider_chunks, provider_input_chars, updated_at
+ FROM document_vector_provider_usage WHERE fingerprint = ?`), fingerprint).Scan(
+ &usage.ProviderCalls, &usage.ProviderDocuments, &usage.ProviderChunks, &usage.ProviderInputChars, &usage.UpdatedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return usage, nil
+ }
+ if err != nil {
+ return DocumentVectorProviderUsage{}, fmt.Errorf("read document vector provider usage: %w", err)
+ }
+ usage.UpdatedAt = normalizeDocumentVectorTime(usage.UpdatedAt)
+ return usage, nil
+}
+
+func (s *Store) GetDocumentVectorOperationsStatus(ctx context.Context, configured DocumentVectorGenerationSpec, documentEgressFingerprint, queryEgressFingerprint string, generationID int64, afterToken string, limit int) (DocumentVectorOperationsStatus, error) {
+ if err := validateDocumentVectorGenerationSpec(configured); err != nil {
+ return DocumentVectorOperationsStatus{}, err
+ }
+ if generationID < 0 {
+ return DocumentVectorOperationsStatus{}, errors.New("document vector status generation id is invalid")
+ }
+ if !documentVectorFingerprintPattern.MatchString(documentEgressFingerprint) ||
+ !documentVectorFingerprintPattern.MatchString(queryEgressFingerprint) {
+ return DocumentVectorOperationsStatus{}, errors.New("document vector status egress fingerprint is invalid")
+ }
+ result := DocumentVectorOperationsStatus{
+ ConfiguredSpec: configured,
+ ConfiguredDocumentEgressFingerprint: documentEgressFingerprint,
+ ConfiguredQueryEgressFingerprint: queryEgressFingerprint,
+ }
+ var err error
+ result.DocumentConsent, err = s.GetDocumentVectorConsent(ctx, documentEgressFingerprint)
+ if err != nil {
+ return DocumentVectorOperationsStatus{}, err
+ }
+ result.QueryConsent, err = s.GetDocumentVectorConsent(ctx, queryEgressFingerprint)
+ if err != nil {
+ return DocumentVectorOperationsStatus{}, err
+ }
+ result.Usage, err = s.GetDocumentVectorProviderUsage(ctx, configured.Fingerprint)
+ if err != nil {
+ return DocumentVectorOperationsStatus{}, err
+ }
+ result.Active, err = s.GetActiveDocumentVectorGeneration(ctx)
+ if err != nil {
+ return DocumentVectorOperationsStatus{}, err
+ }
+ result.Building, err = s.GetBuildingDocumentVectorGeneration(ctx)
+ if err != nil {
+ return DocumentVectorOperationsStatus{}, err
+ }
+ selectedID := generationID
+ if selectedID == 0 && result.Building != nil {
+ selectedID = result.Building.ID
+ } else if selectedID == 0 && result.Active != nil {
+ selectedID = result.Active.ID
+ }
+ if selectedID != 0 {
+ status, err := s.GetDocumentVectorGenerationStatus(ctx, selectedID, afterToken, limit)
+ if err != nil {
+ return DocumentVectorOperationsStatus{}, err
+ }
+ result.Selected = &status
+ selectedUsesConfiguredTarget := result.Active != nil && result.Active.ID == selectedID &&
+ result.Active.TargetExtractionProfileID == configured.TargetExtractionProfileID
+ selectedUsesConfiguredTarget = selectedUsesConfiguredTarget || result.Building != nil && result.Building.ID == selectedID &&
+ result.Building.TargetExtractionProfileID == configured.TargetExtractionProfileID
+ if status.State != DocumentVectorGenerationRetired && selectedUsesConfiguredTarget {
+ coverage, err := s.GetDocumentVectorCoverage(ctx, selectedID)
+ if err != nil {
+ return DocumentVectorOperationsStatus{}, err
+ }
+ result.Coverage = &coverage
+ }
+ }
+ return result, nil
+}
+
+// GetOldestRetiredDocumentVectorGeneration returns the next durable cleanup
+// ledger. Scheduled convergence handles at most one bounded page per tick.
+func (s *Store) GetOldestRetiredDocumentVectorGeneration(ctx context.Context) (*DocumentVectorGeneration, error) {
+ generation, found, err := scanDocumentVectorGeneration(s.db.QueryRowContext(ctx, s.Rebind(`
+ SELECT id, fingerprint, target_extraction_profile_id, embedding_profile, model, dimension,
+ state, created_at, activated_at, retired_at
+ FROM document_vector_generations WHERE state = ? ORDER BY id LIMIT 1`), string(DocumentVectorGenerationRetired)))
+ if err != nil {
+ return nil, err
+ }
+ if !found {
+ return nil, nil //nolint:nilnil // Absence means there is no retired generation awaiting cleanup.
+ }
+ return &generation, nil
+}
+
+// StartDocumentVectorRebuild creates a fresh building generation for the
+// currently configured exact policy while leaving the active generation live.
+func (s *Store) StartDocumentVectorRebuild(ctx context.Context, activeGenerationID int64, desired DocumentVectorGenerationSpec, now time.Time) (DocumentVectorGeneration, error) {
+ if activeGenerationID <= 0 {
+ return DocumentVectorGeneration{}, errors.New("active document vector generation id must be positive")
+ }
+ if err := validateDocumentVectorGenerationSpec(desired); err != nil {
+ return DocumentVectorGeneration{}, err
+ }
+ now = normalizeDocumentVectorTime(now)
+ if now.IsZero() {
+ return DocumentVectorGeneration{}, errors.New("document vector rebuild time is required")
+ }
+ var result DocumentVectorGeneration
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, _, err := s.lockDocumentVectorGeneration(q, activeGenerationID)
+ if err != nil {
+ return err
+ }
+ if state != DocumentVectorGenerationActive {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ var target string
+ if err := q.QueryRow(`SELECT target_profile_id FROM document_index_state WHERE singleton = 1`).Scan(&target); err != nil {
+ return fmt.Errorf("read document vector target profile: %w", err)
+ }
+ if desired.TargetExtractionProfileID != target {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ var building int
+ if err := q.QueryRow(`SELECT COUNT(*) FROM document_vector_generations WHERE state = ?`, string(DocumentVectorGenerationBuilding)).Scan(&building); err != nil {
+ return fmt.Errorf("check building document vector generation: %w", err)
+ }
+ if building != 0 {
+ return errors.New("a document vector generation is already building")
+ }
+ rows, err := tx.QueryContext(ctx, s.Rebind(`
+ SELECT target_extraction_profile_id, embedding_profile, model, dimension
+ FROM document_vector_generations WHERE fingerprint = ?`), desired.Fingerprint)
+ if err != nil {
+ return fmt.Errorf("check document vector rebuild fingerprint: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ for rows.Next() {
+ var existing DocumentVectorGenerationSpec
+ existing.Fingerprint = desired.Fingerprint
+ if err := rows.Scan(&existing.TargetExtractionProfileID, &existing.EmbeddingProfile, &existing.Model, &existing.Dimension); err != nil {
+ return fmt.Errorf("scan document vector rebuild fingerprint: %w", err)
+ }
+ if existing != desired {
+ return fmt.Errorf("document vector generation fingerprint %q collides with a different immutable specification", desired.Fingerprint)
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return err
+ }
+ var id int64
+ if err := q.QueryRow(`INSERT INTO document_vector_generations
+ (fingerprint, target_extraction_profile_id, embedding_profile, model, dimension, state, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`, desired.Fingerprint, desired.TargetExtractionProfileID,
+ desired.EmbeddingProfile, desired.Model, desired.Dimension, string(DocumentVectorGenerationBuilding),
+ s.dialect.TimestampParam(now)).Scan(&id); err != nil {
+ return fmt.Errorf("start document vector rebuild: %w", err)
+ }
+ generation, found, err := scanDocumentVectorGeneration(q.QueryRow(`
+ SELECT id, fingerprint, target_extraction_profile_id, embedding_profile, model, dimension,
+ state, created_at, activated_at, retired_at
+ FROM document_vector_generations WHERE id = ?`, id))
+ if err != nil {
+ return err
+ }
+ if !found {
+ return fmt.Errorf("created document vector rebuild %d was not found", id)
+ }
+ result = generation
+ return nil
+ })
+ return result, err
+}
diff --git a/internal/store/document_vectors.go b/internal/store/document_vectors.go
new file mode 100644
index 000000000..d2f34b179
--- /dev/null
+++ b/internal/store/document_vectors.go
@@ -0,0 +1,1595 @@
+package store
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+
+ docembedding "go.kenn.io/docbank/document/embedding"
+)
+
+const maxDocumentVectorCandidateLimit = docembedding.MaxCandidateLimit
+
+var documentVectorFingerprintPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
+
+var (
+ ErrDocumentVectorClaimLost = errors.New("document vector chunk claim lost")
+ ErrDocumentVectorSourceChanged = errors.New("document vector chunk source changed")
+ ErrDocumentVectorInvalidGenerationState = errors.New("document vector generation state is invalid for this operation")
+ ErrDocumentVectorCoverageIncomplete = errors.New("document vector generation coverage is incomplete")
+ ErrDocumentVectorGenerationBlocked = errors.New("document vector generation is blocked by terminal failures")
+ ErrDocumentVectorCleanupIncomplete = errors.New("document vector generation backend cleanup is incomplete")
+)
+
+type DocumentVectorGenerationState string
+
+const (
+ DocumentVectorGenerationBuilding DocumentVectorGenerationState = "building"
+ DocumentVectorGenerationActive DocumentVectorGenerationState = "active"
+ DocumentVectorGenerationRetired DocumentVectorGenerationState = "retired"
+)
+
+type DocumentVectorGenerationSpec struct {
+ Fingerprint string `json:"fingerprint"`
+ TargetExtractionProfileID string `json:"target_extraction_profile_id"`
+ EmbeddingProfile string `json:"embedding_profile"`
+ Model string `json:"model"`
+ Dimension int `json:"dimension"`
+}
+
+type DocumentVectorGeneration struct {
+ DocumentVectorGenerationSpec
+
+ ID int64 `json:"id"`
+ State DocumentVectorGenerationState `json:"state"`
+ CreatedAt time.Time `json:"created_at"`
+ ActivatedAt *time.Time `json:"activated_at,omitempty"`
+ RetiredAt *time.Time `json:"retired_at,omitempty"`
+}
+
+type DocumentVectorChunkCandidate struct {
+ GenerationID, ChunkID int64
+ ExtractionID, ExtractionProfileID, CanonicalBlobHash string
+ ExtractionInputKey, ChunkKey, ChunkChecksum, Text string
+ ChunkOrdinal int
+ SourceSequence int64
+}
+
+type DocumentVectorChunkClaim struct {
+ DocumentVectorChunkCandidate
+
+ Token string
+ LeaseOwner string
+ LeaseFence int64
+ LeaseUntil time.Time
+ AttemptCount int
+}
+
+// DocumentVectorCoverage compares the current live corpus with ready publications.
+type DocumentVectorCoverage struct {
+ Required int64 `json:"required"`
+ Ready int64 `json:"ready"`
+}
+
+// Complete reports whether every currently served chunk has a ready publication.
+func (c DocumentVectorCoverage) Complete() bool { return c.Required == c.Ready }
+
+// DocumentVectorLivePublication is a ready token resolved to its current chunk snapshot.
+type DocumentVectorLivePublication struct {
+ DocumentVectorChunkCandidate
+
+ Token string
+}
+
+// DocumentVectorCleanupToken identifies one opaque backend row to delete.
+type DocumentVectorCleanupToken struct {
+ GenerationID int64
+ Token string
+}
+
+// DocumentVectorCleanupPage is one durable cleanup-parking page. Exhausted
+// pages atomically reset the restorable generation/token cursor pair.
+type DocumentVectorCleanupPage struct {
+ Tokens []DocumentVectorCleanupToken
+ AfterGenerationID int64
+ AfterToken string
+ Exhausted bool
+}
+
+// DocumentVectorFailureDiagnostic is the bounded, non-PII failure surface.
+type DocumentVectorFailureDiagnostic struct {
+ Token string `json:"token"`
+ AttemptCount int `json:"attempt_count"`
+ NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
+ Terminal bool `json:"terminal"`
+ ErrorCode string `json:"error_code"`
+}
+
+// DocumentVectorGenerationStatus counts mutually exclusive current
+// publication states. Obsolete counts retired or noncurrent snapshots;
+// CleanupPending is the uncleaned subset of Obsolete. Failures may include
+// obsolete rows so operators can inspect durable failure history.
+type DocumentVectorGenerationStatus struct {
+ GenerationID int64 `json:"generation_id"`
+ State DocumentVectorGenerationState `json:"state"`
+ Blocked bool `json:"blocked"`
+ Pending int64 `json:"pending"`
+ Retryable int64 `json:"retryable"`
+ Terminal int64 `json:"terminal"`
+ ReadyLive int64 `json:"ready_live"`
+ Obsolete int64 `json:"stale_obsolete"`
+ CleanupPending int64 `json:"cleanup_pending"`
+ Failures []DocumentVectorFailureDiagnostic `json:"failures"`
+ FailureAfterGenerationID int64 `json:"failure_after_generation_id,omitempty"`
+ FailureAfterToken string `json:"failure_after_token,omitempty"`
+ FailuresExhausted bool `json:"failures_exhausted"`
+}
+
+// DocumentVectorFailureResetResult reports one bounded stable-token scan.
+type DocumentVectorFailureResetResult struct {
+ Scanned int `json:"scanned"`
+ Reset int `json:"reset"`
+ AfterGenerationID int64 `json:"after_generation_id,omitempty"`
+ AfterToken string `json:"after_token,omitempty"`
+ Exhausted bool `json:"exhausted"`
+}
+
+func (s *Store) EnsureDocumentVectorGeneration(ctx context.Context, spec DocumentVectorGenerationSpec) (DocumentVectorGeneration, bool, error) {
+ if err := validateDocumentVectorGenerationSpec(spec); err != nil {
+ return DocumentVectorGeneration{}, false, err
+ }
+ var result DocumentVectorGeneration
+ created := false
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ // Serialize target rotation before consulting or creating a generation.
+ // This follows the same index-state -> generation lock order as claim and
+ // activation, and keeps the target check in the creation transaction.
+ if _, err := q.Exec(`UPDATE document_index_state SET revision = revision WHERE singleton = 1`); err != nil {
+ return fmt.Errorf("lock document vector index state: %w", err)
+ }
+ var currentTarget sql.NullString
+ if err := q.QueryRow(`SELECT target_profile_id FROM document_index_state WHERE singleton = 1`).Scan(¤tTarget); err != nil {
+ return fmt.Errorf("read document vector target profile: %w", err)
+ }
+ if !currentTarget.Valid || currentTarget.String != spec.TargetExtractionProfileID {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ rows, err := tx.QueryContext(ctx, `
+ SELECT id, fingerprint, target_extraction_profile_id, embedding_profile, model, dimension,
+ state, created_at, activated_at, retired_at
+ FROM document_vector_generations WHERE fingerprint = ? ORDER BY id DESC`, spec.Fingerprint)
+ if err != nil {
+ return fmt.Errorf("find document vector generation fingerprint %q: %w", spec.Fingerprint, err)
+ }
+ defer func() { _ = rows.Close() }()
+ var matching *DocumentVectorGeneration
+ for rows.Next() {
+ existing, _, scanErr := scanDocumentVectorGeneration(rows)
+ if scanErr != nil {
+ return scanErr
+ }
+ if existing.DocumentVectorGenerationSpec != spec {
+ return fmt.Errorf("document vector generation fingerprint %q collides with a different immutable specification", spec.Fingerprint)
+ }
+ if matching == nil && existing.State != DocumentVectorGenerationRetired {
+ matching = &existing
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return fmt.Errorf("iterate document vector generations: %w", err)
+ }
+ if matching != nil {
+ result = *matching
+ return nil
+ }
+ var building int
+ if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM document_vector_generations WHERE state = ?`, string(DocumentVectorGenerationBuilding)).Scan(&building); err != nil {
+ return fmt.Errorf("check building document vector generation: %w", err)
+ }
+ if building != 0 {
+ return errors.New("a document vector generation is already building")
+ }
+ var id int64
+ if err := tx.QueryRowContext(ctx, `INSERT INTO document_vector_generations
+ (fingerprint, target_extraction_profile_id, embedding_profile, model, dimension, state)
+ VALUES (?, ?, ?, ?, ?, ?) RETURNING id`, spec.Fingerprint, spec.TargetExtractionProfileID, spec.EmbeddingProfile, spec.Model, spec.Dimension, string(DocumentVectorGenerationBuilding)).Scan(&id); err != nil {
+ return fmt.Errorf("create document vector generation: %w", err)
+ }
+ generation, found, err := scanDocumentVectorGeneration(tx.QueryRowContext(ctx, `
+ SELECT id, fingerprint, target_extraction_profile_id, embedding_profile, model, dimension,
+ state, created_at, activated_at, retired_at
+ FROM document_vector_generations WHERE id = ?`, id))
+ if err != nil {
+ return err
+ }
+ if !found {
+ return fmt.Errorf("created document vector generation %d was not found", id)
+ }
+ result, created = generation, true
+ return nil
+ })
+ return result, created, err
+}
+
+func validateDocumentVectorGenerationSpec(spec DocumentVectorGenerationSpec) error {
+ if !documentVectorFingerprintPattern.MatchString(spec.Fingerprint) || strings.TrimSpace(spec.TargetExtractionProfileID) == "" || strings.TrimSpace(spec.Model) == "" {
+ return errors.New("document vector generation immutable fields are required")
+ }
+ if spec.EmbeddingProfile != "vector.embeddings" {
+ return errors.New("document vector generation embedding profile must be vector.embeddings")
+ }
+ if spec.Dimension <= 0 {
+ return errors.New("document vector generation dimension must be positive")
+ }
+ return nil
+}
+
+func (s *Store) GetDocumentVectorGeneration(ctx context.Context, id int64) (DocumentVectorGeneration, error) {
+ if id <= 0 {
+ return DocumentVectorGeneration{}, errors.New("document vector generation id must be positive")
+ }
+ g, found, err := scanDocumentVectorGeneration(s.db.QueryRowContext(ctx, s.Rebind(`SELECT id, fingerprint, target_extraction_profile_id, embedding_profile, model, dimension, state, created_at, activated_at, retired_at FROM document_vector_generations WHERE id = ?`), id))
+ if err != nil {
+ return DocumentVectorGeneration{}, err
+ }
+ if !found {
+ return DocumentVectorGeneration{}, fmt.Errorf("document vector generation %d not found", id)
+ }
+ return g, nil
+}
+
+func (s *Store) GetBuildingDocumentVectorGeneration(ctx context.Context) (*DocumentVectorGeneration, error) {
+ return s.getDocumentVectorGenerationByState(ctx, DocumentVectorGenerationBuilding)
+}
+func (s *Store) GetActiveDocumentVectorGeneration(ctx context.Context) (*DocumentVectorGeneration, error) {
+ return s.getDocumentVectorGenerationByState(ctx, DocumentVectorGenerationActive)
+}
+func (s *Store) getDocumentVectorGenerationByState(ctx context.Context, state DocumentVectorGenerationState) (*DocumentVectorGeneration, error) {
+ g, found, err := scanDocumentVectorGeneration(s.db.QueryRowContext(ctx, s.Rebind(`SELECT id, fingerprint, target_extraction_profile_id, embedding_profile, model, dimension, state, created_at, activated_at, retired_at FROM document_vector_generations WHERE state = ?`), string(state)))
+ if err != nil {
+ return nil, err
+ }
+ if !found {
+ return nil, nil //nolint:nilnil // Absence is a valid optional state lookup result.
+ }
+ return &g, nil
+}
+
+func (s *Store) ListDocumentVectorChunkCandidates(ctx context.Context, generationID, afterChunkID int64, limit int) ([]DocumentVectorChunkCandidate, error) {
+ if generationID <= 0 || afterChunkID < 0 {
+ return nil, errors.New("document vector candidate generation and after chunk id have invalid bounds")
+ }
+ if limit < 1 || limit > maxDocumentVectorCandidateLimit {
+ return nil, fmt.Errorf("document vector candidate limit must be between 1 and %d", maxDocumentVectorCandidateLimit)
+ }
+ query := `SELECT dc.id, h.extraction_id, h.profile_id, h.canonical_blob_hash, h.extraction_input_key,
+ dc.chunk_key, dc.checksum, dc.ordinal, dc.text, e.source_sequence
+ FROM (SELECT id, target_extraction_profile_id AS target_profile_id, state
+ FROM document_vector_generations WHERE id = ?) g
+ JOIN document_index_state ds ON ds.singleton = 1
+ JOIN document_extraction_heads h ON h.profile_id = g.target_profile_id
+ JOIN document_extractions e ON e.id = h.extraction_id
+ JOIN document_extraction_profiles p ON p.id = h.profile_id
+ JOIN document_provider_consents c ON c.profile_id = p.id
+ JOIN document_chunks dc ON dc.extraction_id = h.extraction_id
+ WHERE g.state <> ? AND ds.target_profile_id = g.target_profile_id AND dc.id > ?
+ AND ` + documentVectorLiveAuthoritySQL() + `
+ ORDER BY dc.id LIMIT ?`
+ rows, err := s.db.QueryContext(ctx, s.Rebind(query), generationID, string(DocumentVectorGenerationRetired), afterChunkID, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list document vector candidates: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ var out []DocumentVectorChunkCandidate
+ for rows.Next() {
+ var c DocumentVectorChunkCandidate
+ c.GenerationID = generationID
+ if err := rows.Scan(&c.ChunkID, &c.ExtractionID, &c.ExtractionProfileID, &c.CanonicalBlobHash, &c.ExtractionInputKey, &c.ChunkKey, &c.ChunkChecksum, &c.ChunkOrdinal, &c.Text, &c.SourceSequence); err != nil {
+ return nil, fmt.Errorf("scan document vector candidate: %w", err)
+ }
+ out = append(out, c)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate document vector candidates: %w", err)
+ }
+ return out, nil
+}
+
+func (s *Store) ClaimDocumentVectorChunk(
+ ctx context.Context,
+ generationID, afterChunkID int64,
+ scanLimit int,
+ owner string,
+ now time.Time,
+ leaseDuration time.Duration,
+) (*DocumentVectorChunkClaim, error) {
+ if err := validateDocumentVectorClaimRequest(generationID, afterChunkID, scanLimit, owner, now, leaseDuration); err != nil {
+ return nil, err
+ }
+ leaseUntil := normalizeDocumentVectorTime(now.Add(leaseDuration))
+ now = normalizeDocumentVectorTime(now)
+ if !leaseUntil.After(now) {
+ return nil, errors.New("document vector lease duration has no effective database precision")
+ }
+ var claimed *DocumentVectorChunkClaim
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, currentTarget, err := s.lockDocumentVectorGeneration(q, generationID)
+ if err != nil {
+ return err
+ }
+ if state != DocumentVectorGenerationBuilding || !currentTarget {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ candidates, err := s.listDocumentVectorChunkCandidatesTx(ctx, tx, generationID, afterChunkID, scanLimit)
+ if err != nil {
+ return err
+ }
+ for _, candidate := range candidates {
+ token := documentVectorToken(candidate)
+ result, err := q.Exec(`
+ INSERT INTO document_vector_publications
+ (generation_id, extraction_id, extraction_profile_id, canonical_blob_hash,
+ extraction_input_key, chunk_id, chunk_key, chunk_checksum, source_sequence,
+ token, state, lease_owner, lease_fence, lease_until, attempt_count,
+ created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 1, ?, 1, ?, ?)
+ ON CONFLICT (generation_id, extraction_id, chunk_id) DO NOTHING`,
+ candidate.GenerationID, candidate.ExtractionID, candidate.ExtractionProfileID,
+ candidate.CanonicalBlobHash, candidate.ExtractionInputKey, candidate.ChunkID,
+ candidate.ChunkKey, candidate.ChunkChecksum, candidate.SourceSequence, token,
+ owner, s.dialect.TimestampParam(leaseUntil), s.dialect.TimestampParam(now),
+ s.dialect.TimestampParam(now))
+ if err != nil {
+ return fmt.Errorf("create document vector chunk claim: %w", err)
+ }
+ inserted, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read document vector chunk claim result: %w", err)
+ }
+ if inserted == 1 {
+ claimed = documentVectorChunkClaim(candidate, token, owner, 1, leaseUntil, 1)
+ return nil
+ }
+
+ publication, found, err := s.getDocumentVectorPublicationForClaim(q, candidate)
+ if err != nil {
+ return err
+ }
+ if !found {
+ continue
+ }
+ if publication.token != token {
+ return errors.New("document vector publication token does not match its immutable identity")
+ }
+ switch publication.state {
+ case "ready":
+ continue
+ case "pending":
+ if publication.leaseUntil.Valid && publication.leaseUntil.Time.After(now) {
+ if publication.leaseOwner.String == owner {
+ claimed = documentVectorChunkClaim(candidate, token, owner, publication.leaseFence, publication.leaseUntil.Time, publication.attemptCount)
+ return nil
+ }
+ continue
+ }
+ case "failed":
+ if !publication.nextRetryAt.Valid || publication.nextRetryAt.Time.After(now) {
+ continue
+ }
+ default:
+ return fmt.Errorf("document vector publication %q has invalid state %q", token, publication.state)
+ }
+
+ result, err = q.Exec(`
+ UPDATE document_vector_publications
+ SET state = 'pending', lease_owner = ?, lease_fence = lease_fence + 1,
+ lease_until = ?, attempt_count = attempt_count + 1,
+ next_retry_at = NULL, error_code = NULL, updated_at = ?
+ WHERE generation_id = ? AND extraction_id = ? AND chunk_id = ?
+ AND lease_fence = ? AND (
+ (state = 'pending' AND (lease_until IS NULL OR lease_until <= ?))
+ OR (state = 'failed' AND next_retry_at IS NOT NULL AND next_retry_at <= ?)
+ )`, owner, s.dialect.TimestampParam(leaseUntil), s.dialect.TimestampParam(now),
+ generationID, candidate.ExtractionID, candidate.ChunkID, publication.leaseFence,
+ s.dialect.TimestampParam(now), s.dialect.TimestampParam(now))
+ if err != nil {
+ return fmt.Errorf("take over document vector chunk claim: %w", err)
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read document vector chunk takeover result: %w", err)
+ }
+ if updated == 1 {
+ claimed = documentVectorChunkClaim(candidate, token, owner, publication.leaseFence+1, leaseUntil, publication.attemptCount+1)
+ return nil
+ }
+ }
+ return nil
+ })
+ return claimed, err
+}
+
+func (s *Store) RenewDocumentVectorChunkClaim(
+ ctx context.Context,
+ generationID int64,
+ token, owner string,
+ fence int64,
+ now time.Time,
+ leaseDuration time.Duration,
+) (time.Time, error) {
+ if err := validateDocumentVectorClaimReference(generationID, token, owner, fence, now); err != nil {
+ return time.Time{}, err
+ }
+ if leaseDuration <= 0 {
+ return time.Time{}, errors.New("document vector lease duration must be positive")
+ }
+ leaseUntil := normalizeDocumentVectorTime(now.Add(leaseDuration))
+ now = normalizeDocumentVectorTime(now)
+ if !leaseUntil.After(now) {
+ return time.Time{}, errors.New("document vector lease duration has no effective database precision")
+ }
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, currentTarget, err := s.lockDocumentVectorGeneration(q, generationID)
+ if err != nil {
+ return err
+ }
+ if state != DocumentVectorGenerationBuilding || !currentTarget {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ result, err := q.Exec(`
+ UPDATE document_vector_publications SET lease_until = ?, updated_at = ?
+ WHERE generation_id = ? AND token = ? AND state = 'pending'
+ AND lease_owner = ? AND lease_fence = ? AND lease_until > ?`,
+ s.dialect.TimestampParam(leaseUntil), s.dialect.TimestampParam(now), generationID,
+ token, owner, fence, s.dialect.TimestampParam(now))
+ if err != nil {
+ return fmt.Errorf("renew document vector chunk claim: %w", err)
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read document vector chunk renewal result: %w", err)
+ }
+ if updated != 1 {
+ return ErrDocumentVectorClaimLost
+ }
+ return nil
+ })
+ if err != nil {
+ return time.Time{}, err
+ }
+ return leaseUntil, nil
+}
+
+func (s *Store) CommitDocumentVectorPublication(
+ ctx context.Context,
+ generationID int64,
+ token, owner string,
+ fence int64,
+ now time.Time,
+) error {
+ if err := validateDocumentVectorClaimReference(generationID, token, owner, fence, now); err != nil {
+ return err
+ }
+ now = normalizeDocumentVectorTime(now)
+ sourceChanged := false
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, currentTarget, err := s.lockDocumentVectorGeneration(q, generationID)
+ if err != nil {
+ return err
+ }
+ publication, found, err := s.getDocumentVectorPublicationByToken(q, generationID, token)
+ if err != nil {
+ return err
+ }
+ if !found || publication.leaseOwner.String != owner || publication.leaseFence != fence {
+ return ErrDocumentVectorClaimLost
+ }
+ if publication.state == "ready" {
+ return nil
+ }
+ if publication.state != "pending" || !publication.leaseUntil.Valid || !publication.leaseUntil.Time.After(now) {
+ return ErrDocumentVectorClaimLost
+ }
+ if state != DocumentVectorGenerationBuilding || !currentTarget {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ current, err := s.isDocumentVectorPublicationCurrent(q, publication)
+ if err != nil {
+ return err
+ }
+ if !current {
+ result, err := q.Exec(`
+ UPDATE document_vector_publications
+ SET state = 'failed', lease_owner = NULL, lease_until = NULL,
+ next_retry_at = NULL, error_code = 'source_changed',
+ backend_cleaned_at = NULL, updated_at = ?
+ WHERE generation_id = ? AND token = ? AND state = 'pending'
+ AND lease_owner = ? AND lease_fence = ?`,
+ s.dialect.TimestampParam(now), generationID, token, owner, fence)
+ if err != nil {
+ return fmt.Errorf("record changed document vector source: %w", err)
+ }
+ updated, err := result.RowsAffected()
+ if err != nil || updated != 1 {
+ return ErrDocumentVectorClaimLost
+ }
+ sourceChanged = true
+ return nil
+ }
+ result, err := q.Exec(`
+ UPDATE document_vector_publications
+ SET state = 'ready', lease_until = NULL, next_retry_at = NULL,
+ error_code = NULL, updated_at = ?
+ WHERE generation_id = ? AND token = ? AND state = 'pending'
+ AND lease_owner = ? AND lease_fence = ?`,
+ s.dialect.TimestampParam(now), generationID, token, owner, fence)
+ if err != nil {
+ return fmt.Errorf("commit document vector publication: %w", err)
+ }
+ updated, err := result.RowsAffected()
+ if err != nil || updated != 1 {
+ return ErrDocumentVectorClaimLost
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ if sourceChanged {
+ return ErrDocumentVectorSourceChanged
+ }
+ return nil
+}
+
+func (s *Store) FailDocumentVectorChunk(
+ ctx context.Context,
+ generationID int64,
+ token, owner string,
+ fence int64,
+ now time.Time,
+ nextRetryAt *time.Time,
+ terminal bool,
+ errorCode string,
+) error {
+ if err := validateDocumentVectorClaimReference(generationID, token, owner, fence, now); err != nil {
+ return err
+ }
+ now = normalizeDocumentVectorTime(now)
+ var normalizedRetryAt *time.Time
+ if nextRetryAt != nil {
+ retryAt := normalizeDocumentVectorTime(*nextRetryAt)
+ normalizedRetryAt = &retryAt
+ }
+ if err := validateDocumentVectorFailure(now, normalizedRetryAt, terminal, errorCode); err != nil {
+ return err
+ }
+ var retryAt any
+ if normalizedRetryAt != nil {
+ retryAt = s.dialect.TimestampParam(*normalizedRetryAt)
+ }
+ return s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, currentTarget, err := s.lockDocumentVectorGeneration(q, generationID)
+ if err != nil {
+ return err
+ }
+ if state != DocumentVectorGenerationBuilding || !currentTarget {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ result, err := q.Exec(`
+ UPDATE document_vector_publications
+ SET state = 'failed', lease_owner = NULL, lease_until = NULL,
+ next_retry_at = ?, error_code = ?, updated_at = ?
+ WHERE generation_id = ? AND token = ? AND state = 'pending'
+ AND lease_owner = ? AND lease_fence = ? AND lease_until > ?`,
+ retryAt, errorCode, s.dialect.TimestampParam(now), generationID, token,
+ owner, fence, s.dialect.TimestampParam(now))
+ if err != nil {
+ return fmt.Errorf("record document vector chunk failure: %w", err)
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read document vector chunk failure result: %w", err)
+ }
+ if updated != 1 {
+ return ErrDocumentVectorClaimLost
+ }
+ return nil
+ })
+}
+
+// GetDocumentVectorGenerationStatus returns exact ledger counts plus one
+// bounded page of stable-token failure diagnostics.
+func (s *Store) GetDocumentVectorGenerationStatus(
+ ctx context.Context, generationID int64, afterToken string, limit int,
+) (DocumentVectorGenerationStatus, error) {
+ if generationID <= 0 {
+ return DocumentVectorGenerationStatus{}, errors.New("document vector generation id must be positive")
+ }
+ if afterToken != "" && !documentVectorFingerprintPattern.MatchString(afterToken) {
+ return DocumentVectorGenerationStatus{}, errors.New("document vector failure cursor must be a lowercase SHA-256 value")
+ }
+ if limit < 1 || limit > maxDocumentVectorCandidateLimit {
+ return DocumentVectorGenerationStatus{}, fmt.Errorf("document vector failure limit must be between 1 and %d", maxDocumentVectorCandidateLimit)
+ }
+ status := DocumentVectorGenerationStatus{GenerationID: generationID}
+ err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error {
+ current := documentVectorPublicationSnapshotCurrentSQL("v", "g")
+ parked := `(v.state = 'failed' AND v.error_code = 'source_changed')`
+ query := `SELECT g.state,
+ COALESCE(SUM(CASE WHEN v.token IS NOT NULL AND g.state <> 'retired' AND NOT (` + parked + `) AND ` + current + ` AND v.state = 'pending' THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN v.token IS NOT NULL AND g.state <> 'retired' AND NOT (` + parked + `) AND ` + current + ` AND v.state = 'failed' AND v.next_retry_at IS NOT NULL THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN v.token IS NOT NULL AND g.state <> 'retired' AND NOT (` + parked + `) AND ` + current + ` AND v.state = 'failed' AND v.next_retry_at IS NULL THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN v.token IS NOT NULL AND g.state <> 'retired' AND NOT (` + parked + `) AND ` + current + ` AND v.state = 'ready' THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN v.token IS NOT NULL AND (g.state = 'retired' OR ` + parked + ` OR NOT (` + current + `)) THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN v.token IS NOT NULL AND v.backend_cleaned_at IS NULL AND (g.state = 'retired' OR ` + parked + ` OR NOT (` + current + `)) THEN 1 ELSE 0 END), 0)
+ FROM document_vector_generations g
+ LEFT JOIN document_vector_publications v ON v.generation_id = g.id
+ WHERE g.id = ? GROUP BY g.state`
+ if err := tx.QueryRowContext(ctx, query, generationID).Scan(
+ &status.State, &status.Pending, &status.Retryable, &status.Terminal,
+ &status.ReadyLive, &status.Obsolete, &status.CleanupPending,
+ ); errors.Is(err, sql.ErrNoRows) {
+ return ErrDocumentVectorInvalidGenerationState
+ } else if err != nil {
+ return fmt.Errorf("count document vector generation status: %w", err)
+ }
+ rows, err := tx.QueryContext(ctx, `
+ SELECT token, attempt_count, next_retry_at, error_code
+ FROM document_vector_publications
+ WHERE generation_id = ? AND state = 'failed' AND token > ?
+ ORDER BY token LIMIT ?`, generationID, afterToken, limit)
+ if err != nil {
+ return fmt.Errorf("list document vector failure diagnostics: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ for rows.Next() {
+ var diagnostic DocumentVectorFailureDiagnostic
+ var retryAt sql.NullTime
+ var errorCode sql.NullString
+ if err := rows.Scan(&diagnostic.Token, &diagnostic.AttemptCount, &retryAt, &errorCode); err != nil {
+ return fmt.Errorf("scan document vector failure diagnostic: %w", err)
+ }
+ if retryAt.Valid {
+ normalized := normalizeDocumentVectorTime(retryAt.Time)
+ diagnostic.NextRetryAt = &normalized
+ }
+ diagnostic.Terminal = !retryAt.Valid
+ diagnostic.ErrorCode = boundedDocumentVectorErrorCode(errorCode.String)
+ status.Failures = append(status.Failures, diagnostic)
+ }
+ if err := rows.Err(); err != nil {
+ return fmt.Errorf("iterate document vector failure diagnostics: %w", err)
+ }
+ if len(status.Failures) < limit {
+ status.FailuresExhausted = true
+ status.FailureAfterGenerationID = 0
+ status.FailureAfterToken = ""
+ } else {
+ status.FailureAfterGenerationID = generationID
+ status.FailureAfterToken = status.Failures[len(status.Failures)-1].Token
+ }
+ status.Blocked = status.State == DocumentVectorGenerationBuilding && status.Terminal > 0
+ return nil
+ })
+ return status, err
+}
+
+// ResetDocumentVectorFailures makes one bounded page of current failed
+// snapshots claimable again. Source-changed and noncurrent rows remain parked.
+func (s *Store) ResetDocumentVectorFailures(
+ ctx context.Context, generationID int64, afterToken string, limit int, now time.Time,
+) (DocumentVectorFailureResetResult, error) {
+ if generationID <= 0 || now.IsZero() {
+ return DocumentVectorFailureResetResult{}, errors.New("document vector failure reset requires a generation and time")
+ }
+ if afterToken != "" && !documentVectorFingerprintPattern.MatchString(afterToken) {
+ return DocumentVectorFailureResetResult{}, errors.New("document vector failure reset cursor must be a lowercase SHA-256 value")
+ }
+ if limit < 1 || limit > maxDocumentVectorCandidateLimit {
+ return DocumentVectorFailureResetResult{}, fmt.Errorf("document vector failure reset limit must be between 1 and %d", maxDocumentVectorCandidateLimit)
+ }
+ now = normalizeDocumentVectorTime(now)
+ var reset DocumentVectorFailureResetResult
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, currentTarget, err := s.lockDocumentVectorGeneration(q, generationID)
+ if err != nil {
+ return err
+ }
+ if state != DocumentVectorGenerationBuilding || !currentTarget {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ rows, err := tx.QueryContext(ctx, `
+ SELECT token, error_code FROM document_vector_publications
+ WHERE generation_id = ? AND state = 'failed' AND token > ?
+ ORDER BY token LIMIT ?`, generationID, afterToken, limit)
+ if err != nil {
+ return fmt.Errorf("list document vector failures to reset: %w", err)
+ }
+ type failure struct{ token, errorCode string }
+ var failures []failure
+ for rows.Next() {
+ var item failure
+ if err := rows.Scan(&item.token, &item.errorCode); err != nil {
+ _ = rows.Close()
+ return fmt.Errorf("scan document vector failure to reset: %w", err)
+ }
+ failures = append(failures, item)
+ }
+ if err := rows.Err(); err != nil {
+ _ = rows.Close()
+ return fmt.Errorf("iterate document vector failures to reset: %w", err)
+ }
+ if err := rows.Close(); err != nil {
+ return fmt.Errorf("close document vector failures to reset: %w", err)
+ }
+ reset.Scanned = len(failures)
+ for _, item := range failures {
+ if item.errorCode == "source_changed" {
+ continue
+ }
+ publication, found, err := s.getDocumentVectorPublicationByToken(q, generationID, item.token)
+ if err != nil {
+ return err
+ }
+ if !found || publication.state != "failed" {
+ continue
+ }
+ current, err := s.isDocumentVectorPublicationCurrent(q, publication)
+ if err != nil {
+ return err
+ }
+ if !current {
+ continue
+ }
+ result, err := q.Exec(`
+ UPDATE document_vector_publications
+ SET state = 'pending', lease_owner = NULL, lease_until = NULL,
+ attempt_count = 0, next_retry_at = NULL, error_code = NULL,
+ backend_cleaned_at = NULL, updated_at = ?
+ WHERE generation_id = ? AND token = ? AND state = 'failed'`,
+ s.dialect.TimestampParam(now), generationID, item.token)
+ if err != nil {
+ return fmt.Errorf("reset document vector failure: %w", err)
+ }
+ changed, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read document vector failure reset result: %w", err)
+ }
+ reset.Reset += int(changed)
+ }
+ if len(failures) < limit {
+ reset.Exhausted = true
+ reset.AfterGenerationID = 0
+ reset.AfterToken = ""
+ } else {
+ reset.AfterGenerationID = generationID
+ reset.AfterToken = failures[len(failures)-1].token
+ }
+ return nil
+ })
+ return reset, err
+}
+
+// GetDocumentVectorCoverage counts the current live corpus and its exact ready publications.
+func (s *Store) GetDocumentVectorCoverage(ctx context.Context, generationID int64) (DocumentVectorCoverage, error) {
+ if generationID <= 0 {
+ return DocumentVectorCoverage{}, errors.New("document vector generation id must be positive")
+ }
+ var coverage DocumentVectorCoverage
+ err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, found, err := getDocumentVectorGenerationState(q, generationID)
+ if err != nil {
+ return err
+ }
+ if !found || state == DocumentVectorGenerationRetired {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ coverage, err = s.getDocumentVectorCoverage(q, generationID)
+ return err
+ })
+ return coverage, err
+}
+
+// ActivateDocumentVectorGeneration atomically swaps in one completely built generation.
+func (s *Store) ActivateDocumentVectorGeneration(ctx context.Context, generationID int64, now time.Time) error {
+ if generationID <= 0 || now.IsZero() {
+ return errors.New("document vector activation requires a generation and time")
+ }
+ now = normalizeDocumentVectorTime(now)
+ return s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, currentTarget, err := s.lockDocumentVectorGeneration(q, generationID)
+ if err != nil {
+ return err
+ }
+ if state != DocumentVectorGenerationBuilding || !currentTarget {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ coverage, err := s.getDocumentVectorCoverage(q, generationID)
+ if err != nil {
+ return err
+ }
+ if !coverage.Complete() {
+ return ErrDocumentVectorCoverageIncomplete
+ }
+ if _, err := q.Exec(`
+ UPDATE document_vector_generations
+ SET state = 'retired', retired_at = ?
+ WHERE state = 'active' AND id <> ?`,
+ s.dialect.TimestampParam(now), generationID); err != nil {
+ return fmt.Errorf("retire prior active document vector generation: %w", err)
+ }
+ result, err := q.Exec(`
+ UPDATE document_vector_generations
+ SET state = 'active', activated_at = ?, retired_at = NULL
+ WHERE id = ? AND state = 'building'`,
+ s.dialect.TimestampParam(now), generationID)
+ if err != nil {
+ return fmt.Errorf("activate document vector generation: %w", err)
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read document vector activation result: %w", err)
+ }
+ if updated != 1 {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ return bumpDocumentIndexRevision(q)
+ })
+}
+
+// RetireDocumentVectorGeneration removes a building or active generation from service.
+func (s *Store) RetireDocumentVectorGeneration(ctx context.Context, generationID int64, now time.Time) (bool, error) {
+ if generationID <= 0 || now.IsZero() {
+ return false, errors.New("document vector retirement requires a generation and time")
+ }
+ now = normalizeDocumentVectorTime(now)
+ changed := false
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, _, found, err := s.lockDocumentVectorGenerationIfExists(q, generationID)
+ if err != nil {
+ return err
+ }
+ if !found || state == DocumentVectorGenerationRetired {
+ return nil
+ }
+ if state != DocumentVectorGenerationActive && state != DocumentVectorGenerationBuilding {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ result, err := q.Exec(`
+ UPDATE document_vector_generations
+ SET state = 'retired', retired_at = ?
+ WHERE id = ? AND state = ?`, s.dialect.TimestampParam(now), generationID, string(state))
+ if err != nil {
+ return fmt.Errorf("retire document vector generation: %w", err)
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read document vector retirement result: %w", err)
+ }
+ if updated != 1 {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ changed = true
+ if state == DocumentVectorGenerationActive {
+ return bumpDocumentIndexRevision(q)
+ }
+ return nil
+ })
+ return changed, err
+}
+
+// ResolveLiveDocumentVectorPublications filters tokens through current archive authority.
+func (s *Store) ResolveLiveDocumentVectorPublications(
+ ctx context.Context, generationID int64, tokens []string,
+) ([]DocumentVectorLivePublication, error) {
+ if generationID <= 0 {
+ return nil, errors.New("document vector generation id must be positive")
+ }
+ if len(tokens) > maxDocumentVectorCandidateLimit {
+ return nil, fmt.Errorf("document vector token limit must not exceed %d", maxDocumentVectorCandidateLimit)
+ }
+ if len(tokens) == 0 {
+ return []DocumentVectorLivePublication{}, nil
+ }
+ unique := make([]string, 0, len(tokens))
+ seen := make(map[string]struct{}, len(tokens))
+ for _, token := range tokens {
+ if !documentVectorFingerprintPattern.MatchString(token) {
+ return nil, errors.New("document vector token must be a lowercase SHA-256 value")
+ }
+ if _, exists := seen[token]; exists {
+ continue
+ }
+ seen[token] = struct{}{}
+ unique = append(unique, token)
+ }
+ args := make([]any, 0, len(unique)+2)
+ args = append(args, generationID, string(DocumentVectorGenerationActive))
+ for _, token := range unique {
+ args = append(args, token)
+ }
+ query := `SELECT v.token, dc.id, v.extraction_id, v.extraction_profile_id,
+ v.canonical_blob_hash, v.extraction_input_key, v.chunk_key, v.chunk_checksum,
+ dc.ordinal, dc.text, v.source_sequence
+ FROM document_vector_publications v
+ JOIN document_vector_generations g ON g.id = v.generation_id
+ JOIN document_index_state ds ON ds.singleton = 1
+ JOIN document_extraction_heads h
+ ON h.extraction_id = v.extraction_id
+ AND h.profile_id = v.extraction_profile_id
+ AND h.canonical_blob_hash = v.canonical_blob_hash
+ AND h.extraction_input_key = v.extraction_input_key
+ JOIN document_extractions e ON e.id = h.extraction_id
+ JOIN document_extraction_profiles p ON p.id = h.profile_id
+ JOIN document_provider_consents c ON c.profile_id = p.id
+ JOIN document_chunks dc
+ ON dc.id = v.chunk_id AND dc.extraction_id = v.extraction_id
+ AND dc.chunk_key = v.chunk_key AND dc.checksum = v.chunk_checksum
+ WHERE g.id = ? AND g.state = ?
+ AND ds.target_profile_id = g.target_extraction_profile_id
+ AND v.state = 'ready' AND e.source_sequence = v.source_sequence
+ AND h.source_sequence = v.source_sequence
+ AND v.token IN (` + documentPlaceholders(len(unique)) + `)
+ AND ` + documentVectorLiveAuthoritySQL()
+ rows, err := s.db.QueryContext(ctx, s.Rebind(query), args...)
+ if err != nil {
+ return nil, fmt.Errorf("resolve live document vector publications: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ byToken := make(map[string]DocumentVectorLivePublication, len(unique))
+ for rows.Next() {
+ var publication DocumentVectorLivePublication
+ publication.GenerationID = generationID
+ if err := rows.Scan(&publication.Token, &publication.ChunkID, &publication.ExtractionID,
+ &publication.ExtractionProfileID, &publication.CanonicalBlobHash,
+ &publication.ExtractionInputKey, &publication.ChunkKey, &publication.ChunkChecksum,
+ &publication.ChunkOrdinal, &publication.Text, &publication.SourceSequence); err != nil {
+ return nil, fmt.Errorf("scan live document vector publication: %w", err)
+ }
+ byToken[publication.Token] = publication
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate live document vector publications: %w", err)
+ }
+ result := make([]DocumentVectorLivePublication, 0, len(byToken))
+ for _, token := range unique {
+ if publication, found := byToken[token]; found {
+ result = append(result, publication)
+ }
+ }
+ return result, nil
+}
+
+// ListDocumentVectorCleanupTokens pages uncleaned backend tokens from a retired generation.
+func (s *Store) ListDocumentVectorCleanupTokens(
+ ctx context.Context, generationID int64, afterToken string, limit int,
+) ([]DocumentVectorCleanupToken, error) {
+ if generationID <= 0 {
+ return nil, errors.New("document vector generation id must be positive")
+ }
+ if afterToken != "" && !documentVectorFingerprintPattern.MatchString(afterToken) {
+ return nil, errors.New("document vector cleanup cursor must be a lowercase SHA-256 value")
+ }
+ if limit < 1 || limit > maxDocumentVectorCandidateLimit {
+ return nil, fmt.Errorf("document vector cleanup limit must be between 1 and %d", maxDocumentVectorCandidateLimit)
+ }
+ q := boundQuerier{ctx: ctx, q: s.db}
+ state, found, err := getDocumentVectorGenerationState(q, generationID)
+ if err != nil {
+ return nil, err
+ }
+ if !found || state != DocumentVectorGenerationRetired {
+ return nil, ErrDocumentVectorInvalidGenerationState
+ }
+ rows, err := s.db.QueryContext(ctx, s.Rebind(`
+ SELECT generation_id, token
+ FROM document_vector_publications
+ WHERE generation_id = ? AND backend_cleaned_at IS NULL AND token > ?
+ ORDER BY token LIMIT ?`), generationID, afterToken, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list document vector cleanup tokens: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ var result []DocumentVectorCleanupToken
+ for rows.Next() {
+ var token DocumentVectorCleanupToken
+ if err := rows.Scan(&token.GenerationID, &token.Token); err != nil {
+ return nil, fmt.Errorf("scan document vector cleanup token: %w", err)
+ }
+ result = append(result, token)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate document vector cleanup tokens: %w", err)
+ }
+ return result, nil
+}
+
+// ParkObsoleteDocumentVectorTokens durably parks nonretired obsolete rows
+// before their backend vectors are deleted. Already parked rows remain in the
+// page even if source authority later returns, making crash replay safe.
+func (s *Store) ParkObsoleteDocumentVectorTokens(
+ ctx context.Context, generationID int64, afterToken string, limit int, now time.Time,
+) (DocumentVectorCleanupPage, error) {
+ if generationID <= 0 || now.IsZero() {
+ return DocumentVectorCleanupPage{}, errors.New("document vector cleanup parking requires a generation and time")
+ }
+ if afterToken != "" && !documentVectorFingerprintPattern.MatchString(afterToken) {
+ return DocumentVectorCleanupPage{}, errors.New("document vector cleanup parking cursor must be a lowercase SHA-256 value")
+ }
+ if limit < 1 || limit > maxDocumentVectorCandidateLimit {
+ return DocumentVectorCleanupPage{}, fmt.Errorf("document vector cleanup parking limit must be between 1 and %d", maxDocumentVectorCandidateLimit)
+ }
+ now = normalizeDocumentVectorTime(now)
+ var page DocumentVectorCleanupPage
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, _, found, err := s.lockDocumentVectorGenerationIfExists(q, generationID)
+ if err != nil {
+ return err
+ }
+ if !found {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ current := documentVectorPublicationSnapshotCurrentSQL("v", "g")
+ query := `SELECT v.generation_id, v.token
+ FROM document_vector_publications v
+ JOIN document_vector_generations g ON g.id = v.generation_id
+ WHERE v.generation_id = ? AND v.backend_cleaned_at IS NULL AND v.token > ?
+ AND (v.state <> 'pending' OR v.lease_until IS NULL OR v.lease_until <= ?)
+ AND (g.state = 'retired'
+ OR (v.state = 'failed' AND v.error_code = 'source_changed')
+ OR NOT (` + current + `))
+ ORDER BY v.token LIMIT ?`
+ rows, err := tx.QueryContext(ctx, query, generationID, afterToken, s.dialect.TimestampParam(now), limit)
+ if err != nil {
+ return fmt.Errorf("list document vector tokens to park: %w", err)
+ }
+ for rows.Next() {
+ var token DocumentVectorCleanupToken
+ if err := rows.Scan(&token.GenerationID, &token.Token); err != nil {
+ _ = rows.Close()
+ return fmt.Errorf("scan document vector token to park: %w", err)
+ }
+ page.Tokens = append(page.Tokens, token)
+ }
+ if err := rows.Err(); err != nil {
+ _ = rows.Close()
+ return fmt.Errorf("iterate document vector tokens to park: %w", err)
+ }
+ if err := rows.Close(); err != nil {
+ return fmt.Errorf("close document vector tokens to park: %w", err)
+ }
+ if state != DocumentVectorGenerationRetired {
+ for _, token := range page.Tokens {
+ if _, err := q.Exec(`
+ UPDATE document_vector_publications
+ SET state = 'failed', lease_owner = NULL, lease_until = NULL,
+ next_retry_at = NULL, error_code = 'source_changed',
+ backend_cleaned_at = NULL, updated_at = ?
+ WHERE generation_id = ? AND token = ?
+ AND NOT (state = 'failed' AND error_code = 'source_changed')`,
+ s.dialect.TimestampParam(now), generationID, token.Token); err != nil {
+ return fmt.Errorf("park obsolete document vector token: %w", err)
+ }
+ }
+ }
+ if len(page.Tokens) < limit {
+ page.Exhausted = true
+ } else {
+ page.AfterGenerationID = generationID
+ page.AfterToken = page.Tokens[len(page.Tokens)-1].Token
+ }
+ return nil
+ })
+ return page, err
+}
+
+// FinalizeObsoleteDocumentVectorToken acknowledges one backend deletion.
+// Nonretired parked rows are removed so a current candidate can be rebuilt;
+// retired rows retain their cleanup marker until generation purge.
+func (s *Store) FinalizeObsoleteDocumentVectorToken(
+ ctx context.Context, generationID int64, token string, now time.Time,
+) (bool, error) {
+ if generationID <= 0 || !documentVectorFingerprintPattern.MatchString(token) || now.IsZero() {
+ return false, errors.New("document vector cleanup finalization is invalid")
+ }
+ now = normalizeDocumentVectorTime(now)
+ changed := false
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, _, found, err := s.lockDocumentVectorGenerationIfExists(q, generationID)
+ if err != nil {
+ return err
+ }
+ if !found {
+ return nil
+ }
+ var result sql.Result
+ if state == DocumentVectorGenerationRetired {
+ result, err = q.Exec(`
+ UPDATE document_vector_publications SET backend_cleaned_at = ?, updated_at = ?
+ WHERE generation_id = ? AND token = ? AND backend_cleaned_at IS NULL`,
+ s.dialect.TimestampParam(now), s.dialect.TimestampParam(now), generationID, token)
+ } else {
+ result, err = q.Exec(`
+ DELETE FROM document_vector_publications
+ WHERE generation_id = ? AND token = ?
+ AND state = 'failed' AND error_code = 'source_changed'`, generationID, token)
+ }
+ if err != nil {
+ return fmt.Errorf("finalize obsolete document vector token: %w", err)
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read obsolete document vector finalization result: %w", err)
+ }
+ changed = updated == 1
+ return nil
+ })
+ return changed, err
+}
+
+// MarkDocumentVectorTokenCleaned records one successful backend deletion idempotently.
+func (s *Store) MarkDocumentVectorTokenCleaned(
+ ctx context.Context, generationID int64, token string, now time.Time,
+) (bool, error) {
+ if generationID <= 0 || !documentVectorFingerprintPattern.MatchString(token) || now.IsZero() {
+ return false, errors.New("document vector cleanup marker is invalid")
+ }
+ now = normalizeDocumentVectorTime(now)
+ changed := false
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, _, found, err := s.lockDocumentVectorGenerationIfExists(q, generationID)
+ if err != nil {
+ return err
+ }
+ if !found || state != DocumentVectorGenerationRetired {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ result, err := q.Exec(`
+ UPDATE document_vector_publications SET backend_cleaned_at = ?, updated_at = ?
+ WHERE generation_id = ? AND token = ? AND backend_cleaned_at IS NULL`,
+ s.dialect.TimestampParam(now), s.dialect.TimestampParam(now), generationID, token)
+ if err != nil {
+ return fmt.Errorf("mark document vector token cleaned: %w", err)
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read document vector cleanup marker result: %w", err)
+ }
+ changed = updated == 1
+ return nil
+ })
+ return changed, err
+}
+
+// PurgeRetiredDocumentVectorGeneration removes a retired ledger after backend cleanup.
+func (s *Store) PurgeRetiredDocumentVectorGeneration(ctx context.Context, generationID int64) (bool, error) {
+ if generationID <= 0 {
+ return false, errors.New("document vector generation id must be positive")
+ }
+ purged := false
+ err := s.withTxContext(ctx, func(tx *loggedTx) error {
+ q := boundQuerier{ctx: ctx, q: tx}
+ state, _, found, err := s.lockDocumentVectorGenerationIfExists(q, generationID)
+ if err != nil {
+ return err
+ }
+ if !found {
+ return nil
+ }
+ if state != DocumentVectorGenerationRetired {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ var uncleaned int64
+ if err := q.QueryRow(`
+ SELECT COUNT(*) FROM document_vector_publications
+ WHERE generation_id = ? AND backend_cleaned_at IS NULL`, generationID).Scan(&uncleaned); err != nil {
+ return fmt.Errorf("count uncleaned document vector tokens: %w", err)
+ }
+ if uncleaned != 0 {
+ return ErrDocumentVectorCleanupIncomplete
+ }
+ if _, err := q.Exec(`DELETE FROM document_vector_publications WHERE generation_id = ?`, generationID); err != nil {
+ return fmt.Errorf("delete cleaned document vector publications: %w", err)
+ }
+ result, err := q.Exec(`DELETE FROM document_vector_generations WHERE id = ? AND state = 'retired'`, generationID)
+ if err != nil {
+ return fmt.Errorf("purge retired document vector generation: %w", err)
+ }
+ deleted, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read document vector generation purge result: %w", err)
+ }
+ if deleted != 1 {
+ return ErrDocumentVectorInvalidGenerationState
+ }
+ purged = true
+ return nil
+ })
+ return purged, err
+}
+
+type documentVectorPublication struct {
+ DocumentVectorChunkCandidate
+
+ token string
+ state string
+ leaseOwner sql.NullString
+ leaseFence int64
+ leaseUntil sql.NullTime
+ attemptCount int
+ nextRetryAt sql.NullTime
+}
+
+func (s *Store) listDocumentVectorChunkCandidatesTx(
+ ctx context.Context, tx *loggedTx, generationID, afterChunkID int64, limit int,
+) ([]DocumentVectorChunkCandidate, error) {
+ query := `SELECT dc.id, h.extraction_id, h.profile_id, h.canonical_blob_hash, h.extraction_input_key,
+ dc.chunk_key, dc.checksum, dc.ordinal, dc.text, e.source_sequence
+ FROM document_vector_generations g
+ JOIN document_index_state ds ON ds.singleton = 1
+ JOIN document_extraction_heads h ON h.profile_id = g.target_extraction_profile_id
+ JOIN document_extractions e ON e.id = h.extraction_id
+ JOIN document_extraction_profiles p ON p.id = h.profile_id
+ JOIN document_provider_consents c ON c.profile_id = p.id
+ JOIN document_chunks dc ON dc.extraction_id = h.extraction_id
+ WHERE g.id = ? AND g.state = ? AND ds.target_profile_id = g.target_extraction_profile_id
+ AND dc.id > ? AND ` + documentVectorLiveAuthoritySQL() + `
+ ORDER BY dc.id LIMIT ?`
+ rows, err := tx.QueryContext(ctx, query, generationID, string(DocumentVectorGenerationBuilding), afterChunkID, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list claimable document vector candidates: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ var candidates []DocumentVectorChunkCandidate
+ for rows.Next() {
+ var candidate DocumentVectorChunkCandidate
+ candidate.GenerationID = generationID
+ if err := rows.Scan(&candidate.ChunkID, &candidate.ExtractionID, &candidate.ExtractionProfileID,
+ &candidate.CanonicalBlobHash, &candidate.ExtractionInputKey, &candidate.ChunkKey,
+ &candidate.ChunkChecksum, &candidate.ChunkOrdinal, &candidate.Text, &candidate.SourceSequence); err != nil {
+ return nil, fmt.Errorf("scan claimable document vector candidate: %w", err)
+ }
+ candidates = append(candidates, candidate)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate claimable document vector candidates: %w", err)
+ }
+ return candidates, nil
+}
+
+func (s *Store) getDocumentVectorCoverage(q boundQuerier, generationID int64) (DocumentVectorCoverage, error) {
+ query := `SELECT COUNT(*), COALESCE(SUM(CASE WHEN EXISTS (
+ SELECT 1 FROM document_vector_publications v
+ WHERE v.generation_id = g.id AND v.state = 'ready'
+ AND v.extraction_id = h.extraction_id
+ AND v.extraction_profile_id = h.profile_id
+ AND v.canonical_blob_hash = h.canonical_blob_hash
+ AND v.extraction_input_key = h.extraction_input_key
+ AND v.chunk_id = dc.id AND v.chunk_key = dc.chunk_key
+ AND v.chunk_checksum = dc.checksum
+ AND v.source_sequence = e.source_sequence
+ AND v.source_sequence = h.source_sequence
+ ) THEN 1 ELSE 0 END), 0)
+ FROM document_vector_generations g
+ JOIN document_index_state ds ON ds.singleton = 1
+ JOIN document_extraction_heads h ON h.profile_id = g.target_extraction_profile_id
+ JOIN document_extractions e ON e.id = h.extraction_id
+ JOIN document_extraction_profiles p ON p.id = h.profile_id
+ JOIN document_provider_consents c ON c.profile_id = p.id
+ JOIN document_chunks dc ON dc.extraction_id = h.extraction_id
+ WHERE g.id = ? AND ds.target_profile_id = g.target_extraction_profile_id
+ AND ` + documentVectorLiveAuthoritySQL()
+ var coverage DocumentVectorCoverage
+ if err := q.QueryRow(query, generationID).Scan(&coverage.Required, &coverage.Ready); err != nil {
+ return DocumentVectorCoverage{}, fmt.Errorf("count document vector coverage: %w", err)
+ }
+ return coverage, nil
+}
+
+func getDocumentVectorGenerationState(
+ q boundQuerier, generationID int64,
+) (DocumentVectorGenerationState, bool, error) {
+ var state DocumentVectorGenerationState
+ err := q.QueryRow(`SELECT state FROM document_vector_generations WHERE id = ?`, generationID).Scan(&state)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", false, nil
+ }
+ if err != nil {
+ return "", false, fmt.Errorf("read document vector generation state: %w", err)
+ }
+ return state, true, nil
+}
+
+func (s *Store) lockDocumentVectorGeneration(
+ q boundQuerier, generationID int64,
+) (DocumentVectorGenerationState, bool, error) {
+ state, currentTarget, found, err := s.lockDocumentVectorGenerationIfExists(q, generationID)
+ if err != nil {
+ return "", false, err
+ }
+ if !found {
+ return "", false, ErrDocumentVectorInvalidGenerationState
+ }
+ return state, currentTarget, nil
+}
+
+func (s *Store) lockDocumentVectorGenerationIfExists(
+ q boundQuerier, generationID int64,
+) (DocumentVectorGenerationState, bool, bool, error) {
+ // The index-state row is the serialization point for source/profile changes.
+ // Taking it before the generation/publication row gives claim, commit, and
+ // the later activation path one lock order on both database backends.
+ if _, err := q.Exec(`UPDATE document_index_state SET revision = revision WHERE singleton = 1`); err != nil {
+ return "", false, false, fmt.Errorf("lock document vector index state: %w", err)
+ }
+ result, err := q.Exec(`UPDATE document_vector_generations SET state = state WHERE id = ?`, generationID)
+ if err != nil {
+ return "", false, false, fmt.Errorf("lock document vector generation: %w", err)
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return "", false, false, fmt.Errorf("read document vector generation lock result: %w", err)
+ }
+ if updated != 1 {
+ return "", false, false, nil
+ }
+ var state DocumentVectorGenerationState
+ var currentTarget bool
+ err = q.QueryRow(`
+ SELECT g.state, COALESCE(ds.target_profile_id = g.target_extraction_profile_id, FALSE)
+ FROM document_vector_generations g
+ JOIN document_index_state ds ON ds.singleton = 1
+ WHERE g.id = ?`, generationID).Scan(&state, ¤tTarget)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", false, false, nil
+ }
+ if err != nil {
+ return "", false, false, fmt.Errorf("check document vector generation state: %w", err)
+ }
+ return state, currentTarget, true, nil
+}
+
+func (s *Store) getDocumentVectorPublicationForClaim(
+ q boundQuerier, candidate DocumentVectorChunkCandidate,
+) (documentVectorPublication, bool, error) {
+ query := `SELECT token, state, lease_owner, lease_fence, lease_until, attempt_count, next_retry_at
+ FROM document_vector_publications
+ WHERE generation_id = ? AND extraction_id = ? AND chunk_id = ?`
+ if s.dialect.DriverName() == postgresDriverName {
+ query += ` FOR UPDATE`
+ }
+ var publication documentVectorPublication
+ publication.DocumentVectorChunkCandidate = candidate
+ err := q.QueryRow(query, candidate.GenerationID, candidate.ExtractionID, candidate.ChunkID).Scan(
+ &publication.token, &publication.state, &publication.leaseOwner, &publication.leaseFence,
+ &publication.leaseUntil, &publication.attemptCount, &publication.nextRetryAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return documentVectorPublication{}, false, nil
+ }
+ if err != nil {
+ return documentVectorPublication{}, false, fmt.Errorf("read document vector publication claim: %w", err)
+ }
+ return publication, true, nil
+}
+
+func (s *Store) getDocumentVectorPublicationByToken(
+ q boundQuerier, generationID int64, token string,
+) (documentVectorPublication, bool, error) {
+ query := `SELECT extraction_id, extraction_profile_id, canonical_blob_hash, extraction_input_key,
+ chunk_id, chunk_key, chunk_checksum, source_sequence, token, state, lease_owner,
+ lease_fence, lease_until, attempt_count, next_retry_at
+ FROM document_vector_publications WHERE generation_id = ? AND token = ?`
+ if s.dialect.DriverName() == postgresDriverName {
+ query += ` FOR UPDATE`
+ }
+ var publication documentVectorPublication
+ publication.GenerationID = generationID
+ err := q.QueryRow(query, generationID, token).Scan(
+ &publication.ExtractionID, &publication.ExtractionProfileID, &publication.CanonicalBlobHash,
+ &publication.ExtractionInputKey, &publication.ChunkID, &publication.ChunkKey,
+ &publication.ChunkChecksum, &publication.SourceSequence, &publication.token,
+ &publication.state, &publication.leaseOwner, &publication.leaseFence,
+ &publication.leaseUntil, &publication.attemptCount, &publication.nextRetryAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return documentVectorPublication{}, false, nil
+ }
+ if err != nil {
+ return documentVectorPublication{}, false, fmt.Errorf("read document vector publication: %w", err)
+ }
+ return publication, true, nil
+}
+
+func (s *Store) isDocumentVectorPublicationCurrent(q boundQuerier, publication documentVectorPublication) (bool, error) {
+ query := `SELECT EXISTS (
+ SELECT 1
+ FROM document_vector_generations g
+ JOIN document_index_state ds ON ds.singleton = 1
+ JOIN document_extraction_heads h ON h.extraction_id = ?
+ JOIN document_extractions e ON e.id = h.extraction_id
+ JOIN document_extraction_profiles p ON p.id = h.profile_id
+ JOIN document_provider_consents c ON c.profile_id = p.id
+ JOIN document_chunks dc ON dc.extraction_id = h.extraction_id
+ WHERE g.id = ? AND ds.target_profile_id = g.target_extraction_profile_id
+ AND h.profile_id = ? AND h.canonical_blob_hash = ? AND h.extraction_input_key = ?
+ AND e.source_sequence = ? AND dc.id = ? AND dc.chunk_key = ? AND dc.checksum = ?
+ AND ` + documentVectorLiveAuthoritySQL() + `
+ )`
+ var current bool
+ err := q.QueryRow(query, publication.ExtractionID, publication.GenerationID,
+ publication.ExtractionProfileID,
+ publication.CanonicalBlobHash, publication.ExtractionInputKey, publication.SourceSequence,
+ publication.ChunkID, publication.ChunkKey, publication.ChunkChecksum).Scan(¤t)
+ if err != nil {
+ return false, fmt.Errorf("recheck document vector publication source: %w", err)
+ }
+ return current, nil
+}
+
+func documentVectorPublicationSnapshotCurrentSQL(publication, generation string) string {
+ return `EXISTS (
+ SELECT 1
+ FROM document_index_state ds
+ JOIN document_extraction_heads h ON h.extraction_id = ` + publication + `.extraction_id
+ JOIN document_extractions e ON e.id = h.extraction_id
+ JOIN document_extraction_profiles p ON p.id = h.profile_id
+ JOIN document_provider_consents c ON c.profile_id = p.id
+ JOIN document_chunks dc ON dc.id = ` + publication + `.chunk_id AND dc.extraction_id = h.extraction_id
+ WHERE ds.singleton = 1
+ AND ds.target_profile_id = ` + generation + `.target_extraction_profile_id
+ AND h.profile_id = ` + publication + `.extraction_profile_id
+ AND h.canonical_blob_hash = ` + publication + `.canonical_blob_hash
+ AND h.extraction_input_key = ` + publication + `.extraction_input_key
+ AND e.source_sequence = ` + publication + `.source_sequence
+ AND h.source_sequence = ` + publication + `.source_sequence
+ AND dc.chunk_key = ` + publication + `.chunk_key
+ AND dc.checksum = ` + publication + `.chunk_checksum
+ AND ` + documentVectorLiveAuthoritySQL() + `
+ )`
+}
+
+func documentVectorLiveAuthoritySQL() string {
+ const head = "h"
+
+ return `EXISTS (
+ SELECT 1 FROM document_occurrences o
+ JOIN attachments a ON a.id = o.attachment_id
+ JOIN messages m ON m.id = o.message_id
+ WHERE o.canonical_blob_hash = ` + head + `.canonical_blob_hash
+ AND ` + documentSearchValidity() + `
+ )`
+}
+
+func boundedDocumentVectorErrorCode(value string) string {
+ if len(value) == 0 || len(value) > 64 {
+ return "unknown"
+ }
+ for _, character := range value {
+ if character != '_' && character != '-' && (character < 'a' || character > 'z') &&
+ (character < '0' || character > '9') {
+ return "unknown"
+ }
+ }
+ return value
+}
+
+func documentVectorToken(candidate DocumentVectorChunkCandidate) string {
+ hasher := sha256.New()
+ _, _ = hasher.Write([]byte("msgvault-document-vector-token-v1"))
+ var size [8]byte
+ binary.BigEndian.PutUint64(size[:], uint64(candidate.GenerationID)) //nolint:gosec // Generation IDs are positive database keys.
+ _, _ = hasher.Write(size[:])
+ for _, field := range []string{candidate.ExtractionID, candidate.ChunkKey, candidate.ChunkChecksum} {
+ binary.BigEndian.PutUint64(size[:], uint64(len(field)))
+ _, _ = hasher.Write(size[:])
+ _, _ = hasher.Write([]byte(field))
+ }
+ return hex.EncodeToString(hasher.Sum(nil))
+}
+
+func documentVectorChunkClaim(
+ candidate DocumentVectorChunkCandidate,
+ token, owner string,
+ fence int64,
+ leaseUntil time.Time,
+ attemptCount int,
+) *DocumentVectorChunkClaim {
+ return &DocumentVectorChunkClaim{
+ DocumentVectorChunkCandidate: candidate,
+ Token: token, LeaseOwner: owner, LeaseFence: fence,
+ LeaseUntil: normalizeDocumentVectorTime(leaseUntil), AttemptCount: attemptCount,
+ }
+}
+
+func normalizeDocumentVectorTime(value time.Time) time.Time {
+ return value.UTC().Truncate(time.Millisecond)
+}
+
+func validateDocumentVectorClaimRequest(
+ generationID, afterChunkID int64,
+ scanLimit int,
+ owner string,
+ now time.Time,
+ leaseDuration time.Duration,
+) error {
+ if generationID <= 0 || afterChunkID < 0 {
+ return errors.New("document vector claim generation and scan position have invalid bounds")
+ }
+ if scanLimit < 1 || scanLimit > maxDocumentVectorCandidateLimit {
+ return fmt.Errorf("document vector claim scan limit must be between 1 and %d", maxDocumentVectorCandidateLimit)
+ }
+ if strings.TrimSpace(owner) == "" || now.IsZero() || leaseDuration <= 0 {
+ return errors.New("document vector claim owner, time, and lease duration are required")
+ }
+ return nil
+}
+
+func validateDocumentVectorClaimReference(generationID int64, token, owner string, fence int64, now time.Time) error {
+ if generationID <= 0 || !documentVectorFingerprintPattern.MatchString(token) ||
+ strings.TrimSpace(owner) == "" || fence <= 0 || now.IsZero() {
+ return errors.New("document vector claim reference is invalid")
+ }
+ return nil
+}
+
+func validateDocumentVectorFailure(now time.Time, nextRetryAt *time.Time, terminal bool, errorCode string) error {
+ if len(errorCode) == 0 || len(errorCode) > 64 {
+ return errors.New("document vector failure error code is invalid")
+ }
+ for _, character := range errorCode {
+ if character != '_' && character != '-' && (character < 'a' || character > 'z') &&
+ (character < '0' || character > '9') {
+ return errors.New("document vector failure error code is invalid")
+ }
+ }
+ if terminal {
+ if nextRetryAt != nil {
+ return errors.New("terminal document vector failure cannot retry")
+ }
+ return nil
+ }
+ if nextRetryAt == nil || !nextRetryAt.After(now) {
+ return errors.New("retryable document vector failure requires a future retry time")
+ }
+ return nil
+}
+
+func scanDocumentVectorGeneration(row scanner) (DocumentVectorGeneration, bool, error) {
+ var g DocumentVectorGeneration
+ var activated, retired sql.NullTime
+ err := row.Scan(&g.ID, &g.Fingerprint, &g.TargetExtractionProfileID, &g.EmbeddingProfile, &g.Model, &g.Dimension, &g.State, &g.CreatedAt, &activated, &retired)
+ if errors.Is(err, sql.ErrNoRows) {
+ return DocumentVectorGeneration{}, false, nil
+ }
+ if err != nil {
+ return DocumentVectorGeneration{}, false, fmt.Errorf("scan document vector generation: %w", err)
+ }
+ g.CreatedAt = normalizeDocumentVectorTime(g.CreatedAt)
+ if activated.Valid {
+ normalized := normalizeDocumentVectorTime(activated.Time)
+ g.ActivatedAt = &normalized
+ }
+ if retired.Valid {
+ normalized := normalizeDocumentVectorTime(retired.Time)
+ g.RetiredAt = &normalized
+ }
+ return g, true, nil
+}
diff --git a/internal/store/document_vectors_pg_test.go b/internal/store/document_vectors_pg_test.go
new file mode 100644
index 000000000..5d4738f8d
--- /dev/null
+++ b/internal/store/document_vectors_pg_test.go
@@ -0,0 +1,22 @@
+package store_test
+
+import (
+ "os"
+ "testing"
+
+ "go.kenn.io/msgvault/internal/store"
+)
+
+func TestDocumentVectorChunkLifecyclePostgreSQLContract(t *testing.T) {
+ if !store.IsPostgresURL(os.Getenv("MSGVAULT_TEST_DB")) {
+ t.Skip("PostgreSQL contract runs when MSGVAULT_TEST_DB selects PostgreSQL")
+ }
+ runDocumentVectorChunkLifecycleContract(t)
+}
+
+func TestDocumentVectorGenerationLifecyclePostgreSQLContract(t *testing.T) {
+ if !store.IsPostgresURL(os.Getenv("MSGVAULT_TEST_DB")) {
+ t.Skip("PostgreSQL contract runs when MSGVAULT_TEST_DB selects PostgreSQL")
+ }
+ runDocumentVectorGenerationLifecycleContract(t)
+}
diff --git a/internal/store/document_vectors_test.go b/internal/store/document_vectors_test.go
new file mode 100644
index 000000000..156762a1c
--- /dev/null
+++ b/internal/store/document_vectors_test.go
@@ -0,0 +1,1565 @@
+package store_test
+
+import (
+ "database/sql"
+ "os"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.kenn.io/msgvault/internal/store"
+ "go.kenn.io/msgvault/internal/testutil/storetest"
+)
+
+func TestDocumentVectorChunkLifecycleSQLiteContract(t *testing.T) {
+ if store.IsPostgresURL(os.Getenv("MSGVAULT_TEST_DB")) {
+ t.Skip("SQLite contract runs without MSGVAULT_TEST_DB")
+ }
+ runDocumentVectorChunkLifecycleContract(t)
+}
+
+func TestDocumentVectorGenerationLifecycleSQLiteContract(t *testing.T) {
+ if store.IsPostgresURL(os.Getenv("MSGVAULT_TEST_DB")) {
+ t.Skip("SQLite contract runs without MSGVAULT_TEST_DB")
+ }
+ runDocumentVectorGenerationLifecycleContract(t)
+}
+
+func TestDocumentVectorOperationLockSerializesPostgresWriters(t *testing.T) {
+ requirements := require.New(t)
+ if !store.IsPostgresURL(os.Getenv("MSGVAULT_TEST_DB")) {
+ t.Skip("PostgreSQL-only cross-process writer lock")
+ }
+ f := storetest.New(t)
+ firstEntered := make(chan struct{})
+ releaseFirst := make(chan struct{})
+ firstDone := make(chan error, 1)
+ go func() {
+ firstDone <- f.Store.WithDocumentVectorOperationLock(t.Context(), func() error {
+ close(firstEntered)
+ <-releaseFirst
+ return nil
+ })
+ }()
+ <-firstEntered
+
+ secondEntered := make(chan struct{})
+ secondDone := make(chan error, 1)
+ go func() {
+ secondDone <- f.Store.WithDocumentVectorOperationLock(t.Context(), func() error {
+ close(secondEntered)
+ return nil
+ })
+ }()
+ select {
+ case <-secondEntered:
+ requirements.FailNow("second document-vector writer entered while the first held the lock")
+ case <-time.After(100 * time.Millisecond):
+ }
+ close(releaseFirst)
+ requirements.NoError(<-firstDone)
+ select {
+ case <-secondEntered:
+ case <-time.After(2 * time.Second):
+ requirements.FailNow("second document-vector writer did not acquire the released lock")
+ }
+ requirements.NoError(<-secondDone)
+}
+
+func TestDocumentVectorOperationLockSerializesSQLiteWriters(t *testing.T) {
+ requirements := require.New(t)
+ if store.IsPostgresURL(os.Getenv("MSGVAULT_TEST_DB")) {
+ t.Skip("SQLite-only process-local writer lock")
+ }
+ f := storetest.New(t)
+ firstEntered := make(chan struct{})
+ releaseFirst := make(chan struct{})
+ firstDone := make(chan error, 1)
+ go func() {
+ firstDone <- f.Store.WithDocumentVectorOperationLock(t.Context(), func() error {
+ close(firstEntered)
+ <-releaseFirst
+ return nil
+ })
+ }()
+ <-firstEntered
+
+ secondEntered := make(chan struct{})
+ secondDone := make(chan error, 1)
+ go func() {
+ secondDone <- f.Store.WithDocumentVectorOperationLock(t.Context(), func() error {
+ close(secondEntered)
+ return nil
+ })
+ }()
+ select {
+ case <-secondEntered:
+ requirements.FailNow("second document-vector writer entered while the first held the lock")
+ case <-time.After(100 * time.Millisecond):
+ }
+ close(releaseFirst)
+ requirements.NoError(<-firstDone)
+ select {
+ case <-secondEntered:
+ case <-time.After(2 * time.Second):
+ requirements.FailNow("second document-vector writer did not acquire the released lock")
+ }
+ requirements.NoError(<-secondDone)
+}
+
+func runDocumentVectorGenerationLifecycleContract(t *testing.T) {
+ t.Helper()
+ t.Run("consent usage progress and same-policy rebuild", testDocumentVectorOperationsState)
+ t.Run("coverage activation and rollback", testDocumentVectorCoverageAndActivation)
+ t.Run("atomic generation swap and retirement", testDocumentVectorActivationSwapAndRetirement)
+ t.Run("live token ordering and invalidation", testDocumentVectorLiveResolution)
+ t.Run("derivative garbage collection preserves cleanup token", testDocumentVectorDerivativeGarbageCollection)
+ t.Run("derivative purge preserves cleanup token", testDocumentVectorDerivativePurge)
+ t.Run("retired token cleanup and purge", testDocumentVectorCleanupAndPurge)
+ t.Run("obsolete cleanup rechecks live snapshot", testDocumentVectorObsoleteCleanup)
+}
+
+func testDocumentVectorOperationsState(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ now := time.Date(2026, time.August, 20, 12, 34, 56, 987654321, time.FixedZone("consent", 2*60*60))
+ require.NoError(t, f.Store.InitSchema(), "the document-vector base schema is replay-safe")
+ if f.Store.IsPostgreSQL() {
+ var indexDefinition string
+ require.NoError(t, f.Store.DB().QueryRow(`
+ SELECT indexdef FROM pg_indexes
+ WHERE schemaname = current_schema() AND indexname = 'idx_document_vector_generations_live_fingerprint'`).Scan(&indexDefinition))
+ assert.NotContains(t, indexDefinition, "UNIQUE")
+ } else {
+ rows, err := f.Store.DB().Query(`PRAGMA index_list(document_vector_generations)`)
+ require.NoError(t, err)
+ defer func() { _ = rows.Close() }()
+ found := false
+ for rows.Next() {
+ var unique int
+ var sequence, partial int
+ var name, origin string
+ require.NoError(t, rows.Scan(&sequence, &name, &unique, &origin, &partial))
+ if name == "idx_document_vector_generations_live_fingerprint" {
+ found = true
+ assert.Zero(t, unique)
+ break
+ }
+ }
+ require.NoError(t, rows.Err())
+ require.True(t, found, "document vector generation fingerprint index")
+ }
+
+ egressFingerprint := strings.Repeat("e", 64)
+ consentSpec := store.DocumentVectorConsentSpec{
+ DocumentVectorGenerationSpec: generation.DocumentVectorGenerationSpec,
+ EgressFingerprint: egressFingerprint,
+ Purpose: "document_embedding",
+ }
+ consent, created, err := f.Store.RecordDocumentVectorConsent(t.Context(), consentSpec, now)
+ require.NoError(t, err)
+ assert.True(t, created)
+ assert.Equal(t, generation.DocumentVectorGenerationSpec, consent.DocumentVectorGenerationSpec)
+ assert.Equal(t, now.UTC().Truncate(time.Millisecond), consent.ConsentedAt)
+
+ gotConsent, err := f.Store.GetDocumentVectorConsent(t.Context(), egressFingerprint)
+ require.NoError(t, err)
+ require.NotNil(t, gotConsent)
+ assert.Equal(t, consent, *gotConsent)
+
+ collision := consentSpec
+ collision.Model = "different-model"
+ _, _, err = f.Store.RecordDocumentVectorConsent(t.Context(), collision, now)
+ require.ErrorContains(t, err, "fingerprint")
+
+ err = f.Store.CheckpointDocumentVectorBuild(t.Context(), generation.ID, 42, false, store.DocumentVectorUsageDelta{
+ ProviderCalls: 1, ProviderDocuments: 2, ProviderChunks: 3, ProviderInputChars: 123,
+ }, now)
+ require.NoError(t, err)
+ cursor, err := f.Store.GetDocumentVectorBuildCursor(t.Context(), generation.ID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(42), cursor)
+ usage, err := f.Store.GetDocumentVectorProviderUsage(t.Context(), generation.Fingerprint)
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), usage.ProviderCalls)
+ assert.Equal(t, int64(2), usage.ProviderDocuments)
+ assert.Equal(t, int64(3), usage.ProviderChunks)
+ assert.Equal(t, int64(123), usage.ProviderInputChars)
+ queryEgressFingerprint := strings.Repeat("d", 64)
+ operations, err := f.Store.GetDocumentVectorOperationsStatus(t.Context(), generation.DocumentVectorGenerationSpec, egressFingerprint, queryEgressFingerprint, 0, "", 10)
+ require.NoError(t, err)
+ require.NotNil(t, operations.DocumentConsent)
+ assert.Nil(t, operations.QueryConsent)
+ require.NotNil(t, operations.Coverage)
+ assert.Equal(t, store.DocumentVectorCoverage{Required: 1, Ready: 0}, *operations.Coverage)
+
+ require.NoError(t, f.Store.CheckpointDocumentVectorBuild(t.Context(), generation.ID, 0, true, store.DocumentVectorUsageDelta{}, now.Add(time.Second)))
+ cursor, err = f.Store.GetDocumentVectorBuildCursor(t.Context(), generation.ID)
+ require.NoError(t, err)
+ assert.Zero(t, cursor)
+ require.ErrorContains(t, f.Store.CheckpointDocumentVectorBuild(t.Context(), generation.ID, 1, false, store.DocumentVectorUsageDelta{}, time.Time{}), "time")
+
+ readyAllDocumentVectorChunks(t, f, generation, now)
+ require.NoError(t, f.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, now.Add(time.Second)))
+ err = f.Store.CheckpointDocumentVectorBuild(t.Context(), generation.ID, 99, false, store.DocumentVectorUsageDelta{
+ ProviderCalls: 2, ProviderDocuments: 3, ProviderChunks: 4, ProviderInputChars: 456,
+ }, now.Add(1500*time.Millisecond))
+ require.ErrorIs(t, err, store.ErrDocumentVectorInvalidGenerationState)
+ usage, err = f.Store.GetDocumentVectorProviderUsage(t.Context(), generation.Fingerprint)
+ require.NoError(t, err)
+ assert.Equal(t, int64(3), usage.ProviderCalls, "completed worker usage survives concurrent activation")
+ assert.Equal(t, int64(5), usage.ProviderDocuments)
+ assert.Equal(t, int64(7), usage.ProviderChunks)
+ assert.Equal(t, int64(579), usage.ProviderInputChars)
+ cursor, err = f.Store.GetDocumentVectorBuildCursor(t.Context(), generation.ID)
+ require.NoError(t, err)
+ assert.Zero(t, cursor, "an active generation cursor is not advanced")
+ _, err = f.Store.StartDocumentVectorRebuild(t.Context(), generation.ID, generation.DocumentVectorGenerationSpec, time.Time{})
+ require.ErrorContains(t, err, "time")
+ rebuild, err := f.Store.StartDocumentVectorRebuild(t.Context(), generation.ID, generation.DocumentVectorGenerationSpec, now.Add(2*time.Second))
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentVectorGenerationBuilding, rebuild.State)
+ assert.Equal(t, generation.Fingerprint, rebuild.Fingerprint)
+ assert.Equal(t, now.Add(2*time.Second).UTC().Truncate(time.Millisecond), rebuild.CreatedAt)
+ resumed, created, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), generation.DocumentVectorGenerationSpec)
+ require.NoError(t, err)
+ assert.False(t, created)
+ assert.Equal(t, rebuild.ID, resumed.ID)
+ active, err := f.Store.GetActiveDocumentVectorGeneration(t.Context())
+ require.NoError(t, err)
+ require.NotNil(t, active)
+ assert.Equal(t, generation.ID, active.ID)
+
+ t.Run("configured target rotation builds beside the old active generation", func(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ rotatedFixture, old := seedDocumentVectorGenerationWithChunks(t, 1)
+ readyAllDocumentVectorChunks(t, rotatedFixture, old, now.Add(-time.Second))
+ require.NoError(rotatedFixture.Store.ActivateDocumentVectorGeneration(t.Context(), old.ID, now))
+ profile := rotatedDocumentVectorProfile()
+ _, err := rotatedFixture.Store.EnsureDocumentExtractionProfile(t.Context(), profile)
+ require.NoError(err)
+ require.NoError(rotatedFixture.Store.RecordDocumentProviderConsent(t.Context(), store.DocumentProviderConsent{
+ ProfileID: profile.ID, ProfileFingerprint: profile.Fingerprint,
+ RetentionPosture: profile.RetentionPosture, TrainingPosture: profile.TrainingPosture,
+ }))
+ _, err = rotatedFixture.Store.DB().Exec(rotatedFixture.Store.Rebind(`
+ UPDATE document_index_state SET target_profile_id = ? WHERE singleton = 1`), profile.ID)
+ require.NoError(err)
+ desired := old.DocumentVectorGenerationSpec
+ desired.Fingerprint = strings.Repeat("3", 64)
+ desired.TargetExtractionProfileID = profile.ID
+ operations, err := rotatedFixture.Store.GetDocumentVectorOperationsStatus(t.Context(), desired, strings.Repeat("d", 64), strings.Repeat("e", 64), 0, "", 10)
+ require.NoError(err)
+ require.NotNil(operations.Selected)
+ assert.Equal(old.ID, operations.Selected.GenerationID)
+ assert.Nil(operations.Coverage, "coverage is undefined for the old target")
+ building, err := rotatedFixture.Store.StartDocumentVectorRebuild(t.Context(), old.ID, desired, now.Add(time.Second))
+ require.NoError(err)
+ assert.Equal(desired, building.DocumentVectorGenerationSpec)
+ stillActive, err := rotatedFixture.Store.GetActiveDocumentVectorGeneration(t.Context())
+ require.NoError(err)
+ require.NotNil(stillActive)
+ assert.Equal(old.ID, stillActive.ID)
+ })
+
+ t.Run("observed usage survives target rotation and retirement without advancing cursor", func(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ rotatedFixture, building := seedDocumentVectorGenerationWithChunks(t, 1)
+ require.NoError(rotatedFixture.Store.CheckpointDocumentVectorBuild(
+ t.Context(), building.ID, 7, false, store.DocumentVectorUsageDelta{}, now))
+ profile := rotatedDocumentVectorProfile()
+ _, err := rotatedFixture.Store.EnsureDocumentExtractionProfile(t.Context(), profile)
+ require.NoError(err)
+ _, err = rotatedFixture.Store.DB().Exec(rotatedFixture.Store.Rebind(
+ `UPDATE document_index_state SET target_profile_id = ? WHERE singleton = 1`), profile.ID)
+ require.NoError(err)
+ err = rotatedFixture.Store.CheckpointDocumentVectorBuild(t.Context(), building.ID, 99, false,
+ store.DocumentVectorUsageDelta{ProviderCalls: 1, ProviderChunks: 2, ProviderInputChars: 30}, now.Add(time.Second))
+ require.ErrorIs(err, store.ErrDocumentVectorInvalidGenerationState)
+ cursor, err := rotatedFixture.Store.GetDocumentVectorBuildCursor(t.Context(), building.ID)
+ require.NoError(err)
+ assert.Equal(int64(7), cursor)
+ retired, err := rotatedFixture.Store.RetireDocumentVectorGeneration(t.Context(), building.ID, now.Add(2*time.Second))
+ require.NoError(err)
+ assert.True(retired)
+ err = rotatedFixture.Store.CheckpointDocumentVectorBuild(t.Context(), building.ID, 101, false,
+ store.DocumentVectorUsageDelta{ProviderCalls: 2, ProviderDocuments: 1, ProviderInputChars: 40}, now.Add(3*time.Second))
+ require.ErrorIs(err, store.ErrDocumentVectorInvalidGenerationState)
+ usage, err := rotatedFixture.Store.GetDocumentVectorProviderUsage(t.Context(), building.Fingerprint)
+ require.NoError(err)
+ assert.Equal(int64(3), usage.ProviderCalls)
+ assert.Equal(int64(1), usage.ProviderDocuments)
+ assert.Equal(int64(2), usage.ProviderChunks)
+ assert.Equal(int64(70), usage.ProviderInputChars)
+ cursor, err = rotatedFixture.Store.GetDocumentVectorBuildCursor(t.Context(), building.ID)
+ require.NoError(err)
+ assert.Equal(int64(7), cursor)
+ purged, err := rotatedFixture.Store.PurgeRetiredDocumentVectorGeneration(t.Context(), building.ID)
+ require.NoError(err)
+ assert.True(purged)
+ err = rotatedFixture.Store.CheckpointDocumentVectorBuildForFingerprint(t.Context(), building.ID, building.Fingerprint,
+ 0, true, store.DocumentVectorUsageDelta{ProviderCalls: 1, ProviderInputChars: 5}, now.Add(4*time.Second))
+ require.ErrorIs(err, store.ErrDocumentVectorInvalidGenerationState)
+ usage, err = rotatedFixture.Store.GetDocumentVectorProviderUsage(t.Context(), building.Fingerprint)
+ require.NoError(err)
+ assert.Equal(int64(4), usage.ProviderCalls, "purged-generation completion retains observed usage by fingerprint")
+ assert.Equal(int64(75), usage.ProviderInputChars)
+ })
+}
+
+func testDocumentVectorObsoleteCleanup(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ now := time.Date(2026, time.August, 20, 23, 15, 0, 654321000, time.FixedZone("cleanup-offset", -2*60*60))
+ claims := readyAllDocumentVectorChunks(t, f, generation, now)
+ require.Len(t, claims, 1)
+ claim := claims[0]
+ var messageID int64
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(`
+ SELECT message_id FROM document_occurrences WHERE canonical_blob_hash = ?`), claim.CanonicalBlobHash).Scan(&messageID))
+ _, err := f.Store.DB().Exec(f.Store.Rebind(
+ `UPDATE messages SET deleted_from_source_at = ? WHERE id = ?`), now, messageID)
+ require.NoError(t, err)
+ page, err := f.Store.ParkObsoleteDocumentVectorTokens(t.Context(), generation.ID, "", 10, now.Add(time.Second))
+ require.NoError(t, err)
+ assert.Equal(t, []store.DocumentVectorCleanupToken{{GenerationID: generation.ID, Token: claim.Token}}, page.Tokens)
+ assert.True(t, page.Exhausted)
+ assert.Zero(t, page.AfterGenerationID)
+ assert.Empty(t, page.AfterToken)
+
+ _, err = f.Store.DB().Exec(f.Store.Rebind(
+ `UPDATE messages SET deleted_from_source_at = NULL WHERE id = ?`), messageID)
+ require.NoError(t, err)
+ status, err := f.Store.GetDocumentVectorGenerationStatus(t.Context(), generation.ID, "", 10)
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), status.Obsolete)
+ assert.Equal(t, int64(1), status.CleanupPending)
+ assert.Zero(t, status.ReadyLive, "a parked row cannot become live when authority returns")
+ replayed, err := f.Store.ParkObsoleteDocumentVectorTokens(t.Context(), generation.ID, "", 10, now.Add(2*time.Second))
+ require.NoError(t, err)
+ assert.Equal(t, page.Tokens, replayed.Tokens, "a crash after parking must replay the durable token")
+
+ finalized, err := f.Store.FinalizeObsoleteDocumentVectorToken(t.Context(), generation.ID, claim.Token, now.Add(3*time.Second))
+ require.NoError(t, err)
+ assert.True(t, finalized)
+ finalized, err = f.Store.FinalizeObsoleteDocumentVectorToken(t.Context(), generation.ID, claim.Token, now.Add(4*time.Second))
+ require.NoError(t, err)
+ assert.False(t, finalized)
+ reclaimed, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "rebuild-worker", now.Add(5*time.Second), time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, reclaimed)
+ assert.Equal(t, claim.Token, reclaimed.Token)
+ assert.Equal(t, 1, reclaimed.AttemptCount)
+
+ t.Run("retired makes every token obsolete", func(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ claims := readyAllDocumentVectorChunks(t, f, generation, now)
+ require.Len(claims, 1)
+ retired, err := f.Store.RetireDocumentVectorGeneration(t.Context(), generation.ID, now.Add(time.Second))
+ require.NoError(err)
+ require.True(retired)
+ page, err := f.Store.ParkObsoleteDocumentVectorTokens(t.Context(), generation.ID, "", 10, now.Add(2*time.Second))
+ require.NoError(err)
+ assert.Equal([]store.DocumentVectorCleanupToken{{GenerationID: generation.ID, Token: claims[0].Token}}, page.Tokens)
+ finalized, err := f.Store.FinalizeObsoleteDocumentVectorToken(t.Context(), generation.ID, claims[0].Token, now.Add(3*time.Second))
+ require.NoError(err)
+ assert.True(finalized)
+ var cleanedAt sql.NullTime
+ require.NoError(f.Store.DB().QueryRow(f.Store.Rebind(`
+ SELECT backend_cleaned_at FROM document_vector_publications
+ WHERE generation_id = ? AND token = ?`), generation.ID, claims[0].Token).Scan(&cleanedAt))
+ assert.True(cleanedAt.Valid, "retired finalization preserves the ledger for purge")
+ })
+
+ t.Run("retired waits for a live publication lease", func(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "retiring-worker", now, time.Minute)
+ require.NoError(err)
+ require.NotNil(claim)
+ renewedUntil, err := f.Store.RenewDocumentVectorChunkClaim(
+ t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence,
+ now.Add(59*time.Second), time.Minute,
+ )
+ require.NoError(err)
+ assert.Equal(now.Add(119*time.Second).UTC().Truncate(time.Millisecond), renewedUntil)
+ retired, err := f.Store.RetireDocumentVectorGeneration(t.Context(), generation.ID, now.Add(60*time.Second))
+ require.NoError(err)
+ require.True(retired)
+ beforeExpiry, err := f.Store.ParkObsoleteDocumentVectorTokens(t.Context(), generation.ID, "", 10, renewedUntil.Add(-time.Millisecond))
+ require.NoError(err)
+ assert.Empty(beforeExpiry.Tokens)
+ atExpiry, err := f.Store.ParkObsoleteDocumentVectorTokens(t.Context(), generation.ID, "", 10, renewedUntil)
+ require.NoError(err)
+ assert.Equal([]store.DocumentVectorCleanupToken{{GenerationID: generation.ID, Token: claim.Token}}, atExpiry.Tokens)
+ })
+
+ t.Run("active generation parks before delete", func(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ claims := readyAllDocumentVectorChunks(t, f, generation, now)
+ require.Len(claims, 1)
+ require.NoError(f.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, now.Add(time.Second)))
+ var messageID int64
+ require.NoError(f.Store.DB().QueryRow(f.Store.Rebind(`
+ SELECT message_id FROM document_occurrences WHERE canonical_blob_hash = ?`), claims[0].CanonicalBlobHash).Scan(&messageID))
+ _, err := f.Store.DB().Exec(f.Store.Rebind(
+ `UPDATE messages SET deleted_from_source_at = ? WHERE id = ?`), now, messageID)
+ require.NoError(err)
+ page, err := f.Store.ParkObsoleteDocumentVectorTokens(t.Context(), generation.ID, "", 10, now.Add(2*time.Second))
+ require.NoError(err)
+ assert.Equal([]store.DocumentVectorCleanupToken{{GenerationID: generation.ID, Token: claims[0].Token}}, page.Tokens)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(
+ `UPDATE messages SET deleted_from_source_at = NULL WHERE id = ?`), messageID)
+ require.NoError(err)
+ live, err := f.Store.ResolveLiveDocumentVectorPublications(t.Context(), generation.ID, []string{claims[0].Token})
+ require.NoError(err)
+ assert.Empty(live)
+ finalized, err := f.Store.FinalizeObsoleteDocumentVectorToken(t.Context(), generation.ID, claims[0].Token, now.Add(3*time.Second))
+ require.NoError(err)
+ assert.True(finalized)
+ var publications int
+ require.NoError(f.Store.DB().QueryRow(f.Store.Rebind(`
+ SELECT COUNT(*) FROM document_vector_publications WHERE generation_id = ?`), generation.ID).Scan(&publications))
+ assert.Zero(publications)
+ })
+}
+
+func testDocumentVectorDerivativeGarbageCollection(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ now := time.Date(2026, time.August, 20, 22, 0, 0, 0, time.UTC)
+ claims := readyAllDocumentVectorChunks(t, f, generation, now)
+ require.Len(t, claims, 1)
+ require.NoError(t, f.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, now.Add(time.Second)))
+ claim := claims[0]
+
+ var attachmentID, messageID int64
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(`
+ SELECT attachment_id, message_id FROM document_occurrences
+ WHERE canonical_blob_hash = ?`), claim.CanonicalBlobHash).Scan(&attachmentID, &messageID))
+ _, err := f.Store.DB().Exec(f.Store.Rebind(
+ `UPDATE messages SET deleted_from_source_at = CURRENT_TIMESTAMP WHERE id = ?`), messageID)
+ require.NoError(t, err)
+ _, eligible, err := f.Store.ReconcileDocumentOccurrence(t.Context(), attachmentID, claim.SourceSequence+1)
+ require.NoError(t, err)
+ assert.False(t, eligible)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(
+ `UPDATE document_extractions SET updated_at = ? WHERE id = ?`), now.Add(-48*time.Hour), claim.ExtractionID)
+ require.NoError(t, err)
+
+ result, err := f.Store.GarbageCollectDocumentDerivatives(t.Context(), now.Add(-24*time.Hour), 10)
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentDerivativeGCResult{ExtractionsRemoved: 1, CurrentHeadsRemoved: 1}, result)
+ assertDocumentVectorPublicationSurvivesSourceRemoval(t, f, generation.ID, claim.Token)
+}
+
+func testDocumentVectorDerivativePurge(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ now := time.Date(2026, time.August, 20, 22, 30, 0, 0, time.UTC)
+ claims := readyAllDocumentVectorChunks(t, f, generation, now)
+ require.Len(t, claims, 1)
+ require.NoError(t, f.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, now.Add(time.Second)))
+ claim := claims[0]
+
+ result, err := f.Store.PurgeDocumentDerivedByHash(t.Context(), claim.CanonicalBlobHash)
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentDerivedPurgeResult{ExtractionsRemoved: 1, HeadsRemoved: 1}, result)
+ assertDocumentVectorPublicationSurvivesSourceRemoval(t, f, generation.ID, claim.Token)
+}
+
+func assertDocumentVectorPublicationSurvivesSourceRemoval(
+ t *testing.T, f *storetest.Fixture, generationID int64, token string,
+) {
+ t.Helper()
+ var storedToken, state string
+ var cleanedAt sql.NullTime
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(`
+ SELECT token, state, backend_cleaned_at
+ FROM document_vector_publications
+ WHERE generation_id = ? AND token = ?`), generationID, token).Scan(
+ &storedToken, &state, &cleanedAt))
+ assert.Equal(t, token, storedToken)
+ assert.Equal(t, "ready", state)
+ assert.False(t, cleanedAt.Valid)
+ resolved, err := f.Store.ResolveLiveDocumentVectorPublications(t.Context(), generationID, []string{token})
+ require.NoError(t, err)
+ assert.Empty(t, resolved)
+}
+
+func testDocumentVectorCoverageAndActivation(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 2)
+ now := time.Date(2026, time.August, 20, 18, 19, 20, 456789000, time.FixedZone("coverage-offset", -3*60*60))
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "coverage-worker", now, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, claim)
+ require.NoError(t, f.Store.CommitDocumentVectorPublication(t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, now.Add(time.Second)))
+
+ coverage, err := f.Store.GetDocumentVectorCoverage(t.Context(), generation.ID)
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentVectorCoverage{Required: 2, Ready: 1}, coverage)
+ assert.False(t, coverage.Complete())
+ revisionBefore, err := f.Store.GetDocumentIndexRevision(t.Context())
+ require.NoError(t, err)
+ err = f.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, now.Add(2*time.Second))
+ require.ErrorIs(t, err, store.ErrDocumentVectorCoverageIncomplete)
+ afterFailedActivation, err := f.Store.GetDocumentVectorGeneration(t.Context(), generation.ID)
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentVectorGenerationBuilding, afterFailedActivation.State)
+ assert.Nil(t, afterFailedActivation.ActivatedAt)
+ revisionAfterFailure, err := f.Store.GetDocumentIndexRevision(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, revisionBefore, revisionAfterFailure)
+
+ remaining := readyAllDocumentVectorChunks(t, f, generation, now.Add(3*time.Second))
+ require.Len(t, remaining, 1)
+ coverage, err = f.Store.GetDocumentVectorCoverage(t.Context(), generation.ID)
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentVectorCoverage{Required: 2, Ready: 2}, coverage)
+ assert.True(t, coverage.Complete())
+
+ activationTime := now.Add(4 * time.Second)
+ require.NoError(t, f.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, activationTime))
+ active, err := f.Store.GetDocumentVectorGeneration(t.Context(), generation.ID)
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentVectorGenerationActive, active.State)
+ require.NotNil(t, active.ActivatedAt)
+ assert.Equal(t, activationTime.UTC().Truncate(time.Millisecond), active.ActivatedAt.UTC())
+ revisionAfterActivation, err := f.Store.GetDocumentIndexRevision(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, revisionBefore+1, revisionAfterActivation)
+
+ empty := storetest.New(t)
+ profile, _ := seedDocumentPublicationAuthority(t, empty)
+ emptyGeneration, _, err := empty.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{
+ Fingerprint: strings.Repeat("0", 64), TargetExtractionProfileID: profile.ID,
+ EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768,
+ })
+ require.NoError(t, err)
+ emptyCoverage, err := empty.Store.GetDocumentVectorCoverage(t.Context(), emptyGeneration.ID)
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentVectorCoverage{}, emptyCoverage)
+ assert.True(t, emptyCoverage.Complete())
+ require.NoError(t, empty.Store.ActivateDocumentVectorGeneration(t.Context(), emptyGeneration.ID, activationTime))
+}
+
+func testDocumentVectorActivationSwapAndRetirement(t *testing.T) {
+ f, oldGeneration := seedDocumentVectorGenerationWithChunks(t, 1)
+ now := time.Date(2026, time.August, 20, 19, 20, 21, 987654000, time.UTC)
+ oldClaims := readyAllDocumentVectorChunks(t, f, oldGeneration, now)
+ require.Len(t, oldClaims, 1)
+ require.NoError(t, f.Store.ActivateDocumentVectorGeneration(t.Context(), oldGeneration.ID, now.Add(time.Second)))
+
+ newSpec := oldGeneration.DocumentVectorGenerationSpec
+ newSpec.Fingerprint = strings.Repeat("1", 64)
+ newSpec.Model = "embed-v2"
+ newGeneration, created, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), newSpec)
+ require.NoError(t, err)
+ require.True(t, created)
+ err = f.Store.ActivateDocumentVectorGeneration(t.Context(), newGeneration.ID, now.Add(2*time.Second))
+ require.ErrorIs(t, err, store.ErrDocumentVectorCoverageIncomplete)
+ stillActive, err := f.Store.GetActiveDocumentVectorGeneration(t.Context())
+ require.NoError(t, err)
+ require.NotNil(t, stillActive)
+ assert.Equal(t, oldGeneration.ID, stillActive.ID, "incomplete activation must not retire the serving generation")
+ newClaims := readyAllDocumentVectorChunks(t, f, newGeneration, now.Add(2*time.Second))
+ require.Len(t, newClaims, 1)
+ revisionBeforeSwap, err := f.Store.GetDocumentIndexRevision(t.Context())
+ require.NoError(t, err)
+ swapTime := now.Add(3 * time.Second)
+ require.NoError(t, f.Store.ActivateDocumentVectorGeneration(t.Context(), newGeneration.ID, swapTime))
+
+ oldAfter, err := f.Store.GetDocumentVectorGeneration(t.Context(), oldGeneration.ID)
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentVectorGenerationRetired, oldAfter.State)
+ require.NotNil(t, oldAfter.RetiredAt)
+ assert.Equal(t, swapTime.UTC().Truncate(time.Millisecond), oldAfter.RetiredAt.UTC())
+ newAfter, err := f.Store.GetDocumentVectorGeneration(t.Context(), newGeneration.ID)
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentVectorGenerationActive, newAfter.State)
+ revisionAfterSwap, err := f.Store.GetDocumentIndexRevision(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, revisionBeforeSwap+1, revisionAfterSwap)
+
+ resolved, err := f.Store.ResolveLiveDocumentVectorPublications(t.Context(), oldGeneration.ID, []string{oldClaims[0].Token})
+ require.NoError(t, err)
+ assert.Empty(t, resolved)
+ resolved, err = f.Store.ResolveLiveDocumentVectorPublications(t.Context(), newGeneration.ID, []string{newClaims[0].Token})
+ require.NoError(t, err)
+ require.Len(t, resolved, 1)
+
+ revisionBeforeRetire, err := f.Store.GetDocumentIndexRevision(t.Context())
+ require.NoError(t, err)
+ retired, err := f.Store.RetireDocumentVectorGeneration(t.Context(), newGeneration.ID, now.Add(4*time.Second))
+ require.NoError(t, err)
+ assert.True(t, retired)
+ revisionAfterRetire, err := f.Store.GetDocumentIndexRevision(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, revisionBeforeRetire+1, revisionAfterRetire)
+ retired, err = f.Store.RetireDocumentVectorGeneration(t.Context(), newGeneration.ID, now.Add(5*time.Second))
+ require.NoError(t, err)
+ assert.False(t, retired)
+ revisionAfterRepeat, err := f.Store.GetDocumentIndexRevision(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, revisionAfterRetire, revisionAfterRepeat)
+ resolved, err = f.Store.ResolveLiveDocumentVectorPublications(t.Context(), newGeneration.ID, []string{newClaims[0].Token})
+ require.NoError(t, err)
+ assert.Empty(t, resolved)
+ _, err = f.Store.GetDocumentVectorCoverage(t.Context(), newGeneration.ID)
+ require.ErrorIs(t, err, store.ErrDocumentVectorInvalidGenerationState)
+}
+
+func testDocumentVectorLiveResolution(t *testing.T) {
+ t.Run("input ordering dedupe and bounds", func(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 2)
+ now := time.Date(2026, time.August, 20, 20, 21, 22, 0, time.UTC)
+ claims := readyAllDocumentVectorChunks(t, f, generation, now)
+ require.Len(claims, 2)
+ require.NoError(f.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, now.Add(time.Second)))
+ resolved, err := f.Store.ResolveLiveDocumentVectorPublications(t.Context(), generation.ID, []string{strings.Repeat("8", 64), claims[1].Token, claims[0].Token, claims[1].Token})
+ require.NoError(err)
+ require.Len(resolved, 2)
+ assert.Equal(claims[1].Token, resolved[0].Token)
+ assert.Equal(claims[0].Token, resolved[1].Token)
+ assert.Equal(claims[1].DocumentVectorChunkCandidate, resolved[0].DocumentVectorChunkCandidate)
+ _, err = f.Store.ResolveLiveDocumentVectorPublications(t.Context(), generation.ID, []string{"invalid"})
+ require.Error(err)
+ _, err = f.Store.ResolveLiveDocumentVectorPublications(t.Context(), generation.ID, make([]string, 1001))
+ require.Error(err)
+ })
+
+ for _, mutation := range []string{"attachment replacement", "occurrence deletion", "role change", "message deletion", "target rotation"} {
+ t.Run(mutation, func(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ now := time.Date(2026, time.August, 20, 21, 22, 23, 0, time.UTC)
+ claims := readyAllDocumentVectorChunks(t, f, generation, now)
+ require.Len(claims, 1)
+ require.NoError(f.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, now.Add(time.Second)))
+ attachmentID := documentVectorAttachmentID(t, f, claims[0].CanonicalBlobHash)
+ file, err := f.Store.GetFileMetadata(t.Context(), attachmentID)
+ require.NoError(err)
+ require.NotNil(file)
+
+ switch mutation {
+ case "attachment replacement":
+ newHash := strings.Repeat("c", 64)
+ require.NoError(f.Store.UpsertAttachmentRecord(t.Context(), file.MessageID, store.AttachmentWrite{
+ Filename: file.Filename, MIMEType: file.MimeType, Size: file.Size,
+ StoragePath: newHash[:2] + "/" + newHash, ContentHash: newHash,
+ Role: file.AttachmentRole, RoleSource: file.RoleSource, SourcePartKey: file.SourcePartKey,
+ }))
+ case "occurrence deletion":
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`DELETE FROM document_occurrences WHERE attachment_id = ?`), attachmentID)
+ require.NoError(err)
+ case "role change":
+ require.NoError(f.Store.UpsertAttachmentRecord(t.Context(), file.MessageID, store.AttachmentWrite{
+ Filename: file.Filename, MIMEType: file.MimeType, Size: file.Size,
+ StoragePath: file.StoragePath, ContentHash: file.ContentHash,
+ Role: store.AttachmentRoleInline, RoleSource: store.AttachmentRoleSourceMIMEDisposition,
+ SourcePartKey: file.SourcePartKey,
+ }))
+ case "message deletion":
+ require.NoError(f.Store.MarkMessageDeleted(file.SourceID, file.SourceMessageID))
+ case "target rotation":
+ profile := rotatedDocumentVectorProfile()
+ _, err = f.Store.EnsureDocumentExtractionProfile(t.Context(), profile)
+ require.NoError(err)
+ require.NoError(f.Store.RecordDocumentProviderConsent(t.Context(), store.DocumentProviderConsent{
+ ProfileID: profile.ID, ProfileFingerprint: profile.Fingerprint,
+ RetentionPosture: profile.RetentionPosture, TrainingPosture: profile.TrainingPosture,
+ }))
+ }
+
+ resolved, err := f.Store.ResolveLiveDocumentVectorPublications(t.Context(), generation.ID, []string{claims[0].Token})
+ require.NoError(err)
+ assert.Empty(resolved)
+ var state string
+ require.NoError(f.Store.DB().QueryRow(f.Store.Rebind(`SELECT state FROM document_vector_publications WHERE generation_id = ? AND token = ?`), generation.ID, claims[0].Token).Scan(&state))
+ assert.Equal("ready", state, "source invalidation must hide without deleting the ledger row")
+ })
+ }
+}
+
+func testDocumentVectorCleanupAndPurge(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 3)
+ now := time.Date(2026, time.August, 20, 22, 23, 24, 654321000, time.FixedZone("cleanup-offset", 4*60*60))
+ ready, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "cleanup-worker", now, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, ready)
+ require.NoError(t, f.Store.CommitDocumentVectorPublication(t.Context(), generation.ID, ready.Token, ready.LeaseOwner, ready.LeaseFence, now.Add(time.Second)))
+ pending, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, ready.ChunkID, 1, "cleanup-worker", now, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, pending)
+ failed, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, pending.ChunkID, 1, "cleanup-worker", now, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, failed)
+ require.NoError(t, f.Store.FailDocumentVectorChunk(t.Context(), generation.ID, failed.Token, failed.LeaseOwner, failed.LeaseFence, now.Add(time.Second), nil, true, "provider_rejected"))
+
+ _, err = f.Store.ListDocumentVectorCleanupTokens(t.Context(), generation.ID, "", 2)
+ require.ErrorIs(t, err, store.ErrDocumentVectorInvalidGenerationState)
+ changed, err := f.Store.MarkDocumentVectorTokenCleaned(t.Context(), generation.ID, ready.Token, now)
+ assert.False(t, changed)
+ require.ErrorIs(t, err, store.ErrDocumentVectorInvalidGenerationState)
+ _, err = f.Store.PurgeRetiredDocumentVectorGeneration(t.Context(), generation.ID)
+ require.ErrorIs(t, err, store.ErrDocumentVectorInvalidGenerationState)
+ revisionBefore, err := f.Store.GetDocumentIndexRevision(t.Context())
+ require.NoError(t, err)
+ retired, err := f.Store.RetireDocumentVectorGeneration(t.Context(), generation.ID, now.Add(2*time.Second))
+ require.NoError(t, err)
+ assert.True(t, retired)
+ retiredGeneration, err := f.Store.GetDocumentVectorGeneration(t.Context(), generation.ID)
+ require.NoError(t, err)
+ require.NotNil(t, retiredGeneration.RetiredAt)
+ assert.Equal(t, now.Add(2*time.Second).UTC().Truncate(time.Millisecond), retiredGeneration.RetiredAt.UTC())
+ revisionAfter, err := f.Store.GetDocumentIndexRevision(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, revisionBefore, revisionAfter, "retiring a building generation need not invalidate search")
+
+ wantTokens := []string{ready.Token, pending.Token, failed.Token}
+ sort.Strings(wantTokens)
+ first, err := f.Store.ListDocumentVectorCleanupTokens(t.Context(), generation.ID, "", 2)
+ require.NoError(t, err)
+ require.Len(t, first, 2)
+ gotTokens := []string{first[0].Token, first[1].Token}
+ assert.Equal(t, wantTokens[:2], gotTokens)
+ second, err := f.Store.ListDocumentVectorCleanupTokens(t.Context(), generation.ID, first[1].Token, 2)
+ require.NoError(t, err)
+ require.Len(t, second, 1)
+ assert.Equal(t, wantTokens[2], second[0].Token)
+ _, err = f.Store.ListDocumentVectorCleanupTokens(t.Context(), generation.ID, "", 0)
+ require.ErrorContains(t, err, "limit")
+ _, err = f.Store.ListDocumentVectorCleanupTokens(t.Context(), generation.ID, "", 1001)
+ require.ErrorContains(t, err, "limit")
+ _, err = f.Store.ListDocumentVectorCleanupTokens(t.Context(), generation.ID, "not-a-token", 1)
+ require.ErrorContains(t, err, "cursor")
+
+ _, err = f.Store.PurgeRetiredDocumentVectorGeneration(t.Context(), generation.ID)
+ require.ErrorIs(t, err, store.ErrDocumentVectorCleanupIncomplete)
+ var generationCount, publicationCount int
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(`SELECT COUNT(*) FROM document_vector_generations WHERE id = ?`), generation.ID).Scan(&generationCount))
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(`SELECT COUNT(*) FROM document_vector_publications WHERE generation_id = ?`), generation.ID).Scan(&publicationCount))
+ assert.Equal(t, 1, generationCount)
+ assert.Equal(t, 3, publicationCount)
+
+ cleanTime := now.Add(3 * time.Second)
+ for _, token := range wantTokens {
+ changed, err := f.Store.MarkDocumentVectorTokenCleaned(t.Context(), generation.ID, token, cleanTime)
+ require.NoError(t, err)
+ assert.True(t, changed)
+ }
+ var cleanedAt time.Time
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(`SELECT backend_cleaned_at FROM document_vector_publications WHERE generation_id = ? AND token = ?`), generation.ID, wantTokens[0]).Scan(&cleanedAt))
+ assert.Equal(t, cleanTime.UTC().Truncate(time.Millisecond), cleanedAt.UTC())
+ changed, err = f.Store.MarkDocumentVectorTokenCleaned(t.Context(), generation.ID, wantTokens[0], cleanTime.Add(time.Second))
+ require.NoError(t, err)
+ assert.False(t, changed)
+ changed, err = f.Store.MarkDocumentVectorTokenCleaned(t.Context(), generation.ID, strings.Repeat("9", 64), cleanTime)
+ require.NoError(t, err)
+ assert.False(t, changed)
+ remaining, err := f.Store.ListDocumentVectorCleanupTokens(t.Context(), generation.ID, "", 10)
+ require.NoError(t, err)
+ assert.Empty(t, remaining)
+
+ purged, err := f.Store.PurgeRetiredDocumentVectorGeneration(t.Context(), generation.ID)
+ require.NoError(t, err)
+ assert.True(t, purged)
+ purged, err = f.Store.PurgeRetiredDocumentVectorGeneration(t.Context(), generation.ID)
+ require.NoError(t, err)
+ assert.False(t, purged)
+}
+
+func runDocumentVectorChunkLifecycleContract(t *testing.T) {
+ t.Helper()
+ t.Run("claim resume bounds and stable token", testDocumentVectorClaimResumeBounds)
+ t.Run("renew and stale fence", testDocumentVectorRenewAndStaleFence)
+ t.Run("retry and terminal failure", testDocumentVectorFailureLifecycle)
+ t.Run("failure status and terminal reset", testDocumentVectorFailureStatusAndReset)
+ t.Run("source changed failure stays parked", testDocumentVectorSourceChangedFailureStaysParked)
+ t.Run("commit idempotence and source race", testDocumentVectorCommitLifecycle)
+ t.Run("invalid generation state", testDocumentVectorInvalidGenerationState)
+ t.Run("timestamp precision parity", testDocumentVectorTimestampPrecisionParity)
+ t.Run("ineffective sub-millisecond boundaries", testDocumentVectorRejectsIneffectiveTimeBoundaries)
+}
+
+func testDocumentVectorFailureStatusAndReset(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 2)
+ now := time.Date(2026, time.August, 20, 12, 30, 0, 456789000, time.FixedZone("reset-offset", 2*60*60))
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", now, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, claim)
+ require.NoError(t, f.Store.FailDocumentVectorChunk(
+ t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence,
+ now.Add(time.Second), nil, true, "provider_rejected",
+ ))
+ retryable, err := f.Store.ClaimDocumentVectorChunk(
+ t.Context(), generation.ID, claim.ChunkID, 1, "worker-a", now, time.Minute,
+ )
+ require.NoError(t, err)
+ require.NotNil(t, retryable)
+ retryAt := now.Add(time.Hour).UTC().Truncate(time.Millisecond)
+ require.NoError(t, f.Store.FailDocumentVectorChunk(
+ t.Context(), generation.ID, retryable.Token, retryable.LeaseOwner, retryable.LeaseFence,
+ now.Add(time.Second), &retryAt, false, "provider_busy",
+ ))
+ firstPage, err := f.Store.GetDocumentVectorGenerationStatus(t.Context(), generation.ID, "", 1)
+ require.NoError(t, err)
+ require.Len(t, firstPage.Failures, 1)
+ assert.False(t, firstPage.FailuresExhausted)
+ assert.Equal(t, generation.ID, firstPage.FailureAfterGenerationID)
+ assert.NotEmpty(t, firstPage.FailureAfterToken)
+ secondPage, err := f.Store.GetDocumentVectorGenerationStatus(
+ t.Context(), generation.ID, firstPage.FailureAfterToken, 1,
+ )
+ require.NoError(t, err)
+ require.Len(t, secondPage.Failures, 1)
+ assert.False(t, secondPage.FailuresExhausted)
+ lastPage, err := f.Store.GetDocumentVectorGenerationStatus(
+ t.Context(), generation.ID, secondPage.FailureAfterToken, 1,
+ )
+ require.NoError(t, err)
+ assert.Empty(t, lastPage.Failures)
+ assert.True(t, lastPage.FailuresExhausted)
+ assert.Zero(t, lastPage.FailureAfterGenerationID)
+ assert.Empty(t, lastPage.FailureAfterToken)
+
+ status, err := f.Store.GetDocumentVectorGenerationStatus(t.Context(), generation.ID, "", 10)
+ require.NoError(t, err)
+ assert.True(t, status.Blocked)
+ assert.Equal(t, int64(1), status.Terminal)
+ assert.Zero(t, status.Pending)
+ assert.Equal(t, int64(1), status.Retryable)
+ assert.Zero(t, status.ReadyLive)
+ assert.Zero(t, status.Obsolete)
+ assert.Zero(t, status.CleanupPending)
+ require.Len(t, status.Failures, 2)
+ diagnostics := make(map[string]store.DocumentVectorFailureDiagnostic, len(status.Failures))
+ for _, diagnostic := range status.Failures {
+ diagnostics[diagnostic.Token] = diagnostic
+ }
+ assert.Equal(t, store.DocumentVectorFailureDiagnostic{
+ Token: claim.Token, AttemptCount: 1, Terminal: true, ErrorCode: "provider_rejected",
+ }, diagnostics[claim.Token])
+ assert.Equal(t, store.DocumentVectorFailureDiagnostic{
+ Token: retryable.Token, AttemptCount: 1, NextRetryAt: &retryAt, ErrorCode: "provider_busy",
+ }, diagnostics[retryable.Token])
+ assert.True(t, status.FailuresExhausted)
+ assert.Empty(t, status.FailureAfterToken)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`
+ UPDATE document_vector_publications SET error_code = ?
+ WHERE generation_id = ? AND token = ?`), "Provider returned private response text", generation.ID, retryable.Token)
+ require.NoError(t, err)
+ sanitized, err := f.Store.GetDocumentVectorGenerationStatus(t.Context(), generation.ID, "", 10)
+ require.NoError(t, err)
+ for _, diagnostic := range sanitized.Failures {
+ if diagnostic.Token == retryable.Token {
+ assert.Equal(t, "unknown", diagnostic.ErrorCode)
+ }
+ }
+
+ reset, err := f.Store.ResetDocumentVectorFailures(t.Context(), generation.ID, "", 1, now.Add(2*time.Second))
+ require.NoError(t, err)
+ assert.Equal(t, 1, reset.Scanned)
+ assert.Equal(t, 1, reset.Reset)
+ assert.False(t, reset.Exhausted)
+ assert.Equal(t, generation.ID, reset.AfterGenerationID)
+ assert.NotEmpty(t, reset.AfterToken)
+ secondReset, err := f.Store.ResetDocumentVectorFailures(
+ t.Context(), generation.ID, reset.AfterToken, 1, now.Add(2*time.Second),
+ )
+ require.NoError(t, err)
+ assert.Equal(t, 1, secondReset.Scanned)
+ assert.Equal(t, 1, secondReset.Reset)
+ assert.False(t, secondReset.Exhausted)
+ finalReset, err := f.Store.ResetDocumentVectorFailures(
+ t.Context(), generation.ID, secondReset.AfterToken, 1, now.Add(2*time.Second),
+ )
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentVectorFailureResetResult{Exhausted: true}, finalReset)
+ resetStatus, err := f.Store.GetDocumentVectorGenerationStatus(t.Context(), generation.ID, "", 10)
+ require.NoError(t, err)
+ assert.False(t, resetStatus.Blocked)
+
+ reclaimed, err := f.Store.ClaimDocumentVectorChunk(
+ t.Context(), generation.ID, 0, 1, "worker-b", now.Add(3*time.Second), time.Minute,
+ )
+ require.NoError(t, err)
+ require.NotNil(t, reclaimed)
+ assert.Equal(t, claim.Token, reclaimed.Token)
+ assert.Equal(t, claim.LeaseFence+1, reclaimed.LeaseFence)
+ assert.Equal(t, 1, reclaimed.AttemptCount)
+ _, err = f.Store.GetDocumentVectorGenerationStatus(t.Context(), generation.ID, "", 0)
+ require.ErrorContains(t, err, "limit")
+ _, err = f.Store.GetDocumentVectorGenerationStatus(t.Context(), generation.ID, "not-a-token", 1)
+ require.ErrorContains(t, err, "cursor")
+ _, err = f.Store.ResetDocumentVectorFailures(t.Context(), generation.ID, "", 1001, now)
+ require.ErrorContains(t, err, "limit")
+}
+
+func testDocumentVectorSourceChangedFailureStaysParked(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ now := time.Date(2026, time.August, 20, 12, 45, 0, 0, time.UTC)
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", now, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, claim)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(
+ `DELETE FROM document_occurrences WHERE canonical_blob_hash = ?`), claim.CanonicalBlobHash)
+ require.NoError(t, err)
+ err = f.Store.CommitDocumentVectorPublication(
+ t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, now.Add(time.Second),
+ )
+ require.ErrorIs(t, err, store.ErrDocumentVectorSourceChanged)
+
+ reset, err := f.Store.ResetDocumentVectorFailures(t.Context(), generation.ID, "", 10, now.Add(2*time.Second))
+ require.NoError(t, err)
+ assert.Equal(t, store.DocumentVectorFailureResetResult{Scanned: 1, Exhausted: true}, reset)
+ status, err := f.Store.GetDocumentVectorGenerationStatus(t.Context(), generation.ID, "", 10)
+ require.NoError(t, err)
+ assert.Zero(t, status.Terminal, "obsolete failures are counted in Obsolete instead")
+ assert.Equal(t, int64(1), status.Obsolete)
+ assert.Equal(t, int64(1), status.CleanupPending)
+ require.Len(t, status.Failures, 1)
+ assert.Equal(t, "source_changed", status.Failures[0].ErrorCode)
+}
+
+func testDocumentVectorClaimResumeBounds(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 3)
+ now := time.Date(2026, time.August, 20, 10, 11, 12, 0, time.UTC)
+
+ first, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", now, 2*time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, first)
+ assert.Equal(t, int64(1), first.LeaseFence)
+ assert.Equal(t, 1, first.AttemptCount)
+ assert.Len(t, first.Token, 64)
+ assert.Equal(t, now.Add(2*time.Minute), first.LeaseUntil)
+
+ observed, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", now.Add(time.Second), 2*time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, observed)
+ assert.Equal(t, first.Token, observed.Token)
+ assert.Equal(t, first.LeaseFence, observed.LeaseFence)
+ assert.Equal(t, first.AttemptCount, observed.AttemptCount)
+ assert.Equal(t, first.LeaseUntil, observed.LeaseUntil)
+
+ blocked, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-b", now.Add(time.Second), 2*time.Minute)
+ require.NoError(t, err)
+ assert.Nil(t, blocked, "bounded scan must not jump past its first busy candidate")
+
+ second, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 2, "worker-b", now.Add(time.Second), 2*time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, second)
+ assert.Greater(t, second.ChunkID, first.ChunkID)
+
+ takeover, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-b", first.LeaseUntil, 2*time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, takeover)
+ assert.Equal(t, first.Token, takeover.Token)
+ assert.Equal(t, first.LeaseFence+1, takeover.LeaseFence)
+ assert.Equal(t, first.AttemptCount+1, takeover.AttemptCount)
+}
+
+func testDocumentVectorRenewAndStaleFence(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ now := time.Date(2026, time.August, 20, 11, 12, 13, 0, time.UTC)
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", now, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, claim)
+
+ renewedUntil, err := f.Store.RenewDocumentVectorChunkClaim(t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, now.Add(30*time.Second), 3*time.Minute)
+ require.NoError(t, err)
+ assert.Equal(t, now.Add(3*time.Minute+30*time.Second), renewedUntil)
+
+ takeover, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", renewedUntil, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, takeover)
+ _, err = f.Store.RenewDocumentVectorChunkClaim(t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, renewedUntil, time.Minute)
+ require.ErrorIs(t, err, store.ErrDocumentVectorClaimLost)
+ assert.NotEqual(t, claim.LeaseFence, takeover.LeaseFence)
+}
+
+func testDocumentVectorFailureLifecycle(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 2)
+ now := time.Date(2026, time.August, 20, 12, 13, 14, 0, time.UTC)
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", now, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, claim)
+ retryAt := now.Add(5 * time.Minute)
+ require.NoError(t, f.Store.FailDocumentVectorChunk(t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, now.Add(time.Second), &retryAt, false, "provider_busy"))
+
+ beforeRetry, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-b", retryAt.Add(-time.Second), time.Minute)
+ require.NoError(t, err)
+ assert.Nil(t, beforeRetry)
+ retry, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-b", retryAt, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, retry)
+ assert.Equal(t, claim.Token, retry.Token)
+ assert.Equal(t, claim.LeaseFence+1, retry.LeaseFence)
+ assert.Equal(t, claim.AttemptCount+1, retry.AttemptCount)
+
+ require.Error(t, f.Store.FailDocumentVectorChunk(t.Context(), generation.ID, retry.Token, retry.LeaseOwner, retry.LeaseFence, retryAt, nil, false, "provider_busy"))
+ require.Error(t, f.Store.FailDocumentVectorChunk(t.Context(), generation.ID, retry.Token, retry.LeaseOwner, retry.LeaseFence, retryAt, &retryAt, true, "provider_busy"))
+ require.Error(t, f.Store.FailDocumentVectorChunk(t.Context(), generation.ID, retry.Token, retry.LeaseOwner, retry.LeaseFence, retryAt, nil, true, strings.Repeat("x", 65)))
+ require.NoError(t, f.Store.FailDocumentVectorChunk(t.Context(), generation.ID, retry.Token, retry.LeaseOwner, retry.LeaseFence, retryAt, nil, true, "provider_rejected"))
+
+ terminal, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-c", retryAt.Add(time.Hour), time.Minute)
+ require.NoError(t, err)
+ assert.Nil(t, terminal)
+}
+
+func testDocumentVectorCommitLifecycle(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 2)
+ now := time.Date(2026, time.August, 20, 13, 14, 15, 0, time.UTC)
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", now, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, claim)
+ require.NoError(t, f.Store.CommitDocumentVectorPublication(t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, now.Add(time.Second)))
+ require.NoError(t, f.Store.CommitDocumentVectorPublication(t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, now.Add(2*time.Second)))
+ err = f.Store.CommitDocumentVectorPublication(t.Context(), generation.ID, claim.Token, "stale-worker", claim.LeaseFence, now.Add(2*time.Second))
+ require.ErrorIs(t, err, store.ErrDocumentVectorClaimLost)
+ err = f.Store.CommitDocumentVectorPublication(t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence+1, now.Add(2*time.Second))
+ require.ErrorIs(t, err, store.ErrDocumentVectorClaimLost)
+
+ tracing, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, claim.ChunkID, 1, "worker-b", now, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, tracing)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`DELETE FROM document_occurrences WHERE canonical_blob_hash = ?`), tracing.CanonicalBlobHash)
+ require.NoError(t, err)
+ err = f.Store.CommitDocumentVectorPublication(t.Context(), generation.ID, tracing.Token, tracing.LeaseOwner, tracing.LeaseFence, now.Add(time.Second))
+ require.ErrorIs(t, err, store.ErrDocumentVectorSourceChanged)
+
+ var state, errorCode string
+ var owner, leaseUntil, cleanedAt sql.NullString
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(`SELECT state, error_code, lease_owner, lease_until, backend_cleaned_at FROM document_vector_publications WHERE generation_id = ? AND token = ?`), generation.ID, tracing.Token).Scan(&state, &errorCode, &owner, &leaseUntil, &cleanedAt))
+ assert.Equal(t, "failed", state)
+ assert.Equal(t, "source_changed", errorCode)
+ assert.False(t, owner.Valid)
+ assert.False(t, leaseUntil.Valid)
+ assert.False(t, cleanedAt.Valid)
+}
+
+func testDocumentVectorInvalidGenerationState(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ now := time.Date(2026, time.August, 20, 14, 15, 16, 0, time.UTC)
+ _, err := f.Store.DB().Exec(f.Store.Rebind(`UPDATE document_vector_generations SET state = 'active' WHERE id = ?`), generation.ID)
+ require.NoError(t, err)
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", now, time.Minute)
+ assert.Nil(t, claim)
+ require.ErrorIs(t, err, store.ErrDocumentVectorInvalidGenerationState)
+
+ _, err = f.Store.RenewDocumentVectorChunkClaim(t.Context(), generation.ID, strings.Repeat("0", 64), "worker-a", 1, now, time.Minute)
+ require.ErrorIs(t, err, store.ErrDocumentVectorInvalidGenerationState)
+
+ f, generation = seedDocumentVectorGenerationWithChunks(t, 1)
+ _, err = f.Store.DB().Exec(`UPDATE document_index_state SET target_profile_id = NULL WHERE singleton = 1`)
+ require.NoError(t, err)
+ claim, err = f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", now, time.Minute)
+ assert.Nil(t, claim)
+ require.ErrorIs(t, err, store.ErrDocumentVectorInvalidGenerationState)
+}
+
+func testDocumentVectorTimestampPrecisionParity(t *testing.T) {
+ t.Run("claim", func(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ rawNow := time.Date(2026, time.August, 20, 15, 16, 17, 123456789, time.FixedZone("test-offset", 2*60*60))
+ leaseDuration := 2*time.Second + 400*time.Microsecond
+ wantUntil := rawNow.Add(leaseDuration).UTC().Truncate(time.Millisecond)
+
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", rawNow, leaseDuration)
+ require.NoError(err)
+ require.NotNil(claim)
+ assert.Equal(wantUntil, claim.LeaseUntil)
+ assert.Equal(wantUntil, documentVectorLeaseUntil(t, f, generation.ID, claim.Token))
+
+ before, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-b", wantUntil.Add(-time.Nanosecond), time.Second)
+ require.NoError(err)
+ assert.Nil(before)
+ atDeadline, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-b", wantUntil, time.Second)
+ require.NoError(err)
+ require.NotNil(atDeadline)
+ assert.Equal(claim.Token, atDeadline.Token)
+ assert.Equal(claim.LeaseFence+1, atDeadline.LeaseFence)
+ })
+
+ t.Run("renew", func(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ claimNow := time.Date(2026, time.August, 20, 16, 17, 18, 0, time.UTC)
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", claimNow, time.Minute)
+ require.NoError(err)
+ require.NotNil(claim)
+
+ rawNow := claimNow.Add(5*time.Second + 456789*time.Nanosecond)
+ leaseDuration := 2*time.Second + 400*time.Microsecond
+ wantUntil := rawNow.Add(leaseDuration).UTC().Truncate(time.Millisecond)
+ renewedUntil, err := f.Store.RenewDocumentVectorChunkClaim(t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, rawNow, leaseDuration)
+ require.NoError(err)
+ assert.Equal(wantUntil, renewedUntil)
+ assert.Equal(wantUntil, documentVectorLeaseUntil(t, f, generation.ID, claim.Token))
+
+ before, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-b", wantUntil.Add(-time.Nanosecond), time.Second)
+ require.NoError(err)
+ assert.Nil(before)
+ atDeadline, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-b", wantUntil, time.Second)
+ require.NoError(err)
+ require.NotNil(atDeadline)
+ assert.Equal(claim.Token, atDeadline.Token)
+ })
+}
+
+func testDocumentVectorRejectsIneffectiveTimeBoundaries(t *testing.T) {
+ f, generation := seedDocumentVectorGenerationWithChunks(t, 1)
+ now := time.Date(2026, time.August, 20, 17, 18, 19, 123000000, time.UTC)
+
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", now, 500*time.Microsecond)
+ assert.Nil(t, claim)
+ require.ErrorContains(t, err, "lease duration")
+
+ claim, err = f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "worker-a", now, time.Minute)
+ require.NoError(t, err)
+ require.NotNil(t, claim)
+ _, err = f.Store.RenewDocumentVectorChunkClaim(t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, now, 500*time.Microsecond)
+ require.ErrorContains(t, err, "lease duration")
+
+ retryAt := now.Add(500 * time.Microsecond)
+ err = f.Store.FailDocumentVectorChunk(t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, now, &retryAt, false, "provider_busy")
+ require.ErrorContains(t, err, "future retry time")
+}
+
+func documentVectorLeaseUntil(t *testing.T, f *storetest.Fixture, generationID int64, token string) time.Time {
+ t.Helper()
+ var leaseUntil time.Time
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(`SELECT lease_until FROM document_vector_publications WHERE generation_id = ? AND token = ?`), generationID, token).Scan(&leaseUntil))
+ return leaseUntil.UTC()
+}
+
+func readyAllDocumentVectorChunks(
+ t *testing.T,
+ f *storetest.Fixture,
+ generation store.DocumentVectorGeneration,
+ now time.Time,
+) []store.DocumentVectorChunkClaim {
+ t.Helper()
+ var claims []store.DocumentVectorChunkClaim
+ for len(claims) < 1000 {
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1000, "ready-worker", now, time.Minute)
+ require.NoError(t, err)
+ if claim == nil {
+ return claims
+ }
+ require.NoError(t, f.Store.CommitDocumentVectorPublication(t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, now.Add(time.Second)))
+ claims = append(claims, *claim)
+ }
+ require.Fail(t, "document vector ready helper exceeded its safety bound")
+ return nil
+}
+
+func documentVectorAttachmentID(t *testing.T, f *storetest.Fixture, hash string) int64 {
+ t.Helper()
+ var attachmentID int64
+ require.NoError(t, f.Store.DB().QueryRow(f.Store.Rebind(`SELECT id FROM attachments WHERE content_hash = ?`), hash).Scan(&attachmentID))
+ return attachmentID
+}
+
+func rotatedDocumentVectorProfile() store.DocumentExtractionProfile {
+ fingerprint := strings.Repeat("2", 64)
+ return store.DocumentExtractionProfile{
+ ID: "profile-" + fingerprint, Fingerprint: fingerprint,
+ Provider: "mistral", Endpoint: "https://api.mistral.ai/v1/ocr",
+ Region: "eu", Model: "mistral-ocr-5-0",
+ RetentionPosture: "standard", TrainingPosture: "opted-out",
+ AllowedMediaTypes: []string{"application/pdf"}, PolicyJSON: []byte(`{"policy":2}`),
+ }
+}
+
+func seedDocumentVectorGenerationWithChunks(t *testing.T, chunkCount int) (*storetest.Fixture, store.DocumentVectorGeneration) {
+ t.Helper()
+ f := storetest.New(t)
+ profile, hash := seedDocumentPublicationAuthority(t, f)
+ publishManySearchChunks(t, f, profile, hash, chunkCount)
+ generation, _, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{
+ Fingerprint: strings.Repeat("f", 64), TargetExtractionProfileID: profile.ID,
+ EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768,
+ })
+ require.NoError(t, err)
+ return f, generation
+}
+
+func TestDocumentVectorGenerationCreateResumeCollisionAndBounds(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f := storetest.New(t)
+ profile, _ := seedDocumentPublicationAuthority(t, f)
+ spec := store.DocumentVectorGenerationSpec{
+ Fingerprint: strings.Repeat("1", 64), TargetExtractionProfileID: profile.ID,
+ EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768,
+ }
+
+ first, created, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), spec)
+ require.NoError(err)
+ assert.True(created)
+ assert.Equal(store.DocumentVectorGenerationBuilding, first.State)
+ assert.NotZero(first.ID)
+
+ second, created, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), spec)
+ require.NoError(err)
+ assert.False(created)
+ assert.Equal(first.ID, second.ID)
+
+ collision := spec
+ collision.Model = "embed-v2"
+ _, _, err = f.Store.EnsureDocumentVectorGeneration(t.Context(), collision)
+ require.ErrorContains(err, "fingerprint")
+
+ other := spec
+ other.Fingerprint = strings.Repeat("2", 64)
+ _, _, err = f.Store.EnsureDocumentVectorGeneration(t.Context(), other)
+ require.ErrorContains(err, "building")
+
+ invalid := spec
+ invalid.Fingerprint = strings.Repeat("3", 64)
+ invalid.Dimension = 0
+ _, _, err = f.Store.EnsureDocumentVectorGeneration(t.Context(), invalid)
+ require.ErrorContains(err, "dimension")
+}
+
+func TestDocumentVectorGenerationRejectsNonCurrentTarget(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f := storetest.New(t)
+ current, _ := seedDocumentPublicationAuthority(t, f)
+ nonCurrent := rotatedDocumentVectorProfile()
+ _, err := f.Store.EnsureDocumentExtractionProfile(t.Context(), nonCurrent)
+ require.NoError(err)
+ require.NoError(f.Store.RecordDocumentProviderConsent(t.Context(), store.DocumentProviderConsent{
+ ProfileID: nonCurrent.ID, ProfileFingerprint: nonCurrent.Fingerprint,
+ RetentionPosture: nonCurrent.RetentionPosture, TrainingPosture: nonCurrent.TrainingPosture,
+ }))
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`
+ UPDATE document_index_state SET target_profile_id = ? WHERE singleton = 1`), current.ID)
+ require.NoError(err)
+
+ _, _, err = f.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{
+ Fingerprint: strings.Repeat("3", 64), TargetExtractionProfileID: nonCurrent.ID,
+ EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768,
+ })
+ require.ErrorIs(err, store.ErrDocumentVectorInvalidGenerationState)
+ var rows int
+ require.NoError(f.Store.DB().QueryRow(f.Store.Rebind(`
+ SELECT COUNT(*) FROM document_vector_generations WHERE target_extraction_profile_id = ?`),
+ nonCurrent.ID).Scan(&rows))
+ assert.Zero(rows)
+
+ generation, created, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{
+ Fingerprint: strings.Repeat("4", 64), TargetExtractionProfileID: current.ID,
+ EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768,
+ })
+ require.NoError(err)
+ assert.True(created)
+ assert.Equal(current.ID, generation.TargetExtractionProfileID)
+}
+
+func TestDocumentVectorBaseSchemaPreservesCleanupAuthority(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f := storetest.New(t)
+ if f.Store.IsPostgreSQL() {
+ rows, err := f.Store.DB().Query(`
+ SELECT confrelid::regclass::text
+ FROM pg_constraint
+ WHERE conrelid = 'document_vector_publications'::regclass
+ AND contype = 'f'
+ ORDER BY confrelid::regclass::text`)
+ require.NoError(err)
+ defer func() { _ = rows.Close() }()
+ var references []string
+ for rows.Next() {
+ var table string
+ require.NoError(rows.Scan(&table))
+ references = append(references, table)
+ }
+ require.NoError(rows.Err())
+ assert.Equal([]string{"document_vector_generations"}, references)
+ var definition string
+ require.NoError(f.Store.DB().QueryRow(`
+ SELECT pg_get_indexdef('idx_document_vector_publications_cleanup'::regclass)`).Scan(&definition))
+ assert.Contains(definition, "(generation_id, backend_cleaned_at, token)")
+ return
+ }
+
+ rows, err := f.Store.DB().Query(`PRAGMA foreign_key_list('document_vector_publications')`)
+ require.NoError(err)
+ defer func() { _ = rows.Close() }()
+ references := map[string]struct{}{}
+ for rows.Next() {
+ var id, sequence int
+ var table, from, to, onUpdate, onDelete, match string
+ require.NoError(rows.Scan(&id, &sequence, &table, &from, &to, &onUpdate, &onDelete, &match))
+ references[table] = struct{}{}
+ }
+ require.NoError(rows.Err())
+ assert.Equal(map[string]struct{}{"document_vector_generations": {}}, references)
+ indexRows, err := f.Store.DB().Query(`PRAGMA index_info('idx_document_vector_publications_cleanup')`)
+ require.NoError(err)
+ defer func() { _ = indexRows.Close() }()
+ var columns []string
+ for indexRows.Next() {
+ var sequence, columnID int
+ var column string
+ require.NoError(indexRows.Scan(&sequence, &columnID, &column))
+ columns = append(columns, column)
+ }
+ require.NoError(indexRows.Err())
+ assert.Equal([]string{"generation_id", "backend_cleaned_at", "token"}, columns)
+}
+
+func TestDocumentVectorGenerationRetirementAndActiveConstraints(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f := storetest.New(t)
+ profile, _ := seedDocumentPublicationAuthority(t, f)
+ spec := store.DocumentVectorGenerationSpec{Fingerprint: strings.Repeat("4", 64), TargetExtractionProfileID: profile.ID, EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768}
+ first, _, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), spec)
+ require.NoError(err)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`UPDATE document_vector_generations SET state = 'retired', retired_at = CURRENT_TIMESTAMP WHERE id = ?`), first.ID)
+ require.NoError(err)
+ fresh, created, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), spec)
+ require.NoError(err)
+ assert.True(created)
+ assert.NotEqual(first.ID, fresh.ID)
+
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`UPDATE document_vector_generations SET state = 'active' WHERE id = ?`), fresh.ID)
+ require.NoError(err)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`INSERT INTO document_vector_generations (fingerprint, target_extraction_profile_id, embedding_profile, model, dimension, state) VALUES (?, ?, ?, ?, ?, 'active')`), strings.Repeat("5", 64), profile.ID, "vector.embeddings", "embed-v1", 768)
+ require.Error(err)
+}
+
+func TestDocumentVectorGenerationRejectsHistoricalSpecCollision(t *testing.T) {
+ f := storetest.New(t)
+ profile, _ := seedDocumentPublicationAuthority(t, f)
+ spec := store.DocumentVectorGenerationSpec{Fingerprint: strings.Repeat("c", 64), TargetExtractionProfileID: profile.ID, EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768}
+ _, err := f.Store.DB().Exec(f.Store.Rebind(`INSERT INTO document_vector_generations (fingerprint, target_extraction_profile_id, embedding_profile, model, dimension, state, retired_at) VALUES (?, ?, ?, ?, ?, 'retired', CURRENT_TIMESTAMP)`), spec.Fingerprint, profile.ID, spec.EmbeddingProfile, "embed-old", spec.Dimension)
+ require.NoError(t, err)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`INSERT INTO document_vector_generations (fingerprint, target_extraction_profile_id, embedding_profile, model, dimension, state) VALUES (?, ?, ?, ?, ?, 'building')`), spec.Fingerprint, profile.ID, spec.EmbeddingProfile, spec.Model, spec.Dimension)
+ require.NoError(t, err)
+ _, _, err = f.Store.EnsureDocumentVectorGeneration(t.Context(), spec)
+ require.ErrorContains(t, err, "collides")
+}
+
+func TestDocumentVectorGenerationRejectsInvalidFingerprintAndProfile(t *testing.T) {
+ f := storetest.New(t)
+ profile, _ := seedDocumentPublicationAuthority(t, f)
+ for _, spec := range []store.DocumentVectorGenerationSpec{
+ {Fingerprint: "not-a-digest", TargetExtractionProfileID: profile.ID, EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768},
+ {Fingerprint: strings.Repeat("6", 64), TargetExtractionProfileID: profile.ID, EmbeddingProfile: "other.embeddings", Model: "embed-v1", Dimension: 768},
+ } {
+ _, _, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), spec)
+ require.Error(t, err)
+ }
+}
+
+func TestDocumentVectorTargetRejectsLegacyNormalizedIdentity(t *testing.T) {
+ requirements := require.New(t)
+ f, _ := seedDocumentVectorGenerationWithChunks(t, 1)
+ var extractionID string
+ requirements.NoError(f.Store.DB().QueryRow(`SELECT extraction_id FROM document_extraction_heads LIMIT 1`).Scan(&extractionID))
+ _, err := f.Store.DB().Exec(f.Store.Rebind(`
+ UPDATE document_extractions
+ SET normalization_version = NULL, document_family = NULL, unit_kind = NULL
+ WHERE id = ?`), extractionID)
+ requirements.NoError(err)
+
+ _, err = f.Store.GetDocumentVectorTargetProfileID(t.Context())
+ requirements.ErrorIs(err, store.ErrDocumentNormalizedIdentityUnavailable)
+ requirements.ErrorContains(err, "documents build --full-rebuild")
+ _, err = f.Store.LoadNormalizedDocument(t.Context(), extractionID)
+ requirements.ErrorIs(err, store.ErrDocumentNormalizedIdentityUnavailable)
+ requirements.ErrorContains(err, "documents build --full-rebuild")
+}
+
+func TestDocumentVectorTargetIgnoresDeadLegacyNormalizedIdentity(t *testing.T) {
+ requirements := require.New(t)
+ f, _ := seedDocumentVectorGenerationWithChunks(t, 1)
+ var extractionID, profileID, hash string
+ requirements.NoError(f.Store.DB().QueryRow(`
+ SELECT extraction_id, profile_id, canonical_blob_hash
+ FROM document_extraction_heads LIMIT 1`).Scan(&extractionID, &profileID, &hash))
+ _, err := f.Store.DB().Exec(f.Store.Rebind(`
+ UPDATE document_extractions
+ SET normalization_version = NULL, document_family = NULL, unit_kind = NULL
+ WHERE id = ?`), extractionID)
+ requirements.NoError(err)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`
+ DELETE FROM document_occurrences WHERE canonical_blob_hash = ?`), hash)
+ requirements.NoError(err)
+
+ target, err := f.Store.GetDocumentVectorTargetProfileID(t.Context())
+ requirements.NoError(err)
+ requirements.Equal(profileID, target)
+}
+
+func TestDocumentVectorChunkCandidatesUseCurrentLiveAuthority(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f := storetest.New(t)
+ profile, hash := seedDocumentPublicationAuthority(t, f)
+ publishSearchDocument(t, f, profile, hash, "vector candidate evidence", "vector-candidate")
+ generation, _, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{
+ Fingerprint: strings.Repeat("7", 64), TargetExtractionProfileID: profile.ID,
+ EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768,
+ })
+ require.NoError(err)
+
+ candidates, err := f.Store.ListDocumentVectorChunkCandidates(t.Context(), generation.ID, 0, 10)
+ require.NoError(err)
+ require.Len(candidates, 1)
+ assert.Equal(generation.ID, candidates[0].GenerationID)
+ assert.Equal(profile.ID, candidates[0].ExtractionProfileID)
+ assert.Equal(hash, candidates[0].CanonicalBlobHash)
+ assert.Equal("vector candidate evidence", candidates[0].Text)
+ assert.Equal("vector-candidate", candidates[0].ExtractionID)
+ assert.NotEmpty(candidates[0].ChunkKey)
+ assert.NotEmpty(candidates[0].ChunkChecksum)
+
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`DELETE FROM document_occurrences WHERE canonical_blob_hash = ?`), hash)
+ require.NoError(err)
+ candidates, err = f.Store.ListDocumentVectorChunkCandidates(t.Context(), generation.ID, 0, 10)
+ require.NoError(err)
+ assert.Empty(candidates)
+}
+
+func TestDocumentVectorChunkCandidatesHideAfterTargetProfileRotation(t *testing.T) {
+ require := require.New(t)
+ f := storetest.New(t)
+ profile, hash := seedDocumentPublicationAuthority(t, f)
+ publishSearchDocument(t, f, profile, hash, "rotation candidate evidence", "vector-rotation")
+ generation, _, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{Fingerprint: strings.Repeat("8", 64), TargetExtractionProfileID: profile.ID, EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768})
+ require.NoError(err)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`UPDATE document_index_state SET target_profile_id = ? WHERE singleton = 1`), "rotated-profile")
+ require.NoError(err)
+ candidates, err := f.Store.ListDocumentVectorChunkCandidates(t.Context(), generation.ID, 0, 10)
+ require.NoError(err)
+ assert.Empty(t, candidates)
+}
+
+func TestDocumentVectorPublicationCommitRejectsInconsistentImmutableSnapshot(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ f := storetest.New(t)
+ profile, hash := seedDocumentPublicationAuthority(t, f)
+ publishSearchDocument(t, f, profile, hash, "integrity candidate evidence", "vector-integrity")
+ generation, _, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{Fingerprint: strings.Repeat("9", 64), TargetExtractionProfileID: profile.ID, EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768})
+ require.NoError(err)
+ now := time.Date(2026, time.August, 20, 23, 0, 0, 0, time.UTC)
+ claim, err := f.Store.ClaimDocumentVectorChunk(t.Context(), generation.ID, 0, 1, "integrity-worker", now, time.Minute)
+ require.NoError(err)
+ require.NotNil(claim)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`
+ UPDATE document_vector_publications SET canonical_blob_hash = ?
+ WHERE generation_id = ? AND token = ?`), strings.Repeat("f", 64), generation.ID, claim.Token)
+ require.NoError(err)
+ err = f.Store.CommitDocumentVectorPublication(
+ t.Context(), generation.ID, claim.Token, claim.LeaseOwner, claim.LeaseFence, now.Add(time.Second))
+ require.ErrorIs(err, store.ErrDocumentVectorSourceChanged)
+ var state, errorCode string
+ require.NoError(f.Store.DB().QueryRow(f.Store.Rebind(`
+ SELECT state, error_code FROM document_vector_publications
+ WHERE generation_id = ? AND token = ?`), generation.ID, claim.Token).Scan(&state, &errorCode))
+ assert.Equal("failed", state)
+ assert.Equal("source_changed", errorCode)
+}
+
+func TestDocumentVectorGenerationCannotDeletePublicationBeforeBackendCleanup(t *testing.T) {
+ require := require.New(t)
+ f := storetest.New(t)
+ profile, hash := seedDocumentPublicationAuthority(t, f)
+ publishSearchDocument(t, f, profile, hash, "retained token evidence", "vector-retained")
+ generation, _, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{Fingerprint: strings.Repeat("d", 64), TargetExtractionProfileID: profile.ID, EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768})
+ require.NoError(err)
+ candidates, err := f.Store.ListDocumentVectorChunkCandidates(t.Context(), generation.ID, 0, 1)
+ require.NoError(err)
+ require.Len(candidates, 1)
+ candidate := candidates[0]
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`INSERT INTO document_vector_publications (generation_id, extraction_id, extraction_profile_id, canonical_blob_hash, extraction_input_key, chunk_id, chunk_key, chunk_checksum, source_sequence, token, state) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')`), generation.ID, candidate.ExtractionID, candidate.ExtractionProfileID, candidate.CanonicalBlobHash, candidate.ExtractionInputKey, candidate.ChunkID, candidate.ChunkKey, candidate.ChunkChecksum, candidate.SourceSequence, "opaque-retained-token")
+ require.NoError(err)
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`DELETE FROM document_vector_generations WHERE id = ?`), generation.ID)
+ require.Error(err)
+}
+
+func TestDocumentVectorChunkCandidatesRequireConfiguredExtractionProfile(t *testing.T) {
+ require := require.New(t)
+ f := storetest.New(t)
+ profile, hash := seedDocumentPublicationAuthority(t, f)
+ publishSearchDocument(t, f, profile, hash, "fallback vector evidence", "vector-fallback")
+
+ target := profile
+ target.Fingerprint = strings.Repeat("e", 64)
+ target.ID = "profile-" + target.Fingerprint
+ target.Model = "mistral-ocr-5-0"
+ target.PolicyJSON = []byte(`{"policy":2}`)
+ _, err := f.Store.EnsureDocumentExtractionProfile(t.Context(), target)
+ require.NoError(err)
+ require.NoError(f.Store.RecordDocumentProviderConsent(t.Context(), store.DocumentProviderConsent{
+ ProfileID: target.ID, ProfileFingerprint: target.Fingerprint,
+ RetentionPosture: target.RetentionPosture, TrainingPosture: target.TrainingPosture,
+ }))
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`UPDATE document_index_state SET target_profile_id = ? WHERE singleton = 1`), target.ID)
+ require.NoError(err)
+
+ _, err = f.Store.DB().Exec(f.Store.Rebind(`UPDATE document_vector_generations SET state = 'active' WHERE state = 'building'`))
+ require.NoError(err)
+ generation, _, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{
+ Fingerprint: strings.Repeat("a", 64), TargetExtractionProfileID: target.ID,
+ EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768,
+ })
+ require.NoError(err)
+
+ candidates, err := f.Store.ListDocumentVectorChunkCandidates(t.Context(), generation.ID, 0, 10)
+ require.NoError(err)
+ assert.Empty(t, candidates, "an older fallback extraction does not satisfy the configured Docbank identity")
+
+ publishSearchDocument(t, f, target, hash, "target vector evidence", "vector-target")
+ candidates, err = f.Store.ListDocumentVectorChunkCandidates(t.Context(), generation.ID, 0, 10)
+ require.NoError(err)
+ require.Len(candidates, 1)
+ assert.Equal(t, target.ID, candidates[0].ExtractionProfileID)
+}
+
+func TestDocumentVectorChunkCandidatesHaveStableBoundedPagination(t *testing.T) {
+ require := require.New(t)
+ f := storetest.New(t)
+ profile, hash := seedDocumentPublicationAuthority(t, f)
+ publishManySearchChunks(t, f, profile, hash, 3)
+ generation, _, err := f.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{
+ Fingerprint: strings.Repeat("b", 64), TargetExtractionProfileID: profile.ID,
+ EmbeddingProfile: "vector.embeddings", Model: "embed-v1", Dimension: 768,
+ })
+ require.NoError(err)
+
+ first, err := f.Store.ListDocumentVectorChunkCandidates(t.Context(), generation.ID, 0, 2)
+ require.NoError(err)
+ require.Len(first, 2)
+ second, err := f.Store.ListDocumentVectorChunkCandidates(t.Context(), generation.ID, first[1].ChunkID, 2)
+ require.NoError(err)
+ require.Len(second, 1)
+ assert.Greater(t, second[0].ChunkID, first[1].ChunkID)
+
+ _, err = f.Store.ListDocumentVectorChunkCandidates(t.Context(), generation.ID, 0, 0)
+ require.ErrorContains(err, "limit")
+ _, err = f.Store.ListDocumentVectorChunkCandidates(t.Context(), generation.ID, 0, 1001)
+ require.ErrorContains(err, "limit")
+ _, err = f.Store.ListDocumentVectorChunkCandidates(t.Context(), generation.ID, -1, 1)
+ require.ErrorContains(err, "after")
+}
diff --git a/internal/store/schema.sql b/internal/store/schema.sql
index 8eb1d7fbc..2a1d6b9c9 100644
--- a/internal/store/schema.sql
+++ b/internal/store/schema.sql
@@ -2195,6 +2195,10 @@ CREATE TABLE IF NOT EXISTS document_extractions (
units_processed INTEGER,
returned_model TEXT,
manifest_checksum TEXT,
+ normalization_version INTEGER,
+ document_family TEXT,
+ unit_kind TEXT,
+ normalized_truncated BOOLEAN NOT NULL DEFAULT FALSE,
terminal_reason TEXT,
source_sequence INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -2209,6 +2213,8 @@ CREATE INDEX IF NOT EXISTS idx_document_extractions_owner
ON document_extractions(profile_id, canonical_blob_hash, extraction_input_key, state);
CREATE INDEX IF NOT EXISTS idx_document_extractions_lease
ON document_extractions(state, lease_until);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_document_extractions_vector_identity
+ ON document_extractions(id, profile_id, canonical_blob_hash, extraction_input_key, source_sequence);
-- One renewable claim per stable content owner. The monotonic fence prevents
-- an expired worker from publishing after a later worker has taken ownership.
@@ -2249,6 +2255,7 @@ CREATE TABLE IF NOT EXISTS document_units (
checksum TEXT NOT NULL,
char_count INTEGER NOT NULL,
truncated BOOLEAN NOT NULL DEFAULT FALSE,
+ heading_marks JSON NOT NULL DEFAULT '[]',
PRIMARY KEY (extraction_id, unit_index),
CHECK (unit_index >= 0),
CHECK (char_count >= 0)
@@ -2275,6 +2282,8 @@ CREATE TABLE IF NOT EXISTS document_chunks (
CHECK (first_unit_index >= 0 AND last_unit_index >= first_unit_index),
CHECK (synthetic_prefix_len >= 0 AND char_count >= 0)
);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_document_chunks_vector_identity
+ ON document_chunks(id, extraction_id, chunk_key, checksum);
CREATE TABLE IF NOT EXISTS document_chunk_spans (
extraction_id TEXT NOT NULL,
@@ -2326,6 +2335,85 @@ CREATE TABLE IF NOT EXISTS document_index_state (
);
INSERT OR IGNORE INTO document_index_state(singleton, revision) VALUES (1, 0);
+-- Document vectors are a corpus separate from message embeddings. The main
+-- archive database owns generation and publication authority; vector backends
+-- only store the opaque token below.
+CREATE TABLE IF NOT EXISTS document_vector_generations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ fingerprint TEXT NOT NULL,
+ target_extraction_profile_id TEXT NOT NULL REFERENCES document_extraction_profiles(id) ON DELETE RESTRICT,
+ embedding_profile TEXT NOT NULL,
+ model TEXT NOT NULL,
+ dimension INTEGER NOT NULL CHECK (dimension > 0),
+ state TEXT NOT NULL CHECK (state IN ('building', 'active', 'retired')),
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ activated_at DATETIME,
+ retired_at DATETIME
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_document_vector_generations_building
+ ON document_vector_generations(state) WHERE state = 'building';
+CREATE UNIQUE INDEX IF NOT EXISTS idx_document_vector_generations_active
+ ON document_vector_generations(state) WHERE state = 'active';
+CREATE INDEX IF NOT EXISTS idx_document_vector_generations_live_fingerprint
+ ON document_vector_generations(fingerprint) WHERE state <> 'retired';
+
+-- Hosted embedding consent is bound to both the reusable generation policy
+-- and a separate canonical egress destination fingerprint. It deliberately
+-- contains no credentials, raw endpoints, or provider payloads.
+CREATE TABLE IF NOT EXISTS document_vector_consents (
+ egress_fingerprint TEXT PRIMARY KEY,
+ purpose TEXT NOT NULL CHECK (purpose IN ('document_embedding', 'query_embedding')),
+ generation_fingerprint TEXT NOT NULL,
+ target_extraction_profile_id TEXT NOT NULL REFERENCES document_extraction_profiles(id) ON DELETE RESTRICT,
+ embedding_profile TEXT NOT NULL,
+ model TEXT NOT NULL,
+ dimension INTEGER NOT NULL CHECK (dimension > 0),
+ consented_at DATETIME NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS document_vector_provider_usage (
+ fingerprint TEXT PRIMARY KEY,
+ provider_calls INTEGER NOT NULL DEFAULT 0 CHECK (provider_calls >= 0),
+ provider_documents INTEGER NOT NULL DEFAULT 0 CHECK (provider_documents >= 0),
+ provider_chunks INTEGER NOT NULL DEFAULT 0 CHECK (provider_chunks >= 0),
+ provider_input_chars INTEGER NOT NULL DEFAULT 0 CHECK (provider_input_chars >= 0),
+ updated_at DATETIME NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS document_vector_build_progress (
+ generation_id INTEGER PRIMARY KEY REFERENCES document_vector_generations(id) ON DELETE CASCADE,
+ after_chunk_id INTEGER NOT NULL CHECK (after_chunk_id > 0),
+ updated_at DATETIME NOT NULL
+);
+
+-- Publication rows are deliberately complete before a vector backend exists:
+-- the durable token is the only identifier a backend receives.
+CREATE TABLE IF NOT EXISTS document_vector_publications (
+ generation_id INTEGER NOT NULL REFERENCES document_vector_generations(id) ON DELETE RESTRICT,
+ extraction_id TEXT NOT NULL,
+ extraction_profile_id TEXT NOT NULL,
+ canonical_blob_hash TEXT NOT NULL CHECK (length(canonical_blob_hash) = 64),
+ extraction_input_key TEXT NOT NULL,
+ chunk_id INTEGER NOT NULL,
+ chunk_key TEXT NOT NULL,
+ chunk_checksum TEXT NOT NULL,
+ source_sequence INTEGER NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ state TEXT NOT NULL CHECK (state IN ('pending', 'ready', 'failed')),
+ lease_owner TEXT,
+ lease_fence INTEGER NOT NULL DEFAULT 0,
+ lease_until DATETIME,
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
+ next_retry_at DATETIME,
+ error_code TEXT,
+ backend_cleaned_at DATETIME,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (generation_id, extraction_id, chunk_id)
+);
+CREATE INDEX IF NOT EXISTS idx_document_vector_publications_cleanup
+ ON document_vector_publications(generation_id, backend_cleaned_at, token);
+
-- Foreign-key cascades can remove occurrences before asynchronous attachment
-- reconciliation observes the deletion. Invalidate search cursors at the
-- authoritative row mutation so every deletion path is covered.
diff --git a/internal/store/schema_pg.sql b/internal/store/schema_pg.sql
index 84dab7f7e..8e081f1ea 100644
--- a/internal/store/schema_pg.sql
+++ b/internal/store/schema_pg.sql
@@ -2192,6 +2192,10 @@ CREATE TABLE IF NOT EXISTS document_extractions (
units_processed INTEGER,
returned_model TEXT,
manifest_checksum TEXT,
+ normalization_version INTEGER,
+ document_family TEXT,
+ unit_kind TEXT,
+ normalized_truncated BOOLEAN NOT NULL DEFAULT FALSE,
terminal_reason TEXT,
source_sequence BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -2206,6 +2210,8 @@ CREATE INDEX IF NOT EXISTS idx_document_extractions_owner
ON document_extractions(profile_id, canonical_blob_hash, extraction_input_key, state);
CREATE INDEX IF NOT EXISTS idx_document_extractions_lease
ON document_extractions(state, lease_until);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_document_extractions_vector_identity
+ ON document_extractions(id, profile_id, canonical_blob_hash, extraction_input_key, source_sequence);
CREATE TABLE IF NOT EXISTS document_extraction_claims (
profile_id TEXT NOT NULL REFERENCES document_extraction_profiles(id) ON DELETE CASCADE,
@@ -2242,6 +2248,7 @@ CREATE TABLE IF NOT EXISTS document_units (
checksum TEXT NOT NULL,
char_count INTEGER NOT NULL,
truncated BOOLEAN NOT NULL DEFAULT FALSE,
+ heading_marks JSONB NOT NULL DEFAULT '[]'::jsonb,
PRIMARY KEY (extraction_id, unit_index),
CHECK (unit_index >= 0),
CHECK (char_count >= 0)
@@ -2269,6 +2276,8 @@ CREATE TABLE IF NOT EXISTS document_chunks (
CHECK (first_unit_index >= 0 AND last_unit_index >= first_unit_index),
CHECK (synthetic_prefix_len >= 0 AND char_count >= 0)
);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_document_chunks_vector_identity
+ ON document_chunks(id, extraction_id, chunk_key, checksum);
CREATE INDEX IF NOT EXISTS idx_document_chunks_search_fts
ON document_chunks USING GIN(search_fts);
@@ -2320,6 +2329,77 @@ CREATE TABLE IF NOT EXISTS document_index_state (
INSERT INTO document_index_state(singleton, revision) VALUES (1, 0)
ON CONFLICT (singleton) DO NOTHING;
+CREATE TABLE IF NOT EXISTS document_vector_generations (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ fingerprint TEXT NOT NULL,
+ target_extraction_profile_id TEXT NOT NULL REFERENCES document_extraction_profiles(id) ON DELETE RESTRICT,
+ embedding_profile TEXT NOT NULL,
+ model TEXT NOT NULL,
+ dimension INTEGER NOT NULL CHECK (dimension > 0),
+ state TEXT NOT NULL CHECK (state IN ('building', 'active', 'retired')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ activated_at TIMESTAMPTZ,
+ retired_at TIMESTAMPTZ
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_document_vector_generations_building
+ ON document_vector_generations(state) WHERE state = 'building';
+CREATE UNIQUE INDEX IF NOT EXISTS idx_document_vector_generations_active
+ ON document_vector_generations(state) WHERE state = 'active';
+CREATE INDEX IF NOT EXISTS idx_document_vector_generations_live_fingerprint
+ ON document_vector_generations(fingerprint) WHERE state <> 'retired';
+
+CREATE TABLE IF NOT EXISTS document_vector_consents (
+ egress_fingerprint TEXT PRIMARY KEY,
+ purpose TEXT NOT NULL CHECK (purpose IN ('document_embedding', 'query_embedding')),
+ generation_fingerprint TEXT NOT NULL,
+ target_extraction_profile_id TEXT NOT NULL REFERENCES document_extraction_profiles(id) ON DELETE RESTRICT,
+ embedding_profile TEXT NOT NULL,
+ model TEXT NOT NULL,
+ dimension INTEGER NOT NULL CHECK (dimension > 0),
+ consented_at TIMESTAMPTZ NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS document_vector_provider_usage (
+ fingerprint TEXT PRIMARY KEY,
+ provider_calls BIGINT NOT NULL DEFAULT 0 CHECK (provider_calls >= 0),
+ provider_documents BIGINT NOT NULL DEFAULT 0 CHECK (provider_documents >= 0),
+ provider_chunks BIGINT NOT NULL DEFAULT 0 CHECK (provider_chunks >= 0),
+ provider_input_chars BIGINT NOT NULL DEFAULT 0 CHECK (provider_input_chars >= 0),
+ updated_at TIMESTAMPTZ NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS document_vector_build_progress (
+ generation_id BIGINT PRIMARY KEY REFERENCES document_vector_generations(id) ON DELETE CASCADE,
+ after_chunk_id BIGINT NOT NULL CHECK (after_chunk_id > 0),
+ updated_at TIMESTAMPTZ NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS document_vector_publications (
+ generation_id BIGINT NOT NULL REFERENCES document_vector_generations(id) ON DELETE RESTRICT,
+ extraction_id TEXT NOT NULL,
+ extraction_profile_id TEXT NOT NULL,
+ canonical_blob_hash TEXT NOT NULL CHECK (length(canonical_blob_hash) = 64),
+ extraction_input_key TEXT NOT NULL,
+ chunk_id BIGINT NOT NULL,
+ chunk_key TEXT NOT NULL,
+ chunk_checksum TEXT NOT NULL,
+ source_sequence BIGINT NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ state TEXT NOT NULL CHECK (state IN ('pending', 'ready', 'failed')),
+ lease_owner TEXT,
+ lease_fence BIGINT NOT NULL DEFAULT 0,
+ lease_until TIMESTAMPTZ,
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
+ next_retry_at TIMESTAMPTZ,
+ error_code TEXT,
+ backend_cleaned_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (generation_id, extraction_id, chunk_id)
+);
+CREATE INDEX IF NOT EXISTS idx_document_vector_publications_cleanup
+ ON document_vector_publications(generation_id, backend_cleaned_at, token);
+
-- Foreign-key cascades can remove occurrences before asynchronous attachment
-- reconciliation observes the deletion. Invalidate search cursors at the
-- authoritative row mutation so every deletion path is covered.
diff --git a/internal/store/sqlite_error_test.go b/internal/store/sqlite_error_test.go
index a5f2ef5ef..61f7e45e3 100644
--- a/internal/store/sqlite_error_test.go
+++ b/internal/store/sqlite_error_test.go
@@ -17,7 +17,7 @@ func TestIsSQLiteError_ValueForm(t *testing.T) {
}
// Wrap the error
- wrappedErr := fmt.Errorf("insert failed: %w", sqliteErr)
+ wrappedErr := errors.Join(sqliteErr)
// sqlite3.Error.Error() returns the code description, e.g. "constraint failed"
assert.True(t, isSQLiteError(wrappedErr, "constraint failed"),
@@ -33,7 +33,6 @@ func TestIsSQLiteError_PointerForm(t *testing.T) {
Code: sqlite3.ErrConstraint,
ExtendedCode: sqlite3.ErrConstraintForeignKey,
}
-
// sqlite3.Error.Error() returns the code description, e.g. "constraint failed"
assert.True(t, isSQLiteError(sqliteErr, "constraint failed"),
"isSQLiteError should match constraint error via pointer, got: %v", sqliteErr.Error())
diff --git a/internal/store/store.go b/internal/store/store.go
index 61d863199..5a254691e 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -51,7 +51,8 @@ type Store struct {
fts5Available bool // Whether FTS5 is available for full-text search
closeCleanup func()
- sqliteOptimizeMu sync.Mutex
+ sqliteOptimizeMu sync.Mutex
+ documentVectorOperationMu sync.Mutex
// Test-only seams into migration, backfill, and transaction paths, nil in
// production and settable only from export_test.go. They belong to the
diff --git a/internal/vector/backend.go b/internal/vector/backend.go
index 4242185e4..2c881ad83 100644
--- a/internal/vector/backend.go
+++ b/internal/vector/backend.go
@@ -8,6 +8,12 @@ import (
// GenerationID identifies one index generation.
type GenerationID int64
+// DocumentInput preserves one document boundary around its ordered embedding
+// inputs. Providers may partially complete only a leading document prefix.
+type DocumentInput struct {
+ Chunks []string
+}
+
// SourceBasis identifies the source text used for chunk offsets.
type SourceBasis uint8
diff --git a/internal/vector/config.go b/internal/vector/config.go
index 85ef39a52..65ab83114 100644
--- a/internal/vector/config.go
+++ b/internal/vector/config.go
@@ -203,6 +203,39 @@ func (e EmbeddingsConfig) EffectiveAPIFormat() EmbeddingAPIFormat {
return e.APIFormat
}
+// Validate checks embedding-provider settings without requiring the ordinary
+// message-vector lane to be enabled. Document vectors use the same provider
+// policy under their own enablement and consent controls.
+func (e EmbeddingsConfig) Validate() error {
+ switch e.EffectiveAPIFormat() {
+ case APIFormatOpenAI, APIFormatVoyageContextual:
+ default:
+ return fmt.Errorf("vector.embeddings.api_format: unknown format %q (supported: %q, %q)",
+ e.APIFormat, APIFormatOpenAI, APIFormatVoyageContextual)
+ }
+ if e.EffectiveAPIFormat() == APIFormatVoyageContextual && e.Model != "voyage-context-4" {
+ return fmt.Errorf("vector.embeddings.model: api_format=%q requires %q, got %q",
+ APIFormatVoyageContextual, "voyage-context-4", e.Model)
+ }
+ if e.Endpoint == "" {
+ return errors.New("vector.embeddings.endpoint: required")
+ }
+ u, err := url.Parse(e.Endpoint)
+ if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
+ return fmt.Errorf("vector.embeddings.endpoint: must be an http or https URL with a host (got %q)", e.Endpoint)
+ }
+ if e.Model == "" {
+ return fmt.Errorf("vector.embeddings.model: required (the index generation fingerprint is %q, which is ambiguous without a model name)", e.Fingerprint())
+ }
+ if e.Dimension <= 0 {
+ return fmt.Errorf("vector.embeddings.dimension: must be positive, got %d", e.Dimension)
+ }
+ if e.BatchSize <= 0 {
+ return fmt.Errorf("vector.embeddings.batch_size: must be positive, got %d", e.BatchSize)
+ }
+ return nil
+}
+
// PreprocessConfig controls message text preprocessing before embedding.
//
// Fields are pointers so the decoder can distinguish "unset" (nil,
@@ -490,32 +523,8 @@ func (c *Config) Validate() error {
if !c.Enabled {
return nil
}
- switch c.Embeddings.EffectiveAPIFormat() {
- case APIFormatOpenAI, APIFormatVoyageContextual:
- default:
- return fmt.Errorf("vector.embeddings.api_format: unknown format %q (supported: %q, %q)",
- c.Embeddings.APIFormat, APIFormatOpenAI, APIFormatVoyageContextual)
- }
- if c.Embeddings.EffectiveAPIFormat() == APIFormatVoyageContextual &&
- c.Embeddings.Model != "voyage-context-4" {
- return fmt.Errorf("vector.embeddings.model: api_format=%q requires %q, got %q",
- APIFormatVoyageContextual, "voyage-context-4", c.Embeddings.Model)
- }
- if c.Embeddings.Endpoint == "" {
- return errors.New("vector.embeddings.endpoint: required")
- }
- u, err := url.Parse(c.Embeddings.Endpoint)
- if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
- return fmt.Errorf("vector.embeddings.endpoint: must be an http or https URL with a host (got %q)", c.Embeddings.Endpoint)
- }
- if c.Embeddings.Model == "" {
- return fmt.Errorf("vector.embeddings.model: required (the index generation fingerprint is %q, which is ambiguous without a model name)", c.Embeddings.Fingerprint())
- }
- if c.Embeddings.Dimension <= 0 {
- return fmt.Errorf("vector.embeddings.dimension: must be positive, got %d", c.Embeddings.Dimension)
- }
- if c.Embeddings.BatchSize <= 0 {
- return fmt.Errorf("vector.embeddings.batch_size: must be positive, got %d", c.Embeddings.BatchSize)
+ if err := c.Embeddings.Validate(); err != nil {
+ return err
}
if c.People.Enabled {
if _, err := c.SemanticPersonEmbeddingProfile(); err != nil {
diff --git a/internal/vector/document/backend.go b/internal/vector/document/backend.go
new file mode 100644
index 000000000..e001f2a11
--- /dev/null
+++ b/internal/vector/document/backend.go
@@ -0,0 +1,97 @@
+package document
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "unicode/utf8"
+)
+
+// ErrInvalidVector reports an invalid document-vector request or a vector
+// that cannot participate in cosine similarity.
+var ErrInvalidVector = errors.New("invalid document vector")
+
+// Embedding pairs an opaque publication token with its vector.
+type Embedding struct {
+ Token string
+ Vector []float32
+}
+
+// Hit is one cosine-similarity result. Higher scores are better and Rank is
+// one-based after deterministic score/token ordering.
+type Hit struct {
+ Token string
+ Score float64
+ Rank int
+}
+
+// HitPage is one deterministic global vector page. Exhausted is authoritative.
+type HitPage struct {
+ Hits []Hit
+ NextCursor string
+ Exhausted bool
+}
+
+const pageCursorVersion = 1
+
+type pageCursor struct {
+ Version int `json:"v"`
+ Distance float64 `json:"d"`
+ Token string `json:"t"`
+ Rank int `json:"r"`
+}
+
+// EncodePageCursor records the last globally ordered vector hit. Backends use
+// it to continue after that hit even when earlier obsolete rows are deleted.
+func EncodePageCursor(distance float64, token string, rank int) (string, error) {
+ if math.IsNaN(distance) || math.IsInf(distance, 0) || token == "" || !utf8.ValidString(token) || rank < 1 {
+ return "", fmt.Errorf("%w: invalid document vector cursor", ErrInvalidVector)
+ }
+ payload, err := json.Marshal(pageCursor{
+ Version: pageCursorVersion, Distance: distance, Token: token, Rank: rank,
+ })
+ if err != nil {
+ return "", fmt.Errorf("encode document vector cursor: %w", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(payload), nil
+}
+
+// DecodePageCursor returns the last distance, token, and stable rank carried
+// by an opaque backend cursor. An empty cursor starts at the first hit.
+func DecodePageCursor(cursor string) (distance float64, token string, rank int, err error) {
+ if cursor == "" {
+ return 0, "", 0, nil
+ }
+ payload, err := base64.RawURLEncoding.DecodeString(cursor)
+ if err != nil {
+ return 0, "", 0, fmt.Errorf("%w: invalid document vector cursor", ErrInvalidVector)
+ }
+ var decoded pageCursor
+ if err := json.Unmarshal(payload, &decoded); err != nil ||
+ decoded.Version != pageCursorVersion || math.IsNaN(decoded.Distance) ||
+ math.IsInf(decoded.Distance, 0) || decoded.Token == "" ||
+ !utf8.ValidString(decoded.Token) || decoded.Rank < 1 {
+ return 0, "", 0, fmt.Errorf("%w: invalid document vector cursor", ErrInvalidVector)
+ }
+ return decoded.Distance, decoded.Token, decoded.Rank, nil
+}
+
+// PagedBackend supports scope-complete semantic retrieval without treating a
+// global top-k cutoff as the final scoped candidate bound.
+type PagedBackend interface {
+ SearchPage(ctx context.Context, generationID GenerationID, dimension int, query []float32, cursor string, limit int) (HitPage, error)
+}
+
+// Backend stores unpublished vectors by opaque publication token. Publication
+// authority remains in the main store; this interface deliberately knows
+// nothing about archive or document identities encoded by callers. A canceled
+// PutUnpublished must expose neither a partial batch nor a partially replaced
+// token so claim-heartbeat loss can safely abort publication.
+type Backend interface {
+ PutUnpublished(ctx context.Context, generationID GenerationID, dimension int, embeddings []Embedding) error
+ DeleteTokens(ctx context.Context, generationID GenerationID, tokens []string) error
+ Search(ctx context.Context, generationID GenerationID, dimension int, query []float32, k int) ([]Hit, error)
+}
diff --git a/internal/vector/document/fusion.go b/internal/vector/document/fusion.go
new file mode 100644
index 000000000..f75a38980
--- /dev/null
+++ b/internal/vector/document/fusion.go
@@ -0,0 +1,128 @@
+package document
+
+import (
+ "fmt"
+ "slices"
+ "sort"
+
+ docembedding "go.kenn.io/docbank/document/embedding"
+ "go.kenn.io/msgvault/internal/store"
+)
+
+type fusedSearchCandidate struct {
+ result store.DocumentSearchResult
+ lexical *store.DocumentSearchResult
+ semantic *store.DocumentSearchResult
+}
+
+func fuseSearchResults(
+ lexical, semantic []store.DocumentSearchResult, limit int,
+) ([]store.DocumentSearchResult, bool, error) {
+ byOccurrence := make(map[string]*fusedSearchCandidate, len(lexical)+len(semantic))
+ for index := range lexical {
+ row := lexical[index]
+ candidate := byOccurrence[row.OccurrenceKey]
+ if candidate == nil {
+ candidate = &fusedSearchCandidate{}
+ byOccurrence[row.OccurrenceKey] = candidate
+ }
+ if candidate.lexical == nil || row.Rank < candidate.lexical.Rank {
+ rowCopy := row
+ candidate.lexical = &rowCopy
+ }
+ }
+ for index := range semantic {
+ row := semantic[index]
+ candidate := byOccurrence[row.OccurrenceKey]
+ if candidate == nil {
+ candidate = &fusedSearchCandidate{}
+ byOccurrence[row.OccurrenceKey] = candidate
+ }
+ if candidate.semantic == nil || semanticResultLess(row, *candidate.semantic) {
+ rowCopy := row
+ candidate.semantic = &rowCopy
+ }
+ }
+ lexicalCandidates := make([]docembedding.RankedCandidate, 0, len(byOccurrence))
+ semanticCandidates := make([]docembedding.RankedCandidate, 0, len(byOccurrence))
+ for _, candidate := range byOccurrence {
+ if candidate.lexical != nil {
+ lexicalCandidates = append(lexicalCandidates, docembedding.RankedCandidate{
+ Key: candidate.lexical.OccurrenceKey, Rank: candidate.lexical.Rank,
+ })
+ }
+ if candidate.semantic != nil {
+ semanticCandidates = append(semanticCandidates, docembedding.RankedCandidate{
+ Key: candidate.semantic.OccurrenceKey, Rank: candidate.semantic.SemanticRank,
+ Score: candidate.semantic.SemanticScore,
+ })
+ }
+ }
+ sort.Slice(lexicalCandidates, func(i, j int) bool { return lexicalCandidates[i].Rank < lexicalCandidates[j].Rank })
+ sort.Slice(semanticCandidates, func(i, j int) bool {
+ if semanticCandidates[i].Rank != semanticCandidates[j].Rank {
+ return semanticCandidates[i].Rank < semanticCandidates[j].Rank
+ }
+ return semanticCandidates[i].Key < semanticCandidates[j].Key
+ })
+ makeCandidateRanksStrict(lexicalCandidates)
+ makeCandidateRanksStrict(semanticCandidates)
+ fused, err := docembedding.FuseReciprocalRank(docembedding.FusionInput{
+ Lexical: docembedding.ScopedCandidates{Candidates: lexicalCandidates},
+ Semantic: docembedding.ScopedCandidates{Candidates: semanticCandidates},
+ }, limit)
+ if err != nil {
+ return nil, false, fmt.Errorf("fuse Docbank ranked candidates: %w", err)
+ }
+ results := make([]store.DocumentSearchResult, 0, len(fused.Candidates))
+ for _, shared := range fused.Candidates {
+ candidate := byOccurrence[shared.Key]
+ if candidate.lexical != nil {
+ candidate.result = *candidate.lexical
+ candidate.result.LexicalRank = candidate.lexical.Rank
+ candidate.result.MatchedSignals = slices.Clone(candidate.lexical.MatchedSignals)
+ if candidate.semantic != nil {
+ if candidate.result.PersonProvenance == nil {
+ candidate.result.PersonProvenance = candidate.semantic.PersonProvenance
+ }
+ candidate.result.SemanticRank = candidate.semantic.SemanticRank
+ candidate.result.SemanticScore = candidate.semantic.SemanticScore
+ candidate.result.VectorToken = candidate.semantic.VectorToken
+ candidate.result.VectorGenerationID = candidate.semantic.VectorGenerationID
+ candidate.result.VectorGenerationFingerprint = candidate.semantic.VectorGenerationFingerprint
+ candidate.result.VectorEmbeddingProfile = candidate.semantic.VectorEmbeddingProfile
+ candidate.result.VectorModel = candidate.semantic.VectorModel
+ candidate.result.VectorDimension = candidate.semantic.VectorDimension
+ candidate.result.MatchedSignals = append(candidate.result.MatchedSignals, "semantic")
+ }
+ } else {
+ candidate.result = *candidate.semantic
+ candidate.result.SemanticRank = candidate.semantic.SemanticRank
+ candidate.result.MatchedSignals = []string{"semantic"}
+ }
+ candidate.result.FusionScore = shared.Score
+ candidate.result.Rank = shared.Rank
+ results = append(results, candidate.result)
+ }
+ return results, fused.Truncated, nil
+}
+
+func makeCandidateRanksStrict(candidates []docembedding.RankedCandidate) {
+ lastRank := 0
+ for index := range candidates {
+ if candidates[index].Rank <= lastRank {
+ candidates[index].Rank = lastRank + 1
+ }
+ lastRank = candidates[index].Rank
+ }
+}
+
+func semanticResultLess(left, right store.DocumentSearchResult) bool {
+ if left.SemanticRank != right.SemanticRank {
+ return left.SemanticRank < right.SemanticRank
+ }
+ if left.SemanticScore != right.SemanticScore {
+ return left.SemanticScore > right.SemanticScore
+ }
+ return left.VectorToken < right.VectorToken
+}
diff --git a/internal/vector/document/fusion_test.go b/internal/vector/document/fusion_test.go
new file mode 100644
index 000000000..c6f68a1fb
--- /dev/null
+++ b/internal/vector/document/fusion_test.go
@@ -0,0 +1,127 @@
+package document
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.kenn.io/msgvault/internal/personscope"
+ "go.kenn.io/msgvault/internal/store"
+)
+
+func TestFuseSearchResultsCollapsesDuplicateSemanticChunksPerOccurrence(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ semantic := []store.DocumentSearchResult{
+ {OccurrenceKey: "occ-a", SemanticRank: 1, SemanticScore: .9, VectorToken: "token-a"},
+ {OccurrenceKey: "occ-a", SemanticRank: 2, SemanticScore: .8, VectorToken: "token-duplicate"},
+ {OccurrenceKey: "occ-b", SemanticRank: 3, SemanticScore: .7, VectorToken: "token-b"},
+ }
+
+ got, truncated, err := fuseSearchResults(nil, semantic, 10)
+
+ requirements.NoError(err)
+ requirements.Len(got, 2)
+ assertions.False(truncated)
+ assertions.Equal("occ-a", got[0].OccurrenceKey)
+ assertions.Equal("token-a", got[0].VectorToken)
+ assertions.Equal(1, got[0].SemanticRank)
+ assertions.Equal([]string{"semantic"}, got[0].MatchedSignals)
+ assertions.Equal("occ-b", got[1].OccurrenceKey)
+ assertions.Equal(1, got[0].Rank)
+}
+
+func TestFuseSearchResultsUsesOneRRFContributionPerSignal(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ provenance := &personscope.Provenance{ParticipantIDs: []int64{7}}
+ lexical := []store.DocumentSearchResult{
+ {OccurrenceKey: "occ-b", Rank: 1, MatchedSignals: []string{"filename"}, PersonProvenance: provenance},
+ }
+ semantic := []store.DocumentSearchResult{
+ {OccurrenceKey: "occ-a", SemanticRank: 1, SemanticScore: .9, VectorToken: "token-a"},
+ {OccurrenceKey: "occ-b", SemanticRank: 2, SemanticScore: .8, VectorToken: "token-b"},
+ {OccurrenceKey: "occ-b", SemanticRank: 3, SemanticScore: .7, VectorToken: "token-duplicate"},
+ }
+
+ got, truncated, err := fuseSearchResults(lexical, semantic, 10)
+
+ requirements.NoError(err)
+ requirements.Len(got, 2)
+ assertions.False(truncated)
+ assertions.Equal("occ-b", got[0].OccurrenceKey)
+ assertions.Equal([]string{"filename", "semantic"}, got[0].MatchedSignals)
+ assertions.Equal(1, got[0].LexicalRank)
+ assertions.Equal(2, got[0].SemanticRank)
+ assertions.Equal(provenance, got[0].PersonProvenance)
+ assertions.InDelta(1.0/61.0+1.0/62.0, got[0].FusionScore, 1e-12)
+ assertions.Equal("occ-a", got[1].OccurrenceKey)
+ assertions.InDelta(1.0/61.0, got[1].FusionScore, 1e-12)
+}
+
+func TestFuseSearchResultsPreservesLaneRanksAndLexicalExcerpt(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ lexical := []store.DocumentSearchResult{
+ {
+ OccurrenceKey: "occ-a", Rank: 4, MatchedSignals: []string{"content"},
+ ChunkKey: "lexical-chunk", ChunkOrdinal: 2, HeadingPath: []string{"Lexical"},
+ FirstUnitIndex: 4, LastUnitIndex: 5,
+ Excerpt: "matching lexical excerpt", HighlightStart: 9, HighlightEnd: 16,
+ },
+ }
+ semantic := []store.DocumentSearchResult{
+ {
+ OccurrenceKey: "occ-a", SemanticRank: 7, SemanticScore: .9, VectorToken: "token-a",
+ ChunkKey: "semantic-chunk", ChunkOrdinal: 8, HeadingPath: []string{"Semantic"},
+ FirstUnitIndex: 12, LastUnitIndex: 13, Excerpt: "semantic excerpt",
+ },
+ }
+
+ got, truncated, err := fuseSearchResults(lexical, semantic, 10)
+
+ requirements.NoError(err)
+ requirements.Len(got, 1)
+ assertions.False(truncated)
+ assertions.Equal(4, got[0].LexicalRank)
+ assertions.Equal(7, got[0].SemanticRank)
+ assertions.InDelta(1.0/64.0+1.0/67.0, got[0].FusionScore, 1e-12)
+ assertions.Equal("matching lexical excerpt", got[0].Excerpt)
+ assertions.Equal(9, got[0].HighlightStart)
+ assertions.Equal(16, got[0].HighlightEnd)
+ assertions.Equal("lexical-chunk", got[0].ChunkKey)
+ assertions.Equal(2, got[0].ChunkOrdinal)
+ assertions.Equal([]string{"Lexical"}, got[0].HeadingPath)
+ assertions.Equal(4, got[0].FirstUnitIndex)
+ assertions.Equal(5, got[0].LastUnitIndex)
+ assertions.Equal("token-a", got[0].VectorToken)
+}
+
+func TestFuseSearchResultsBreaksSemanticTiesByOccurrenceIdentity(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ semantic := []store.DocumentSearchResult{
+ {OccurrenceKey: "occ-z", SemanticRank: 1, SemanticScore: .5},
+ {OccurrenceKey: "occ-a", SemanticRank: 1, SemanticScore: .5},
+ }
+
+ got, truncated, err := fuseSearchResults(nil, semantic, 10)
+
+ require.NoError(err)
+ require.Len(got, 2)
+ assert.False(truncated)
+ assert.Equal([]string{"occ-a", "occ-z"}, []string{got[0].OccurrenceKey, got[1].OccurrenceKey})
+}
+
+func TestFuseSearchResultsReportsDisjointCandidateOverflow(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ lexical := []store.DocumentSearchResult{{OccurrenceKey: "occ-lexical", Rank: 1}}
+ semantic := []store.DocumentSearchResult{{OccurrenceKey: "occ-semantic", SemanticRank: 1}}
+
+ got, truncated, err := fuseSearchResults(lexical, semantic, 1)
+
+ require.NoError(err)
+ require.Len(got, 1)
+ assert.True(truncated)
+}
diff --git a/internal/vector/document/model.go b/internal/vector/document/model.go
new file mode 100644
index 000000000..6f4b9457d
--- /dev/null
+++ b/internal/vector/document/model.go
@@ -0,0 +1,165 @@
+// Package document defines the independent attachment-document vector corpus.
+package document
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ docembedding "go.kenn.io/docbank/document/embedding"
+ "go.kenn.io/msgvault/internal/vector"
+)
+
+// GenerationID identifies one document-vector generation.
+type GenerationID int64
+
+// GenerationState describes a document-vector generation's lifecycle state.
+type GenerationState string
+
+const (
+ GenerationBuilding GenerationState = "building"
+ GenerationActive GenerationState = "active"
+ GenerationRetired GenerationState = "retired"
+)
+
+// GenerationSpec is the immutable identity of one document-vector corpus
+// generation. Its Fingerprint is derived from the extraction profile and
+// embedding policy that determine the stored vectors.
+type GenerationSpec struct {
+ Fingerprint string
+ ExtractionProfileID string
+ EmbeddingProfile string
+ Model string
+ Dimension int
+}
+
+// SearchMode uses Docbank's shared retrieval modes.
+type SearchMode = docembedding.SearchMode
+
+const (
+ SearchModeAuto = docembedding.SearchModeAuto
+ SearchModeLexical = docembedding.SearchModeLexical
+ SearchModeSemantic = docembedding.SearchModeSemantic
+ SearchModeHybrid = docembedding.SearchModeHybrid
+)
+
+// ParseSearchMode normalizes a document search mode. Omitted values normalize
+// to auto; SearchService keeps auto lexical so query text leaves the process
+// only on an explicit semantic or hybrid request.
+func ParseSearchMode(value string) (SearchMode, error) {
+ mode := docembedding.SearchMode(strings.ToLower(strings.TrimSpace(value)))
+ if mode == "" {
+ return SearchModeAuto, nil
+ }
+ switch mode {
+ case SearchModeAuto, SearchModeLexical, SearchModeSemantic, SearchModeHybrid:
+ return mode, nil
+ default:
+ return "", fmt.Errorf("unsupported document search mode %q", value)
+ }
+}
+
+// EmbeddingRecipe returns the shared raw preparation recipe used by this PR.
+func EmbeddingRecipe(cfg vector.Config) (docembedding.Recipe, error) {
+ recipe, err := docembedding.NewRecipe(docembedding.RecipeConfig{
+ Mode: docembedding.RepresentationRaw, MaxInputRunes: cfg.Embeddings.MaxInputChars,
+ })
+ if err != nil {
+ return docembedding.Recipe{}, fmt.Errorf(
+ "construct document embedding recipe from vector.embeddings.max_input_chars=%d: %w",
+ cfg.Embeddings.MaxInputChars, err,
+ )
+ }
+ return recipe, nil
+}
+
+func vectorSpaceIdentity(cfg vector.Config) (docembedding.VectorSpaceIdentity, error) {
+ deploymentFingerprint, err := (docembedding.EgressIdentity{
+ Purpose: docembedding.EgressDocumentEmbedding, Provider: string(cfg.Embeddings.EffectiveAPIFormat()),
+ Endpoint: cfg.Embeddings.Endpoint, Model: cfg.Embeddings.Model, ModelRevision: cfg.Embeddings.Model,
+ }).Fingerprint()
+ if err != nil {
+ return docembedding.VectorSpaceIdentity{}, fmt.Errorf("fingerprint document embedding deployment: %w", err)
+ }
+ return docembedding.VectorSpaceIdentity{
+ Provider: string(cfg.Embeddings.EffectiveAPIFormat()), Model: cfg.Embeddings.Model,
+ ModelRevision: deploymentFingerprint, Dimension: cfg.Embeddings.Dimension,
+ Normalization: "provider-output-v1",
+ }, nil
+}
+
+// Fingerprint returns the immutable, non-secret document-vector corpus
+// identity. The canonical endpoint is represented only by a deployment hash so
+// OpenAI-compatible services cannot reuse each other's vector spaces. Credential
+// location, message preprocessing, and message scope remain excluded.
+func Fingerprint(extractionProfileID string, cfg vector.Config) (string, error) {
+ recipe, err := EmbeddingRecipe(cfg)
+ if err != nil {
+ return "", err
+ }
+ spaceIdentity, err := vectorSpaceIdentity(cfg)
+ if err != nil {
+ return "", err
+ }
+ spaceFingerprint, err := spaceIdentity.Fingerprint()
+ if err != nil {
+ return "", fmt.Errorf("fingerprint document vector space: %w", err)
+ }
+ payload := struct {
+ Version int `json:"version"`
+ ExtractionProfileID string `json:"extraction_profile_id"`
+ RecipeFingerprint string `json:"recipe_fingerprint"`
+ VectorSpace string `json:"vector_space_fingerprint"`
+ }{
+ Version: 1, ExtractionProfileID: extractionProfileID,
+ RecipeFingerprint: recipe.Fingerprint(), VectorSpace: spaceFingerprint,
+ }
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("marshal document vector fingerprint: %w", err)
+ }
+ hash := sha256.Sum256(encoded)
+ return hex.EncodeToString(hash[:]), nil
+}
+
+// EgressFingerprint binds operator consent to both Msgvault's exact corpus
+// policy and Docbank's canonical provider destination.
+func EgressFingerprint(extractionProfileID string, cfg vector.Config) (string, error) {
+ return egressFingerprint(extractionProfileID, docembedding.EgressDocumentEmbedding, cfg)
+}
+
+// QueryEgressFingerprint is purpose-separated from document-text consent.
+func QueryEgressFingerprint(extractionProfileID string, cfg vector.Config) (string, error) {
+ return egressFingerprint(extractionProfileID, docembedding.EgressQueryEmbedding, cfg)
+}
+
+func egressFingerprint(extractionProfileID string, purpose docembedding.EgressPurpose, cfg vector.Config) (string, error) {
+ destination, err := (docembedding.EgressIdentity{
+ Purpose: purpose, Provider: string(cfg.Embeddings.EffectiveAPIFormat()),
+ Endpoint: cfg.Embeddings.Endpoint, Model: cfg.Embeddings.Model,
+ ModelRevision: cfg.Embeddings.Model,
+ }).Fingerprint()
+ if err != nil {
+ return "", fmt.Errorf("fingerprint document egress destination: %w", err)
+ }
+ generationFingerprint, err := Fingerprint(extractionProfileID, cfg)
+ if err != nil {
+ return "", err
+ }
+ payload := struct {
+ Version int `json:"version"`
+ GenerationFingerprint string `json:"generation_fingerprint"`
+ DestinationFingerprint string `json:"destination_fingerprint"`
+ }{
+ Version: 1, GenerationFingerprint: generationFingerprint,
+ DestinationFingerprint: destination,
+ }
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("marshal document egress consent identity: %w", err)
+ }
+ hash := sha256.Sum256(encoded)
+ return hex.EncodeToString(hash[:]), nil
+}
diff --git a/internal/vector/document/model_test.go b/internal/vector/document/model_test.go
new file mode 100644
index 000000000..7d427d66a
--- /dev/null
+++ b/internal/vector/document/model_test.go
@@ -0,0 +1,121 @@
+package document
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.kenn.io/msgvault/internal/vector"
+)
+
+func requireFingerprint(t *testing.T, extractionProfileID string, config vector.Config) string {
+ t.Helper()
+ fingerprint, err := Fingerprint(extractionProfileID, config)
+ require.NoError(t, err)
+ return fingerprint
+}
+
+func TestFingerprintBindsDocumentExtractionAndEmbeddingPolicy(t *testing.T) {
+ config := vector.Config{Embeddings: vector.EmbeddingsConfig{
+ Endpoint: "https://embeddings.example.test/v1",
+ APIFormat: vector.APIFormatOpenAI, Model: "embed-v1", Dimension: 768, MaxInputChars: 8192,
+ }}
+
+ baseline := requireFingerprint(t, "extract-v1", config)
+ assert.Regexp(t, "^[0-9a-f]{64}$", baseline)
+ assert.Equal(t, baseline, requireFingerprint(t, "extract-v1", config))
+
+ tests := []struct {
+ name string
+ mutate func(*vector.Config)
+ }{
+ {name: "api format", mutate: func(c *vector.Config) { c.Embeddings.APIFormat = vector.APIFormatVoyageContextual }},
+ {name: "endpoint", mutate: func(c *vector.Config) { c.Embeddings.Endpoint = "https://other.example.test/v1" }},
+ {name: "model", mutate: func(c *vector.Config) { c.Embeddings.Model = "embed-v2" }},
+ {name: "dimension", mutate: func(c *vector.Config) { c.Embeddings.Dimension++ }},
+ {name: "max input chars", mutate: func(c *vector.Config) { c.Embeddings.MaxInputChars++ }},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ changed := config
+ test.mutate(&changed)
+ assert.NotEqual(t, baseline, requireFingerprint(t, "extract-v1", changed))
+ })
+ }
+ assert.NotEqual(t, baseline, requireFingerprint(t, "extract-v2", config))
+}
+
+func TestFingerprintExcludesMessageCorpusAndCredentials(t *testing.T) {
+ config := vector.Config{Embeddings: vector.EmbeddingsConfig{
+ Endpoint: "https://embeddings.example.test/v1",
+ APIFormat: vector.APIFormatOpenAI, Model: "embed-v1", Dimension: 768, MaxInputChars: 8192,
+ }}
+ baseline := requireFingerprint(t, "extract-v1", config)
+
+ changed := config
+ changed.Embeddings.APIKeyEnv = "SYNTHETIC_EMBEDDING_KEY"
+ stripQuotes := false
+ changed.Preprocess.StripQuotes = &stripQuotes
+ changed.Embed.Scope.MessageTypes = []string{"email"}
+ changed.Embed.Scope.SourceIDs = []int64{7}
+
+ assert.Equal(t, baseline, requireFingerprint(t, "extract-v1", changed))
+}
+
+func TestEgressFingerprintBindsCanonicalDestinationAndCorpus(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ config := vector.Config{Embeddings: vector.EmbeddingsConfig{
+ Endpoint: "https://trusted.example.test/v1",
+ APIFormat: vector.APIFormatOpenAI, Model: "embed-v1", Dimension: 768, MaxInputChars: 8192,
+ }}
+ corpus := requireFingerprint(t, "extract-v1", config)
+ baseline, err := EgressFingerprint("extract-v1", config)
+ requirements.NoError(err)
+
+ credentialChange := config
+ credentialChange.Embeddings.APIKeyEnv = "OTHER_SYNTHETIC_EMBEDDING_KEY"
+ credentialFingerprint, err := EgressFingerprint("extract-v1", credentialChange)
+ requirements.NoError(err)
+ assertions.Equal(baseline, credentialFingerprint)
+
+ hosted := config
+ hosted.Embeddings.Endpoint = "https://hosted.example.test/v1"
+ hostedFingerprint, err := EgressFingerprint("extract-v1", hosted)
+ requirements.NoError(err)
+ assertions.NotEqual(baseline, hostedFingerprint)
+ assertions.NotEqual(corpus, requireFingerprint(t, "extract-v1", hosted))
+ rotatedCorpusFingerprint, err := EgressFingerprint("extract-v2", config)
+ requirements.NoError(err)
+ assertions.NotEqual(baseline, rotatedCorpusFingerprint)
+ queryFingerprint, err := QueryEgressFingerprint("extract-v1", config)
+ requirements.NoError(err)
+ assertions.NotEqual(baseline, queryFingerprint)
+
+ unsafeEndpoint := config
+ unsafeEndpoint.Embeddings.Endpoint = "https://user:secret@trusted.example.test/v1?api_key=secret"
+ _, err = EgressFingerprint("extract-v1", unsafeEndpoint)
+ requirements.Error(err)
+}
+
+func TestParseSearchMode(t *testing.T) {
+ for _, test := range []struct {
+ input string
+ want SearchMode
+ }{
+ {input: "", want: SearchModeAuto},
+ {input: " AUTO ", want: SearchModeAuto},
+ {input: "lexical", want: SearchModeLexical},
+ {input: "SEMANTIC", want: SearchModeSemantic},
+ {input: "hybrid", want: SearchModeHybrid},
+ } {
+ t.Run(test.input, func(t *testing.T) {
+ got, err := ParseSearchMode(test.input)
+ require.NoError(t, err)
+ assert.Equal(t, test.want, got)
+ })
+ }
+
+ _, err := ParseSearchMode("unsupported")
+ require.ErrorContains(t, err, "unsupported")
+}
diff --git a/internal/vector/document/provider.go b/internal/vector/document/provider.go
new file mode 100644
index 000000000..5441feff8
--- /dev/null
+++ b/internal/vector/document/provider.go
@@ -0,0 +1,13 @@
+package document
+
+import (
+ "context"
+
+ "go.kenn.io/msgvault/internal/vector"
+)
+
+// Provider is the document-building subset of embed.SemanticClient.
+// Query embedding belongs to retrieval, not generation publication.
+type Provider interface {
+ EmbedDocuments(ctx context.Context, documents []vector.DocumentInput) ([][][]float32, error)
+}
diff --git a/internal/vector/document/reconcile.go b/internal/vector/document/reconcile.go
new file mode 100644
index 000000000..cbfa23c3e
--- /dev/null
+++ b/internal/vector/document/reconcile.go
@@ -0,0 +1,252 @@
+package document
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "go.kenn.io/msgvault/internal/store"
+)
+
+const maxReconcileLimit = 1000
+
+type WorkerRunner interface {
+ Run(ctx context.Context, generationID GenerationID, limit int) (RunResult, error)
+}
+
+type ReconcileLedger interface {
+ GetDocumentVectorGeneration(ctx context.Context, id int64) (store.DocumentVectorGeneration, error)
+ GetDocumentVectorGenerationStatus(ctx context.Context, generationID int64, afterToken string, limit int) (store.DocumentVectorGenerationStatus, error)
+ ParkObsoleteDocumentVectorTokens(ctx context.Context, generationID int64, afterToken string, limit int, now time.Time) (store.DocumentVectorCleanupPage, error)
+ FinalizeObsoleteDocumentVectorToken(ctx context.Context, generationID int64, token string, now time.Time) (bool, error)
+ GetDocumentVectorCoverage(ctx context.Context, generationID int64) (store.DocumentVectorCoverage, error)
+ ActivateDocumentVectorGeneration(ctx context.Context, generationID int64, now time.Time) error
+ PurgeRetiredDocumentVectorGeneration(ctx context.Context, generationID int64) (bool, error)
+}
+
+var (
+ _ WorkerRunner = (*Worker)(nil)
+ _ ReconcileLedger = (*store.Store)(nil)
+)
+
+type ReconcilerDeps struct {
+ Ledger ReconcileLedger
+ Worker WorkerRunner
+ Backend Backend
+ Now func() time.Time
+
+ AfterCleanupGenerationID GenerationID
+ AfterCleanupToken string
+}
+
+type ReconcileResult struct {
+ WorkerRan bool `json:"worker_ran"`
+ Worker RunResult `json:"worker"`
+
+ CleanupListed int `json:"cleanup_listed"`
+ CleanupDeleted int `json:"cleanup_deleted"`
+ CleanupFinalized int `json:"cleanup_finalized"`
+ CleanupAfterGenerationID GenerationID `json:"cleanup_after_generation_id,omitempty"`
+ CleanupAfterToken string `json:"cleanup_after_token,omitempty"`
+ CleanupExhausted bool `json:"cleanup_exhausted"`
+
+ Status GenerationStatus `json:"status"`
+ Coverage store.DocumentVectorCoverage `json:"coverage"`
+ Blocked bool `json:"blocked"`
+ Activated bool `json:"activated"`
+ Purged bool `json:"purged"`
+ Converged bool `json:"converged"`
+}
+
+type Reconciler struct {
+ deps ReconcilerDeps
+ cleanupCursorGenerationID GenerationID
+ cleanupAfterToken string
+}
+
+func NewReconciler(deps ReconcilerDeps) *Reconciler {
+ if deps.Now == nil {
+ deps.Now = func() time.Time { return time.Now().UTC() }
+ }
+ return &Reconciler{
+ deps: deps, cleanupCursorGenerationID: deps.AfterCleanupGenerationID,
+ cleanupAfterToken: deps.AfterCleanupToken,
+ }
+}
+
+func (r *Reconciler) Run(ctx context.Context, generationID GenerationID, limit int) (ReconcileResult, error) {
+ var result ReconcileResult
+ if err := r.validate(generationID, limit); err != nil {
+ return result, err
+ }
+ generation, err := r.deps.Ledger.GetDocumentVectorGeneration(ctx, int64(generationID))
+ if err != nil {
+ return result, fmt.Errorf("read document vector generation for reconciliation: %w", err)
+ }
+ if generation.ID != int64(generationID) {
+ return result, store.ErrDocumentVectorInvalidGenerationState
+ }
+ if generation.State == store.DocumentVectorGenerationBuilding && r.deps.Worker == nil {
+ return result, errors.New("document vector reconciliation worker is required for a building generation")
+ }
+ r.bindCleanupCursor(generationID)
+
+ var reconcileErr error
+ if generation.State == store.DocumentVectorGenerationBuilding {
+ result.WorkerRan = true
+ result.Worker, err = r.deps.Worker.Run(ctx, generationID, limit)
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return result, ctxErr
+ }
+ reconcileErr = errors.Join(reconcileErr, err)
+ }
+
+ cleanupErr := r.cleanup(ctx, generationID, limit, &result)
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return result, errors.Join(reconcileErr, ctxErr)
+ }
+ reconcileErr = errors.Join(reconcileErr, cleanupErr)
+
+ status, statusErr := r.deps.Ledger.GetDocumentVectorGenerationStatus(ctx, int64(generationID), "", limit)
+ if statusErr == nil {
+ result.Status = status
+ if status.CleanupPending == 0 {
+ r.resetCleanupCursor(&result)
+ }
+ }
+ reconcileErr = errors.Join(reconcileErr, statusErr)
+ if generation.State == store.DocumentVectorGenerationBuilding && statusErr == nil && status.Blocked {
+ result.Blocked = true
+ reconcileErr = errors.Join(reconcileErr, store.ErrDocumentVectorGenerationBlocked)
+ }
+
+ switch generation.State {
+ case store.DocumentVectorGenerationBuilding, store.DocumentVectorGenerationActive:
+ coverage, coverageErr := r.deps.Ledger.GetDocumentVectorCoverage(ctx, int64(generationID))
+ if coverageErr == nil {
+ result.Coverage = coverage
+ }
+ reconcileErr = errors.Join(reconcileErr, coverageErr)
+ if generation.State == store.DocumentVectorGenerationBuilding && coverageErr == nil &&
+ coverage.Complete() && reconcileErr == nil {
+ activationErr := r.deps.Ledger.ActivateDocumentVectorGeneration(ctx, int64(generationID), r.now())
+ if activationErr == nil {
+ result.Activated = true
+ if refreshed, refreshErr := r.deps.Ledger.GetDocumentVectorGenerationStatus(ctx, int64(generationID), "", limit); refreshErr == nil {
+ result.Status = refreshed
+ } else {
+ reconcileErr = refreshErr
+ }
+ } else {
+ reconcileErr = activationErr
+ }
+ }
+ result.Converged = (generation.State == store.DocumentVectorGenerationActive || result.Activated) &&
+ result.Coverage.Complete() && result.Status.CleanupPending == 0 && reconcileErr == nil
+ case store.DocumentVectorGenerationRetired:
+ if result.Status.CleanupPending == 0 && reconcileErr == nil {
+ result.Purged, err = r.deps.Ledger.PurgeRetiredDocumentVectorGeneration(ctx, int64(generationID))
+ reconcileErr = errors.Join(reconcileErr, err)
+ }
+ result.Converged = result.Purged && reconcileErr == nil
+ default:
+ reconcileErr = errors.Join(reconcileErr, store.ErrDocumentVectorInvalidGenerationState)
+ }
+ return result, reconcileErr
+}
+
+func (r *Reconciler) cleanup(
+ ctx context.Context, generationID GenerationID, limit int, result *ReconcileResult,
+) error {
+ page, err := r.deps.Ledger.ParkObsoleteDocumentVectorTokens(
+ ctx, int64(generationID), r.cleanupAfterToken, limit, r.now(),
+ )
+ if err != nil {
+ return fmt.Errorf("park obsolete document vector tokens: %w", err)
+ }
+ result.CleanupListed = len(page.Tokens)
+ if len(page.Tokens) == 0 {
+ r.resetCleanupCursor(result)
+ return nil
+ }
+ opaque := make([]string, len(page.Tokens))
+ for index := range page.Tokens {
+ opaque[index] = page.Tokens[index].Token
+ }
+ if err := r.deps.Backend.DeleteTokens(ctx, generationID, opaque); err != nil {
+ r.invalidateCleanupCursor(result)
+ return fmt.Errorf("delete obsolete document vector tokens: %w", err)
+ }
+ result.CleanupDeleted = len(opaque)
+ var finalizeErr error
+ for _, token := range opaque {
+ finalized, err := r.deps.Ledger.FinalizeObsoleteDocumentVectorToken(
+ ctx, int64(generationID), token, r.now(),
+ )
+ if err != nil {
+ finalizeErr = errors.Join(finalizeErr, fmt.Errorf("finalize obsolete document vector token %q: %w", token, err))
+ continue
+ }
+ if finalized {
+ result.CleanupFinalized++
+ }
+ }
+ if finalizeErr != nil {
+ r.invalidateCleanupCursor(result)
+ return finalizeErr
+ }
+ if page.Exhausted {
+ r.resetCleanupCursor(result)
+ } else {
+ r.cleanupCursorGenerationID = GenerationID(page.AfterGenerationID)
+ r.cleanupAfterToken = page.AfterToken
+ result.CleanupAfterGenerationID = GenerationID(page.AfterGenerationID)
+ result.CleanupAfterToken = page.AfterToken
+ }
+ return nil
+}
+
+func (r *Reconciler) validate(generationID GenerationID, limit int) error {
+ if generationID <= 0 {
+ return errors.New("document vector reconciliation generation must be positive")
+ }
+ if limit < 1 || limit > maxReconcileLimit {
+ return fmt.Errorf("document vector reconciliation limit must be between 1 and %d", maxReconcileLimit)
+ }
+ if r == nil || r.deps.Ledger == nil || r.deps.Backend == nil || r.deps.Now == nil {
+ return errors.New("document vector reconciliation dependencies are required")
+ }
+ if r.deps.AfterCleanupGenerationID < 0 ||
+ (r.deps.AfterCleanupGenerationID == 0) != (r.deps.AfterCleanupToken == "") {
+ return errors.New("document vector reconciliation cleanup cursor is invalid")
+ }
+ return nil
+}
+
+func (r *Reconciler) now() time.Time {
+ return r.deps.Now().UTC().Truncate(time.Millisecond)
+}
+
+func (r *Reconciler) bindCleanupCursor(generationID GenerationID) {
+ if r.cleanupCursorGenerationID != 0 && r.cleanupCursorGenerationID != generationID {
+ r.cleanupAfterToken = ""
+ }
+ r.cleanupCursorGenerationID = generationID
+}
+
+func (r *Reconciler) resetCleanupCursor(result *ReconcileResult) {
+ r.cleanupCursorGenerationID = 0
+ r.cleanupAfterToken = ""
+ result.CleanupAfterGenerationID = 0
+ result.CleanupAfterToken = ""
+ result.CleanupExhausted = true
+}
+
+func (r *Reconciler) invalidateCleanupCursor(result *ReconcileResult) {
+ r.cleanupCursorGenerationID = 0
+ r.cleanupAfterToken = ""
+ result.CleanupAfterGenerationID = 0
+ result.CleanupAfterToken = ""
+ result.CleanupExhausted = false
+}
diff --git a/internal/vector/document/reconcile_test.go b/internal/vector/document/reconcile_test.go
new file mode 100644
index 000000000..988de5579
--- /dev/null
+++ b/internal/vector/document/reconcile_test.go
@@ -0,0 +1,387 @@
+package document
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.kenn.io/msgvault/internal/store"
+)
+
+func TestReconcilerRunActivatesCompleteBuildingGeneration(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ worker := &fakeDocumentVectorWorkerRunner{result: RunResult{Claimed: 1, Embedded: 1, Published: 1}}
+ ledger := newFakeDocumentVectorReconcileLedger(store.DocumentVectorGenerationBuilding)
+ ledger.coverage = store.DocumentVectorCoverage{Required: 1, Ready: 1}
+ backend := &fakeDocumentVectorBackend{}
+ reconciler := NewReconciler(ReconcilerDeps{
+ Ledger: ledger, Worker: worker, Backend: backend,
+ Now: func() time.Time { return workerNow },
+ })
+
+ result, err := reconciler.Run(t.Context(), 1, 10)
+ requirements.NoError(err)
+ assertions.True(result.WorkerRan)
+ assertions.Equal(worker.result, result.Worker)
+ assertions.Equal(1, worker.calls)
+ assertions.Equal([]int{10}, worker.limits)
+ assertions.Equal(ledger.coverage, result.Coverage)
+ assertions.True(result.Activated)
+ assertions.True(result.Converged)
+ assertions.Equal(1, ledger.activateCalls)
+ assertions.Zero(ledger.purgeCalls)
+ assertions.Empty(backend.deletes)
+}
+
+func TestReconcilerRunRejectsInvalidRequestAndMissingBuildingWorker(t *testing.T) {
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorReconcileLedger(store.DocumentVectorGenerationBuilding)
+ reconciler := NewReconciler(ReconcilerDeps{
+ Ledger: ledger, Backend: &fakeDocumentVectorBackend{}, Now: func() time.Time { return workerNow },
+ })
+
+ _, err := reconciler.Run(t.Context(), 0, 1)
+ requirements.ErrorContains(err, "generation")
+ _, err = reconciler.Run(t.Context(), 1, 0)
+ requirements.ErrorContains(err, "limit")
+ _, err = reconciler.Run(t.Context(), 1, 1001)
+ requirements.ErrorContains(err, "limit")
+ _, err = reconciler.Run(t.Context(), 1, 1)
+ requirements.ErrorContains(err, "worker")
+}
+
+func TestReconcilerRunTimestampsCleanupAndActivationAfterWorker(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ worker := &fakeDocumentVectorWorkerRunner{}
+ ledger := newFakeDocumentVectorReconcileLedger(store.DocumentVectorGenerationBuilding)
+ ledger.coverage = store.DocumentVectorCoverage{Required: 1, Ready: 1}
+ ledger.obsolete = []store.DocumentVectorCleanupToken{{GenerationID: 1, Token: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}}
+ ledger.status.CleanupPending = 1
+ base := workerNow.Add(123456 * time.Nanosecond)
+ clockCalls := 0
+ reconciler := NewReconciler(ReconcilerDeps{
+ Ledger: ledger, Worker: worker, Backend: &fakeDocumentVectorBackend{},
+ Now: func() time.Time {
+ at := base.Add(time.Duration(clockCalls) * time.Second)
+ clockCalls++
+ return at
+ },
+ })
+
+ _, err := reconciler.Run(t.Context(), 1, 10)
+ requirements.NoError(err)
+ assertions.Equal(base.UTC().Truncate(time.Millisecond), ledger.parkAt)
+ assertions.Equal(base.Add(time.Second).UTC().Truncate(time.Millisecond), ledger.finalizeAt)
+ assertions.Equal(base.Add(2*time.Second).UTC().Truncate(time.Millisecond), ledger.activateAt)
+}
+
+func TestReconcilerRunContinuesSafeWorkAfterWorkerErrorWithoutActivation(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ workerErr := errors.New("provider unavailable")
+ worker := &fakeDocumentVectorWorkerRunner{err: workerErr}
+ ledger := newFakeDocumentVectorReconcileLedger(store.DocumentVectorGenerationBuilding)
+ ledger.coverage = store.DocumentVectorCoverage{Required: 1, Ready: 1}
+ ledger.obsolete = []store.DocumentVectorCleanupToken{{GenerationID: 1, Token: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
+ ledger.status.CleanupPending = 1
+ backend := &fakeDocumentVectorBackend{}
+ reconciler := NewReconciler(ReconcilerDeps{Ledger: ledger, Worker: worker, Backend: backend, Now: func() time.Time { return workerNow }})
+
+ result, err := reconciler.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, workerErr)
+ assertions.Equal(1, worker.calls)
+ assertions.Equal(1, result.CleanupDeleted)
+ assertions.Equal(1, result.CleanupFinalized)
+ assertions.Equal(1, ledger.statusCalls)
+ assertions.Equal(1, ledger.coverageCalls)
+ assertions.Zero(ledger.activateCalls)
+ assertions.False(result.Activated)
+ assertions.False(result.Converged)
+ assertions.Equal([][]string{{ledger.obsolete[0].Token}}, backend.deletes)
+}
+
+func TestReconcilerRunReportsTerminalGenerationAsBlocked(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorReconcileLedger(store.DocumentVectorGenerationBuilding)
+ ledger.status.Terminal = 1
+ ledger.status.Blocked = true
+ ledger.coverage = store.DocumentVectorCoverage{Required: 2, Ready: 1}
+ reconciler := NewReconciler(ReconcilerDeps{
+ Ledger: ledger, Worker: &fakeDocumentVectorWorkerRunner{}, Backend: &fakeDocumentVectorBackend{},
+ Now: func() time.Time { return workerNow },
+ })
+
+ result, err := reconciler.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, store.ErrDocumentVectorGenerationBlocked)
+ assertions.True(result.Blocked)
+ assertions.False(result.Activated)
+ assertions.False(result.Converged)
+ assertions.Zero(ledger.activateCalls)
+}
+
+func TestReconcilerRunNeverWorksActiveGeneration(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorReconcileLedger(store.DocumentVectorGenerationActive)
+ ledger.coverage = store.DocumentVectorCoverage{Required: 2, Ready: 2}
+ reconciler := NewReconciler(ReconcilerDeps{
+ Ledger: ledger, Backend: &fakeDocumentVectorBackend{},
+ Now: func() time.Time { return workerNow },
+ })
+
+ result, err := reconciler.Run(t.Context(), 1, 10)
+ requirements.NoError(err)
+ assertions.False(result.WorkerRan)
+ assertions.True(result.Converged)
+ assertions.Zero(ledger.activateCalls)
+ assertions.Zero(ledger.purgeCalls)
+}
+
+func TestReconcilerRunCleansRetiredGenerationWithoutWorkerDependency(t *testing.T) {
+ ledger := newFakeDocumentVectorReconcileLedger(store.DocumentVectorGenerationRetired)
+ ledger.purgeResult = true
+ reconciler := NewReconciler(ReconcilerDeps{
+ Ledger: ledger, Backend: &fakeDocumentVectorBackend{}, Now: func() time.Time { return workerNow },
+ })
+
+ result, err := reconciler.Run(t.Context(), 1, 10)
+ require.NoError(t, err)
+ assert.False(t, result.WorkerRan)
+ assert.True(t, result.Purged)
+}
+
+func TestReconcilerRunReplaysRetiredDeleteAfterFinalizeCrashThenPurges(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ finalizeErr := errors.New("main store unavailable")
+ worker := &fakeDocumentVectorWorkerRunner{}
+ ledger := newFakeDocumentVectorReconcileLedger(store.DocumentVectorGenerationRetired)
+ ledger.obsolete = []store.DocumentVectorCleanupToken{{GenerationID: 1, Token: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
+ ledger.status.CleanupPending = 1
+ ledger.finalizeErr = finalizeErr
+ ledger.purgeResult = true
+ backend := &fakeDocumentVectorBackend{}
+ reconciler := NewReconciler(ReconcilerDeps{Ledger: ledger, Worker: worker, Backend: backend, Now: func() time.Time { return workerNow }})
+
+ first, err := reconciler.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, finalizeErr)
+ assertions.Zero(worker.calls)
+ assertions.Equal(1, first.CleanupDeleted)
+ assertions.Zero(first.CleanupFinalized)
+ assertions.Zero(first.CleanupAfterGenerationID)
+ assertions.Empty(first.CleanupAfterToken)
+ assertions.Zero(ledger.purgeCalls)
+
+ ledger.finalizeErr = nil
+ second, err := reconciler.Run(t.Context(), 1, 10)
+ requirements.NoError(err)
+ assertions.Equal(1, second.CleanupDeleted)
+ assertions.Equal(1, second.CleanupFinalized)
+ assertions.True(second.Purged)
+ assertions.True(second.Converged)
+ assertions.Equal(1, ledger.purgeCalls)
+ assertions.Equal([][]string{{ledger.obsolete[0].Token}, {ledger.obsolete[0].Token}}, backend.deletes)
+}
+
+func TestReconcilerRunReplaysParkedPageAfterBackendDeleteFailure(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ deleteErr := errors.New("backend unavailable")
+ worker := &fakeDocumentVectorWorkerRunner{}
+ ledger := newFakeDocumentVectorReconcileLedger(store.DocumentVectorGenerationBuilding)
+ ledger.obsolete = []store.DocumentVectorCleanupToken{{GenerationID: 1, Token: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}}
+ ledger.status.CleanupPending = 1
+ backend := &fakeDocumentVectorBackend{deleteErr: deleteErr}
+ reconciler := NewReconciler(ReconcilerDeps{Ledger: ledger, Worker: worker, Backend: backend, Now: func() time.Time { return workerNow }})
+
+ first, err := reconciler.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, deleteErr)
+ assertions.Equal(1, first.CleanupListed)
+ assertions.Zero(first.CleanupFinalized)
+ assertions.Zero(first.CleanupAfterGenerationID)
+ assertions.Empty(first.CleanupAfterToken)
+ assertions.False(first.CleanupExhausted)
+
+ backend.deleteErr = nil
+ second, err := reconciler.Run(t.Context(), 1, 10)
+ requirements.NoError(err)
+ assertions.Equal(1, second.CleanupDeleted)
+ assertions.Equal(1, second.CleanupFinalized)
+ assertions.Equal([][]string{{ledger.obsolete[0].Token}, {ledger.obsolete[0].Token}}, backend.deletes)
+}
+
+func TestReconcilerRunDoesNotAdvancePastPartialFinalizeFailure(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ finalizeErr := errors.New("finalize interrupted")
+ worker := &fakeDocumentVectorWorkerRunner{}
+ ledger := newFakeDocumentVectorReconcileLedger(store.DocumentVectorGenerationBuilding)
+ ledger.obsolete = []store.DocumentVectorCleanupToken{
+ {GenerationID: 1, Token: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
+ {GenerationID: 1, Token: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
+ }
+ ledger.status.CleanupPending = 2
+ ledger.finalizeErrFor = ledger.obsolete[1].Token
+ ledger.finalizeErr = finalizeErr
+ backend := &fakeDocumentVectorBackend{}
+ reconciler := NewReconciler(ReconcilerDeps{Ledger: ledger, Worker: worker, Backend: backend, Now: func() time.Time { return workerNow }})
+
+ first, err := reconciler.Run(t.Context(), 1, 2)
+ requirements.ErrorIs(err, finalizeErr)
+ assertions.Equal(1, first.CleanupFinalized)
+ assertions.Zero(first.CleanupAfterGenerationID)
+ assertions.Empty(first.CleanupAfterToken)
+
+ ledger.finalizeErr = nil
+ second, err := reconciler.Run(t.Context(), 1, 2)
+ requirements.NoError(err)
+ assertions.Equal(1, second.CleanupListed)
+ assertions.Equal(ledger.obsolete[1].Token, ledger.finalized[len(ledger.finalized)-1])
+ assertions.Equal([][]string{
+ {ledger.obsolete[0].Token, ledger.obsolete[1].Token},
+ {ledger.obsolete[1].Token},
+ }, backend.deletes)
+}
+
+func TestReconcilerRunBoundsCleanupAndReturnsRestorableCursor(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ worker := &fakeDocumentVectorWorkerRunner{}
+ ledger := newFakeDocumentVectorReconcileLedger(store.DocumentVectorGenerationBuilding)
+ ledger.coverage = store.DocumentVectorCoverage{Required: 1}
+ ledger.obsolete = []store.DocumentVectorCleanupToken{
+ {GenerationID: 1, Token: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
+ {GenerationID: 1, Token: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
+ }
+ ledger.status.CleanupPending = 2
+ backend := &fakeDocumentVectorBackend{}
+ reconciler := NewReconciler(ReconcilerDeps{Ledger: ledger, Worker: worker, Backend: backend, Now: func() time.Time { return workerNow }})
+
+ first, err := reconciler.Run(t.Context(), 1, 1)
+ requirements.NoError(err)
+ assertions.Equal(1, first.CleanupListed)
+ assertions.Equal(GenerationID(1), first.CleanupAfterGenerationID)
+ assertions.Equal(ledger.obsolete[0].Token, first.CleanupAfterToken)
+ assertions.False(first.CleanupExhausted)
+
+ second, err := reconciler.Run(t.Context(), 1, 1)
+ requirements.NoError(err)
+ assertions.Equal(1, second.CleanupListed)
+ assertions.Zero(second.CleanupAfterGenerationID)
+ assertions.Empty(second.CleanupAfterToken)
+ assertions.True(second.CleanupExhausted)
+ assertions.Equal([][]string{{ledger.obsolete[0].Token}, {ledger.obsolete[1].Token}}, backend.deletes)
+}
+
+type fakeDocumentVectorWorkerRunner struct {
+ result RunResult
+ err error
+ calls int
+ limits []int
+}
+
+func (w *fakeDocumentVectorWorkerRunner) Run(_ context.Context, _ GenerationID, limit int) (RunResult, error) {
+ w.calls++
+ w.limits = append(w.limits, limit)
+ return w.result, w.err
+}
+
+type fakeDocumentVectorReconcileLedger struct {
+ generation store.DocumentVectorGeneration
+ status store.DocumentVectorGenerationStatus
+ coverage store.DocumentVectorCoverage
+ obsolete []store.DocumentVectorCleanupToken
+ finalized []string
+ finalizeErr error
+ finalizeErrFor string
+ activateErr error
+ purgeResult bool
+ purgeErr error
+ activateCalls int
+ purgeCalls int
+ statusCalls int
+ coverageCalls int
+ finalizedSet map[string]bool
+ parkAt time.Time
+ finalizeAt time.Time
+ activateAt time.Time
+}
+
+func newFakeDocumentVectorReconcileLedger(state store.DocumentVectorGenerationState) *fakeDocumentVectorReconcileLedger {
+ return &fakeDocumentVectorReconcileLedger{
+ generation: store.DocumentVectorGeneration{ID: 1, State: state},
+ status: store.DocumentVectorGenerationStatus{GenerationID: 1, State: state, FailuresExhausted: true},
+ finalizedSet: map[string]bool{},
+ }
+}
+
+func (l *fakeDocumentVectorReconcileLedger) GetDocumentVectorGeneration(context.Context, int64) (store.DocumentVectorGeneration, error) {
+ return l.generation, nil
+}
+
+func (l *fakeDocumentVectorReconcileLedger) GetDocumentVectorGenerationStatus(context.Context, int64, string, int) (store.DocumentVectorGenerationStatus, error) {
+ l.statusCalls++
+ return l.status, nil
+}
+
+func (l *fakeDocumentVectorReconcileLedger) ParkObsoleteDocumentVectorTokens(_ context.Context, generationID int64, after string, limit int, now time.Time) (store.DocumentVectorCleanupPage, error) {
+ l.parkAt = now
+ page := store.DocumentVectorCleanupPage{}
+ var tokens []store.DocumentVectorCleanupToken
+ for _, token := range l.obsolete {
+ if token.Token <= after || l.finalizedSet[token.Token] {
+ continue
+ }
+ tokens = append(tokens, token)
+ if len(tokens) == limit {
+ break
+ }
+ }
+ page.Tokens = tokens
+ if len(tokens) < limit {
+ page.Exhausted = true
+ } else {
+ page.AfterGenerationID = generationID
+ page.AfterToken = tokens[len(tokens)-1].Token
+ }
+ return page, nil
+}
+
+func (l *fakeDocumentVectorReconcileLedger) FinalizeObsoleteDocumentVectorToken(_ context.Context, _ int64, token string, now time.Time) (bool, error) {
+ l.finalizeAt = now
+ if l.finalizeErr != nil && (l.finalizeErrFor == "" || l.finalizeErrFor == token) {
+ return false, l.finalizeErr
+ }
+ l.finalized = append(l.finalized, token)
+ l.finalizedSet[token] = true
+ if l.status.CleanupPending > 0 {
+ l.status.CleanupPending--
+ }
+ return true, nil
+}
+
+func (l *fakeDocumentVectorReconcileLedger) GetDocumentVectorCoverage(context.Context, int64) (store.DocumentVectorCoverage, error) {
+ l.coverageCalls++
+ return l.coverage, nil
+}
+
+func (l *fakeDocumentVectorReconcileLedger) ActivateDocumentVectorGeneration(_ context.Context, _ int64, now time.Time) error {
+ l.activateCalls++
+ l.activateAt = now
+ if l.activateErr == nil {
+ l.generation.State = store.DocumentVectorGenerationActive
+ l.status.State = store.DocumentVectorGenerationActive
+ }
+ return l.activateErr
+}
+
+func (l *fakeDocumentVectorReconcileLedger) PurgeRetiredDocumentVectorGeneration(context.Context, int64) (bool, error) {
+ l.purgeCalls++
+ return l.purgeResult, l.purgeErr
+}
diff --git a/internal/vector/document/search.go b/internal/vector/document/search.go
new file mode 100644
index 000000000..ab2715a46
--- /dev/null
+++ b/internal/vector/document/search.go
@@ -0,0 +1,545 @@
+package document
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "slices"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ docembedding "go.kenn.io/docbank/document/embedding"
+ "go.kenn.io/msgvault/internal/store"
+ "go.kenn.io/msgvault/internal/vector"
+)
+
+const (
+ searchCursorVersion = 1
+ maxSearchPageSize = 100
+ maxSearchQueryBytes = 1024
+ maxSearchQueryTerms = 20
+)
+
+var ErrSemanticSearchUnavailable = errors.New("semantic document search is unavailable")
+
+// QueryEmbedder embeds one search query using the provider's query role.
+type QueryEmbedder interface {
+ EmbedQuery(ctx context.Context, text string) ([]float32, error)
+}
+
+type SearchLedger interface {
+ SearchDocuments(ctx context.Context, request store.DocumentSearchRequest) (store.DocumentSearchResponse, error)
+ GetDocumentIndexRevision(ctx context.Context) (int64, error)
+ GetActiveDocumentVectorGeneration(ctx context.Context) (*store.DocumentVectorGeneration, error)
+ GetDocumentVectorTargetProfileID(ctx context.Context) (string, error)
+ ResolveDocumentVectorSearchOccurrences(ctx context.Context, generationID int64, hits []store.DocumentVectorSearchHit, request store.DocumentSearchRequest, limit int) ([]store.DocumentSearchResult, bool, error)
+}
+
+var _ SearchLedger = (*store.Store)(nil)
+
+type SearchDeps struct {
+ Ledger SearchLedger
+ Embedder QueryEmbedder
+ Backend Backend
+ // ExpectedFingerprint binds daemon capability to the currently configured
+ // and consented policy. Empty preserves the standalone service contract.
+ ExpectedFingerprint string
+}
+
+type SearchService struct{ deps SearchDeps }
+
+type searchCursor struct {
+ Version int `json:"version"`
+ RequestHash string `json:"request_hash"`
+ Revision int64 `json:"revision"`
+ EffectiveMode string `json:"effective_mode"`
+ GenerationID int64 `json:"generation_id"`
+ GenerationFingerprint string `json:"generation_fingerprint"`
+ CandidateLimit int `json:"candidate_limit"`
+ CandidateDigest string `json:"candidate_digest"`
+ Offset int `json:"offset"`
+}
+
+func NewSearchService(deps SearchDeps) *SearchService { return &SearchService{deps: deps} }
+
+func (s *SearchService) Search(ctx context.Context, request store.DocumentSearchRequest) (store.DocumentSearchResponse, error) {
+ prepared, requestedMode, requestHash, err := normalizeSearchRequest(request)
+ if err != nil {
+ return store.DocumentSearchResponse{}, err
+ }
+ if s == nil || s.deps.Ledger == nil {
+ return store.DocumentSearchResponse{}, errors.New("document search ledger is required")
+ }
+ var generation *store.DocumentVectorGeneration
+ if requestedMode == SearchModeSemantic || requestedMode == SearchModeHybrid {
+ generation, err = s.deps.Ledger.GetActiveDocumentVectorGeneration(ctx)
+ if err != nil {
+ return store.DocumentSearchResponse{}, fmt.Errorf("read active document vector generation: %w", err)
+ }
+ if generation != nil {
+ target, targetErr := s.deps.Ledger.GetDocumentVectorTargetProfileID(ctx)
+ if targetErr != nil || generation.TargetExtractionProfileID != target {
+ return store.DocumentSearchResponse{}, ErrSemanticSearchUnavailable
+ }
+ }
+ }
+ effectiveMode, err := s.effectiveMode(requestedMode, generation)
+ if err != nil {
+ return store.DocumentSearchResponse{}, err
+ }
+ if effectiveMode == SearchModeLexical {
+ prepared.SearchMode = ""
+ response, searchErr := s.deps.Ledger.SearchDocuments(ctx, prepared)
+ if searchErr != nil {
+ return store.DocumentSearchResponse{}, searchErr
+ }
+ response.EffectiveMode = string(SearchModeLexical)
+ for index := range response.Results {
+ response.Results[index].LexicalRank = response.Results[index].Rank
+ }
+ return response, nil
+ }
+ revision, err := s.deps.Ledger.GetDocumentIndexRevision(ctx)
+ if err != nil {
+ return store.DocumentSearchResponse{}, fmt.Errorf("read document search revision: %w", err)
+ }
+ generationID, generationFingerprint := generation.ID, generation.Fingerprint
+ offset, expectedDigest, err := validateSearchCursor(prepared.Cursor, requestHash, revision, effectiveMode, generationID, generationFingerprint, prepared.CandidateLimit)
+ if err != nil {
+ return store.DocumentSearchResponse{}, err
+ }
+
+ prepared.Cursor = ""
+ var candidates []store.DocumentSearchResult
+ truncated := false
+ switch effectiveMode {
+ case SearchModeSemantic, SearchModeHybrid:
+ var lexical []store.DocumentSearchResult
+ if effectiveMode == SearchModeHybrid {
+ var more bool
+ lexical, more, err = s.collectLexical(ctx, prepared)
+ if err != nil {
+ return store.DocumentSearchResponse{}, err
+ }
+ truncated = more
+ }
+ semantic, more, semanticErr := s.collectSemantic(ctx, prepared, *generation)
+ if semanticErr != nil {
+ return store.DocumentSearchResponse{}, semanticErr
+ }
+ truncated = truncated || more
+ var fusionMore bool
+ candidates, fusionMore, err = fuseSearchResults(lexical, semantic, prepared.CandidateLimit)
+ if err != nil {
+ return store.DocumentSearchResponse{}, fmt.Errorf("fuse document candidates: %w", err)
+ }
+ truncated = truncated || fusionMore
+ default:
+ return store.DocumentSearchResponse{}, fmt.Errorf("%w: unsupported effective mode", store.ErrDocumentSearchInvalidRequest)
+ }
+ if err := s.validateSearchAuthorityUnchanged(ctx, *generation, revision); err != nil {
+ return store.DocumentSearchResponse{}, err
+ }
+ digest, err := digestSearchCandidates(candidates)
+ if err != nil {
+ return store.DocumentSearchResponse{}, err
+ }
+ if expectedDigest != "" && expectedDigest != digest {
+ return store.DocumentSearchResponse{}, store.ErrDocumentSearchCursorStale
+ }
+
+ response := store.DocumentSearchResponse{
+ Revision: revision, EffectiveMode: string(effectiveMode),
+ VectorGenerationID: generationID, VectorGenerationFingerprint: generationFingerprint,
+ Truncated: truncated,
+ }
+ if offset >= len(candidates) {
+ return response, nil
+ }
+ end := min(offset+prepared.PageSize, len(candidates))
+ response.Results = slices.Clone(candidates[offset:end])
+ if end < len(candidates) {
+ response.NextCursor, err = encodeSearchCursor(searchCursor{
+ Version: searchCursorVersion, RequestHash: requestHash, Revision: revision,
+ EffectiveMode: string(effectiveMode), GenerationID: generationID,
+ GenerationFingerprint: generationFingerprint, CandidateLimit: prepared.CandidateLimit,
+ CandidateDigest: digest, Offset: end,
+ })
+ if err != nil {
+ return store.DocumentSearchResponse{}, err
+ }
+ }
+ return response, nil
+}
+
+func (s *SearchService) validateSearchAuthorityUnchanged(
+ ctx context.Context,
+ expected store.DocumentVectorGeneration,
+ expectedRevision int64,
+) error {
+ active, err := s.deps.Ledger.GetActiveDocumentVectorGeneration(ctx)
+ if err != nil {
+ return fmt.Errorf("revalidate active document vector generation: %w", err)
+ }
+ if active == nil || active.ID != expected.ID || active.Fingerprint != expected.Fingerprint ||
+ active.TargetExtractionProfileID != expected.TargetExtractionProfileID {
+ return store.ErrDocumentSearchCursorStale
+ }
+ target, err := s.deps.Ledger.GetDocumentVectorTargetProfileID(ctx)
+ if errors.Is(err, store.ErrDocumentVectorInvalidGenerationState) {
+ return store.ErrDocumentSearchCursorStale
+ }
+ if err != nil {
+ return fmt.Errorf("revalidate document vector target profile: %w", err)
+ }
+ revision, err := s.deps.Ledger.GetDocumentIndexRevision(ctx)
+ if err != nil {
+ return fmt.Errorf("revalidate document search revision: %w", err)
+ }
+ if target != expected.TargetExtractionProfileID || revision != expectedRevision {
+ return store.ErrDocumentSearchCursorStale
+ }
+ return nil
+}
+
+func (s *SearchService) effectiveMode(requested SearchMode, generation *store.DocumentVectorGeneration) (SearchMode, error) {
+ semanticReady := generation != nil && s.deps.Embedder != nil && s.deps.Backend != nil &&
+ (s.deps.ExpectedFingerprint == "" || generation.Fingerprint == s.deps.ExpectedFingerprint)
+ switch requested {
+ case SearchModeLexical:
+ return SearchModeLexical, nil
+ case SearchModeAuto:
+ // Automatic searches stay local. Sending a query to the configured
+ // provider requires an explicit semantic or hybrid request.
+ return SearchModeLexical, nil
+ case SearchModeSemantic, SearchModeHybrid:
+ if !semanticReady {
+ return "", ErrSemanticSearchUnavailable
+ }
+ return requested, nil
+ default:
+ return "", fmt.Errorf("%w: unsupported search mode", store.ErrDocumentSearchInvalidRequest)
+ }
+}
+
+func (s *SearchService) collectLexical(ctx context.Context, request store.DocumentSearchRequest) ([]store.DocumentSearchResult, bool, error) {
+ pageSize := min(request.CandidateLimit, maxSearchPageSize)
+ lexicalRequest := request
+ lexicalRequest.PageSize = pageSize
+ lexicalRequest.Cursor = ""
+ lexicalRequest.SearchMode = ""
+ lexicalRequest.CandidateLimit = request.CandidateLimit
+ results := make([]store.DocumentSearchResult, 0, request.CandidateLimit)
+ for len(results) < request.CandidateLimit {
+ response, err := s.deps.Ledger.SearchDocuments(ctx, lexicalRequest)
+ if err != nil {
+ return nil, false, err
+ }
+ remaining := request.CandidateLimit - len(results)
+ if len(response.Results) > remaining {
+ results = append(results, response.Results[:remaining]...)
+ return results, true, nil
+ }
+ results = append(results, response.Results...)
+ if response.NextCursor == "" {
+ return results, response.Truncated, nil
+ }
+ if len(results) == request.CandidateLimit {
+ return results, true, nil
+ }
+ lexicalRequest.Cursor = response.NextCursor
+ }
+ return results, true, nil
+}
+
+func (s *SearchService) collectSemantic(ctx context.Context, request store.DocumentSearchRequest, generation store.DocumentVectorGeneration) ([]store.DocumentSearchResult, bool, error) {
+ query, err := s.deps.Embedder.EmbedQuery(ctx, request.Query)
+ if err != nil {
+ return nil, false, fmt.Errorf("embed document search query: %w", err)
+ }
+ if err := validateSearchVector(query, generation.Dimension); err != nil {
+ return nil, false, err
+ }
+ backend, ok := s.deps.Backend.(PagedBackend)
+ if !ok {
+ return nil, false, errors.New("document vector backend does not support scoped paging")
+ }
+ source := &semanticScopedSource{
+ ledger: s.deps.Ledger, backend: backend, generation: generation,
+ query: query, request: request, seen: make(map[string]bool),
+ results: make(map[string]store.DocumentSearchResult),
+ }
+ collected, err := docembedding.CollectScopedCandidates(
+ ctx, source, request.CandidateLimit, semanticBackendPageSize,
+ )
+ if err != nil {
+ return nil, false, fmt.Errorf("collect scoped document vectors: %w", err)
+ }
+ results := make([]store.DocumentSearchResult, len(collected.Candidates))
+ for index, candidate := range collected.Candidates {
+ result := source.results[candidate.Key]
+ result.SemanticRank = candidate.Rank
+ result.Rank = candidate.Rank
+ results[index] = result
+ }
+ return results, collected.Truncated, nil
+}
+
+const semanticBackendPageSize = 100
+
+type semanticScopedSource struct {
+ ledger SearchLedger
+ backend PagedBackend
+ generation store.DocumentVectorGeneration
+ query []float32
+ request store.DocumentSearchRequest
+
+ backendCursor string
+ backendDone bool
+ pageSerial int
+ buffer []store.DocumentSearchResult
+ seen map[string]bool
+ results map[string]store.DocumentSearchResult
+}
+
+func (s *semanticScopedSource) SearchPage(ctx context.Context, request docembedding.PageRequest) (docembedding.CandidatePage, error) {
+ expected := ""
+ if s.pageSerial > 0 {
+ expected = strconv.Itoa(s.pageSerial)
+ }
+ if request.Cursor != expected {
+ return docembedding.CandidatePage{}, errors.New("semantic scoped cursor is invalid")
+ }
+ for len(s.buffer) < request.Limit && !s.backendDone {
+ page, err := s.backend.SearchPage(
+ ctx, GenerationID(s.generation.ID), s.generation.Dimension,
+ s.query, s.backendCursor, semanticBackendPageSize,
+ )
+ if err != nil {
+ return docembedding.CandidatePage{}, fmt.Errorf("search document vector backend page: %w", err)
+ }
+ storeHits := make([]store.DocumentVectorSearchHit, len(page.Hits))
+ for index, hit := range page.Hits {
+ storeHits[index] = store.DocumentVectorSearchHit{Token: hit.Token, Score: hit.Score, Rank: hit.Rank}
+ }
+ resolved, expandedMore, err := s.ledger.ResolveDocumentVectorSearchOccurrences(
+ ctx, s.generation.ID, storeHits, s.request, docembedding.MaxCandidateLimit+1,
+ )
+ if err != nil {
+ return docembedding.CandidatePage{}, err
+ }
+ for _, result := range resolved {
+ if s.seen[result.OccurrenceKey] {
+ continue
+ }
+ s.seen[result.OccurrenceKey] = true
+ s.results[result.OccurrenceKey] = result
+ s.buffer = append(s.buffer, result)
+ }
+ if expandedMore && len(s.buffer) < request.Limit {
+ return docembedding.CandidatePage{}, errors.New("semantic occurrence expansion exceeded its bounded page")
+ }
+ s.backendDone = page.Exhausted
+ s.backendCursor = page.NextCursor
+ }
+
+ take := min(request.Limit, len(s.buffer))
+ candidates := make([]docembedding.Candidate, take)
+ for index := range candidates {
+ result := s.buffer[index]
+ candidates[index] = docembedding.Candidate{Key: result.OccurrenceKey, Score: result.SemanticScore}
+ }
+ s.buffer = s.buffer[take:]
+ exhausted := s.backendDone && len(s.buffer) == 0
+ page := docembedding.CandidatePage{Candidates: candidates, Exhausted: exhausted}
+ if !exhausted {
+ s.pageSerial++
+ page.NextCursor = strconv.Itoa(s.pageSerial)
+ }
+ return page, nil
+}
+
+func validateSearchVector(value []float32, dimension int) error {
+ if dimension <= 0 || len(value) != dimension {
+ return fmt.Errorf("%w: query vector length %d does not match dimension %d", vector.ErrDimensionMismatch, len(value), dimension)
+ }
+ norm := float64(0)
+ for _, component := range value {
+ if math.IsNaN(float64(component)) || math.IsInf(float64(component), 0) {
+ return fmt.Errorf("%w: query vector contains a nonfinite component", ErrInvalidVector)
+ }
+ norm += float64(component) * float64(component)
+ }
+ if norm == 0 {
+ return fmt.Errorf("%w: query vector has zero norm", ErrInvalidVector)
+ }
+ return nil
+}
+
+func normalizeSearchRequest(request store.DocumentSearchRequest) (store.DocumentSearchRequest, SearchMode, string, error) {
+ mode, err := ParseSearchMode(request.SearchMode)
+ if err != nil {
+ return request, "", "", fmt.Errorf("%w: %w", store.ErrDocumentSearchInvalidRequest, err)
+ }
+ request.Query = strings.ToLower(strings.Join(strings.Fields(request.Query), " "))
+ if request.Query == "" || len(request.Query) > maxSearchQueryBytes || !utf8.ValidString(request.Query) || len(strings.Fields(request.Query)) > maxSearchQueryTerms {
+ return request, "", "", fmt.Errorf("%w: requires a bounded UTF-8 query", store.ErrDocumentSearchInvalidRequest)
+ }
+ if request.PageSize == 0 {
+ request.PageSize = 20
+ }
+ switch mode {
+ case SearchModeAuto, SearchModeLexical:
+ if request.CandidateLimit == 0 {
+ request.CandidateLimit = store.DefaultLexicalDocumentSearchCandidateLimit
+ }
+ if request.CandidateLimit < 1 || request.CandidateLimit > store.MaxLexicalDocumentSearchCandidateLimit {
+ return request, "", "", fmt.Errorf("%w: lexical candidate limit must be between 1 and %d",
+ store.ErrDocumentSearchInvalidRequest, store.MaxLexicalDocumentSearchCandidateLimit)
+ }
+ case SearchModeSemantic, SearchModeHybrid:
+ options, normalizeErr := docembedding.NormalizeSearchOptions(docembedding.SearchOptions{
+ Mode: mode, CandidateLimit: request.CandidateLimit,
+ })
+ if normalizeErr != nil {
+ return request, "", "", fmt.Errorf("%w: %w", store.ErrDocumentSearchInvalidRequest, normalizeErr)
+ }
+ request.CandidateLimit = options.CandidateLimit
+ }
+ if request.PageSize < 1 || request.PageSize > maxSearchPageSize || request.AttachmentID < 0 || request.MessageID < 0 {
+ return request, "", "", fmt.Errorf("%w: request has invalid bounds", store.ErrDocumentSearchInvalidRequest)
+ }
+ request.SourceIDs = slices.Clone(request.SourceIDs)
+ slices.Sort(request.SourceIDs)
+ request.SourceIDs = slices.Compact(request.SourceIDs)
+ for _, id := range request.SourceIDs {
+ if id <= 0 {
+ return request, "", "", fmt.Errorf("%w: source IDs must be positive", store.ErrDocumentSearchInvalidRequest)
+ }
+ }
+ request.MessageTypes = slices.Clone(request.MessageTypes)
+ for index := range request.MessageTypes {
+ request.MessageTypes[index] = strings.ToLower(strings.TrimSpace(request.MessageTypes[index]))
+ if request.MessageTypes[index] == "" {
+ return request, "", "", fmt.Errorf("%w: message types must be nonempty", store.ErrDocumentSearchInvalidRequest)
+ }
+ }
+ slices.Sort(request.MessageTypes)
+ request.MessageTypes = slices.Compact(request.MessageTypes)
+ request.SearchMode = string(mode)
+ hash, err := hashSearchRequest(request)
+ return request, mode, hash, err
+}
+
+func hashSearchRequest(request store.DocumentSearchRequest) (string, error) {
+ request.Cursor = ""
+ encoded, err := json.Marshal(request)
+ if err != nil {
+ return "", fmt.Errorf("encode document semantic search request: %w", err)
+ }
+ digest := sha256.Sum256(encoded)
+ return hex.EncodeToString(digest[:]), nil
+}
+
+func digestSearchCandidates(results []store.DocumentSearchResult) (string, error) {
+ type candidateIdentity struct {
+ OccurrenceKey string `json:"occurrence_key"`
+ ChunkKey string `json:"chunk_key"`
+ ExtractionID string `json:"extraction_id"`
+ VectorToken string `json:"vector_token"`
+ VectorGenerationFingerprint string `json:"vector_generation_fingerprint"`
+ VectorEmbeddingProfile string `json:"vector_embedding_profile"`
+ VectorModel string `json:"vector_model"`
+ AttachmentID int64 `json:"attachment_id"`
+ MessageID int64 `json:"message_id"`
+ VectorGenerationID int64 `json:"vector_generation_id"`
+ LexicalRank int `json:"lexical_rank"`
+ SemanticRank int `json:"semantic_rank"`
+ VectorDimension int `json:"vector_dimension"`
+ SemanticScore float64 `json:"semantic_score"`
+ FusionScore float64 `json:"fusion_score"`
+ MatchedSignals []string `json:"matched_signals"`
+ }
+ identities := make([]candidateIdentity, len(results))
+ for index, result := range results {
+ identities[index] = candidateIdentity{
+ OccurrenceKey: result.OccurrenceKey, ChunkKey: result.ChunkKey, ExtractionID: result.ExtractionID,
+ VectorToken: result.VectorToken, VectorGenerationFingerprint: result.VectorGenerationFingerprint,
+ VectorEmbeddingProfile: result.VectorEmbeddingProfile, VectorModel: result.VectorModel,
+ AttachmentID: result.AttachmentID, MessageID: result.MessageID, VectorGenerationID: result.VectorGenerationID,
+ LexicalRank: result.LexicalRank, SemanticRank: result.SemanticRank, VectorDimension: result.VectorDimension,
+ SemanticScore: result.SemanticScore, FusionScore: result.FusionScore, MatchedSignals: result.MatchedSignals,
+ }
+ }
+ encoded, err := json.Marshal(identities)
+ if err != nil {
+ return "", fmt.Errorf("encode document search candidate digest: %w", err)
+ }
+ digest := sha256.Sum256(encoded)
+ return hex.EncodeToString(digest[:]), nil
+}
+
+func validateSearchCursor(value, requestHash string, revision int64, mode SearchMode, generationID int64, generationFingerprint string, candidateLimit int) (int, string, error) {
+ if value == "" {
+ return 0, "", nil
+ }
+ cursor, err := decodeSearchCursor(value)
+ if err != nil {
+ return 0, "", err
+ }
+ if cursor.RequestHash != requestHash || cursor.CandidateLimit != candidateLimit {
+ return 0, "", store.ErrDocumentSearchInvalidCursor
+ }
+ if cursor.Offset > cursor.CandidateLimit {
+ return 0, "", store.ErrDocumentSearchInvalidCursor
+ }
+ if cursor.Revision != revision || cursor.EffectiveMode != string(mode) || cursor.GenerationID != generationID || cursor.GenerationFingerprint != generationFingerprint {
+ return 0, "", store.ErrDocumentSearchCursorStale
+ }
+ return cursor.Offset, cursor.CandidateDigest, nil
+}
+
+func encodeSearchCursor(cursor searchCursor) (string, error) {
+ encoded, err := json.Marshal(cursor)
+ if err != nil {
+ return "", fmt.Errorf("encode document semantic search cursor: %w", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(encoded), nil
+}
+
+func decodeSearchCursor(value string) (searchCursor, error) {
+ decoded, err := base64.RawURLEncoding.DecodeString(value)
+ if err != nil || len(decoded) > 4096 {
+ return searchCursor{}, store.ErrDocumentSearchInvalidCursor
+ }
+ var cursor searchCursor
+ decoder := json.NewDecoder(strings.NewReader(string(decoded)))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&cursor); err != nil || cursor.Version != searchCursorVersion || !validSearchDigest(cursor.RequestHash) || !validSearchDigest(cursor.CandidateDigest) || cursor.Revision < 0 || cursor.Offset < 1 || cursor.Offset > store.MaxLexicalDocumentSearchCandidateLimit || cursor.CandidateLimit < 1 || cursor.CandidateLimit > store.MaxLexicalDocumentSearchCandidateLimit || (cursor.GenerationID == 0) != (cursor.GenerationFingerprint == "") || (cursor.GenerationFingerprint != "" && !validSearchDigest(cursor.GenerationFingerprint)) {
+ return searchCursor{}, store.ErrDocumentSearchInvalidCursor
+ }
+ mode, err := ParseSearchMode(cursor.EffectiveMode)
+ if err != nil || mode == SearchModeAuto ||
+ (mode != SearchModeLexical && cursor.CandidateLimit > store.MaxDocumentSearchCandidateLimit) {
+ return searchCursor{}, store.ErrDocumentSearchInvalidCursor
+ }
+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
+ return searchCursor{}, store.ErrDocumentSearchInvalidCursor
+ }
+ return cursor, nil
+}
+
+func validSearchDigest(value string) bool {
+ if len(value) != sha256.Size*2 {
+ return false
+ }
+ _, err := hex.DecodeString(value)
+ return err == nil && value == strings.ToLower(value)
+}
diff --git a/internal/vector/document/search_test.go b/internal/vector/document/search_test.go
new file mode 100644
index 000000000..2ce611c6d
--- /dev/null
+++ b/internal/vector/document/search_test.go
@@ -0,0 +1,600 @@
+package document
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ docbankdocument "go.kenn.io/docbank/document"
+ "go.kenn.io/msgvault/internal/store"
+ "go.kenn.io/msgvault/internal/testutil/storetest"
+ "go.kenn.io/msgvault/internal/vector"
+)
+
+type countingSearchLedger struct {
+ *store.Store
+
+ searchCalls int
+}
+
+type changingSearchAuthorityLedger struct {
+ SearchLedger
+
+ generationAfterCollection *store.DocumentVectorGeneration
+ revisionAfterCollection bool
+ generationReads int
+ revisionReads int
+}
+
+func (ledger *changingSearchAuthorityLedger) GetActiveDocumentVectorGeneration(
+ ctx context.Context,
+) (*store.DocumentVectorGeneration, error) {
+ ledger.generationReads++
+ if ledger.generationReads > 1 && ledger.generationAfterCollection != nil {
+ generation := *ledger.generationAfterCollection
+ return &generation, nil
+ }
+ return ledger.SearchLedger.GetActiveDocumentVectorGeneration(ctx)
+}
+
+func (ledger *changingSearchAuthorityLedger) GetDocumentIndexRevision(ctx context.Context) (int64, error) {
+ ledger.revisionReads++
+ revision, err := ledger.SearchLedger.GetDocumentIndexRevision(ctx)
+ if err == nil && ledger.revisionReads > 1 && ledger.revisionAfterCollection {
+ revision++
+ }
+ return revision, err
+}
+
+func (ledger *countingSearchLedger) SearchDocuments(
+ ctx context.Context,
+ request store.DocumentSearchRequest,
+) (store.DocumentSearchResponse, error) {
+ ledger.searchCalls++
+ return ledger.Store.SearchDocuments(ctx, request)
+}
+
+func TestSearchServiceAppliesCandidateBoundsByMode(t *testing.T) {
+ fixture := storetest.New(t)
+ service := NewSearchService(SearchDeps{Ledger: fixture.Store})
+
+ for _, mode := range []SearchMode{SearchModeAuto, SearchModeLexical} {
+ _, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "synthetic query", SearchMode: string(mode), CandidateLimit: 1001,
+ })
+ require.NoError(t, err)
+ }
+ for _, mode := range []SearchMode{SearchModeSemantic, SearchModeHybrid} {
+ _, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "synthetic query", SearchMode: string(mode), CandidateLimit: 1001,
+ })
+ require.ErrorIs(t, err, store.ErrDocumentSearchInvalidRequest)
+ }
+}
+
+func TestSearchServiceDefaultsCandidateLimitToSharedContract(t *testing.T) {
+ fixture := seedSemanticSearch(t, "default candidate evidence")
+ embedder := &searchQueryEmbedder{vector: []float32{1, 0, 0}}
+ backend := &searchBackend{hits: []Hit{{Token: fixture.claims[0].Token, Score: .9, Rank: 1}}}
+ service := NewSearchService(SearchDeps{
+ Ledger: fixture.store.Store, Embedder: embedder, Backend: backend,
+ })
+
+ _, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "candidate", SearchMode: string(SearchModeSemantic),
+ })
+ require.NoError(t, err)
+ require.Len(t, backend.searches, 1)
+ assert.Equal(t, store.DefaultDocumentSearchCandidateLimit, backend.searches[0].k)
+}
+
+func TestSearchServiceAutoFallsBackToLexicalWhenSemanticCapabilityIsAbsent(t *testing.T) {
+ fixture := storetest.New(t)
+ service := NewSearchService(SearchDeps{Ledger: fixture.Store})
+
+ response, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "synthetic query", SearchMode: string(SearchModeAuto),
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, string(SearchModeLexical), response.EffectiveMode)
+ assert.Empty(t, response.Results)
+}
+
+func TestSearchServiceExplicitSemanticDoesNotMasqueradeAsLexical(t *testing.T) {
+ fixture := storetest.New(t)
+ service := NewSearchService(SearchDeps{Ledger: fixture.Store})
+
+ for _, mode := range []SearchMode{SearchModeSemantic, SearchModeHybrid} {
+ _, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "synthetic query", SearchMode: string(mode),
+ })
+ require.ErrorIs(t, err, ErrSemanticSearchUnavailable)
+ assert.NotErrorIs(t, err, store.ErrDocumentSearchUnavailable)
+ }
+}
+
+func TestSearchServiceSemanticReturnsAuthoritativeOccurrenceProvenance(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ fixture := seedSemanticSearch(t, "alpha evidence", "beta evidence")
+ embedder := &searchQueryEmbedder{vector: []float32{1, 0, 0}}
+ backend := &searchBackend{hits: []Hit{{Token: fixture.claims[1].Token, Score: .75, Rank: 1}}}
+ service := NewSearchService(SearchDeps{Ledger: fixture.store.Store, Embedder: embedder, Backend: backend})
+
+ response, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: " FIND beta ", SearchMode: string(SearchModeSemantic), CandidateLimit: 10,
+ })
+
+ requirements.NoError(err)
+ requirements.Len(response.Results, 1)
+ result := response.Results[0]
+ assertions.Equal(string(SearchModeSemantic), response.EffectiveMode)
+ assertions.Equal(fixture.generation.ID, response.VectorGenerationID)
+ assertions.Equal(fixture.generation.Fingerprint, response.VectorGenerationFingerprint)
+ assertions.Equal(fixture.claims[1].Token, result.VectorToken)
+ assertions.Equal(fixture.claims[1].ChunkKey, result.ChunkKey)
+ assertions.Equal(fixture.claims[1].ChunkOrdinal, result.ChunkOrdinal)
+ assertions.Equal(fixture.claims[1].ExtractionID, result.ExtractionID)
+ assertions.Equal(fixture.claims[1].ExtractionProfileID, result.ProfileID)
+ assertions.Equal(fixture.generation.EmbeddingProfile, result.VectorEmbeddingProfile)
+ assertions.Equal(fixture.generation.Model, result.VectorModel)
+ assertions.Equal(fixture.generation.Dimension, result.VectorDimension)
+ assertions.Equal(1, result.SemanticRank)
+ assertions.Equal(1, result.Rank)
+ assertions.Equal([]string{"semantic"}, result.MatchedSignals)
+ assertions.Equal([]string{"find beta"}, embedder.queries)
+ requirements.Len(backend.searches, 1)
+ assertions.Equal(fixture.generation.ID, int64(backend.searches[0].generationID))
+ assertions.Equal(3, backend.searches[0].dimension)
+ assertions.Equal([]float32{1, 0, 0}, backend.searches[0].query)
+ assertions.Equal(semanticBackendPageSize, backend.searches[0].k)
+}
+
+func TestSearchServiceRejectsAuthorityChangesDuringCandidateCollection(t *testing.T) {
+ fixture := seedSemanticSearch(t, "authority evidence")
+ changedGeneration := fixture.generation
+ changedGeneration.ID++
+ changedGeneration.Fingerprint = strings.Repeat("e", 64)
+ for _, test := range []struct {
+ name string
+ generationAfter *store.DocumentVectorGeneration
+ revisionAfter bool
+ }{
+ {name: "generation activation", generationAfter: &changedGeneration},
+ {name: "document reindex", revisionAfter: true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := &changingSearchAuthorityLedger{
+ SearchLedger: fixture.store.Store, generationAfterCollection: test.generationAfter,
+ revisionAfterCollection: test.revisionAfter,
+ }
+ embedder := &searchQueryEmbedder{vector: []float32{1, 0, 0}}
+ backend := &searchBackend{hits: []Hit{{Token: fixture.claims[0].Token, Score: .9, Rank: 1}}}
+ service := NewSearchService(SearchDeps{Ledger: ledger, Embedder: embedder, Backend: backend})
+
+ _, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "authority", SearchMode: string(SearchModeSemantic), CandidateLimit: 10,
+ })
+
+ requirements.ErrorIs(err, store.ErrDocumentSearchCursorStale)
+ assertions.Equal([]string{"authority"}, embedder.queries)
+ })
+ }
+}
+
+func TestSearchServiceSemanticPagesPastOutOfScopeGlobalHits(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ fixture := seedSemanticSearch(t, "scoped evidence")
+ hits := make([]Hit, semanticBackendPageSize+1)
+ for index := range semanticBackendPageSize {
+ hits[index] = Hit{Token: fmt.Sprintf("%064x", index+1), Score: 1 - float64(index)/1000}
+ }
+ hits[semanticBackendPageSize] = Hit{Token: fixture.claims[0].Token, Score: .5}
+ backend := &searchBackend{hits: hits}
+ service := NewSearchService(SearchDeps{
+ Ledger: fixture.store.Store, Embedder: &searchQueryEmbedder{vector: []float32{1, 0, 0}},
+ Backend: backend, ExpectedFingerprint: fixture.generation.Fingerprint,
+ })
+
+ response, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "scoped evidence", SearchMode: string(SearchModeSemantic), CandidateLimit: 10,
+ })
+
+ require.NoError(err)
+ require.Len(response.Results, 1)
+ assert.Equal(fixture.claims[0].Token, response.Results[0].VectorToken)
+ assert.Len(backend.searches, 2)
+}
+
+func TestSearchServiceAutoKeepsQueryLocalWhenSemanticCapabilityIsReady(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ fixture := seedSemanticSearch(t, "nebula evidence")
+ embedder := &searchQueryEmbedder{vector: []float32{1, 0, 0}}
+ backend := &searchBackend{hits: []Hit{{Token: fixture.claims[0].Token, Score: .9, Rank: 1}}}
+ service := NewSearchService(SearchDeps{Ledger: fixture.store.Store, Embedder: embedder, Backend: backend})
+
+ for _, mode := range []string{"", string(SearchModeAuto)} {
+ response, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "nebula", SearchMode: mode, CandidateLimit: 10,
+ })
+
+ requirements.NoError(err)
+ requirements.Len(response.Results, 1)
+ assertions.Equal(string(SearchModeLexical), response.EffectiveMode)
+ assertions.Equal([]string{"content"}, response.Results[0].MatchedSignals)
+ }
+ assertions.Empty(embedder.queries)
+ assertions.Empty(backend.searches)
+}
+
+func TestSearchServiceExplicitLexicalPreservesStoreRankingWithoutProviderWork(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ fixture := seedSemanticSearch(t, "nebula evidence")
+ copyMessageID := fixture.store.CreateMessage("lexical-ranking-copy")
+ copyAttachmentID := addSemanticSearchAttachment(
+ t, fixture.store, copyMessageID, fixture.claims[0].CanonicalBlobHash, "nebula-copy.pdf", "provider:lexical-copy",
+ )
+ _, eligible, err := fixture.store.Store.ReconcileDocumentOccurrence(t.Context(), copyAttachmentID, 2)
+ requirements.NoError(err)
+ requirements.True(eligible)
+ direct, err := fixture.store.Store.SearchDocuments(t.Context(), store.DocumentSearchRequest{Query: "nebula"})
+ requirements.NoError(err)
+ embedder := &searchQueryEmbedder{vector: []float32{1, 0, 0}}
+ backend := &searchBackend{hits: []Hit{{Token: fixture.claims[0].Token, Score: .9, Rank: 1}}}
+ service := NewSearchService(SearchDeps{Ledger: fixture.store.Store, Embedder: embedder, Backend: backend})
+
+ response, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "nebula", SearchMode: string(SearchModeLexical), CandidateLimit: 10,
+ })
+
+ requirements.NoError(err)
+ requirements.Len(response.Results, len(direct.Results))
+ for index := range direct.Results {
+ assertions.Equal(direct.Results[index].OccurrenceKey, response.Results[index].OccurrenceKey)
+ assertions.Equal(direct.Results[index].MatchedSignals, response.Results[index].MatchedSignals)
+ assertions.Equal(direct.Results[index].Rank, response.Results[index].Rank)
+ }
+ assertions.Empty(embedder.queries)
+ assertions.Empty(backend.searches)
+}
+
+func TestSearchServiceLexicalUsesStoreCursorWithoutExhaustingCandidates(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ fixture := seedSemanticSearch(t, "paged lexical evidence")
+ for index := range 100 {
+ messageID := fixture.store.CreateMessage(fmt.Sprintf("lexical-page-%03d", index))
+ attachmentID := addSemanticSearchAttachment(
+ t, fixture.store, messageID, fixture.claims[0].CanonicalBlobHash,
+ fmt.Sprintf("paged-%03d.pdf", index), fmt.Sprintf("provider:paged-%03d", index),
+ )
+ _, eligible, err := fixture.store.Store.ReconcileDocumentOccurrence(t.Context(), attachmentID, int64(index+2))
+ requirements.NoError(err)
+ requirements.True(eligible)
+ }
+ ledger := &countingSearchLedger{Store: fixture.store.Store}
+ service := NewSearchService(SearchDeps{Ledger: ledger})
+ request := store.DocumentSearchRequest{
+ Query: "paged", SearchMode: string(SearchModeLexical), CandidateLimit: 101, PageSize: 1,
+ }
+
+ first, err := service.Search(t.Context(), request)
+ requirements.NoError(err)
+ requirements.Len(first.Results, 1)
+ requirements.NotEmpty(first.NextCursor)
+ assertions.Equal(1, ledger.searchCalls)
+ assertions.Equal(1, first.Results[0].Rank)
+ assertions.Equal(1, first.Results[0].LexicalRank)
+
+ request.Cursor = first.NextCursor
+ second, err := service.Search(t.Context(), request)
+ requirements.NoError(err)
+ requirements.Len(second.Results, 1)
+ assertions.Equal(2, ledger.searchCalls)
+ assertions.Equal(2, second.Results[0].Rank)
+ assertions.Equal(2, second.Results[0].LexicalRank)
+ assertions.NotEqual(first.Results[0].OccurrenceKey, second.Results[0].OccurrenceKey)
+}
+
+func TestSearchServiceExplicitSemanticModesDoNotFallbackAfterProviderAttempt(t *testing.T) {
+ fixture := seedSemanticSearch(t, "nebula evidence")
+ providerErr := errors.New("synthetic provider unavailable")
+ service := NewSearchService(SearchDeps{
+ Ledger: fixture.store.Store, Embedder: &searchQueryEmbedder{err: providerErr}, Backend: &searchBackend{},
+ })
+
+ _, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "nebula", SearchMode: string(SearchModeHybrid), CandidateLimit: 10,
+ })
+ require.ErrorIs(t, err, providerErr)
+
+ backendErr := errors.New("synthetic backend unavailable")
+ service = NewSearchService(SearchDeps{
+ Ledger: fixture.store.Store, Embedder: &searchQueryEmbedder{vector: []float32{1, 0, 0}},
+ Backend: &searchBackend{err: backendErr},
+ })
+ _, err = service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "nebula", SearchMode: string(SearchModeSemantic), CandidateLimit: 10,
+ })
+ require.ErrorIs(t, err, backendErr)
+}
+
+func TestSearchServiceRejectsInvalidQueryVectorBeforeBackendWork(t *testing.T) {
+ fixture := seedSemanticSearch(t, "vector evidence")
+ tests := []struct {
+ name string
+ vector []float32
+ wantErr error
+ }{
+ {name: "dimension", vector: []float32{1, 0}, wantErr: vector.ErrDimensionMismatch},
+ {name: "zero norm", vector: []float32{0, 0, 0}, wantErr: ErrInvalidVector},
+ {name: "nonfinite", vector: []float32{1, float32(math.NaN()), 0}, wantErr: ErrInvalidVector},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ backend := &searchBackend{}
+ service := NewSearchService(SearchDeps{
+ Ledger: fixture.store.Store, Embedder: &searchQueryEmbedder{vector: test.vector}, Backend: backend,
+ })
+ _, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "vector", SearchMode: string(SearchModeSemantic), CandidateLimit: 10,
+ })
+ require.ErrorIs(t, err, test.wantErr)
+ assert.Empty(t, backend.searches)
+ })
+ }
+}
+
+func TestSearchServiceCursorBindsFixedCandidatesAndChecksRevisionBeforeEmbedding(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ fixture := seedSemanticSearch(t, "cursor evidence")
+ copyMessageID := fixture.store.CreateMessage("semantic-cursor-copy")
+ copyAttachmentID := addSemanticSearchAttachment(
+ t, fixture.store, copyMessageID, fixture.claims[0].CanonicalBlobHash, "cursor-copy.pdf", "provider:cursor-copy",
+ )
+ _, eligible, err := fixture.store.Store.ReconcileDocumentOccurrence(t.Context(), copyAttachmentID, 2)
+ requirements.NoError(err)
+ requirements.True(eligible)
+ embedder := &searchQueryEmbedder{vector: []float32{1, 0, 0}}
+ backend := &searchBackend{hits: []Hit{{Token: fixture.claims[0].Token, Score: .9, Rank: 1}}}
+ service := NewSearchService(SearchDeps{Ledger: fixture.store.Store, Embedder: embedder, Backend: backend})
+ request := store.DocumentSearchRequest{
+ Query: "cursor", SearchMode: string(SearchModeSemantic), CandidateLimit: 10, PageSize: 1,
+ }
+
+ first, err := service.Search(t.Context(), request)
+ requirements.NoError(err)
+ requirements.Len(first.Results, 1)
+ requirements.NotEmpty(first.NextCursor)
+ providerCalls := len(embedder.queries)
+ _, err = service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "different", SearchMode: string(SearchModeSemantic), CandidateLimit: 10,
+ PageSize: 1, Cursor: first.NextCursor,
+ })
+ requirements.ErrorIs(err, store.ErrDocumentSearchInvalidCursor)
+ assertions.Len(embedder.queries, providerCalls, "mismatched request must fail before provider work")
+
+ request.Cursor = first.NextCursor
+ second, err := service.Search(t.Context(), request)
+ requirements.NoError(err)
+ requirements.Len(second.Results, 1)
+ assertions.NotEqual(first.Results[0].OccurrenceKey, second.Results[0].OccurrenceKey)
+ assertions.Equal(2, second.Results[0].Rank)
+ assertions.Empty(second.NextCursor)
+
+ request.Cursor = first.NextCursor
+ backend.hits[0].Score = .7
+ _, err = service.Search(t.Context(), request)
+ requirements.ErrorIs(err, store.ErrDocumentSearchCursorStale)
+
+ backend.hits[0].Score = .9
+ fresh, err := service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "cursor", SearchMode: string(SearchModeSemantic), CandidateLimit: 10, PageSize: 1,
+ })
+ requirements.NoError(err)
+ providerCalls = len(embedder.queries)
+ thirdMessageID := fixture.store.CreateMessage("semantic-cursor-revision")
+ thirdAttachmentID := addSemanticSearchAttachment(
+ t, fixture.store, thirdMessageID, fixture.claims[0].CanonicalBlobHash, "third.pdf", "provider:third",
+ )
+ _, eligible, err = fixture.store.Store.ReconcileDocumentOccurrence(t.Context(), thirdAttachmentID, 3)
+ requirements.NoError(err)
+ requirements.True(eligible)
+ _, err = service.Search(t.Context(), store.DocumentSearchRequest{
+ Query: "cursor", SearchMode: string(SearchModeSemantic), CandidateLimit: 10,
+ PageSize: 1, Cursor: fresh.NextCursor,
+ })
+ requirements.ErrorIs(err, store.ErrDocumentSearchCursorStale)
+ assertions.Len(embedder.queries, providerCalls, "stale revision must fail before provider work")
+}
+
+func TestSearchCursorRejectsOffsetBeyondCandidateSet(t *testing.T) {
+ cursor, err := encodeSearchCursor(searchCursor{
+ Version: searchCursorVersion, RequestHash: strings.Repeat("a", 64), Revision: 1,
+ EffectiveMode: string(SearchModeLexical), CandidateLimit: 2,
+ CandidateDigest: strings.Repeat("b", 64), Offset: 3,
+ })
+ require.NoError(t, err)
+
+ _, _, err = validateSearchCursor(cursor, strings.Repeat("a", 64), 1, SearchModeLexical, 0, "", 2)
+ require.ErrorIs(t, err, store.ErrDocumentSearchInvalidCursor)
+}
+
+type semanticSearchFixture struct {
+ store *storetest.Fixture
+ generation store.DocumentVectorGeneration
+ claims []store.DocumentVectorChunkClaim
+}
+
+func seedSemanticSearch(t *testing.T, chunks ...string) semanticSearchFixture {
+ t.Helper()
+ requirements := require.New(t)
+ normalizationPolicy, err := docbankdocument.NewNormalizePolicy(1)
+ requirements.NoError(err)
+ fixture := storetest.New(t)
+ profileFingerprint := strings.Repeat("a", 64)
+ profile := store.DocumentExtractionProfile{
+ ID: "profile-" + profileFingerprint, Fingerprint: profileFingerprint,
+ Provider: "mistral", Endpoint: "https://api.example.invalid/v1/ocr", Region: "test",
+ Model: "ocr-test", RetentionPosture: "standard", TrainingPosture: "opted-out",
+ AllowedMediaTypes: []string{"application/pdf"}, PolicyJSON: []byte(`{"policy":1}`),
+ }
+ _, err = fixture.Store.EnsureDocumentExtractionProfile(t.Context(), profile)
+ requirements.NoError(err)
+ requirements.NoError(fixture.Store.RecordDocumentProviderConsent(t.Context(), store.DocumentProviderConsent{
+ ProfileID: profile.ID, ProfileFingerprint: profile.Fingerprint,
+ RetentionPosture: profile.RetentionPosture, TrainingPosture: profile.TrainingPosture,
+ }))
+ messageID := fixture.CreateMessage("semantic-search-document")
+ hash := strings.Repeat("b", 64)
+ attachmentID := addSemanticSearchAttachment(t, fixture, messageID, hash, "semantic.pdf", "mime:1")
+ occurrence, eligible, err := fixture.Store.ReconcileDocumentOccurrence(t.Context(), attachmentID, 1)
+ requirements.NoError(err)
+ requirements.True(eligible)
+ claim, err := fixture.Store.ClaimDocumentExtraction(t.Context(), store.DocumentExtractionClaimInput{
+ ExtractionID: "semantic-extraction", ProfileID: profile.ID, CanonicalBlobHash: hash,
+ ExtractionInputKey: "original", OccurrenceAttachmentID: occurrence.AttachmentID,
+ OccurrenceMIMEType: occurrence.MIMEType, OccurrenceMessageType: "email",
+ LeaseOwner: "semantic-extractor", LeaseUntil: time.Now().UTC().Add(time.Minute),
+ LocalBytes: 128, SourceSequence: occurrence.SourceSequence,
+ })
+ requirements.NoError(err)
+ unitText := strings.Join(chunks, " ")
+ publication := store.DocumentExtractionPublication{
+ ExtractionID: claim.ExtractionID, ProfileID: claim.ProfileID,
+ CanonicalBlobHash: claim.CanonicalBlobHash, ExtractionInputKey: claim.ExtractionInputKey,
+ OccurrenceAttachmentID: claim.OccurrenceAttachmentID, OccurrenceMIMEType: claim.OccurrenceMIMEType,
+ OccurrenceMessageType: claim.OccurrenceMessageType, LeaseOwner: claim.LeaseOwner, LeaseFence: claim.LeaseFence,
+ ReturnedModel: profile.Model, UnitsProcessed: 1, RequestCount: 1,
+ ManifestChecksum: strings.Repeat("c", 64), NormalizationVersion: normalizationPolicy.Identity().Version,
+ DocumentFamily: "pdf", UnitKind: "page",
+ Units: []store.DocumentPublishedUnit{{
+ Index: 0, Kind: "page", Text: unitText, Checksum: strings.Repeat("d", 64), CharCount: len([]rune(unitText)),
+ }},
+ }
+ offset := 0
+ for index, text := range chunks {
+ length := len([]rune(text))
+ publication.Chunks = append(publication.Chunks, store.DocumentPublishedChunk{
+ Key: fmt.Sprintf("chunk-%d", index), Ordinal: index, Text: text,
+ FirstUnitIndex: 0, LastUnitIndex: 0, Checksum: fmt.Sprintf("%064x", index+1),
+ CharCount: length,
+ Spans: []store.DocumentPublishedSpan{{UnitIndex: 0, CharStart: offset, CharEnd: offset + length}},
+ })
+ offset += length + 1
+ }
+ requirements.NoError(fixture.Store.PublishDocumentExtraction(t.Context(), publication))
+ generation, _, err := fixture.Store.EnsureDocumentVectorGeneration(t.Context(), store.DocumentVectorGenerationSpec{
+ Fingerprint: strings.Repeat("f", 64), TargetExtractionProfileID: profile.ID,
+ EmbeddingProfile: "vector.embeddings", Model: "embed-test", Dimension: 3,
+ })
+ requirements.NoError(err)
+ now := time.Date(2026, time.August, 20, 12, 0, 0, 0, time.UTC)
+ claims := make([]store.DocumentVectorChunkClaim, 0, len(chunks))
+ for range chunks {
+ vectorClaim, claimErr := fixture.Store.ClaimDocumentVectorChunk(
+ t.Context(), generation.ID, 0, 1000, "semantic-worker", now, time.Minute,
+ )
+ requirements.NoError(claimErr)
+ requirements.NotNil(vectorClaim)
+ requirements.NoError(fixture.Store.CommitDocumentVectorPublication(
+ t.Context(), generation.ID, vectorClaim.Token, vectorClaim.LeaseOwner,
+ vectorClaim.LeaseFence, now.Add(time.Second),
+ ))
+ claims = append(claims, *vectorClaim)
+ }
+ requirements.NoError(fixture.Store.ActivateDocumentVectorGeneration(t.Context(), generation.ID, now.Add(2*time.Second)))
+ return semanticSearchFixture{store: fixture, generation: generation, claims: claims}
+}
+
+func addSemanticSearchAttachment(
+ t *testing.T, fixture *storetest.Fixture, messageID int64, hash, filename, sourcePartKey string,
+) int64 {
+ t.Helper()
+ require.NoError(t, fixture.Store.UpsertAttachmentRecord(t.Context(), messageID, store.AttachmentWrite{
+ Filename: filename, MIMEType: "application/pdf", Size: 128,
+ StoragePath: hash[:2] + "/" + hash, ContentHash: hash,
+ Role: store.AttachmentRoleStandalone, RoleSource: store.AttachmentRoleSourceImporterSemantics,
+ SourcePartKey: sourcePartKey,
+ }))
+ var attachmentID int64
+ require.NoError(t, fixture.Store.DB().QueryRow(fixture.Store.Rebind(
+ `SELECT id FROM attachments WHERE message_id = ?`), messageID).Scan(&attachmentID))
+ return attachmentID
+}
+
+type searchQueryEmbedder struct {
+ vector []float32
+ err error
+ queries []string
+}
+
+func (e *searchQueryEmbedder) EmbedQuery(_ context.Context, query string) ([]float32, error) {
+ e.queries = append(e.queries, query)
+ return e.vector, e.err
+}
+
+type searchBackendCall struct {
+ generationID GenerationID
+ dimension int
+ query []float32
+ k int
+}
+
+type searchBackend struct {
+ hits []Hit
+ err error
+ searches []searchBackendCall
+}
+
+func (*searchBackend) PutUnpublished(context.Context, GenerationID, int, []Embedding) error {
+ return nil
+}
+func (*searchBackend) DeleteTokens(context.Context, GenerationID, []string) error { return nil }
+func (b *searchBackend) Search(
+ _ context.Context, generationID GenerationID, dimension int, query []float32, k int,
+) ([]Hit, error) {
+ b.searches = append(b.searches, searchBackendCall{
+ generationID: generationID, dimension: dimension, query: append([]float32(nil), query...), k: k,
+ })
+ return append([]Hit(nil), b.hits...), b.err
+}
+
+func (b *searchBackend) SearchPage(
+ _ context.Context, generationID GenerationID, dimension int, query []float32, cursor string, k int,
+) (HitPage, error) {
+ offset := 0
+ if cursor != "" {
+ var err error
+ offset, err = strconv.Atoi(cursor)
+ if err != nil {
+ return HitPage{}, fmt.Errorf("parse search backend cursor: %w", err)
+ }
+ }
+ b.searches = append(b.searches, searchBackendCall{
+ generationID: generationID, dimension: dimension, query: append([]float32(nil), query...), k: k,
+ })
+ end := min(offset+k, len(b.hits))
+ page := HitPage{Hits: append([]Hit(nil), b.hits[offset:end]...), Exhausted: end == len(b.hits)}
+ for index := range page.Hits {
+ page.Hits[index].Rank = offset + index + 1
+ }
+ if !page.Exhausted {
+ page.NextCursor = strconv.Itoa(end)
+ }
+ return page, b.err
+}
diff --git a/internal/vector/document/status.go b/internal/vector/document/status.go
new file mode 100644
index 000000000..07a03743e
--- /dev/null
+++ b/internal/vector/document/status.go
@@ -0,0 +1,45 @@
+package document
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "go.kenn.io/msgvault/internal/store"
+)
+
+type FailureDiagnostic = store.DocumentVectorFailureDiagnostic
+type GenerationStatus = store.DocumentVectorGenerationStatus
+type FailureResetResult = store.DocumentVectorFailureResetResult
+
+// StatusLedger is the authoritative inspection and manual-recovery seam.
+type StatusLedger interface {
+ GetDocumentVectorGenerationStatus(ctx context.Context, generationID int64, afterToken string, limit int) (store.DocumentVectorGenerationStatus, error)
+ ResetDocumentVectorFailures(ctx context.Context, generationID int64, afterToken string, limit int, now time.Time) (store.DocumentVectorFailureResetResult, error)
+}
+
+var _ StatusLedger = (*store.Store)(nil)
+
+// StatusService exposes bounded, non-PII generation diagnostics and explicit
+// failed-publication recovery without exposing store implementation details.
+type StatusService struct{ ledger StatusLedger }
+
+func NewStatusService(ledger StatusLedger) *StatusService { return &StatusService{ledger: ledger} }
+
+func (s *StatusService) Inspect(
+ ctx context.Context, generationID GenerationID, afterToken string, limit int,
+) (GenerationStatus, error) {
+ if s == nil || s.ledger == nil {
+ return GenerationStatus{}, errors.New("document vector status ledger is required")
+ }
+ return s.ledger.GetDocumentVectorGenerationStatus(ctx, int64(generationID), afterToken, limit)
+}
+
+func (s *StatusService) ResetFailures(
+ ctx context.Context, generationID GenerationID, afterToken string, limit int, now time.Time,
+) (FailureResetResult, error) {
+ if s == nil || s.ledger == nil {
+ return FailureResetResult{}, errors.New("document vector status ledger is required")
+ }
+ return s.ledger.ResetDocumentVectorFailures(ctx, int64(generationID), afterToken, limit, now)
+}
diff --git a/internal/vector/document/status_test.go b/internal/vector/document/status_test.go
new file mode 100644
index 000000000..8d63920d9
--- /dev/null
+++ b/internal/vector/document/status_test.go
@@ -0,0 +1,47 @@
+package document
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.kenn.io/msgvault/internal/store"
+)
+
+func TestStatusServiceInspectsAndResetsFailures(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ wantStatus := store.DocumentVectorGenerationStatus{
+ GenerationID: 7, State: store.DocumentVectorGenerationBuilding,
+ Pending: 2, Retryable: 1, Terminal: 3, ReadyLive: 4, Obsolete: 5, CleanupPending: 2,
+ }
+ wantReset := store.DocumentVectorFailureResetResult{Scanned: 3, Reset: 2, Exhausted: true}
+ ledger := &fakeDocumentVectorStatusLedger{status: wantStatus, reset: wantReset}
+ service := NewStatusService(ledger)
+
+ status, err := service.Inspect(t.Context(), 7, "", 25)
+ requirements.NoError(err)
+ assertions.Equal(wantStatus, status)
+ resetAt := time.Date(2026, time.August, 20, 23, 30, 0, 987654000, time.UTC)
+ reset, err := service.ResetFailures(t.Context(), 7, "", 25, resetAt)
+ requirements.NoError(err)
+ assertions.Equal(wantReset, reset)
+ assertions.Equal(resetAt, ledger.resetAt)
+}
+
+type fakeDocumentVectorStatusLedger struct {
+ status store.DocumentVectorGenerationStatus
+ reset store.DocumentVectorFailureResetResult
+ resetAt time.Time
+}
+
+func (l *fakeDocumentVectorStatusLedger) GetDocumentVectorGenerationStatus(context.Context, int64, string, int) (store.DocumentVectorGenerationStatus, error) {
+ return l.status, nil
+}
+
+func (l *fakeDocumentVectorStatusLedger) ResetDocumentVectorFailures(_ context.Context, _ int64, _ string, _ int, now time.Time) (store.DocumentVectorFailureResetResult, error) {
+ l.resetAt = now
+ return l.reset, nil
+}
diff --git a/internal/vector/document/worker.go b/internal/vector/document/worker.go
new file mode 100644
index 000000000..eca628e67
--- /dev/null
+++ b/internal/vector/document/worker.go
@@ -0,0 +1,650 @@
+package document
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ docbankdocument "go.kenn.io/docbank/document"
+ docembedding "go.kenn.io/docbank/document/embedding"
+ "go.kenn.io/msgvault/internal/store"
+ "go.kenn.io/msgvault/internal/vector"
+)
+
+const (
+ maxWorkerRunLimit = 1000
+ maxWorkerRetryDelay = 7 * 24 * time.Hour
+)
+
+var (
+ errInvalidProviderShape = vector.ErrInvalidProviderShape
+ errInvalidProviderVector = vector.ErrInvalidProviderVector
+ errInputPreparation = errors.New("document embedding input preparation failed")
+ errBackendPut = errors.New("document vector backend put failed")
+)
+
+// Ledger is the authoritative publication surface used by Worker.
+// *store.Store satisfies it.
+type Ledger interface {
+ GetDocumentVectorGeneration(ctx context.Context, id int64) (store.DocumentVectorGeneration, error)
+ ListDocumentVectorChunkCandidates(ctx context.Context, generationID, afterChunkID int64, limit int) ([]store.DocumentVectorChunkCandidate, error)
+ ClaimDocumentVectorChunk(ctx context.Context, generationID, afterChunkID int64, scanLimit int, owner string, now time.Time, leaseDuration time.Duration) (*store.DocumentVectorChunkClaim, error)
+ RenewDocumentVectorChunkClaim(ctx context.Context, generationID int64, token, owner string, fence int64, now time.Time, leaseDuration time.Duration) (time.Time, error)
+ CommitDocumentVectorPublication(ctx context.Context, generationID int64, token, owner string, fence int64, now time.Time) error
+ FailDocumentVectorChunk(ctx context.Context, generationID int64, token, owner string, fence int64, now time.Time, nextRetryAt *time.Time, terminal bool, errorCode string) error
+}
+
+var _ Ledger = (*store.Store)(nil)
+
+// WorkerDeps are the bounded collaborators and policy values for Worker.
+type WorkerDeps struct {
+ Ledger Ledger
+ Provider Provider
+ Backend Backend
+
+ Owner string
+ Dimension int
+ MaxInputChars int
+ LeaseDuration time.Duration
+ HeartbeatInterval time.Duration
+ RetryDelay time.Duration
+ MaxAttempts int
+ Recipe docembedding.Recipe
+ // ContextualDocuments keeps each durable extraction chunk in its own stable
+ // provider document. A worker batch must never redefine contextual scope.
+ ContextualDocuments bool
+ // AfterGenerationID and AfterChunkID restore the bounded scan cursor reported
+ // by a prior run of the same generation. Task 6b may persist this pair; Worker
+ // also carries it across sequential runs and resets it when generations change.
+ AfterGenerationID GenerationID
+ AfterChunkID int64
+ Now func() time.Time
+ prepareInputs func(context.Context, Ledger, docembedding.Recipe, []*store.DocumentVectorChunkClaim) (map[string]string, error)
+}
+
+// RunResult reports only locally observable accounting. Provider token usage
+// is deliberately absent because EmbedDocuments does not report it.
+type RunResult struct {
+ Claimed int `json:"claimed"`
+ Embedded int `json:"embedded"`
+ Published int `json:"published"`
+ Retry int `json:"retry"`
+ Terminal int `json:"terminal"`
+ SourceChanged int `json:"source_changed"`
+
+ ProviderCalls int `json:"provider_calls"`
+ ProviderDocuments int `json:"provider_documents"`
+ ProviderChunks int `json:"provider_chunks"`
+ ProviderInputChars int `json:"provider_input_chars"`
+
+ AfterGenerationID GenerationID `json:"after_generation_id,omitempty"`
+ AfterChunkID int64 `json:"after_chunk_id,omitempty"`
+ Exhausted bool `json:"exhausted"`
+}
+
+// Worker publishes one bounded page of a building document-vector generation.
+// A Worker is safe for sequential use.
+type Worker struct {
+ deps WorkerDeps
+ cursorGenerationID GenerationID
+ afterChunkID int64
+}
+
+// workerClaimHeartbeat owns every live claim from provider dispatch through
+// backend publication. releaseForTransition serializes the final renewal and
+// ownership removal against periodic renewal before Commit or Fail begins.
+type workerClaimHeartbeat struct {
+ worker *Worker
+ generationID GenerationID
+ ctx context.Context
+ cancel context.CancelFunc
+ stopCh chan struct{}
+ doneCh chan struct{}
+ stopOnce sync.Once
+
+ mu sync.Mutex
+ active map[string]*store.DocumentVectorChunkClaim
+ runErr error
+}
+
+func newWorkerClaimHeartbeat(
+ ctx context.Context, worker *Worker, generationID GenerationID, claims []*store.DocumentVectorChunkClaim,
+) *workerClaimHeartbeat {
+ workCtx, cancel := context.WithCancel(ctx)
+ heartbeat := &workerClaimHeartbeat{
+ worker: worker, generationID: generationID, ctx: workCtx, cancel: cancel,
+ stopCh: make(chan struct{}), doneCh: make(chan struct{}),
+ active: make(map[string]*store.DocumentVectorChunkClaim, len(claims)),
+ }
+ for _, claim := range claims {
+ heartbeat.active[claim.Token] = claim
+ }
+ go heartbeat.run(ctx)
+ return heartbeat
+}
+
+func (h *workerClaimHeartbeat) context() context.Context { return h.ctx }
+
+func (h *workerClaimHeartbeat) err() error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.runErr
+}
+
+func (h *workerClaimHeartbeat) releaseForTransition(claim *store.DocumentVectorChunkClaim) error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.runErr != nil {
+ return h.runErr
+ }
+ if err := h.ctx.Err(); err != nil {
+ return err
+ }
+ if _, ok := h.active[claim.Token]; !ok {
+ return fmt.Errorf("document vector claim %q is no longer heartbeat-owned", claim.Token)
+ }
+ if _, err := h.worker.deps.Ledger.RenewDocumentVectorChunkClaim(
+ h.ctx, int64(h.generationID), claim.Token, claim.LeaseOwner, claim.LeaseFence,
+ h.worker.deps.Now(), h.worker.deps.LeaseDuration,
+ ); err != nil {
+ h.failLocked(claim.Token, err)
+ return h.runErr
+ }
+ delete(h.active, claim.Token)
+ return nil
+}
+
+func (h *workerClaimHeartbeat) run(parent context.Context) {
+ defer close(h.doneCh)
+ ticker := time.NewTicker(h.worker.deps.HeartbeatInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-parent.Done():
+ h.mu.Lock()
+ if h.runErr == nil {
+ h.runErr = parent.Err()
+ }
+ h.cancel()
+ h.mu.Unlock()
+ return
+ case <-h.stopCh:
+ return
+ case <-ticker.C:
+ h.mu.Lock()
+ for _, claim := range h.active {
+ _, err := h.worker.deps.Ledger.RenewDocumentVectorChunkClaim(
+ h.ctx, int64(h.generationID), claim.Token, claim.LeaseOwner,
+ claim.LeaseFence, h.worker.deps.Now(), h.worker.deps.LeaseDuration,
+ )
+ if err != nil {
+ h.failLocked(claim.Token, err)
+ h.mu.Unlock()
+ return
+ }
+ }
+ h.mu.Unlock()
+ }
+ }
+}
+
+func (h *workerClaimHeartbeat) failLocked(token string, err error) {
+ if h.runErr == nil {
+ h.runErr = fmt.Errorf("renew document vector claim %q: %w", token, err)
+ }
+ h.cancel()
+}
+
+func (h *workerClaimHeartbeat) stop() {
+ h.stopOnce.Do(func() { close(h.stopCh) })
+ <-h.doneCh
+ h.cancel()
+}
+
+func NewWorker(deps WorkerDeps) *Worker {
+ if deps.Now == nil {
+ deps.Now = func() time.Time { return time.Now().UTC() }
+ }
+ if deps.Recipe.Fingerprint() == "" && deps.MaxInputChars > 0 {
+ deps.Recipe, _ = docembedding.NewRecipe(docembedding.RecipeConfig{
+ Mode: docembedding.RepresentationRaw, MaxInputRunes: deps.MaxInputChars,
+ })
+ }
+ if deps.prepareInputs == nil {
+ deps.prepareInputs = prepareDocbankClaimInputs
+ }
+ return &Worker{
+ deps: deps,
+ cursorGenerationID: deps.AfterGenerationID,
+ afterChunkID: deps.AfterChunkID,
+ }
+}
+
+func (w *Worker) Run(ctx context.Context, generationID GenerationID, limit int) (RunResult, error) {
+ var result RunResult
+ if limit < 1 || limit > maxWorkerRunLimit {
+ return result, fmt.Errorf("document vector worker limit must be between 1 and %d", maxWorkerRunLimit)
+ }
+ if err := w.validate(generationID); err != nil {
+ return result, err
+ }
+ generation, err := w.deps.Ledger.GetDocumentVectorGeneration(ctx, int64(generationID))
+ if err != nil {
+ return result, fmt.Errorf("read document vector generation: %w", err)
+ }
+ if generation.ID != int64(generationID) || generation.State != store.DocumentVectorGenerationBuilding {
+ return result, store.ErrDocumentVectorInvalidGenerationState
+ }
+ if generation.Dimension != w.deps.Dimension {
+ return result, fmt.Errorf("document vector worker dimension %d does not match generation dimension %d", w.deps.Dimension, generation.Dimension)
+ }
+ w.bindCursor(generationID)
+ result.AfterGenerationID = generationID
+
+ claims, err := w.collectClaims(ctx, generationID, limit, &result)
+ if err != nil || len(claims) == 0 {
+ return result, err
+ }
+ heartbeat := newWorkerClaimHeartbeat(ctx, w, generationID, claims)
+ defer heartbeat.stop()
+ preparedTexts := make(map[string]string, len(claims))
+ preparedClaims := make([]*store.DocumentVectorChunkClaim, 0, len(claims))
+ var runErr error
+ for _, claimGroup := range groupWorkerClaimsByExtraction(claims) {
+ groupTexts, prepareErr := w.deps.prepareInputs(
+ heartbeat.context(), w.deps.Ledger, w.deps.Recipe, claimGroup,
+ )
+ if prepareErr != nil {
+ cause := fmt.Errorf("%w: extraction %q: %w", errInputPreparation, claimGroup[0].ExtractionID, prepareErr)
+ failureErr := w.failClaims(ctx, generationID, claimGroup, cause, heartbeat, &result)
+ runErr = errors.Join(runErr, cause, failureErr)
+ continue
+ }
+ for _, claim := range claimGroup {
+ text, ok := groupTexts[claim.Token]
+ if !ok {
+ cause := fmt.Errorf("%w: extraction %q omitted token %q", errInputPreparation, claim.ExtractionID, claim.Token)
+ failureErr := w.failClaims(ctx, generationID, []*store.DocumentVectorChunkClaim{claim}, cause, heartbeat, &result)
+ runErr = errors.Join(runErr, cause, failureErr)
+ continue
+ }
+ preparedTexts[claim.Token] = text
+ preparedClaims = append(preparedClaims, claim)
+ }
+ }
+ if len(preparedClaims) == 0 {
+ return result, errors.Join(runErr, heartbeat.err())
+ }
+ groups, inputs := groupWorkerClaims(preparedClaims, preparedTexts, w.deps.ContextualDocuments)
+ result.ProviderCalls = 1
+ result.ProviderDocuments = len(inputs)
+ for _, input := range inputs {
+ result.ProviderChunks += len(input.Chunks)
+ for _, text := range input.Chunks {
+ result.ProviderInputChars += utf8.RuneCountInString(text)
+ }
+ }
+
+ vectors, providerErr := w.deps.Provider.EmbedDocuments(heartbeat.context(), inputs)
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return result, ctxErr
+ }
+ if heartbeatErr := heartbeat.err(); heartbeatErr != nil {
+ return result, heartbeatErr
+ }
+ outcomes, responseErr := validateWorkerProviderDocuments(inputs, vectors, providerErr, w.deps.Dimension)
+ var embeddings []Embedding
+ var completedClaims []*store.DocumentVectorChunkClaim
+ for documentIndex, outcome := range outcomes {
+ if outcome.err != nil {
+ continue
+ }
+ for chunkIndex, vector := range outcome.vectors {
+ claim := groups[documentIndex][chunkIndex]
+ embeddings = append(embeddings, Embedding{Token: claim.Token, Vector: vector})
+ completedClaims = append(completedClaims, claim)
+ }
+ }
+ result.Embedded = len(completedClaims)
+ if len(embeddings) > 0 {
+ if err := w.deps.Backend.PutUnpublished(heartbeat.context(), generationID, w.deps.Dimension, embeddings); err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return result, ctxErr
+ }
+ if heartbeatErr := heartbeat.err(); heartbeatErr != nil {
+ return result, heartbeatErr
+ }
+ putErr := fmt.Errorf("%w: %w", errBackendPut, err)
+ failureErr := w.failClaims(ctx, generationID, completedClaims, putErr, heartbeat, &result)
+ runErr = errors.Join(runErr, fmt.Errorf("put unpublished document vectors: %w", err), failureErr)
+ } else {
+ for _, claim := range completedClaims {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return result, errors.Join(runErr, ctxErr)
+ }
+ if err := heartbeat.releaseForTransition(claim); err != nil {
+ runErr = errors.Join(runErr, err)
+ break
+ }
+ err := w.deps.Ledger.CommitDocumentVectorPublication(heartbeat.context(), int64(generationID), claim.Token, claim.LeaseOwner, claim.LeaseFence, w.deps.Now())
+ switch {
+ case err == nil:
+ result.Published++
+ case errors.Is(err, store.ErrDocumentVectorSourceChanged):
+ result.SourceChanged++
+ if deleteErr := w.deps.Backend.DeleteTokens(heartbeat.context(), generationID, []string{claim.Token}); deleteErr != nil {
+ runErr = errors.Join(runErr, fmt.Errorf("delete changed document vector token: %w", deleteErr))
+ }
+ default:
+ runErr = errors.Join(runErr, err)
+ }
+ }
+ }
+ }
+ for documentIndex, outcome := range outcomes {
+ if outcome.err == nil {
+ continue
+ }
+ failureErr := w.failClaims(ctx, generationID, groups[documentIndex], outcome.err, heartbeat, &result)
+ runErr = errors.Join(runErr, outcome.err, failureErr)
+ }
+ runErr = errors.Join(runErr, responseErr)
+ runErr = errors.Join(runErr, heartbeat.err())
+ return result, runErr
+}
+
+func (w *Worker) validate(generationID GenerationID) error {
+ if generationID <= 0 {
+ return errors.New("document vector worker generation must be positive")
+ }
+ if w.deps.Ledger == nil || w.deps.Provider == nil || w.deps.Backend == nil {
+ return errors.New("document vector worker ledger, provider, and backend are required")
+ }
+ if strings.TrimSpace(w.deps.Owner) == "" || w.deps.Dimension <= 0 || w.deps.MaxInputChars <= 0 ||
+ w.deps.LeaseDuration <= 0 || w.deps.HeartbeatInterval <= 0 ||
+ w.deps.HeartbeatInterval >= w.deps.LeaseDuration || w.deps.RetryDelay <= 0 ||
+ w.deps.RetryDelay > maxWorkerRetryDelay ||
+ w.deps.MaxAttempts <= 0 || w.deps.AfterGenerationID < 0 || w.deps.AfterChunkID < 0 ||
+ (w.deps.AfterGenerationID == 0) != (w.deps.AfterChunkID == 0) {
+ return errors.New("document vector worker policy is invalid")
+ }
+ if w.deps.Recipe.Fingerprint() == "" || w.deps.Recipe.Values().Mode != docembedding.RepresentationRaw {
+ return errors.New("document vector worker requires a valid raw Docbank embedding recipe")
+ }
+ return nil
+}
+
+type normalizedDocumentLedger interface {
+ LoadNormalizedDocument(ctx context.Context, extractionID string) (docbankdocument.NormalizedDocument, error)
+}
+
+func prepareDocbankClaimInputs(
+ ctx context.Context, ledger Ledger, recipe docembedding.Recipe,
+ claims []*store.DocumentVectorChunkClaim,
+) (map[string]string, error) {
+ source, ok := ledger.(normalizedDocumentLedger)
+ if !ok {
+ return nil, errors.New("document vector ledger cannot load normalized documents")
+ }
+ inputsByToken := make(map[string]string, len(claims))
+ plans := make(map[string]map[string]docembedding.EmbeddingInput)
+ for _, claim := range claims {
+ byChunk := plans[claim.ExtractionID]
+ if byChunk == nil {
+ normalized, err := source.LoadNormalizedDocument(ctx, claim.ExtractionID)
+ if err != nil {
+ return nil, fmt.Errorf("load normalized document for embedding: %w", err)
+ }
+ plan, err := docembedding.BuildEmbeddingPlan(
+ normalized, docembedding.DocumentContext{}, recipe, nil,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("build document embedding plan: %w", err)
+ }
+ byChunk = make(map[string]docembedding.EmbeddingInput, len(plan.Inputs))
+ for _, input := range plan.Inputs {
+ if input.Kind != docembedding.RepresentationKindRaw || len(input.SourceRefs) != 1 {
+ return nil, errors.New("raw document embedding plan returned an invalid input")
+ }
+ byChunk[input.SourceRefs[0].ChunkKey] = input
+ }
+ plans[claim.ExtractionID] = byChunk
+ }
+ input, ok := byChunk[claim.ChunkKey]
+ if !ok || len(input.SourceRefs) != 1 || input.SourceRefs[0].ChunkChecksum != claim.ChunkChecksum {
+ return nil, fmt.Errorf("document embedding plan does not match claimed chunk %q", claim.ChunkKey)
+ }
+ inputsByToken[claim.Token] = input.Text
+ }
+ return inputsByToken, nil
+}
+
+func (w *Worker) bindCursor(generationID GenerationID) {
+ if w.cursorGenerationID != 0 && w.cursorGenerationID != generationID {
+ w.afterChunkID = 0
+ }
+ w.cursorGenerationID = generationID
+}
+
+func (w *Worker) collectClaims(ctx context.Context, generationID GenerationID, limit int, result *RunResult) ([]*store.DocumentVectorChunkClaim, error) {
+ claims := make([]*store.DocumentVectorChunkClaim, 0, limit)
+ after := w.afterChunkID
+ result.AfterChunkID = after
+ candidates, err := w.deps.Ledger.ListDocumentVectorChunkCandidates(ctx, int64(generationID), after, maxWorkerRunLimit)
+ if err != nil {
+ return nil, fmt.Errorf("list document vector candidates: %w", err)
+ }
+ if len(candidates) == 0 {
+ result.Exhausted = true
+ w.resetCursor(result)
+ return claims, nil
+ }
+ processed := 0
+ for _, candidate := range candidates {
+ processed++
+ if candidate.ChunkID <= after {
+ continue
+ }
+ claim, err := w.deps.Ledger.ClaimDocumentVectorChunk(ctx, int64(generationID), after, 1, w.deps.Owner, w.deps.Now(), w.deps.LeaseDuration)
+ if err != nil {
+ return nil, fmt.Errorf("claim document vector chunk: %w", err)
+ }
+ after = candidate.ChunkID
+ if claim != nil {
+ claims = append(claims, claim)
+ result.Claimed++
+ if claim.ChunkID > after {
+ after = claim.ChunkID
+ }
+ }
+ result.AfterChunkID = after
+ if len(claims) == limit {
+ break
+ }
+ }
+ if processed == len(candidates) && len(candidates) < maxWorkerRunLimit {
+ result.Exhausted = true
+ w.resetCursor(result)
+ } else {
+ w.afterChunkID = after
+ }
+ return claims, nil
+}
+
+func (w *Worker) resetCursor(result *RunResult) {
+ w.cursorGenerationID = 0
+ w.afterChunkID = 0
+ result.AfterGenerationID = 0
+ result.AfterChunkID = 0
+}
+
+func groupWorkerClaims(
+ claims []*store.DocumentVectorChunkClaim, preparedTexts map[string]string, contextualDocuments bool,
+) ([][]*store.DocumentVectorChunkClaim, []vector.DocumentInput) {
+ if contextualDocuments {
+ groups := make([][]*store.DocumentVectorChunkClaim, len(claims))
+ inputs := make([]vector.DocumentInput, len(claims))
+ for index, claim := range claims {
+ groups[index] = []*store.DocumentVectorChunkClaim{claim}
+ inputs[index] = vector.DocumentInput{Chunks: []string{preparedTexts[claim.Token]}}
+ }
+ return groups, inputs
+ }
+ var groups [][]*store.DocumentVectorChunkClaim
+ var inputs []vector.DocumentInput
+ indices := make(map[string]int)
+ for _, claim := range claims {
+ index, ok := indices[claim.ExtractionID]
+ if !ok {
+ index = len(groups)
+ indices[claim.ExtractionID] = index
+ groups = append(groups, nil)
+ inputs = append(inputs, vector.DocumentInput{})
+ }
+ groups[index] = append(groups[index], claim)
+ inputs[index].Chunks = append(inputs[index].Chunks, preparedTexts[claim.Token])
+ }
+ return groups, inputs
+}
+
+func groupWorkerClaimsByExtraction(claims []*store.DocumentVectorChunkClaim) [][]*store.DocumentVectorChunkClaim {
+ groups := make([][]*store.DocumentVectorChunkClaim, 0)
+ indices := make(map[string]int)
+ for _, claim := range claims {
+ index, ok := indices[claim.ExtractionID]
+ if !ok {
+ index = len(groups)
+ indices[claim.ExtractionID] = index
+ groups = append(groups, nil)
+ }
+ groups[index] = append(groups[index], claim)
+ }
+ return groups
+}
+
+func (w *Worker) failClaims(
+ ctx context.Context,
+ generationID GenerationID,
+ claims []*store.DocumentVectorChunkClaim,
+ cause error,
+ heartbeat *workerClaimHeartbeat,
+ result *RunResult,
+) error {
+ var failureErr error
+ for _, claim := range claims {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return errors.Join(failureErr, ctxErr)
+ }
+ if err := heartbeat.releaseForTransition(claim); err != nil {
+ return errors.Join(failureErr, err)
+ }
+ now := w.deps.Now()
+ terminal, code := workerFailureDisposition(cause, claim.AttemptCount, w.deps.MaxAttempts)
+ var retryAt *time.Time
+ if !terminal {
+ deadline := now.Add(w.deps.RetryDelay)
+ retryAt = &deadline
+ }
+ err := w.deps.Ledger.FailDocumentVectorChunk(
+ heartbeat.context(), int64(generationID), claim.Token, claim.LeaseOwner, claim.LeaseFence,
+ now, retryAt, terminal, code,
+ )
+ if err != nil {
+ failureErr = errors.Join(failureErr, err)
+ continue
+ }
+ if terminal {
+ result.Terminal++
+ } else {
+ result.Retry++
+ }
+ }
+ return failureErr
+}
+
+func workerFailureDisposition(cause error, attemptCount, maxAttempts int) (bool, string) {
+ if attemptCount >= maxAttempts {
+ return true, "attempt_limit"
+ }
+ switch {
+ case errors.Is(cause, errInvalidProviderShape):
+ return true, "invalid_provider_shape"
+ case errors.Is(cause, errInvalidProviderVector):
+ return true, "invalid_provider_vector"
+ case errors.Is(cause, vector.ErrPermanent4xx):
+ return true, "provider_rejected"
+ case errors.Is(cause, errInputPreparation):
+ return false, "input_preparation"
+ case errors.Is(cause, errBackendPut):
+ return false, "backend_transient"
+ default:
+ return false, "provider_transient"
+ }
+}
+
+type workerProviderDocumentOutcome struct {
+ vectors [][]float32
+ err error
+}
+
+func validateWorkerProviderDocuments(
+ inputs []vector.DocumentInput, vectors [][][]float32, providerErr error, dimension int,
+) ([]workerProviderDocumentOutcome, error) {
+ outcomes := make([]workerProviderDocumentOutcome, len(inputs))
+ var responseErr error
+ if len(vectors) > len(inputs) {
+ responseErr = fmt.Errorf("%w: document count got %d, expected at most %d", errInvalidProviderShape, len(vectors), len(inputs))
+ }
+ for documentIndex := range inputs {
+ if documentIndex >= len(vectors) {
+ if providerErr != nil {
+ outcomes[documentIndex].err = providerErr
+ } else {
+ outcomes[documentIndex].err = fmt.Errorf(
+ "%w: document count got %d, expected %d", errInvalidProviderShape, len(vectors), len(inputs),
+ )
+ }
+ continue
+ }
+ documentVectors := vectors[documentIndex]
+ if len(documentVectors) != len(inputs[documentIndex].Chunks) {
+ outcomes[documentIndex].err = fmt.Errorf(
+ "%w: document %d chunk count got %d, expected %d",
+ errInvalidProviderShape, documentIndex, len(documentVectors), len(inputs[documentIndex].Chunks),
+ )
+ continue
+ }
+ for _, embedding := range documentVectors {
+ if vectorErr := validateWorkerProviderVector(embedding, dimension); vectorErr != nil {
+ outcomes[documentIndex].err = vectorErr
+ break
+ }
+ }
+ if outcomes[documentIndex].err == nil {
+ outcomes[documentIndex].vectors = documentVectors
+ }
+ }
+ return outcomes, errors.Join(responseErr, providerErr)
+}
+
+func validateWorkerProviderVector(embedding []float32, dimension int) error {
+ if len(embedding) != dimension {
+ return fmt.Errorf("%w: dimension got %d, expected %d", errInvalidProviderVector, len(embedding), dimension)
+ }
+ var squaredNorm float64
+ for _, value := range embedding {
+ if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
+ return fmt.Errorf("%w: nonfinite component", errInvalidProviderVector)
+ }
+ squaredNorm += float64(value) * float64(value)
+ }
+ if squaredNorm == 0 {
+ return fmt.Errorf("%w: zero norm", errInvalidProviderVector)
+ }
+ return nil
+}
diff --git a/internal/vector/document/worker_test.go b/internal/vector/document/worker_test.go
new file mode 100644
index 000000000..5297ac7c3
--- /dev/null
+++ b/internal/vector/document/worker_test.go
@@ -0,0 +1,1151 @@
+package document
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+ "unicode/utf8"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ docbankdocument "go.kenn.io/docbank/document"
+ docembedding "go.kenn.io/docbank/document/embedding"
+ "go.kenn.io/msgvault/internal/store"
+ "go.kenn.io/msgvault/internal/vector/embed"
+)
+
+var _ Provider = (embed.SemanticClient)(nil)
+
+func TestPrepareDocbankClaimInputsUsesValidatedRawPlan(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+ policy, err := docbankdocument.NewNormalizePolicy(10_000)
+ require.NoError(err)
+ normalized, err := docbankdocument.NormalizeDocument(docbankdocument.SourceDocument{
+ Family: "pdf", UnitKind: "page", Units: []docbankdocument.SourceUnit{{
+ Index: 0, Markdown: "# Findings\n\nImportant evidence",
+ }},
+ }, policy)
+ require.NoError(err)
+ recipe, err := docembedding.NewRecipe(docembedding.RecipeConfig{
+ Mode: docembedding.RepresentationRaw, MaxInputRunes: 1000,
+ })
+ require.NoError(err)
+ claim := workerClaim("extract-a", 1, normalized.Chunks[0].Text, strings.Repeat("a", 64))
+ claim.ChunkKey = normalized.Chunks[0].Key
+ claim.ChunkChecksum = normalized.Chunks[0].Checksum
+ ledger := &normalizedFakeDocumentVectorLedger{
+ fakeDocumentVectorLedger: newFakeDocumentVectorLedger(claim), normalized: normalized,
+ }
+
+ inputs, err := prepareDocbankClaimInputs(t.Context(), ledger, recipe, []*store.DocumentVectorChunkClaim{claim})
+
+ require.NoError(err)
+ assert.Contains(inputs[claim.Token], "Heading: Findings")
+ assert.Contains(inputs[claim.Token], "Source: page 1")
+ assert.Contains(inputs[claim.Token], "Content:\n# Findings\nImportant evidence")
+}
+
+type normalizedFakeDocumentVectorLedger struct {
+ *fakeDocumentVectorLedger
+
+ normalized docbankdocument.NormalizedDocument
+}
+
+func (l *normalizedFakeDocumentVectorLedger) LoadNormalizedDocument(context.Context, string) (docbankdocument.NormalizedDocument, error) {
+ return l.normalized, nil
+}
+
+func TestWorkerRunRejectsInvalidLimit(t *testing.T) {
+ worker := NewWorker(WorkerDeps{})
+
+ for _, limit := range []int{0, 1001} {
+ _, err := worker.Run(t.Context(), 1, limit)
+ require.ErrorContains(t, err, "limit")
+ }
+}
+
+func TestWorkerRunRejectsInvalidGenerationAndDimension(t *testing.T) {
+ ledger := newFakeDocumentVectorLedger()
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{}, &fakeDocumentVectorBackend{})
+
+ _, err := worker.Run(t.Context(), 0, 1)
+ require.ErrorContains(t, err, "generation")
+
+ ledger.generation.State = store.DocumentVectorGenerationActive
+ _, err = worker.Run(t.Context(), 1, 1)
+ require.ErrorIs(t, err, store.ErrDocumentVectorInvalidGenerationState)
+
+ ledger.generation.State = store.DocumentVectorGenerationBuilding
+ ledger.generation.Dimension = 4
+ _, err = worker.Run(t.Context(), 1, 1)
+ require.ErrorContains(t, err, "dimension")
+}
+
+func TestWorkerRunRejectsUnboundedRetryAndIncompleteRestoredCursor(t *testing.T) {
+ ledger := newFakeDocumentVectorLedger()
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{}, &fakeDocumentVectorBackend{})
+ worker.deps.RetryDelay = 7*24*time.Hour + time.Millisecond
+
+ _, err := worker.Run(t.Context(), 1, 1)
+ require.ErrorContains(t, err, "policy")
+
+ worker = newFakeWorker(ledger, &fakeDocumentVectorProvider{}, &fakeDocumentVectorBackend{})
+ worker.deps.AfterChunkID = 12
+ worker.afterChunkID = 12
+ _, err = worker.Run(t.Context(), 1, 1)
+ require.ErrorContains(t, err, "policy")
+}
+
+func TestWorkerRunPreservesExactTextAndExtractionBoundaries(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(
+ workerClaim("extract-a", 1, " alpha\n", "token-a"),
+ workerClaim("extract-a", 2, "beta", "token-b"),
+ workerClaim("extract-b", 3, "γamma", "token-c"),
+ )
+ provider := &fakeDocumentVectorProvider{vectors: [][][]float32{
+ {{1, 2, 3}, {4, 5, 6}},
+ {{7, 8, 9}},
+ }}
+ provider.call = func(_ context.Context, _ []embed.DocumentInput) ([][][]float32, error) {
+ assert.Equal(t, 3, ledger.claimCalls, "all provider inputs must be claimed first")
+ return provider.vectors, nil
+ }
+ backend := &fakeDocumentVectorBackend{}
+ ledger.beforeCommit = func(testedToken string) {
+ assert.NotEmpty(t, backend.puts, "backend put must precede fenced commit")
+ assert.Contains(t, embeddingTokens(backend.puts[0]), testedToken)
+ }
+ worker := newFakeWorker(ledger, provider, backend)
+
+ result, err := worker.Run(t.Context(), 1, 4)
+ requirements.NoError(err)
+ requirements.Len(provider.calls, 1)
+ assertions.Equal([]embed.DocumentInput{
+ {Chunks: []string{" alpha\n", "beta"}},
+ {Chunks: []string{"γamma"}},
+ }, provider.calls[0])
+ assertions.Equal(3, result.Claimed)
+ assertions.Equal(3, result.Embedded)
+ assertions.Equal(3, result.Published)
+ assertions.Equal(1, result.ProviderCalls)
+ assertions.Equal(2, result.ProviderDocuments)
+ assertions.Equal(3, result.ProviderChunks)
+ assertions.Equal(utf8.RuneCountInString(" alpha\n")+utf8.RuneCountInString("beta")+utf8.RuneCountInString("γamma"),
+ result.ProviderInputChars,
+ )
+ assertions.True(result.Exhausted)
+ assertions.Zero(result.AfterGenerationID)
+ assertions.Zero(result.AfterChunkID)
+ requirements.Len(backend.puts, 1)
+ assertions.Equal([]string{"token-a", "token-b", "token-c"}, embeddingTokens(backend.puts[0]))
+ assertions.Equal([]string{"token-a", "token-b", "token-c"}, ledger.committed)
+}
+
+func TestWorkerRunCapsProviderInputOnRuneBoundary(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(
+ workerClaim("extract-a", 1, "αβγδε", "token-a"),
+ )
+ provider := &fakeDocumentVectorProvider{vectors: [][][]float32{{{1, 2, 3}}}}
+ worker := newFakeWorker(ledger, provider, &fakeDocumentVectorBackend{})
+ worker.deps.MaxInputChars = 3
+ recipe, err := docembedding.NewRecipe(docembedding.RecipeConfig{
+ Mode: docembedding.RepresentationRaw, MaxInputRunes: 3,
+ })
+ requirements.NoError(err)
+ worker.deps.Recipe = recipe
+
+ result, err := worker.Run(t.Context(), 1, 1)
+ requirements.NoError(err)
+ requirements.Len(provider.calls, 1)
+ assertions.Equal([]embed.DocumentInput{{Chunks: []string{"αβγ"}}}, provider.calls[0])
+ assertions.Equal(3, result.ProviderInputChars)
+}
+
+func TestWorkerRunNeverClaimsMoreThanLimit(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(
+ workerClaim("extract-a", 1, "one", "token-a"),
+ workerClaim("extract-a", 2, "two", "token-b"),
+ workerClaim("extract-a", 3, "three", "token-c"),
+ )
+ provider := &fakeDocumentVectorProvider{vectors: [][][]float32{{{1, 2, 3}, {4, 5, 6}}}}
+ worker := newFakeWorker(ledger, provider, &fakeDocumentVectorBackend{})
+
+ result, err := worker.Run(t.Context(), 1, 2)
+ requirements.NoError(err)
+ assertions.Equal(2, result.Claimed)
+ assertions.False(result.Exhausted)
+ assertions.Equal(2, ledger.claimCalls)
+ requirements.Len(provider.calls, 1)
+ assertions.Equal([]embed.DocumentInput{{Chunks: []string{"one", "two"}}}, provider.calls[0])
+}
+
+func TestWorkerRunContextualBoundariesDoNotDependOnLimit(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ claims := func() []*store.DocumentVectorChunkClaim {
+ return []*store.DocumentVectorChunkClaim{
+ workerClaim("extract-a", 1, "one", "token-a"),
+ workerClaim("extract-a", 2, "two", "token-b"),
+ workerClaim("extract-a", 3, "three", "token-c"),
+ }
+ }
+ wantInputs := []embed.DocumentInput{
+ {Chunks: []string{"one"}}, {Chunks: []string{"two"}}, {Chunks: []string{"three"}},
+ }
+
+ boundedProvider := &fakeDocumentVectorProvider{vectors: [][][]float32{{{1, 2, 3}}, {{4, 5, 6}}}}
+ boundedWorker := newFakeWorker(newFakeDocumentVectorLedger(claims()...), boundedProvider, &fakeDocumentVectorBackend{})
+ boundedWorker.deps.ContextualDocuments = true
+ first, err := boundedWorker.Run(t.Context(), 1, 2)
+ requirements.NoError(err)
+ requirements.False(first.Exhausted)
+ boundedProvider.vectors = [][][]float32{{{7, 8, 9}}}
+ second, err := boundedWorker.Run(t.Context(), 1, 2)
+ requirements.NoError(err)
+ requirements.True(second.Exhausted)
+
+ unboundedProvider := &fakeDocumentVectorProvider{vectors: [][][]float32{
+ {{1, 2, 3}}, {{4, 5, 6}}, {{7, 8, 9}},
+ }}
+ unboundedWorker := newFakeWorker(newFakeDocumentVectorLedger(claims()...), unboundedProvider, &fakeDocumentVectorBackend{})
+ unboundedWorker.deps.ContextualDocuments = true
+ _, err = unboundedWorker.Run(t.Context(), 1, 3)
+ requirements.NoError(err)
+
+ assertions.Equal(wantInputs, append(boundedProvider.calls[0], boundedProvider.calls[1]...))
+ assertions.Equal(wantInputs, unboundedProvider.calls[0])
+}
+
+func TestWorkerRunBoundsScanningAndCarriesCursorWithoutStarvation(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ claims := make([]*store.DocumentVectorChunkClaim, 1500)
+ for index := range claims {
+ claims[index] = workerClaim(
+ "extract-a", int64(index+1), fmt.Sprintf("chunk-%d", index+1), fmt.Sprintf("token-%d", index+1),
+ )
+ }
+ ledger := newFakeDocumentVectorLedger(claims...)
+ ledger.unclaimableThrough = 1499
+ provider := &fakeDocumentVectorProvider{vectors: [][][]float32{{{1, 2, 3}}}}
+ worker := newFakeWorker(ledger, provider, &fakeDocumentVectorBackend{})
+
+ first, err := worker.Run(t.Context(), 1, 1)
+ requirements.NoError(err)
+ assertions.Zero(first.Claimed)
+ assertions.Equal(int64(1000), first.AfterChunkID)
+ assertions.False(first.Exhausted)
+ assertions.Equal(1000, ledger.claimCalls)
+ assertions.Empty(provider.calls)
+
+ second, err := worker.Run(t.Context(), 1, 1)
+ requirements.NoError(err)
+ assertions.Equal(1, second.Claimed)
+ assertions.Equal(1, second.Published)
+ assertions.True(second.Exhausted)
+ assertions.Zero(second.AfterGenerationID)
+ assertions.Zero(second.AfterChunkID)
+ assertions.Equal(1500, ledger.claimCalls)
+ requirements.Len(provider.calls, 1)
+ assertions.Equal([]embed.DocumentInput{{Chunks: []string{"chunk-1500"}}}, provider.calls[0])
+}
+
+func TestWorkerRunExhaustedCursorRoundTripsAsReset(t *testing.T) {
+ tests := []struct {
+ name string
+ claims []*store.DocumentVectorChunkClaim
+ unclaimable int64
+ wantClaimCalls int
+ }{
+ {name: "empty corpus"},
+ {
+ name: "nonempty tail",
+ claims: []*store.DocumentVectorChunkClaim{workerClaim("extract-a", 1, "leased", "token-a")},
+ unclaimable: 1,
+ wantClaimCalls: 2,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(test.claims...)
+ ledger.unclaimableThrough = test.unclaimable
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{}, &fakeDocumentVectorBackend{})
+
+ first, err := worker.Run(t.Context(), 1, 1)
+ requirements.NoError(err)
+ requirements.True(first.Exhausted)
+
+ resumeDeps := worker.deps
+ resumeDeps.AfterGenerationID = first.AfterGenerationID
+ resumeDeps.AfterChunkID = first.AfterChunkID
+ resumed := NewWorker(resumeDeps)
+ second, err := resumed.Run(t.Context(), 1, 1)
+ requirements.NoError(err)
+ assertions.True(second.Exhausted)
+ assertions.Zero(first.AfterGenerationID)
+ assertions.Zero(first.AfterChunkID)
+ assertions.Zero(second.AfterGenerationID)
+ assertions.Zero(second.AfterChunkID)
+ assertions.Equal(test.wantClaimCalls, ledger.claimCalls)
+ })
+ }
+}
+
+func TestWorkerRunResetsInMemoryCursorWhenGenerationChanges(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ claims := make([]*store.DocumentVectorChunkClaim, 1000)
+ for index := range claims {
+ claims[index] = workerClaim("extract-a", int64(index+1), "old", fmt.Sprintf("old-%d", index+1))
+ }
+ ledger := newFakeDocumentVectorLedger(claims...)
+ ledger.unclaimableThrough = 1000
+ provider := &fakeDocumentVectorProvider{}
+ worker := newFakeWorker(ledger, provider, &fakeDocumentVectorBackend{})
+
+ first, err := worker.Run(t.Context(), 1, 1)
+ requirements.NoError(err)
+ assertions.Equal(GenerationID(1), first.AfterGenerationID)
+ assertions.Equal(int64(1000), first.AfterChunkID)
+
+ ledger.generation.ID = 2
+ ledger.unclaimableThrough = 0
+ newClaim := workerClaim("extract-b", 1, "new generation", "new-token")
+ newClaim.GenerationID = 2
+ nextClaim := workerClaim("extract-b", 2, "next", "next-token")
+ nextClaim.GenerationID = 2
+ ledger.claims = []*store.DocumentVectorChunkClaim{newClaim, nextClaim}
+ provider.vectors = [][][]float32{{{1, 2, 3}}}
+ second, err := worker.Run(t.Context(), 2, 1)
+ requirements.NoError(err)
+ assertions.Equal(1, second.Published)
+ assertions.Equal(GenerationID(2), second.AfterGenerationID)
+ assertions.Equal(int64(1), second.AfterChunkID)
+ assertions.False(second.Exhausted)
+ requirements.Len(provider.calls, 1)
+ assertions.Equal([]embed.DocumentInput{{Chunks: []string{"new generation"}}}, provider.calls[0])
+}
+
+func TestWorkerRunPublishesCompletedProviderPrefixAndRetriesOnlySuffix(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ providerErr := errors.New("provider unavailable")
+ ledger := newFakeDocumentVectorLedger(
+ workerClaim("extract-a", 1, "first", "token-a"),
+ workerClaim("extract-b", 2, "second", "token-b"),
+ )
+ provider := &fakeDocumentVectorProvider{
+ vectors: [][][]float32{{{1, 2, 3}}},
+ err: providerErr,
+ }
+ worker := newFakeWorker(ledger, provider, &fakeDocumentVectorBackend{})
+
+ result, err := worker.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, providerErr)
+ assertions.Equal(1, result.Embedded)
+ assertions.Equal(1, result.Published)
+ assertions.Equal(1, result.Retry)
+ assertions.Zero(result.Terminal)
+ assertions.Equal([]string{"token-a"}, ledger.committed)
+ requirements.Len(ledger.failures, 1)
+ assertions.Equal("token-b", ledger.failures[0].token)
+ assertions.False(ledger.failures[0].terminal)
+ assertions.Equal("provider_transient", ledger.failures[0].errorCode)
+ requirements.NotNil(ledger.failures[0].retryAt)
+ assertions.Equal(workerNow.Add(time.Minute), *ledger.failures[0].retryAt)
+}
+
+func TestWorkerRunMakesPermanentAndMalformedProviderFailuresTerminal(t *testing.T) {
+ tests := []struct {
+ name string
+ vectors [][][]float32
+ err error
+ wantCode string
+ }{
+ {
+ name: "permanent provider rejection",
+ err: fmt.Errorf("request rejected: %w", embed.ErrPermanent4xx),
+ wantCode: "provider_rejected",
+ },
+ {
+ name: "document cardinality",
+ vectors: [][][]float32{{{1, 2, 3}}, {{4, 5, 6}}},
+ wantCode: "invalid_provider_shape",
+ },
+ {
+ name: "chunk cardinality",
+ vectors: [][][]float32{{{1, 2, 3}}},
+ wantCode: "invalid_provider_shape",
+ },
+ {
+ name: "vector dimension",
+ vectors: [][][]float32{{{1, 2}, {4, 5, 6}}},
+ wantCode: "invalid_provider_vector",
+ },
+ {
+ name: "nonfinite vector",
+ vectors: [][][]float32{{{1, float32(math.NaN()), 3}, {4, 5, 6}}},
+ wantCode: "invalid_provider_vector",
+ },
+ {
+ name: "zero norm vector",
+ vectors: [][][]float32{{{0, 0, 0}, {4, 5, 6}}},
+ wantCode: "invalid_provider_vector",
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(
+ workerClaim("extract-a", 1, "first", "token-a"),
+ workerClaim("extract-a", 2, "second", "token-b"),
+ )
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{
+ vectors: test.vectors, err: test.err,
+ }, &fakeDocumentVectorBackend{})
+
+ result, err := worker.Run(t.Context(), 1, 10)
+ requirements.Error(err)
+ assertions.Zero(result.Published)
+ assertions.Equal(2, result.Terminal)
+ requirements.Len(ledger.failures, 2)
+ assertions.Equal(test.wantCode, ledger.failures[0].errorCode)
+ assertions.Equal(test.wantCode, ledger.failures[1].errorCode)
+ assertions.True(ledger.failures[0].terminal)
+ assertions.Nil(ledger.failures[0].retryAt)
+ })
+ }
+}
+
+func TestWorkerRunMakesMalformedHTTPProviderResponsesTerminal(t *testing.T) {
+ tests := []struct {
+ name string
+ response string
+ wantCode string
+ }{
+ {
+ name: "response shape",
+ response: `{"data":[]}`,
+ wantCode: "invalid_provider_shape",
+ },
+ {
+ name: "vector dimension",
+ response: `{"data":[{"embedding":[1,2],"index":0}]}`,
+ wantCode: "invalid_provider_vector",
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, err := w.Write([]byte(test.response))
+ assertions.NoError(err)
+ }))
+ t.Cleanup(server.Close)
+ ledger := newFakeDocumentVectorLedger(workerClaim("extract-a", 1, "first", "token-a"))
+ client := embed.NewClient(embed.Config{
+ Endpoint: server.URL, Model: "embed-test", Dimension: 3, MaxRetries: 1,
+ })
+ worker := newFakeWorker(ledger, client, &fakeDocumentVectorBackend{})
+
+ result, err := worker.Run(t.Context(), 1, 1)
+
+ requirements.Error(err)
+ assertions.Equal(1, result.Terminal)
+ assertions.Zero(result.Retry)
+ requirements.Len(ledger.failures, 1)
+ assertions.True(ledger.failures[0].terminal)
+ assertions.Equal(test.wantCode, ledger.failures[0].errorCode)
+ })
+ }
+}
+
+func TestWorkerRunIsolatesMalformedProviderDocumentFromHealthySuffix(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(
+ workerClaim("extract-a", 1, "first", "token-a"),
+ workerClaim("extract-b", 2, "second", "token-b"),
+ workerClaim("extract-c", 3, "third", "token-c"),
+ )
+ backend := &fakeDocumentVectorBackend{}
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{vectors: [][][]float32{
+ {{1, 2, 3}}, {{4, 5}}, {{7, 8, 9}},
+ }}, backend)
+
+ result, err := worker.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, errInvalidProviderVector)
+ assertions.Equal(2, result.Embedded)
+ assertions.Equal(2, result.Published)
+ assertions.Equal(1, result.Terminal)
+ assertions.Equal([]string{"token-a", "token-c"}, ledger.committed)
+ requirements.Len(ledger.failures, 1)
+ assertions.Equal("token-b", ledger.failures[0].token)
+ assertions.Equal("invalid_provider_vector", ledger.failures[0].errorCode)
+ requirements.Len(backend.puts, 1)
+ assertions.Equal([]string{"token-a", "token-c"}, embeddingTokens(backend.puts[0]))
+}
+
+func TestWorkerRunRecordsPreparationFailureAndPublishesHealthyExtraction(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(
+ workerClaim("extract-a", 1, "first", "token-a"),
+ workerClaim("extract-b", 2, "second", "token-b"),
+ )
+ provider := &fakeDocumentVectorProvider{vectors: [][][]float32{{{1, 2, 3}}}}
+ worker := newFakeWorker(ledger, provider, &fakeDocumentVectorBackend{})
+ worker.deps.prepareInputs = func(_ context.Context, _ Ledger, _ docembedding.Recipe, claims []*store.DocumentVectorChunkClaim) (map[string]string, error) {
+ if claims[0].ExtractionID == "extract-b" {
+ return nil, errors.New("normalized document is corrupt")
+ }
+ return map[string]string{claims[0].Token: claims[0].Text}, nil
+ }
+
+ result, err := worker.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, errInputPreparation)
+ assertions.Equal(1, result.Published)
+ assertions.Equal(1, result.Retry)
+ assertions.Zero(result.Terminal)
+ assertions.Equal([]string{"token-a"}, ledger.committed)
+ requirements.Len(ledger.failures, 1)
+ assertions.Equal("token-b", ledger.failures[0].token)
+ assertions.Equal("input_preparation", ledger.failures[0].errorCode)
+ requirements.Len(provider.calls, 1)
+ assertions.Equal([]embed.DocumentInput{{Chunks: []string{"first"}}}, provider.calls[0])
+}
+
+func TestWorkerRunPreparationFailureHonorsAttemptCeiling(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ claim := workerClaim("extract-a", 1, "first", "token-a")
+ claim.AttemptCount = 3
+ ledger := newFakeDocumentVectorLedger(claim)
+ provider := &fakeDocumentVectorProvider{}
+ worker := newFakeWorker(ledger, provider, &fakeDocumentVectorBackend{})
+ worker.deps.prepareInputs = func(context.Context, Ledger, docembedding.Recipe, []*store.DocumentVectorChunkClaim) (map[string]string, error) {
+ return nil, errors.New("normalized document is corrupt")
+ }
+
+ result, err := worker.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, errInputPreparation)
+ assertions.Zero(result.Published)
+ assertions.Zero(result.Retry)
+ assertions.Equal(1, result.Terminal)
+ requirements.Len(ledger.failures, 1)
+ assertions.Equal("attempt_limit", ledger.failures[0].errorCode)
+ assertions.Empty(provider.calls)
+}
+
+func TestWorkerRunAttemptCeilingMakesTransientFailureTerminal(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ claim := workerClaim("extract-a", 1, "first", "token-a")
+ claim.AttemptCount = 3
+ ledger := newFakeDocumentVectorLedger(claim)
+ providerErr := errors.New("provider unavailable")
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{err: providerErr}, &fakeDocumentVectorBackend{})
+
+ result, err := worker.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, providerErr)
+ assertions.Zero(result.Retry)
+ assertions.Equal(1, result.Terminal)
+ requirements.Len(ledger.failures, 1)
+ assertions.Equal("attempt_limit", ledger.failures[0].errorCode)
+ assertions.True(ledger.failures[0].terminal)
+}
+
+func TestWorkerRunDeletesOnlySourceChangedTokens(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(
+ workerClaim("extract-a", 1, "first", "token-source"),
+ workerClaim("extract-b", 2, "second", "token-lost"),
+ )
+ ledger.commitErr["token-source"] = store.ErrDocumentVectorSourceChanged
+ ledger.commitErr["token-lost"] = store.ErrDocumentVectorClaimLost
+ provider := &fakeDocumentVectorProvider{vectors: [][][]float32{
+ {{1, 2, 3}}, {{4, 5, 6}},
+ }}
+ backend := &fakeDocumentVectorBackend{}
+ worker := newFakeWorker(ledger, provider, backend)
+
+ result, err := worker.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, store.ErrDocumentVectorClaimLost)
+ assertions.Zero(result.Published)
+ assertions.Equal(1, result.SourceChanged)
+ assertions.Equal([][]string{{"token-source"}}, backend.deletes)
+ assertions.NotContains(backend.deletes[0], "token-lost")
+}
+
+func TestWorkerRunContextInterruptionLeavesClaimsLeasedForTakeover(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(workerClaim("extract-a", 1, "first", "token-a"))
+ started := make(chan struct{})
+ provider := &fakeDocumentVectorProvider{call: func(ctx context.Context, _ []embed.DocumentInput) ([][][]float32, error) {
+ close(started)
+ <-ctx.Done()
+ return nil, ctx.Err()
+ }}
+ backend := &fakeDocumentVectorBackend{}
+ worker := newFakeWorker(ledger, provider, backend)
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan error, 1)
+ go func() {
+ _, err := worker.Run(ctx, 1, 1)
+ done <- err
+ }()
+ <-started
+ cancel()
+ requirements.ErrorIs(<-done, context.Canceled)
+ assertions.Empty(ledger.failures)
+ assertions.Empty(ledger.committed)
+ assertions.Empty(backend.puts)
+
+ takeover := workerClaim("extract-a", 1, "first", "token-a")
+ takeover.LeaseOwner = "worker-b"
+ takeover.LeaseFence = 2
+ takeover.AttemptCount = 2
+ ledger.claims = append(ledger.claims, takeover)
+ resumeProvider := &fakeDocumentVectorProvider{vectors: [][][]float32{{{1, 2, 3}}}}
+ resumer := newFakeWorker(ledger, resumeProvider, backend)
+ resumer.deps.Owner = "worker-b"
+ result, err := resumer.Run(t.Context(), 1, 1)
+ requirements.NoError(err)
+ assertions.Equal(1, result.Published)
+ assertions.Equal([]string{"token-a"}, ledger.committed)
+}
+
+func TestWorkerRunReplayDoesNotRepublishReadyWork(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(workerClaim("extract-a", 1, "first", "token-a"))
+ provider := &fakeDocumentVectorProvider{vectors: [][][]float32{{{1, 2, 3}}}}
+ backend := &fakeDocumentVectorBackend{}
+ worker := newFakeWorker(ledger, provider, backend)
+
+ first, err := worker.Run(t.Context(), 1, 10)
+ requirements.NoError(err)
+ second, err := worker.Run(t.Context(), 1, 10)
+ requirements.NoError(err)
+ assertions.Equal(1, first.Published)
+ assertions.Zero(second.Claimed)
+ assertions.True(second.Exhausted)
+ assertions.Len(provider.calls, 1)
+ assertions.Len(backend.puts, 1)
+ assertions.Equal([]string{"token-a"}, ledger.committed)
+}
+
+func TestWorkerRunRetriesBackendPutFailureWithoutCommitting(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(workerClaim("extract-a", 1, "first", "token-a"))
+ backendErr := errors.New("backend unavailable")
+ backend := &fakeDocumentVectorBackend{putErr: backendErr}
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{
+ vectors: [][][]float32{{{1, 2, 3}}},
+ }, backend)
+
+ result, err := worker.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, backendErr)
+ assertions.Equal(1, result.Embedded)
+ assertions.Zero(result.Published)
+ assertions.Equal(1, result.Retry)
+ assertions.Empty(ledger.committed)
+ requirements.Len(ledger.failures, 1)
+ assertions.Equal("backend_transient", ledger.failures[0].errorCode)
+}
+
+func TestWorkerRunClassifiesProviderSuffixWhenPrefixBackendPutFails(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(
+ workerClaim("extract-a", 1, "first", "token-a"),
+ workerClaim("extract-b", 2, "second", "token-b"),
+ )
+ providerErr := errors.New("provider unavailable")
+ backendErr := errors.New("backend unavailable")
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{
+ vectors: [][][]float32{{{1, 2, 3}}}, err: providerErr,
+ }, &fakeDocumentVectorBackend{putErr: backendErr})
+
+ result, err := worker.Run(t.Context(), 1, 10)
+ requirements.ErrorIs(err, providerErr)
+ requirements.ErrorIs(err, backendErr)
+ assertions.Equal(2, result.Retry)
+ requirements.Len(ledger.failures, 2)
+ assertions.Equal("token-a", ledger.failures[0].token)
+ assertions.Equal("backend_transient", ledger.failures[0].errorCode)
+ assertions.Equal("token-b", ledger.failures[1].token)
+ assertions.Equal("provider_transient", ledger.failures[1].errorCode)
+}
+
+func TestWorkerRunRenewsClaimsDuringLongProviderCall(t *testing.T) {
+ ledger := newFakeDocumentVectorLedger(workerClaim("extract-a", 1, "first", "token-a"))
+ ledger.renewed = make(chan struct{})
+ provider := &fakeDocumentVectorProvider{call: func(ctx context.Context, _ []embed.DocumentInput) ([][][]float32, error) {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-ledger.renewed:
+ return [][][]float32{{{1, 2, 3}}}, nil
+ case <-time.After(100 * time.Millisecond):
+ return nil, errors.New("heartbeat did not renew claim")
+ }
+ }}
+ worker := newFakeWorker(ledger, provider, &fakeDocumentVectorBackend{})
+ worker.deps.LeaseDuration = 100 * time.Millisecond
+ worker.deps.HeartbeatInterval = 10 * time.Millisecond
+
+ result, err := worker.Run(t.Context(), 1, 1)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result.Published)
+ assert.Positive(t, ledger.renewCallCount())
+}
+
+func TestWorkerRunRenewsClaimsDuringLongBackendPut(t *testing.T) {
+ ledger := newFakeDocumentVectorLedger(workerClaim("extract-a", 1, "first", "token-a"))
+ ledger.renewed = make(chan struct{})
+ backend := &fakeDocumentVectorBackend{put: func(ctx context.Context, _ []Embedding) error {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-ledger.renewed:
+ return nil
+ case <-time.After(100 * time.Millisecond):
+ return errors.New("heartbeat did not renew during backend put")
+ }
+ }}
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{
+ vectors: [][][]float32{{{1, 2, 3}}},
+ }, backend)
+ worker.deps.LeaseDuration = 100 * time.Millisecond
+ worker.deps.HeartbeatInterval = 10 * time.Millisecond
+
+ result, err := worker.Run(t.Context(), 1, 1)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result.Published)
+ assert.Positive(t, ledger.renewCallCount())
+}
+
+func TestWorkerRunCancelsBackendPutWhenHeartbeatLosesClaim(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(workerClaim("extract-a", 1, "first", "token-a"))
+ ledger.renewErr = store.ErrDocumentVectorClaimLost
+ backendCanceled := make(chan struct{})
+ backend := &fakeDocumentVectorBackend{put: func(ctx context.Context, _ []Embedding) error {
+ select {
+ case <-ctx.Done():
+ close(backendCanceled)
+ return ctx.Err()
+ case <-time.After(100 * time.Millisecond):
+ return errors.New("heartbeat did not cancel backend put")
+ }
+ }}
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{
+ vectors: [][][]float32{{{1, 2, 3}}},
+ }, backend)
+ worker.deps.LeaseDuration = 100 * time.Millisecond
+ worker.deps.HeartbeatInterval = 10 * time.Millisecond
+
+ result, err := worker.Run(t.Context(), 1, 1)
+ requirements.ErrorIs(err, store.ErrDocumentVectorClaimLost)
+ assertions.Zero(result.Published)
+ assertions.Zero(result.Retry)
+ assertions.Zero(result.Terminal)
+ assertions.Empty(ledger.committed)
+ assertions.Empty(ledger.failures)
+ assertions.Empty(backend.deletes)
+ select {
+ case <-backendCanceled:
+ default:
+ requirements.Fail("backend context was not canceled after renewal loss")
+ }
+}
+
+func TestWorkerRunStopsRenewingClaimBeforeCommitAfterSynchronousRenewal(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(workerClaim("extract-a", 1, "first", "token-a"))
+ commitStarted := make(chan struct{})
+ releaseCommit := make(chan struct{})
+ ledger.beforeCommit = func(string) {
+ close(commitStarted)
+ <-releaseCommit
+ }
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{
+ vectors: [][][]float32{{{1, 2, 3}}},
+ }, &fakeDocumentVectorBackend{})
+ worker.deps.LeaseDuration = 100 * time.Millisecond
+ worker.deps.HeartbeatInterval = 10 * time.Millisecond
+ type outcome struct {
+ result RunResult
+ err error
+ }
+ done := make(chan outcome, 1)
+ go func() {
+ result, err := worker.Run(t.Context(), 1, 1)
+ done <- outcome{result: result, err: err}
+ }()
+ select {
+ case <-commitStarted:
+ case <-time.After(time.Second):
+ requirements.FailNow("worker did not reach commit")
+ }
+ before := ledger.renewTokenCallCount("token-a")
+ time.Sleep(30 * time.Millisecond)
+ after := ledger.renewTokenCallCount("token-a")
+ close(releaseCommit)
+ completed := <-done
+ time.Sleep(30 * time.Millisecond)
+ afterStop := ledger.renewTokenCallCount("token-a")
+
+ requirements.NoError(completed.err)
+ assertions.Equal(1, completed.result.Published)
+ assertions.Equal(1, before, "claim is synchronously renewed before commit")
+ assertions.Equal(before, after, "heartbeat no longer owns a claim once commit begins")
+ assertions.Equal(after, afterStop, "worker shutdown leaves no heartbeat goroutine renewing claims")
+}
+
+func TestWorkerRunCancelsCommitWhenAnotherClaimLosesHeartbeat(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(
+ workerClaim("extract-a", 1, "first", "token-a"),
+ workerClaim("extract-b", 2, "second", "token-b"),
+ )
+ ledger.renewErrByToken["token-b"] = store.ErrDocumentVectorClaimLost
+ commitCanceled := make(chan struct{})
+ ledger.commit = func(ctx context.Context, token string) error {
+ if token != "token-a" {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ close(commitCanceled)
+ return ctx.Err()
+ case <-time.After(100 * time.Millisecond):
+ return errors.New("heartbeat loss did not cancel commit")
+ }
+ }
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{vectors: [][][]float32{
+ {{1, 2, 3}}, {{4, 5, 6}},
+ }}, &fakeDocumentVectorBackend{})
+ worker.deps.LeaseDuration = 100 * time.Millisecond
+ worker.deps.HeartbeatInterval = 10 * time.Millisecond
+
+ result, err := worker.Run(t.Context(), 1, 2)
+ requirements.ErrorIs(err, store.ErrDocumentVectorClaimLost)
+ assertions.Zero(result.Published)
+ assertions.Empty(ledger.committed)
+ assertions.Empty(ledger.failures)
+ select {
+ case <-commitCanceled:
+ default:
+ requirements.Fail("commit context was not canceled after another renewal loss")
+ }
+}
+
+func TestWorkerRunCancelsFailureTransitionWhenAnotherClaimLosesHeartbeat(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(
+ workerClaim("extract-a", 1, "first", "token-a"),
+ workerClaim("extract-b", 2, "second", "token-b"),
+ )
+ ledger.renewErrByToken["token-b"] = store.ErrDocumentVectorClaimLost
+ failureCanceled := make(chan struct{})
+ ledger.fail = func(ctx context.Context, token string) error {
+ if token != "token-a" {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ close(failureCanceled)
+ return ctx.Err()
+ case <-time.After(100 * time.Millisecond):
+ return errors.New("heartbeat loss did not cancel failure transition")
+ }
+ }
+ providerErr := errors.New("provider unavailable")
+ worker := newFakeWorker(ledger, &fakeDocumentVectorProvider{err: providerErr}, &fakeDocumentVectorBackend{})
+ worker.deps.LeaseDuration = 100 * time.Millisecond
+ worker.deps.HeartbeatInterval = 10 * time.Millisecond
+
+ result, err := worker.Run(t.Context(), 1, 2)
+ requirements.ErrorIs(err, store.ErrDocumentVectorClaimLost)
+ requirements.ErrorIs(err, providerErr)
+ assertions.Zero(result.Retry)
+ assertions.Zero(result.Terminal)
+ assertions.Empty(ledger.committed)
+ assertions.Empty(ledger.failures)
+ select {
+ case <-failureCanceled:
+ default:
+ requirements.Fail("failure context was not canceled after another renewal loss")
+ }
+}
+
+func TestWorkerRunCancelsPublicationWhenHeartbeatLosesClaim(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ ledger := newFakeDocumentVectorLedger(workerClaim("extract-a", 1, "first", "token-a"))
+ ledger.renewErr = store.ErrDocumentVectorClaimLost
+ providerCanceled := make(chan struct{})
+ provider := &fakeDocumentVectorProvider{call: func(ctx context.Context, _ []embed.DocumentInput) ([][][]float32, error) {
+ select {
+ case <-ctx.Done():
+ close(providerCanceled)
+ return nil, ctx.Err()
+ case <-time.After(100 * time.Millisecond):
+ return nil, errors.New("heartbeat did not cancel provider")
+ }
+ }}
+ backend := &fakeDocumentVectorBackend{}
+ worker := newFakeWorker(ledger, provider, backend)
+ worker.deps.LeaseDuration = 100 * time.Millisecond
+ worker.deps.HeartbeatInterval = 10 * time.Millisecond
+
+ result, err := worker.Run(t.Context(), 1, 1)
+ requirements.ErrorIs(err, store.ErrDocumentVectorClaimLost)
+ assertions.Zero(result.Embedded)
+ assertions.Empty(backend.puts)
+ assertions.Empty(backend.deletes)
+ assertions.Empty(ledger.committed)
+ assertions.Empty(ledger.failures)
+ select {
+ case <-providerCanceled:
+ default:
+ requirements.Fail("provider context was not canceled after renewal loss")
+ }
+}
+
+type fakeDocumentVectorLedger struct {
+ mu sync.Mutex
+ generation store.DocumentVectorGeneration
+ claims []*store.DocumentVectorChunkClaim
+ claimCalls int
+ committed []string
+ failures []fakeDocumentVectorFailure
+ commitErr map[string]error
+ commit func(context.Context, string) error
+ fail func(context.Context, string) error
+ beforeCommit func(string)
+ renewErr error
+ renewErrByToken map[string]error
+ renewCalls int
+ renewTokens map[string]int
+ renewed chan struct{}
+ renewOnce sync.Once
+ unclaimableThrough int64
+}
+
+type fakeDocumentVectorFailure struct {
+ token string
+ retryAt *time.Time
+ terminal bool
+ errorCode string
+}
+
+func newFakeDocumentVectorLedger(claims ...*store.DocumentVectorChunkClaim) *fakeDocumentVectorLedger {
+ return &fakeDocumentVectorLedger{
+ generation: store.DocumentVectorGeneration{
+ ID: 1, State: store.DocumentVectorGenerationBuilding,
+ DocumentVectorGenerationSpec: store.DocumentVectorGenerationSpec{Dimension: 3},
+ },
+ claims: claims, commitErr: map[string]error{}, renewTokens: map[string]int{},
+ renewErrByToken: map[string]error{},
+ }
+}
+
+func (l *fakeDocumentVectorLedger) GetDocumentVectorGeneration(context.Context, int64) (store.DocumentVectorGeneration, error) {
+ return l.generation, nil
+}
+
+func (l *fakeDocumentVectorLedger) ListDocumentVectorChunkCandidates(_ context.Context, _ int64, after int64, limit int) ([]store.DocumentVectorChunkCandidate, error) {
+ candidates := make([]store.DocumentVectorChunkCandidate, 0, min(limit, len(l.claims)))
+ for _, claim := range l.claims {
+ if claim.ChunkID > after {
+ candidates = append(candidates, claim.DocumentVectorChunkCandidate)
+ if len(candidates) == limit {
+ break
+ }
+ }
+ }
+ return candidates, nil
+}
+
+func (l *fakeDocumentVectorLedger) ClaimDocumentVectorChunk(_ context.Context, _ int64, after int64, _ int, _ string, _ time.Time, _ time.Duration) (*store.DocumentVectorChunkClaim, error) {
+ l.claimCalls++
+ for index, claim := range l.claims {
+ if claim.ChunkID <= after {
+ continue
+ }
+ if claim.ChunkID <= l.unclaimableThrough {
+ return nil, nil //nolint:nilnil // No claim is a valid ledger result.
+ }
+ l.claims = append(l.claims[:index], l.claims[index+1:]...)
+ return claim, nil
+ }
+ return nil, nil //nolint:nilnil // No claim is a valid ledger result.
+}
+
+func (l *fakeDocumentVectorLedger) RenewDocumentVectorChunkClaim(_ context.Context, _ int64, token, _ string, _ int64, _ time.Time, _ time.Duration) (time.Time, error) {
+ l.mu.Lock()
+ l.renewCalls++
+ l.renewTokens[token]++
+ err := l.renewErr
+ if tokenErr := l.renewErrByToken[token]; tokenErr != nil {
+ err = tokenErr
+ }
+ renewed := l.renewed
+ l.mu.Unlock()
+ if renewed != nil {
+ l.renewOnce.Do(func() { close(renewed) })
+ }
+ return workerNow.Add(time.Minute), err
+}
+
+func (l *fakeDocumentVectorLedger) renewTokenCallCount(token string) int {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.renewTokens[token]
+}
+
+func (l *fakeDocumentVectorLedger) renewCallCount() int {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.renewCalls
+}
+
+func (l *fakeDocumentVectorLedger) CommitDocumentVectorPublication(ctx context.Context, _ int64, token, _ string, _ int64, _ time.Time) error {
+ if l.beforeCommit != nil {
+ l.beforeCommit(token)
+ }
+ if l.commit != nil {
+ if err := l.commit(ctx, token); err != nil {
+ return err
+ }
+ }
+ if err := l.commitErr[token]; err != nil {
+ return err
+ }
+ l.committed = append(l.committed, token)
+ return nil
+}
+
+func (l *fakeDocumentVectorLedger) FailDocumentVectorChunk(ctx context.Context, _ int64, token, _ string, _ int64, _ time.Time, retryAt *time.Time, terminal bool, errorCode string) error {
+ if l.fail != nil {
+ if err := l.fail(ctx, token); err != nil {
+ return err
+ }
+ }
+ l.failures = append(l.failures, fakeDocumentVectorFailure{
+ token: token, retryAt: retryAt, terminal: terminal, errorCode: errorCode,
+ })
+ return nil
+}
+
+type fakeDocumentVectorProvider struct {
+ calls [][]embed.DocumentInput
+ vectors [][][]float32
+ err error
+ call func(context.Context, []embed.DocumentInput) ([][][]float32, error)
+}
+
+func (p *fakeDocumentVectorProvider) EmbedDocuments(ctx context.Context, inputs []embed.DocumentInput) ([][][]float32, error) {
+ cloned := make([]embed.DocumentInput, len(inputs))
+ for index := range inputs {
+ cloned[index].Chunks = append([]string(nil), inputs[index].Chunks...)
+ }
+ p.calls = append(p.calls, cloned)
+ if p.call != nil {
+ return p.call(ctx, inputs)
+ }
+ return p.vectors, p.err
+}
+
+type fakeDocumentVectorBackend struct {
+ puts [][]Embedding
+ deletes [][]string
+ putErr error
+ deleteErr error
+ put func(context.Context, []Embedding) error
+}
+
+func (b *fakeDocumentVectorBackend) PutUnpublished(ctx context.Context, _ GenerationID, _ int, embeddings []Embedding) error {
+ b.puts = append(b.puts, append([]Embedding(nil), embeddings...))
+ if b.put != nil {
+ return b.put(ctx, embeddings)
+ }
+ return b.putErr
+}
+
+func (b *fakeDocumentVectorBackend) DeleteTokens(_ context.Context, _ GenerationID, tokens []string) error {
+ b.deletes = append(b.deletes, append([]string(nil), tokens...))
+ return b.deleteErr
+}
+
+func (*fakeDocumentVectorBackend) Search(context.Context, GenerationID, int, []float32, int) ([]Hit, error) {
+ return nil, errors.New("not used")
+}
+
+var workerNow = time.Date(2026, 8, 20, 22, 0, 0, 0, time.UTC)
+
+func newFakeWorker(ledger *fakeDocumentVectorLedger, provider Provider, backend Backend) *Worker {
+ return NewWorker(WorkerDeps{
+ Ledger: ledger, Provider: provider, Backend: backend,
+ Owner: "worker-a", Dimension: 3, MaxInputChars: 1000, LeaseDuration: time.Minute,
+ HeartbeatInterval: 10 * time.Second, RetryDelay: time.Minute,
+ MaxAttempts: 3, Now: func() time.Time { return workerNow },
+ prepareInputs: func(_ context.Context, _ Ledger, recipe docembedding.Recipe, claims []*store.DocumentVectorChunkClaim) (map[string]string, error) {
+ inputs := make(map[string]string, len(claims))
+ for _, claim := range claims {
+ runes := []rune(claim.Text)
+ if len(runes) > recipe.Values().MaxInputRunes {
+ runes = runes[:recipe.Values().MaxInputRunes]
+ }
+ inputs[claim.Token] = string(runes)
+ }
+ return inputs, nil
+ },
+ })
+}
+
+func workerClaim(extractionID string, chunkID int64, text, token string) *store.DocumentVectorChunkClaim {
+ return &store.DocumentVectorChunkClaim{
+ GenerationID: 1, ExtractionID: extractionID, ChunkID: chunkID,
+ ChunkKey: token + "-key", Text: text,
+ Token: token, LeaseOwner: "worker-a", LeaseFence: 1,
+ LeaseUntil: time.Date(2026, 8, 20, 22, 1, 0, 0, time.UTC), AttemptCount: 1,
+ }
+}
+
+func embeddingTokens(embeddings []Embedding) []string {
+ tokens := make([]string, len(embeddings))
+ for index := range embeddings {
+ tokens[index] = embeddings[index].Token
+ }
+ return tokens
+}
diff --git a/internal/vector/embed/client.go b/internal/vector/embed/client.go
index 0472a2dff..ba63cc853 100644
--- a/internal/vector/embed/client.go
+++ b/internal/vector/embed/client.go
@@ -14,6 +14,8 @@ import (
"strconv"
"strings"
"time"
+
+ "go.kenn.io/msgvault/internal/vector"
)
// ErrPermanent4xx marks a non-retryable HTTP 4xx response from the
@@ -21,7 +23,7 @@ import (
// it; the error message still carries the status code and a bounded
// response body. 429 (rate-limited) and 5xx are NOT wrapped — they
// flow through the retry loop as transient errors.
-var ErrPermanent4xx = errors.New("embed: non-retryable 4xx response")
+var ErrPermanent4xx = vector.ErrPermanent4xx
// Config controls an embeddings Client. The zero value is not usable; callers
// must set Endpoint, Model, and Dimension at a minimum.
@@ -45,6 +47,9 @@ type Config struct {
// BeforeRequest reauthorizes each concrete HTTP attempt. A returned error
// is propagated without retrying. Nil leaves the client ungated.
BeforeRequest BeforeRequestFunc
+ // RejectRedirects prevents provider responses from replaying embedding input
+ // to another URL. BeforeRequest clients always reject redirects as well.
+ RejectRedirects bool
}
// Client calls an OpenAI-compatible /v1/embeddings endpoint.
@@ -61,7 +66,7 @@ func NewClient(cfg Config) *Client {
if cfg.MaxRetries == 0 {
cfg.MaxRetries = 3
}
- return &Client{cfg: cfg, http: newHTTPClient(cfg.Timeout, cfg.BeforeRequest)}
+ return &Client{cfg: cfg, http: newHTTPClient(cfg.Timeout, cfg.BeforeRequest, cfg.RejectRedirects)}
}
// embeddingRequest is the JSON body sent to the server.
@@ -149,7 +154,7 @@ func (c *Client) EmbedQuery(ctx context.Context, text string) ([]float32, error)
return nil, err
}
if len(vecs) != 1 {
- return nil, fmt.Errorf("embed query: expected exactly one vector, got %d", len(vecs))
+ return nil, fmt.Errorf("%w: embed query expected exactly one vector, got %d", vector.ErrInvalidProviderShape, len(vecs))
}
return vecs[0], nil
}
@@ -167,7 +172,7 @@ func (c *Client) EmbedDocuments(ctx context.Context, documents []DocumentInput)
return nil, err
}
if len(vecs) != len(inputs) {
- return nil, fmt.Errorf("embed documents: expected %d vectors, got %d", len(inputs), len(vecs))
+ return nil, fmt.Errorf("%w: embed documents expected %d vectors, got %d", vector.ErrInvalidProviderShape, len(inputs), len(vecs))
}
documentVecs := make([][][]float32, len(documents))
@@ -241,22 +246,22 @@ func (c *Client) doOnce(ctx context.Context, body []byte, want int) ([][]float32
return nil, &retryError{err: fmt.Errorf("decode response: %w", err)}
}
if len(r.Data) != want {
- return nil, fmt.Errorf("embed: response count mismatch: got %d, expected %d", len(r.Data), want)
+ return nil, fmt.Errorf("%w: embed response count mismatch: got %d, expected %d", vector.ErrInvalidProviderShape, len(r.Data), want)
}
vecs := make([][]float32, want)
for _, d := range r.Data {
if d.Index < 0 || d.Index >= want {
- return nil, fmt.Errorf("embed: invalid index %d (len=%d)", d.Index, want)
+ return nil, fmt.Errorf("%w: embed invalid index %d (len=%d)", vector.ErrInvalidProviderShape, d.Index, want)
}
if len(d.Embedding) != c.cfg.Dimension {
- return nil, fmt.Errorf("embed: dimension mismatch: got %d, configured %d",
- len(d.Embedding), c.cfg.Dimension)
+ return nil, fmt.Errorf("%w: embed dimension mismatch: got %d, configured %d",
+ vector.ErrInvalidProviderVector, len(d.Embedding), c.cfg.Dimension)
}
vecs[d.Index] = d.Embedding
}
for i, v := range vecs {
if v == nil {
- return nil, fmt.Errorf("embed: missing embedding at index %d", i)
+ return nil, fmt.Errorf("%w: embed missing embedding at index %d", vector.ErrInvalidProviderShape, i)
}
}
return vecs, nil
diff --git a/internal/vector/embed/document.go b/internal/vector/embed/document.go
index e58acdfc3..a796d716f 100644
--- a/internal/vector/embed/document.go
+++ b/internal/vector/embed/document.go
@@ -58,9 +58,7 @@ type Document struct {
}
// DocumentInput is one document's ordered embedding inputs.
-type DocumentInput struct {
- Chunks []string
-}
+type DocumentInput = vector.DocumentInput
// SemanticClient separates query embedding from document embedding while
// preserving document boundaries. EmbedDocuments can return a completed
diff --git a/internal/vector/embed/request_gate.go b/internal/vector/embed/request_gate.go
index 84d9faf0e..0874d1da2 100644
--- a/internal/vector/embed/request_gate.go
+++ b/internal/vector/embed/request_gate.go
@@ -34,12 +34,14 @@ func (t beforeRequestTransport) RoundTrip(request *http.Request) (*http.Response
return t.base.RoundTrip(request)
}
-func newHTTPClient(timeout time.Duration, before BeforeRequestFunc) *http.Client {
+func newHTTPClient(timeout time.Duration, before BeforeRequestFunc, rejectRedirects bool) *http.Client {
client := &http.Client{Timeout: timeout}
if before != nil {
client.Transport = beforeRequestTransport{
base: http.DefaultTransport, before: before,
}
+ }
+ if before != nil || rejectRedirects {
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
diff --git a/internal/vector/embed/voyage.go b/internal/vector/embed/voyage.go
index e57b268eb..c01259ae7 100644
--- a/internal/vector/embed/voyage.go
+++ b/internal/vector/embed/voyage.go
@@ -10,6 +10,8 @@ import (
"net/http"
"strings"
"time"
+
+ "go.kenn.io/msgvault/internal/vector"
)
// VoyageConfig controls the contextualized embeddings client.
@@ -24,6 +26,9 @@ type VoyageConfig struct {
// BeforeRequest reauthorizes each concrete HTTP attempt. A returned error
// is propagated without retrying. Nil leaves the client ungated.
BeforeRequest BeforeRequestFunc
+ // RejectRedirects prevents provider responses from replaying embedding input
+ // to another URL. BeforeRequest clients always reject redirects as well.
+ RejectRedirects bool
}
// VoyageClient calls Voyage's nested contextualized embeddings endpoint.
@@ -50,7 +55,7 @@ func NewVoyageClient(cfg VoyageConfig) *VoyageClient {
cfg.Limits.MaxUTF8Bytes = defaultVoyageRequestLimits.MaxUTF8Bytes
}
cfg.Limits = capVoyageRequestLimits(cfg.Limits)
- return &VoyageClient{cfg: cfg, http: newHTTPClient(cfg.Timeout, cfg.BeforeRequest)}
+ return &VoyageClient{cfg: cfg, http: newHTTPClient(cfg.Timeout, cfg.BeforeRequest, cfg.RejectRedirects)}
}
type voyageRequest struct {
@@ -227,35 +232,35 @@ func (c *VoyageClient) doVoyageOnce(ctx context.Context, body []byte, inputs [][
func (c *VoyageClient) decodeVoyageResponse(response voyageResponse, inputs [][]string) ([][][]float32, error) {
if len(response.Data) != len(inputs) {
- return nil, fmt.Errorf("embed: Voyage outer response count mismatch: got %d, expected %d", len(response.Data), len(inputs))
+ return nil, fmt.Errorf("%w: Voyage outer response count mismatch: got %d, expected %d", vector.ErrInvalidProviderShape, len(response.Data), len(inputs))
}
results := make([][][]float32, len(inputs))
outerSeen := make([]bool, len(inputs))
for _, outer := range response.Data {
if outer.Index < 0 || outer.Index >= len(inputs) {
- return nil, fmt.Errorf("embed: Voyage invalid outer index %d (len=%d)", outer.Index, len(inputs))
+ return nil, fmt.Errorf("%w: Voyage invalid outer index %d (len=%d)", vector.ErrInvalidProviderShape, outer.Index, len(inputs))
}
if outerSeen[outer.Index] {
- return nil, fmt.Errorf("embed: Voyage duplicate outer index %d", outer.Index)
+ return nil, fmt.Errorf("%w: Voyage duplicate outer index %d", vector.ErrInvalidProviderShape, outer.Index)
}
outerSeen[outer.Index] = true
expectedChunks := len(inputs[outer.Index])
if len(outer.Data) != expectedChunks {
- return nil, fmt.Errorf("embed: Voyage inner response count mismatch at outer index %d: got %d, expected %d", outer.Index, len(outer.Data), expectedChunks)
+ return nil, fmt.Errorf("%w: Voyage inner response count mismatch at outer index %d: got %d, expected %d", vector.ErrInvalidProviderShape, outer.Index, len(outer.Data), expectedChunks)
}
vectors := make([][]float32, expectedChunks)
innerSeen := make([]bool, expectedChunks)
for _, inner := range outer.Data {
if inner.Index < 0 || inner.Index >= expectedChunks {
- return nil, fmt.Errorf("embed: Voyage invalid inner index %d at outer index %d (len=%d)", inner.Index, outer.Index, expectedChunks)
+ return nil, fmt.Errorf("%w: Voyage invalid inner index %d at outer index %d (len=%d)", vector.ErrInvalidProviderShape, inner.Index, outer.Index, expectedChunks)
}
if innerSeen[inner.Index] {
- return nil, fmt.Errorf("embed: Voyage duplicate inner index %d at outer index %d", inner.Index, outer.Index)
+ return nil, fmt.Errorf("%w: Voyage duplicate inner index %d at outer index %d", vector.ErrInvalidProviderShape, inner.Index, outer.Index)
}
innerSeen[inner.Index] = true
if len(inner.Embedding) != c.cfg.Dimension {
- return nil, fmt.Errorf("embed: Voyage dimension mismatch at outer index %d, inner index %d: got %d, configured %d", outer.Index, inner.Index, len(inner.Embedding), c.cfg.Dimension)
+ return nil, fmt.Errorf("%w: Voyage dimension mismatch at outer index %d, inner index %d: got %d, configured %d", vector.ErrInvalidProviderVector, outer.Index, inner.Index, len(inner.Embedding), c.cfg.Dimension)
}
vectors[inner.Index] = inner.Embedding
}
@@ -263,7 +268,7 @@ func (c *VoyageClient) decodeVoyageResponse(response voyageResponse, inputs [][]
}
for i, seen := range outerSeen {
if !seen {
- return nil, fmt.Errorf("embed: Voyage missing outer index %d", i)
+ return nil, fmt.Errorf("%w: Voyage missing outer index %d", vector.ErrInvalidProviderShape, i)
}
}
return results, nil
diff --git a/internal/vector/errors.go b/internal/vector/errors.go
index 91ba27db8..dc15d87ed 100644
--- a/internal/vector/errors.go
+++ b/internal/vector/errors.go
@@ -5,6 +5,16 @@ import "errors"
// Sentinel errors used across the vector package. Callers should use
// errors.Is to check for these.
var (
+ // ErrPermanent4xx marks a non-retryable HTTP 4xx response from an
+ // embeddings provider. Rate limits and server errors remain transient.
+ ErrPermanent4xx = errors.New("embed: non-retryable 4xx response")
+ // ErrInvalidProviderShape identifies a complete provider response whose
+ // document, chunk, or index layout cannot match the request.
+ ErrInvalidProviderShape = errors.New("invalid embedding provider response shape")
+ // ErrInvalidProviderVector identifies a provider vector that cannot belong
+ // to the configured vector space.
+ ErrInvalidProviderVector = errors.New("invalid embedding provider vector")
+
// ErrNotEnabled is returned when vector search is requested but
// [vector] is not configured.
ErrNotEnabled = errors.New("vector search not enabled")
diff --git a/internal/vector/pgvector/document_vectors.go b/internal/vector/pgvector/document_vectors.go
new file mode 100644
index 000000000..e443362f1
--- /dev/null
+++ b/internal/vector/pgvector/document_vectors.go
@@ -0,0 +1,284 @@
+//go:build pgvector
+
+package pgvector
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "math"
+ "strings"
+ "unicode/utf8"
+
+ "go.kenn.io/msgvault/internal/vector"
+ "go.kenn.io/msgvault/internal/vector/document"
+)
+
+const (
+ pgDocumentVectorBatchLimit = 1000
+ pgDocumentVectorTokenLimit = 1024
+)
+
+// DocumentBackend is the independent attachment-document vector store. It
+// borrows its parent's pool and does not own or close it.
+type DocumentBackend struct {
+ db *sql.DB
+}
+
+var _ document.Backend = (*DocumentBackend)(nil)
+
+// DocumentBackend returns a non-owning document-vector view of this backend.
+func (b *Backend) DocumentBackend() *DocumentBackend {
+ return &DocumentBackend{db: b.db}
+}
+
+// DocumentBackendForDB returns a non-owning document-vector backend for an
+// already initialized PostgreSQL database. It is used by cleanup operations
+// that must not initialize an embedding provider or rerun backend migrations.
+func DocumentBackendForDB(db *sql.DB) (*DocumentBackend, error) {
+ if db == nil {
+ return nil, errors.New("pgvector document backend database is required")
+ }
+ return &DocumentBackend{db: db}, nil
+}
+
+func (b *DocumentBackend) PutUnpublished(ctx context.Context, generationID document.GenerationID, dimension int, embeddings []document.Embedding) error {
+ if err := validatePGDocumentPut(generationID, dimension, embeddings); err != nil {
+ return err
+ }
+ if len(embeddings) == 0 {
+ return nil
+ }
+ if err := EnsureDocumentVectorIndex(ctx, b.db, dimension); err != nil {
+ return err
+ }
+ tx, err := b.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin document vector put: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ // Establish a lockable generation row before inspecting its dimension.
+ // For the first concurrent writers, PostgreSQL's unique-index conflict
+ // serializes the INSERTs: the loser waits for the winner, then locks and
+ // observes the committed authority row. The authority insert and all token
+ // writes share this transaction, so a later batch failure rolls everything
+ // back and lets the next writer establish the generation cleanly.
+ if _, err := tx.ExecContext(ctx, `
+ INSERT INTO document_vector_backend_generations (generation_id, dimension)
+ VALUES ($1, $2)
+ ON CONFLICT (generation_id) DO NOTHING`, int64(generationID), dimension); err != nil {
+ return fmt.Errorf("establish document vector generation: %w", err)
+ }
+ var authoritativeDimension int
+ if err := tx.QueryRowContext(ctx, `
+ SELECT dimension FROM document_vector_backend_generations
+ WHERE generation_id = $1 FOR UPDATE`, int64(generationID)).Scan(&authoritativeDimension); err != nil {
+ return fmt.Errorf("lock document vector generation: %w", err)
+ }
+ if authoritativeDimension != dimension {
+ return fmt.Errorf("%w: generation %d already uses dimension %d, got %d",
+ document.ErrInvalidVector, generationID, authoritativeDimension, dimension)
+ }
+
+ stmt, err := tx.PrepareContext(ctx, `
+ INSERT INTO document_vector_embeddings (token, generation_id, dimension, embedding)
+ VALUES ($1, $2, $3, $4::vector)
+ ON CONFLICT (token) DO UPDATE SET
+ dimension = excluded.dimension,
+ embedding = excluded.embedding
+ WHERE document_vector_embeddings.generation_id = excluded.generation_id
+ RETURNING generation_id`)
+ if err != nil {
+ return fmt.Errorf("prepare document vector put: %w", err)
+ }
+ defer func() { _ = stmt.Close() }()
+ for _, embedding := range embeddings {
+ var storedGeneration int64
+ err := stmt.QueryRowContext(ctx, embedding.Token, int64(generationID), dimension,
+ vectorLiteral(embedding.Vector)).Scan(&storedGeneration)
+ if errors.Is(err, sql.ErrNoRows) {
+ return fmt.Errorf("%w: token %q belongs to another generation",
+ document.ErrInvalidVector, embedding.Token)
+ }
+ if err != nil {
+ return fmt.Errorf("put document vector %q: %w", embedding.Token, err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit document vector put: %w", err)
+ }
+ return nil
+}
+
+func (b *DocumentBackend) DeleteTokens(ctx context.Context, generationID document.GenerationID, tokens []string) error {
+ if err := validatePGDocumentTokens(generationID, tokens); err != nil {
+ return err
+ }
+ if len(tokens) == 0 {
+ return nil
+ }
+ unique := make([]string, 0, len(tokens))
+ seen := make(map[string]struct{}, len(tokens))
+ for _, token := range tokens {
+ if _, ok := seen[token]; ok {
+ continue
+ }
+ seen[token] = struct{}{}
+ unique = append(unique, token)
+ }
+ tx, err := b.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin document vector delete: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ if _, err := tx.ExecContext(ctx, `
+ DELETE FROM document_vector_embeddings
+ WHERE generation_id = $1 AND token = ANY($2::text[])`, int64(generationID), textArray(unique)); err != nil {
+ return fmt.Errorf("delete document vectors: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit document vector delete: %w", err)
+ }
+ return nil
+}
+
+func (b *DocumentBackend) Search(ctx context.Context, generationID document.GenerationID, dimension int, query []float32, k int) ([]document.Hit, error) {
+ page, err := b.SearchPage(ctx, generationID, dimension, query, "", k)
+ return page.Hits, err
+}
+
+func (b *DocumentBackend) SearchPage(ctx context.Context, generationID document.GenerationID, dimension int, query []float32, cursor string, k int) (document.HitPage, error) {
+ if err := validatePGDocumentSearch(generationID, dimension, query, k); err != nil {
+ return document.HitPage{}, err
+ }
+ afterDistance, afterToken, afterRank, err := document.DecodePageCursor(cursor)
+ if err != nil {
+ return document.HitPage{}, err
+ }
+ // Scope is resolved after this backend page, so Exhausted must mean the
+ // complete generation was consumed. Materialize every generation distance
+ // before sorting so PostgreSQL cannot substitute the approximate HNSW order.
+ pagePredicate := ""
+ limitPlaceholder := "$3"
+ args := []any{vectorLiteral(query), int64(generationID), k + 1}
+ if cursor != "" {
+ pagePredicate = "WHERE distance > $3 OR (distance = $3 AND token > $4)"
+ limitPlaceholder = "$5"
+ args = []any{vectorLiteral(query), int64(generationID), afterDistance, afterToken, k + 1}
+ }
+ stmt := fmt.Sprintf(`
+ WITH exact AS MATERIALIZED (
+ SELECT token, (embedding::vector(%[1]d)) <=> $1::vector AS distance
+ FROM document_vector_embeddings
+ WHERE generation_id = $2 AND dimension = %[1]d
+ )
+ SELECT token, 1.0 - distance AS score, distance
+ FROM exact
+ %[2]s
+ ORDER BY distance ASC, token ASC
+ LIMIT %[3]s`, dimension, pagePredicate, limitPlaceholder)
+ rows, err := b.db.QueryContext(ctx, stmt, args...)
+ if err != nil {
+ return document.HitPage{}, fmt.Errorf("search document vectors: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ hits := make([]document.Hit, 0, k+1)
+ distances := make([]float64, 0, k+1)
+ for rows.Next() {
+ var hit document.Hit
+ var distance float64
+ if err := rows.Scan(&hit.Token, &hit.Score, &distance); err != nil {
+ return document.HitPage{}, fmt.Errorf("scan document vector hit: %w", err)
+ }
+ hit.Rank = afterRank + len(hits) + 1
+ hits = append(hits, hit)
+ distances = append(distances, distance)
+ }
+ if err := rows.Err(); err != nil {
+ return document.HitPage{}, fmt.Errorf("iterate document vector hits: %w", err)
+ }
+ page := document.HitPage{Exhausted: len(hits) <= k}
+ if len(hits) > k {
+ page.Hits = hits[:k]
+ } else {
+ page.Hits = hits
+ }
+ if !page.Exhausted {
+ page.NextCursor, err = document.EncodePageCursor(distances[k-1], hits[k-1].Token, hits[k-1].Rank)
+ if err != nil {
+ return document.HitPage{}, err
+ }
+ }
+ return page, nil
+}
+
+func validatePGDocumentPut(generationID document.GenerationID, dimension int, embeddings []document.Embedding) error {
+ if generationID <= 0 || dimension <= 0 || len(embeddings) > pgDocumentVectorBatchLimit {
+ return fmt.Errorf("%w: generation, dimension, or batch bound", document.ErrInvalidVector)
+ }
+ seen := make(map[string]struct{}, len(embeddings))
+ for i, embedding := range embeddings {
+ if err := validatePGDocumentToken(embedding.Token); err != nil {
+ return fmt.Errorf("embedding %d: %w", i, err)
+ }
+ if _, ok := seen[embedding.Token]; ok {
+ return fmt.Errorf("%w: duplicate token %q", document.ErrInvalidVector, embedding.Token)
+ }
+ seen[embedding.Token] = struct{}{}
+ if len(embedding.Vector) != dimension {
+ return fmt.Errorf("%w: token %q has %d dimensions, want %d",
+ vector.ErrDimensionMismatch, embedding.Token, len(embedding.Vector), dimension)
+ }
+ if err := validatePGDocumentVector(embedding.Vector); err != nil {
+ return fmt.Errorf("token %q: %w", embedding.Token, err)
+ }
+ }
+ return nil
+}
+
+func validatePGDocumentTokens(generationID document.GenerationID, tokens []string) error {
+ if generationID <= 0 || len(tokens) > pgDocumentVectorBatchLimit {
+ return fmt.Errorf("%w: generation or token batch bound", document.ErrInvalidVector)
+ }
+ for _, token := range tokens {
+ if err := validatePGDocumentToken(token); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validatePGDocumentSearch(generationID document.GenerationID, dimension int, query []float32, k int) error {
+ if generationID <= 0 || dimension <= 0 || k <= 0 || k > pgDocumentVectorBatchLimit {
+ return fmt.Errorf("%w: generation, dimension, or result bound", document.ErrInvalidVector)
+ }
+ if len(query) != dimension {
+ return fmt.Errorf("%w: query has %d dimensions, want %d", vector.ErrDimensionMismatch, len(query), dimension)
+ }
+ return validatePGDocumentVector(query)
+}
+
+func validatePGDocumentToken(token string) error {
+ if token == "" || len(token) > pgDocumentVectorTokenLimit ||
+ !utf8.ValidString(token) || strings.ContainsRune(token, 0) {
+ return fmt.Errorf("%w: token must contain 1..%d bytes", document.ErrInvalidVector, pgDocumentVectorTokenLimit)
+ }
+ return nil
+}
+
+func validatePGDocumentVector(values []float32) error {
+ var norm float64
+ for _, value := range values {
+ f := float64(value)
+ if math.IsNaN(f) || math.IsInf(f, 0) {
+ return fmt.Errorf("%w: non-finite component", document.ErrInvalidVector)
+ }
+ norm += f * f
+ }
+ if norm == 0 {
+ return fmt.Errorf("%w: zero-norm vector", document.ErrInvalidVector)
+ }
+ return nil
+}
diff --git a/internal/vector/pgvector/document_vectors_stub.go b/internal/vector/pgvector/document_vectors_stub.go
new file mode 100644
index 000000000..06d6ba3dc
--- /dev/null
+++ b/internal/vector/pgvector/document_vectors_stub.go
@@ -0,0 +1,31 @@
+//go:build !pgvector
+
+package pgvector
+
+import (
+ "context"
+ "database/sql"
+
+ "go.kenn.io/msgvault/internal/vector/document"
+)
+
+// DocumentBackend is unavailable without pgvector support.
+type DocumentBackend struct{}
+
+var _ document.Backend = (*DocumentBackend)(nil)
+
+func (b *Backend) DocumentBackend() *DocumentBackend { return &DocumentBackend{} }
+
+func DocumentBackendForDB(*sql.DB) (*DocumentBackend, error) { return nil, ErrNotBuilt }
+
+func (b *DocumentBackend) PutUnpublished(context.Context, document.GenerationID, int, []document.Embedding) error {
+ return ErrNotBuilt
+}
+
+func (b *DocumentBackend) DeleteTokens(context.Context, document.GenerationID, []string) error {
+ return ErrNotBuilt
+}
+
+func (b *DocumentBackend) Search(context.Context, document.GenerationID, int, []float32, int) ([]document.Hit, error) {
+ return nil, ErrNotBuilt
+}
diff --git a/internal/vector/pgvector/document_vectors_test.go b/internal/vector/pgvector/document_vectors_test.go
new file mode 100644
index 000000000..e147ecca0
--- /dev/null
+++ b/internal/vector/pgvector/document_vectors_test.go
@@ -0,0 +1,327 @@
+//go:build pgvector
+
+package pgvector
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "go.kenn.io/msgvault/internal/vector"
+ "go.kenn.io/msgvault/internal/vector/document"
+)
+
+func TestDocumentBackendConcurrentFirstPutSerializesGenerationDimensionPostgres(t *testing.T) {
+ db := openPGTestDB(t)
+ parent, err := Open(t.Context(), Options{DB: db})
+ require.NoError(t, err)
+ backend := parent.DocumentBackend()
+ require.NoError(t, EnsureDocumentVectorIndex(t.Context(), db, 3))
+ require.NoError(t, EnsureDocumentVectorIndex(t.Context(), db, 4))
+
+ for round := range 12 {
+ generationID := document.GenerationID(100 + round)
+ start := make(chan struct{})
+ type result struct {
+ dimension int
+ err error
+ }
+ results := make(chan result, 2)
+ var ready sync.WaitGroup
+ ready.Add(2)
+ put := func(dimension int, embedding document.Embedding) {
+ ready.Done()
+ <-start
+ results <- result{dimension: dimension, err: backend.PutUnpublished(
+ t.Context(), generationID, dimension, []document.Embedding{embedding})}
+ }
+ go put(3, document.Embedding{Token: "concurrent-3-" + string(rune('a'+round)), Vector: []float32{1, 0, 0}})
+ go put(4, document.Embedding{Token: "concurrent-4-" + string(rune('a'+round)), Vector: []float32{1, 0, 0, 0}})
+ ready.Wait()
+ close(start)
+
+ first, second := <-results, <-results
+ outcomes := []result{first, second}
+ winnerDimension := 0
+ for _, outcome := range outcomes {
+ if outcome.err == nil {
+ require.Zero(t, winnerDimension, "round %d admitted two dimensions", round)
+ winnerDimension = outcome.dimension
+ continue
+ }
+ require.ErrorIs(t, outcome.err, document.ErrInvalidVector, "round %d loser", round)
+ }
+ require.NotZero(t, winnerDimension, "round %d admitted no writer", round)
+
+ var authorityDimension, rowCount, minDimension, maxDimension int
+ require.NoError(t, db.QueryRow(`
+ SELECT dimension FROM document_vector_backend_generations WHERE generation_id = $1`,
+ int64(generationID)).Scan(&authorityDimension))
+ require.NoError(t, db.QueryRow(`
+ SELECT COUNT(*), MIN(dimension), MAX(dimension)
+ FROM document_vector_embeddings WHERE generation_id = $1`,
+ int64(generationID)).Scan(&rowCount, &minDimension, &maxDimension))
+ assert.Equal(t, winnerDimension, authorityDimension)
+ assert.Equal(t, 1, rowCount)
+ assert.Equal(t, winnerDimension, minDimension)
+ assert.Equal(t, winnerDimension, maxDimension)
+ }
+}
+
+func TestDocumentBackendGenerationAuthorityRollsBackWithBatchPostgres(t *testing.T) {
+ db := openPGTestDB(t)
+ parent, err := Open(t.Context(), Options{DB: db})
+ require.NoError(t, err)
+ backend := parent.DocumentBackend()
+ require.NoError(t, backend.PutUnpublished(t.Context(), 801, 3, []document.Embedding{
+ {Token: "authority-owner", Vector: []float32{1, 0, 0}},
+ }))
+
+ err = backend.PutUnpublished(t.Context(), 802, 3, []document.Embedding{
+ {Token: "authority-partial", Vector: []float32{0, 1, 0}},
+ {Token: "authority-owner", Vector: []float32{0, 0, 1}},
+ })
+ require.ErrorIs(t, err, document.ErrInvalidVector)
+ var generations, embeddings int
+ require.NoError(t, db.QueryRow(`
+ SELECT COUNT(*) FROM document_vector_backend_generations WHERE generation_id = 802`).Scan(&generations))
+ require.NoError(t, db.QueryRow(`
+ SELECT COUNT(*) FROM document_vector_embeddings WHERE generation_id = 802`).Scan(&embeddings))
+ assert.Zero(t, generations, "failed token batch rolls back generation authority")
+ assert.Zero(t, embeddings, "failed token batch rolls back embeddings")
+
+ require.NoError(t, backend.PutUnpublished(t.Context(), 802, 4, []document.Embedding{
+ {Token: "authority-after-rollback", Vector: []float32{1, 0, 0, 0}},
+ }), "a later dimension may establish authority after the failed transaction")
+}
+
+func TestDocumentBackendSearchPageReadsBeyondHNSWBudgetPostgres(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ db := openPGTestDB(t)
+ db.SetMaxOpenConns(1)
+ parent, err := Open(t.Context(), Options{DB: db})
+ requirements.NoError(err)
+ backend := parent.DocumentBackend()
+
+ const total = 1105
+ embeddings := make([]document.Embedding, total)
+ for index := range embeddings {
+ embeddings[index] = document.Embedding{
+ Token: fmt.Sprintf("%064x", index), Vector: []float32{1, float32(index) / total, 0},
+ }
+ }
+ requirements.NoError(backend.PutUnpublished(t.Context(), 901, 3, embeddings[:1000]))
+ requirements.NoError(backend.PutUnpublished(t.Context(), 901, 3, embeddings[1000:]))
+ _, err = db.ExecContext(t.Context(), `SET enable_seqscan = off`)
+ requirements.NoError(err)
+ _, err = db.ExecContext(t.Context(), `SET hnsw.ef_search = 40`)
+ requirements.NoError(err)
+
+ firstPage, err := backend.SearchPage(t.Context(), 901, 3, []float32{1, 0, 0}, "", 1000)
+ requirements.NoError(err)
+ requirements.False(firstPage.Exhausted)
+ page, err := backend.SearchPage(t.Context(), 901, 3, []float32{1, 0, 0}, firstPage.NextCursor, 100)
+
+ requirements.NoError(err)
+ requirements.Len(page.Hits, 100)
+ assertions.False(page.Exhausted)
+ assertions.Equal(fmt.Sprintf("%064x", 1000), page.Hits[0].Token)
+ assertions.Equal(1001, page.Hits[0].Rank)
+}
+
+func TestDocumentBackendContractPostgres(t *testing.T) {
+ db := openPGTestDB(t)
+ parent, err := Open(context.Background(), Options{DB: db, Dimension: 3})
+ require.NoError(t, err)
+ backend := parent.DocumentBackend()
+ testPGDocumentBackendContract(t, backend, func(token string) int {
+ t.Helper()
+ var count int
+ require.NoError(t, db.QueryRow(
+ `SELECT COUNT(*) FROM document_vector_embeddings WHERE token = $1`, token).Scan(&count))
+ return count
+ })
+
+ var definition string
+ require.NoError(t, db.QueryRow(`SELECT pg_get_indexdef(indexrelid) FROM pg_index WHERE indexrelid = $1::regclass`,
+ DocumentVectorIndexName(3)).Scan(&definition))
+ assert.Contains(t, definition, "vector_cosine_ops")
+ assert.Contains(t, definition, "WHERE (dimension = 3)")
+}
+
+func testPGDocumentBackendContract(t *testing.T, backend document.Backend, tokenCount func(string) int) {
+ t.Helper()
+ ctx := context.Background()
+ gen1 := document.GenerationID(11)
+ gen2 := document.GenerationID(12)
+ require.NoError(t, backend.PutUnpublished(ctx, gen1, 3, nil))
+ require.NoError(t, backend.DeleteTokens(ctx, gen1, nil))
+ empty, err := backend.Search(ctx, 99, 5, []float32{1, 0, 0, 0, 0}, 1)
+ require.NoError(t, err)
+ assert.Empty(t, empty)
+ embeddings := []document.Embedding{
+ {Token: "token-d", Vector: []float32{-1, 0, 0}},
+ {Token: "token-c", Vector: []float32{0, -1, 0}},
+ {Token: "token-a", Vector: []float32{1, 0, 0}},
+ {Token: "token-b", Vector: []float32{0, 1, 0}},
+ }
+ require.NoError(t, backend.PutUnpublished(ctx, gen1, 3, embeddings))
+ require.NoError(t, backend.PutUnpublished(ctx, gen1, 3, embeddings))
+ canceledCtx, cancel := context.WithCancel(ctx)
+ cancel()
+ err = backend.PutUnpublished(canceledCtx, gen1, 3, []document.Embedding{
+ {Token: "canceled-first", Vector: []float32{1, 0, 0}},
+ {Token: "canceled-second", Vector: []float32{0, 1, 0}},
+ })
+ require.ErrorIs(t, err, context.Canceled)
+ assert.Zero(t, tokenCount("canceled-first"))
+ assert.Zero(t, tokenCount("canceled-second"), "a canceled put exposes no partial batch")
+
+ hits, err := backend.Search(ctx, gen1, 3, []float32{1, 0, 0}, 4)
+ require.NoError(t, err)
+ require.Len(t, hits, 4)
+ assert.Equal(t, []string{"token-a", "token-b", "token-c", "token-d"}, pgHitTokens(hits))
+ assert.Equal(t, []int{1, 2, 3, 4}, pgHitRanks(hits))
+ assert.InDelta(t, 1, hits[0].Score, 1e-6)
+ assert.InDelta(t, 0, hits[1].Score, 1e-6)
+ assert.InDelta(t, 0, hits[2].Score, 1e-6)
+ assert.InDelta(t, -1, hits[3].Score, 1e-6)
+ paged, ok := backend.(document.PagedBackend)
+ require.True(t, ok)
+ firstPage, err := paged.SearchPage(ctx, gen1, 3, []float32{1, 0, 0}, "", 2)
+ require.NoError(t, err)
+ assert.False(t, firstPage.Exhausted)
+ assert.Equal(t, []string{"token-a", "token-b"}, pgHitTokens(firstPage.Hits))
+ require.NoError(t, backend.DeleteTokens(ctx, gen1, []string{"token-a"}))
+ secondPage, err := paged.SearchPage(ctx, gen1, 3, []float32{1, 0, 0}, firstPage.NextCursor, 2)
+ require.NoError(t, err)
+ assert.True(t, secondPage.Exhausted)
+ assert.Equal(t, []string{"token-c", "token-d"}, pgHitTokens(secondPage.Hits))
+ assert.Equal(t, []int{3, 4}, pgHitRanks(secondPage.Hits))
+
+ require.NoError(t, backend.PutUnpublished(ctx, gen1, 3, []document.Embedding{
+ {Token: "token-a", Vector: []float32{0, 0, 1}},
+ }))
+ hits, err = backend.Search(ctx, gen1, 3, []float32{0, 0, 1}, 1)
+ require.NoError(t, err)
+ require.Len(t, hits, 1)
+ assert.Equal(t, "token-a", hits[0].Token)
+
+ require.NoError(t, backend.PutUnpublished(ctx, gen2, 4, []document.Embedding{
+ {Token: "token-four", Vector: []float32{0, 0, 0, 1}},
+ }))
+ hits, err = backend.Search(ctx, gen2, 4, []float32{0, 0, 0, 1}, 10)
+ require.NoError(t, err)
+ require.Len(t, hits, 1)
+ assert.Equal(t, "token-four", hits[0].Token)
+ hits, err = backend.Search(ctx, gen1, 3, []float32{0, 0, 1}, 10)
+ require.NoError(t, err)
+ assert.NotContains(t, pgHitTokens(hits), "token-four")
+
+ err = backend.PutUnpublished(ctx, gen2, 4, []document.Embedding{
+ {Token: "token-new", Vector: []float32{1, 0, 0, 0}},
+ {Token: "token-a", Vector: []float32{0, 1, 0, 0}},
+ })
+ require.ErrorIs(t, err, document.ErrInvalidVector)
+ assert.Zero(t, tokenCount("token-new"))
+
+ err = backend.PutUnpublished(ctx, gen1, 3, []document.Embedding{
+ {Token: "token-partial", Vector: []float32{1, 0, 0}},
+ {Token: "token-wrong", Vector: []float32{1, 0}},
+ })
+ require.ErrorIs(t, err, vector.ErrDimensionMismatch)
+ assert.Zero(t, tokenCount("token-partial"))
+ err = backend.PutUnpublished(ctx, gen1, 3, []document.Embedding{
+ {Token: "token-partial-nonfinite", Vector: []float32{1, 0, 0}},
+ {Token: "token-nonfinite", Vector: []float32{1, float32(math.NaN()), 0}},
+ })
+ require.ErrorIs(t, err, document.ErrInvalidVector)
+ assert.Zero(t, tokenCount("token-partial-nonfinite"))
+
+ for name, batch := range map[string][]document.Embedding{
+ "duplicate": {
+ {Token: "duplicate", Vector: []float32{1, 0, 0}},
+ {Token: "duplicate", Vector: []float32{0, 1, 0}},
+ },
+ "empty token": {{Token: "", Vector: []float32{1, 0, 0}}},
+ "long token": {{Token: strings.Repeat("x", 1025), Vector: []float32{1, 0, 0}}},
+ "nul token": {{Token: "x\x00y", Vector: []float32{1, 0, 0}}},
+ "nonfinite": {{Token: "nan", Vector: []float32{1, float32(math.NaN()), 0}}},
+ "infinite": {{Token: "inf", Vector: []float32{1, float32(math.Inf(1)), 0}}},
+ "zero norm": {{Token: "zero", Vector: []float32{0, 0, 0}}},
+ } {
+ t.Run("put rejects "+name, func(t *testing.T) {
+ require.ErrorIs(t, backend.PutUnpublished(ctx, gen1, 3, batch), document.ErrInvalidVector)
+ })
+ }
+
+ tooMany := make([]document.Embedding, 1001)
+ for i := range tooMany {
+ tooMany[i] = document.Embedding{Token: "bounded-" + string(rune(i+1)), Vector: []float32{1, 0, 0}}
+ }
+ for name, call := range map[string]func() error{
+ "put generation": func() error { return backend.PutUnpublished(ctx, 0, 3, embeddings[:1]) },
+ "put dimension": func() error { return backend.PutUnpublished(ctx, gen1, 0, embeddings[:1]) },
+ "put batch": func() error { return backend.PutUnpublished(ctx, gen1, 3, tooMany) },
+ "delete gen": func() error { return backend.DeleteTokens(ctx, 0, []string{"token-a"}) },
+ "delete batch": func() error { return backend.DeleteTokens(ctx, gen1, make([]string, 1001)) },
+ "search gen": func() error {
+ _, err := backend.Search(ctx, 0, 3, []float32{1, 0, 0}, 1)
+ return err
+ },
+ "search dimension": func() error {
+ _, err := backend.Search(ctx, gen1, 0, []float32{1, 0, 0}, 1)
+ return err
+ },
+ "search k zero": func() error {
+ _, err := backend.Search(ctx, gen1, 3, []float32{1, 0, 0}, 0)
+ return err
+ },
+ "search k large": func() error {
+ _, err := backend.Search(ctx, gen1, 3, []float32{1, 0, 0}, 1001)
+ return err
+ },
+ "search nonfinite": func() error {
+ _, err := backend.Search(ctx, gen1, 3, []float32{1, float32(math.Inf(-1)), 0}, 1)
+ return err
+ },
+ } {
+ t.Run("bounds reject "+name, func(t *testing.T) {
+ require.ErrorIs(t, call(), document.ErrInvalidVector)
+ })
+ }
+ _, err = backend.Search(ctx, gen1, 3, []float32{1, 0}, 1)
+ require.ErrorIs(t, err, vector.ErrDimensionMismatch)
+
+ require.NoError(t, backend.DeleteTokens(ctx, gen2, []string{"token-a"}))
+ assert.Equal(t, 1, tokenCount("token-a"))
+ require.NoError(t, backend.DeleteTokens(ctx, gen1, []string{"token-a"}))
+ require.NoError(t, backend.DeleteTokens(ctx, gen1, []string{"token-a"}))
+ assert.Zero(t, tokenCount("token-a"))
+ hits, err = backend.Search(ctx, gen1, 3, []float32{0, 0, 1}, 10)
+ require.NoError(t, err)
+ assert.NotContains(t, pgHitTokens(hits), "token-a")
+}
+
+func pgHitTokens(hits []document.Hit) []string {
+ out := make([]string, len(hits))
+ for i := range hits {
+ out[i] = hits[i].Token
+ }
+ return out
+}
+
+func pgHitRanks(hits []document.Hit) []int {
+ out := make([]int, len(hits))
+ for i := range hits {
+ out[i] = hits[i].Rank
+ }
+ return out
+}
diff --git a/internal/vector/pgvector/migrate.go b/internal/vector/pgvector/migrate.go
index c8fdc6093..3bb4304af 100644
--- a/internal/vector/pgvector/migrate.go
+++ b/internal/vector/pgvector/migrate.go
@@ -128,6 +128,9 @@ func Migrate(ctx context.Context, db migrateExecer, defaultDim int, skipExtensio
if err := EnsurePersonVectorIndex(ctx, db, defaultDim); err != nil {
return err
}
+ if err := EnsureDocumentVectorIndex(ctx, db, defaultDim); err != nil {
+ return err
+ }
}
return nil
}
@@ -183,3 +186,38 @@ func EnsureVectorIndex(ctx context.Context, db migrateExecer, dim int) error {
func VectorIndexName(dim int) string {
return fmt.Sprintf("idx_embeddings_hnsw_d%d", dim)
}
+
+// EnsureDocumentVectorIndex creates the dedicated per-dimension cosine HNSW
+// index for attachment-document vectors. It never indexes message embeddings.
+func EnsureDocumentVectorIndex(ctx context.Context, db migrateExecer, dim int) error {
+ if dim <= 0 {
+ return fmt.Errorf("invalid document vector dimension %d", dim)
+ }
+ stmt := fmt.Sprintf(
+ `CREATE INDEX IF NOT EXISTS %s
+ ON document_vector_embeddings
+ USING hnsw ((embedding::vector(%d)) vector_cosine_ops)
+ WHERE dimension = %d`,
+ DocumentVectorIndexName(dim), dim, dim,
+ )
+ tx, err := db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin document hnsw index tx for dim %d: %w", dim, err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ if _, err := tx.ExecContext(ctx, "SET LOCAL statement_timeout = 0"); err != nil {
+ return fmt.Errorf("disable statement_timeout for document hnsw index: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx, stmt); err != nil {
+ return fmt.Errorf("create document hnsw index for dim %d: %w", dim, err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit document hnsw index for dim %d: %w", dim, err)
+ }
+ return nil
+}
+
+// DocumentVectorIndexName returns the dedicated dimension-specific HNSW name.
+func DocumentVectorIndexName(dim int) string {
+ return fmt.Sprintf("idx_document_vector_embeddings_hnsw_d%d", dim)
+}
diff --git a/internal/vector/pgvector/schema.sql b/internal/vector/pgvector/schema.sql
index 26545e370..70d55fe89 100644
--- a/internal/vector/pgvector/schema.sql
+++ b/internal/vector/pgvector/schema.sql
@@ -144,7 +144,6 @@ CREATE TABLE IF NOT EXISTS embedding_document_progress (
reconcile_cursor TEXT NOT NULL DEFAULT '',
journal_cursor TEXT NOT NULL DEFAULT ''
);
-
-- Visual vectors share the PostgreSQL database with their authoritative
-- publication rows but remain independently keyed by an opaque publication
-- token. A prepared vector cannot be searched until visual_publications points
@@ -159,3 +158,24 @@ CREATE INDEX IF NOT EXISTS idx_visual_vectors_hnsw_d1024
ON visual_vectors
USING hnsw ((embedding::vector(1024)) vector_cosine_ops)
WHERE dimension = 1024;
+
+-- Independent attachment-document vectors. Tokens are opaque globally unique
+-- publication identities; generation IDs intentionally do not reference the
+-- message-vector index_generations table.
+CREATE TABLE IF NOT EXISTS document_vector_backend_generations (
+ generation_id BIGINT PRIMARY KEY,
+ dimension INTEGER NOT NULL CHECK (dimension > 0),
+ UNIQUE (generation_id, dimension)
+);
+
+CREATE TABLE IF NOT EXISTS document_vector_embeddings (
+ token TEXT PRIMARY KEY,
+ generation_id BIGINT NOT NULL,
+ dimension INTEGER NOT NULL CHECK (dimension > 0),
+ embedding vector NOT NULL,
+ CONSTRAINT document_vector_embeddings_generation_dimension_fkey
+ FOREIGN KEY (generation_id, dimension)
+ REFERENCES document_vector_backend_generations(generation_id, dimension)
+);
+CREATE INDEX IF NOT EXISTS idx_document_vector_embeddings_generation
+ ON document_vector_embeddings(generation_id, dimension, token);
diff --git a/internal/vector/sqlitevec/document_vectors.go b/internal/vector/sqlitevec/document_vectors.go
new file mode 100644
index 000000000..74cdf11a0
--- /dev/null
+++ b/internal/vector/sqlitevec/document_vectors.go
@@ -0,0 +1,294 @@
+//go:build sqlite_vec
+
+package sqlitevec
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "math"
+ "strings"
+ "unicode/utf8"
+
+ "go.kenn.io/msgvault/internal/vector"
+ "go.kenn.io/msgvault/internal/vector/document"
+)
+
+const (
+ documentVectorBatchLimit = 1000
+ documentVectorTokenLimit = 1024
+)
+
+// DocumentBackend is the independent attachment-document vector store. It
+// borrows its parent's connection and does not own or close it.
+type DocumentBackend struct {
+ db *sql.DB
+}
+
+var _ document.Backend = (*DocumentBackend)(nil)
+
+// DocumentBackend returns a non-owning document-vector view of this backend.
+func (b *Backend) DocumentBackend() *DocumentBackend {
+ return &DocumentBackend{db: b.db}
+}
+
+func (b *DocumentBackend) PutUnpublished(ctx context.Context, generationID document.GenerationID, dimension int, embeddings []document.Embedding) error {
+ if err := validateDocumentPut(generationID, dimension, embeddings); err != nil {
+ return err
+ }
+ if len(embeddings) == 0 {
+ return nil
+ }
+ if err := EnsureDocumentVectorTable(ctx, b.db, dimension); err != nil {
+ return err
+ }
+
+ tx, err := b.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin document vector put: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ var existingDimension int
+ err = tx.QueryRowContext(ctx,
+ `SELECT dimension FROM document_vector_embeddings WHERE generation_id = ? LIMIT 1`, int64(generationID)).Scan(&existingDimension)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return fmt.Errorf("lookup document generation dimension: %w", err)
+ }
+ if err == nil && existingDimension != dimension {
+ return fmt.Errorf("%w: generation %d already uses dimension %d, got %d",
+ document.ErrInvalidVector, generationID, existingDimension, dimension)
+ }
+
+ vecTable := DocumentVectorTableName(dimension)
+ for _, embedding := range embeddings {
+ var rowID int64
+ var existingGeneration int64
+ err := tx.QueryRowContext(ctx,
+ `SELECT document_vector_id, generation_id FROM document_vector_embeddings WHERE token = ?`, embedding.Token).
+ Scan(&rowID, &existingGeneration)
+ switch {
+ case errors.Is(err, sql.ErrNoRows):
+ err = tx.QueryRowContext(ctx, `
+ INSERT INTO document_vector_embeddings (token, generation_id, dimension)
+ VALUES (?, ?, ?) RETURNING document_vector_id`,
+ embedding.Token, int64(generationID), dimension).Scan(&rowID)
+ if err != nil {
+ return fmt.Errorf("insert document vector metadata: %w", err)
+ }
+ case err != nil:
+ return fmt.Errorf("lookup document vector token: %w", err)
+ case existingGeneration != int64(generationID):
+ return fmt.Errorf("%w: token %q belongs to generation %d",
+ document.ErrInvalidVector, embedding.Token, existingGeneration)
+ default:
+ if _, err := tx.ExecContext(ctx,
+ fmt.Sprintf(`DELETE FROM %s WHERE generation_id = ? AND document_vector_id = ?`, vecTable),
+ int64(generationID), rowID); err != nil {
+ return fmt.Errorf("delete replaced document vector: %w", err)
+ }
+ }
+ if _, err := tx.ExecContext(ctx, fmt.Sprintf(`
+ INSERT INTO %s (generation_id, document_vector_id, embedding)
+ VALUES (?, ?, ?)`, vecTable), int64(generationID), rowID, float32SliceBlob(embedding.Vector)); err != nil {
+ return fmt.Errorf("insert document vector %q: %w", embedding.Token, err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit document vector put: %w", err)
+ }
+ return nil
+}
+
+func (b *DocumentBackend) DeleteTokens(ctx context.Context, generationID document.GenerationID, tokens []string) error {
+ if err := validateDocumentTokens(generationID, tokens); err != nil {
+ return err
+ }
+ if len(tokens) == 0 {
+ return nil
+ }
+ tx, err := b.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin document vector delete: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ seen := make(map[string]struct{}, len(tokens))
+ for _, token := range tokens {
+ if _, ok := seen[token]; ok {
+ continue
+ }
+ seen[token] = struct{}{}
+ var rowID int64
+ var dimension int
+ err := tx.QueryRowContext(ctx, `
+ SELECT document_vector_id, dimension FROM document_vector_embeddings
+ WHERE generation_id = ? AND token = ?`, int64(generationID), token).Scan(&rowID, &dimension)
+ if errors.Is(err, sql.ErrNoRows) {
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("lookup document vector for delete: %w", err)
+ }
+ if _, err := tx.ExecContext(ctx, fmt.Sprintf(`
+ DELETE FROM %s WHERE generation_id = ? AND document_vector_id = ?`, DocumentVectorTableName(dimension)),
+ int64(generationID), rowID); err != nil {
+ return fmt.Errorf("delete document vector %q: %w", token, err)
+ }
+ if _, err := tx.ExecContext(ctx, `
+ DELETE FROM document_vector_embeddings
+ WHERE generation_id = ? AND token = ?`, int64(generationID), token); err != nil {
+ return fmt.Errorf("delete document vector metadata %q: %w", token, err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit document vector delete: %w", err)
+ }
+ return nil
+}
+
+func (b *DocumentBackend) Search(ctx context.Context, generationID document.GenerationID, dimension int, query []float32, k int) ([]document.Hit, error) {
+ page, err := b.SearchPage(ctx, generationID, dimension, query, "", k)
+ return page.Hits, err
+}
+
+func (b *DocumentBackend) SearchPage(ctx context.Context, generationID document.GenerationID, dimension int, query []float32, cursor string, k int) (document.HitPage, error) {
+ if err := validateDocumentSearch(generationID, dimension, query, k); err != nil {
+ return document.HitPage{}, err
+ }
+ afterDistance, afterToken, afterRank, err := document.DecodePageCursor(cursor)
+ if err != nil {
+ return document.HitPage{}, err
+ }
+ var exists int
+ err = b.db.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, DocumentVectorTableName(dimension)).Scan(&exists)
+ if err != nil {
+ return document.HitPage{}, fmt.Errorf("check document vector table: %w", err)
+ }
+ if exists == 0 {
+ return document.HitPage{Exhausted: true}, nil
+ }
+ pagePredicate := ""
+ args := []any{float32SliceBlob(query), int64(generationID), int64(generationID), dimension}
+ if cursor != "" {
+ pagePredicate = "WHERE distance > ? OR (distance = ? AND token > ?)"
+ args = append(args, afterDistance, afterDistance, afterToken)
+ }
+ args = append(args, k+1)
+ q := fmt.Sprintf(`
+ WITH exact AS (
+ SELECT m.token, vec_distance_cosine(v.embedding, ?) AS distance
+ FROM %s v
+ JOIN document_vector_embeddings m
+ ON m.document_vector_id = v.document_vector_id
+ WHERE v.generation_id = ? AND m.generation_id = ? AND m.dimension = ?
+ )
+ SELECT token, 1.0 - distance AS score, distance
+ FROM exact
+ %s
+ ORDER BY distance ASC, token ASC
+ LIMIT ?`, DocumentVectorTableName(dimension), pagePredicate)
+ rows, err := b.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return document.HitPage{}, fmt.Errorf("search document vectors: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ hits := make([]document.Hit, 0, k+1)
+ distances := make([]float64, 0, k+1)
+ for rows.Next() {
+ var hit document.Hit
+ var distance float64
+ if err := rows.Scan(&hit.Token, &hit.Score, &distance); err != nil {
+ return document.HitPage{}, fmt.Errorf("scan document vector hit: %w", err)
+ }
+ hit.Rank = afterRank + len(hits) + 1
+ hits = append(hits, hit)
+ distances = append(distances, distance)
+ }
+ if err := rows.Err(); err != nil {
+ return document.HitPage{}, fmt.Errorf("iterate document vector hits: %w", err)
+ }
+ page := document.HitPage{Exhausted: len(hits) <= k}
+ if len(hits) > k {
+ page.Hits = hits[:k]
+ } else {
+ page.Hits = hits
+ }
+ if !page.Exhausted {
+ page.NextCursor, err = document.EncodePageCursor(distances[k-1], hits[k-1].Token, hits[k-1].Rank)
+ if err != nil {
+ return document.HitPage{}, err
+ }
+ }
+ return page, nil
+}
+
+func validateDocumentPut(generationID document.GenerationID, dimension int, embeddings []document.Embedding) error {
+ if generationID <= 0 || dimension <= 0 || len(embeddings) > documentVectorBatchLimit {
+ return fmt.Errorf("%w: generation, dimension, or batch bound", document.ErrInvalidVector)
+ }
+ seen := make(map[string]struct{}, len(embeddings))
+ for i, embedding := range embeddings {
+ if err := validateDocumentToken(embedding.Token); err != nil {
+ return fmt.Errorf("embedding %d: %w", i, err)
+ }
+ if _, ok := seen[embedding.Token]; ok {
+ return fmt.Errorf("%w: duplicate token %q", document.ErrInvalidVector, embedding.Token)
+ }
+ seen[embedding.Token] = struct{}{}
+ if len(embedding.Vector) != dimension {
+ return fmt.Errorf("%w: token %q has %d dimensions, want %d",
+ vector.ErrDimensionMismatch, embedding.Token, len(embedding.Vector), dimension)
+ }
+ if err := validateDocumentVector(embedding.Vector); err != nil {
+ return fmt.Errorf("token %q: %w", embedding.Token, err)
+ }
+ }
+ return nil
+}
+
+func validateDocumentTokens(generationID document.GenerationID, tokens []string) error {
+ if generationID <= 0 || len(tokens) > documentVectorBatchLimit {
+ return fmt.Errorf("%w: generation or token batch bound", document.ErrInvalidVector)
+ }
+ for _, token := range tokens {
+ if err := validateDocumentToken(token); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validateDocumentSearch(generationID document.GenerationID, dimension int, query []float32, k int) error {
+ if generationID <= 0 || dimension <= 0 || k <= 0 || k > documentVectorBatchLimit {
+ return fmt.Errorf("%w: generation, dimension, or result bound", document.ErrInvalidVector)
+ }
+ if len(query) != dimension {
+ return fmt.Errorf("%w: query has %d dimensions, want %d", vector.ErrDimensionMismatch, len(query), dimension)
+ }
+ return validateDocumentVector(query)
+}
+
+func validateDocumentToken(token string) error {
+ if token == "" || len(token) > documentVectorTokenLimit ||
+ !utf8.ValidString(token) || strings.ContainsRune(token, 0) {
+ return fmt.Errorf("%w: token must contain 1..%d bytes", document.ErrInvalidVector, documentVectorTokenLimit)
+ }
+ return nil
+}
+
+func validateDocumentVector(values []float32) error {
+ var norm float64
+ for _, value := range values {
+ f := float64(value)
+ if math.IsNaN(f) || math.IsInf(f, 0) {
+ return fmt.Errorf("%w: non-finite component", document.ErrInvalidVector)
+ }
+ norm += f * f
+ }
+ if norm == 0 {
+ return fmt.Errorf("%w: zero-norm vector", document.ErrInvalidVector)
+ }
+ return nil
+}
diff --git a/internal/vector/sqlitevec/document_vectors_stub.go b/internal/vector/sqlitevec/document_vectors_stub.go
new file mode 100644
index 000000000..171dc7c11
--- /dev/null
+++ b/internal/vector/sqlitevec/document_vectors_stub.go
@@ -0,0 +1,28 @@
+//go:build !sqlite_vec
+
+package sqlitevec
+
+import (
+ "context"
+
+ "go.kenn.io/msgvault/internal/vector/document"
+)
+
+// DocumentBackend is unavailable without sqlite_vec support.
+type DocumentBackend struct{}
+
+var _ document.Backend = (*DocumentBackend)(nil)
+
+func (b *Backend) DocumentBackend() *DocumentBackend { return &DocumentBackend{} }
+
+func (b *DocumentBackend) PutUnpublished(context.Context, document.GenerationID, int, []document.Embedding) error {
+ return ErrNotBuilt
+}
+
+func (b *DocumentBackend) DeleteTokens(context.Context, document.GenerationID, []string) error {
+ return ErrNotBuilt
+}
+
+func (b *DocumentBackend) Search(context.Context, document.GenerationID, int, []float32, int) ([]document.Hit, error) {
+ return nil, ErrNotBuilt
+}
diff --git a/internal/vector/sqlitevec/document_vectors_test.go b/internal/vector/sqlitevec/document_vectors_test.go
new file mode 100644
index 000000000..613127f7f
--- /dev/null
+++ b/internal/vector/sqlitevec/document_vectors_test.go
@@ -0,0 +1,268 @@
+//go:build sqlite_vec
+
+package sqlitevec
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "go.kenn.io/msgvault/internal/vector"
+ "go.kenn.io/msgvault/internal/vector/document"
+)
+
+func openDocumentBackendForTest(t *testing.T) (*Backend, *DocumentBackend) {
+ t.Helper()
+ b, err := Open(context.Background(), Options{
+ Path: filepath.Join(t.TempDir(), "vectors.db"),
+ Dimension: 3,
+ })
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, b.Close()) })
+ return b, b.DocumentBackend()
+}
+
+func TestDocumentBackendContractSQLite(t *testing.T) {
+ parent, backend := openDocumentBackendForTest(t)
+ testDocumentBackendContract(t, backend, func(token string) int {
+ t.Helper()
+ var count int
+ require.NoError(t, parent.db.QueryRow(
+ `SELECT COUNT(*) FROM document_vector_embeddings WHERE token = ?`, token).Scan(&count))
+ return count
+ })
+ require.NoError(t, backend.PutUnpublished(t.Context(), 21, 3, []document.Embedding{
+ {Token: "stable-rowid", Vector: []float32{1, 0, 0}},
+ }))
+ var before, after int64
+ require.NoError(t, parent.db.QueryRow(
+ `SELECT document_vector_id FROM document_vector_embeddings WHERE token = ?`, "stable-rowid").Scan(&before))
+ require.NoError(t, backend.PutUnpublished(t.Context(), 21, 3, []document.Embedding{
+ {Token: "stable-rowid", Vector: []float32{0, 1, 0}},
+ }))
+ require.NoError(t, parent.db.QueryRow(
+ `SELECT document_vector_id FROM document_vector_embeddings WHERE token = ?`, "stable-rowid").Scan(&after))
+ assert.Equal(t, before, after, "replacement preserves the vec0-linked metadata rowid")
+}
+
+func TestDocumentBackendSearchPagesEqualDistancesWithoutOverlapSQLite(t *testing.T) {
+ assertions := assert.New(t)
+ requirements := require.New(t)
+ _, backend := openDocumentBackendForTest(t)
+
+ const total = 300
+ embeddings := make([]document.Embedding, total)
+ for index := range embeddings {
+ embeddings[index] = document.Embedding{
+ Token: fmt.Sprintf("%064x", index), Vector: []float32{1, 0, 0},
+ }
+ }
+ requirements.NoError(backend.PutUnpublished(t.Context(), 31, 3, embeddings))
+
+ first, err := backend.SearchPage(t.Context(), 31, 3, []float32{1, 0, 0}, "", 100)
+ requirements.NoError(err)
+ requirements.False(first.Exhausted)
+ second, err := backend.SearchPage(t.Context(), 31, 3, []float32{1, 0, 0}, first.NextCursor, 100)
+ requirements.NoError(err)
+ requirements.False(second.Exhausted)
+
+ got := append(hitTokens(first.Hits), hitTokens(second.Hits)...)
+ want := make([]string, 200)
+ for index := range want {
+ want[index] = fmt.Sprintf("%064x", index)
+ }
+ assertions.Equal(want, got)
+}
+
+func testDocumentBackendContract(t *testing.T, backend document.Backend, tokenCount func(string) int) {
+ t.Helper()
+ ctx := context.Background()
+ gen1 := document.GenerationID(11)
+ gen2 := document.GenerationID(12)
+ require.NoError(t, backend.PutUnpublished(ctx, gen1, 3, nil))
+ require.NoError(t, backend.DeleteTokens(ctx, gen1, nil))
+ empty, err := backend.Search(ctx, 99, 5, []float32{1, 0, 0, 0, 0}, 1)
+ require.NoError(t, err)
+ assert.Empty(t, empty)
+ embeddings := []document.Embedding{
+ {Token: "token-d", Vector: []float32{-1, 0, 0}},
+ {Token: "token-c", Vector: []float32{0, -1, 0}},
+ {Token: "token-a", Vector: []float32{1, 0, 0}},
+ {Token: "token-b", Vector: []float32{0, 1, 0}},
+ }
+ require.NoError(t, backend.PutUnpublished(ctx, gen1, 3, embeddings))
+ require.NoError(t, backend.PutUnpublished(ctx, gen1, 3, embeddings), "same batch is idempotent")
+ canceledCtx, cancel := context.WithCancel(ctx)
+ cancel()
+ err = backend.PutUnpublished(canceledCtx, gen1, 3, []document.Embedding{
+ {Token: "canceled-first", Vector: []float32{1, 0, 0}},
+ {Token: "canceled-second", Vector: []float32{0, 1, 0}},
+ })
+ require.ErrorIs(t, err, context.Canceled)
+ assert.Zero(t, tokenCount("canceled-first"))
+ assert.Zero(t, tokenCount("canceled-second"), "a canceled put exposes no partial batch")
+
+ hits, err := backend.Search(ctx, gen1, 3, []float32{1, 0, 0}, 4)
+ require.NoError(t, err)
+ require.Len(t, hits, 4)
+ assert.Equal(t, []string{"token-a", "token-b", "token-c", "token-d"}, hitTokens(hits))
+ assert.Equal(t, []int{1, 2, 3, 4}, hitRanks(hits))
+ assert.InDelta(t, 1, hits[0].Score, 1e-6)
+ assert.InDelta(t, 0, hits[1].Score, 1e-6)
+ assert.InDelta(t, 0, hits[2].Score, 1e-6)
+ assert.InDelta(t, -1, hits[3].Score, 1e-6)
+ paged, ok := backend.(document.PagedBackend)
+ require.True(t, ok)
+ firstPage, err := paged.SearchPage(ctx, gen1, 3, []float32{1, 0, 0}, "", 2)
+ require.NoError(t, err)
+ assert.False(t, firstPage.Exhausted)
+ assert.Equal(t, []string{"token-a", "token-b"}, hitTokens(firstPage.Hits))
+ require.NoError(t, backend.DeleteTokens(ctx, gen1, []string{"token-a"}))
+ secondPage, err := paged.SearchPage(ctx, gen1, 3, []float32{1, 0, 0}, firstPage.NextCursor, 2)
+ require.NoError(t, err)
+ assert.True(t, secondPage.Exhausted)
+ assert.Equal(t, []string{"token-c", "token-d"}, hitTokens(secondPage.Hits))
+ assert.Equal(t, []int{3, 4}, hitRanks(secondPage.Hits))
+
+ require.NoError(t, backend.PutUnpublished(ctx, gen1, 3, []document.Embedding{
+ {Token: "token-a", Vector: []float32{0, 0, 1}},
+ }))
+ hits, err = backend.Search(ctx, gen1, 3, []float32{0, 0, 1}, 1)
+ require.NoError(t, err)
+ require.Len(t, hits, 1)
+ assert.Equal(t, "token-a", hits[0].Token)
+ assert.InDelta(t, 1, hits[0].Score, 1e-6)
+
+ require.NoError(t, backend.PutUnpublished(ctx, gen2, 4, []document.Embedding{
+ {Token: "token-four", Vector: []float32{0, 0, 0, 1}},
+ }))
+ hits, err = backend.Search(ctx, gen2, 4, []float32{0, 0, 0, 1}, 10)
+ require.NoError(t, err)
+ require.Len(t, hits, 1)
+ assert.Equal(t, "token-four", hits[0].Token)
+ hits, err = backend.Search(ctx, gen1, 3, []float32{0, 0, 1}, 10)
+ require.NoError(t, err)
+ assert.NotContains(t, hitTokens(hits), "token-four")
+
+ err = backend.PutUnpublished(ctx, gen2, 4, []document.Embedding{
+ {Token: "token-new", Vector: []float32{1, 0, 0, 0}},
+ {Token: "token-a", Vector: []float32{0, 1, 0, 0}},
+ })
+ require.ErrorIs(t, err, document.ErrInvalidVector)
+ assert.Zero(t, tokenCount("token-new"), "generation collision rolls back the whole batch")
+
+ err = backend.PutUnpublished(ctx, gen1, 3, []document.Embedding{
+ {Token: "token-partial", Vector: []float32{1, 0, 0}},
+ {Token: "token-wrong", Vector: []float32{1, 0}},
+ })
+ require.ErrorIs(t, err, vector.ErrDimensionMismatch)
+ assert.Zero(t, tokenCount("token-partial"), "dimension rejection happens before any write")
+ err = backend.PutUnpublished(ctx, gen1, 3, []document.Embedding{
+ {Token: "token-partial-nonfinite", Vector: []float32{1, 0, 0}},
+ {Token: "token-nonfinite", Vector: []float32{1, float32(math.NaN()), 0}},
+ })
+ require.ErrorIs(t, err, document.ErrInvalidVector)
+ assert.Zero(t, tokenCount("token-partial-nonfinite"), "nonfinite rejection happens before any write")
+
+ for name, batch := range map[string][]document.Embedding{
+ "duplicate": {
+ {Token: "duplicate", Vector: []float32{1, 0, 0}},
+ {Token: "duplicate", Vector: []float32{0, 1, 0}},
+ },
+ "empty token": {{Token: "", Vector: []float32{1, 0, 0}}},
+ "long token": {{Token: strings.Repeat("x", 1025), Vector: []float32{1, 0, 0}}},
+ "nul token": {{Token: "x\x00y", Vector: []float32{1, 0, 0}}},
+ "nonfinite": {{Token: "nan", Vector: []float32{1, float32(math.NaN()), 0}}},
+ "infinite": {{Token: "inf", Vector: []float32{1, float32(math.Inf(1)), 0}}},
+ "zero norm": {{Token: "zero", Vector: []float32{0, 0, 0}}},
+ } {
+ t.Run("put rejects "+name, func(t *testing.T) {
+ err := backend.PutUnpublished(ctx, gen1, 3, batch)
+ require.ErrorIs(t, err, document.ErrInvalidVector)
+ })
+ }
+
+ tooMany := make([]document.Embedding, 1001)
+ for i := range tooMany {
+ tooMany[i] = document.Embedding{Token: "bounded-" + string(rune(i+1)), Vector: []float32{1, 0, 0}}
+ }
+ for name, call := range map[string]func() error{
+ "put generation": func() error { return backend.PutUnpublished(ctx, 0, 3, embeddings[:1]) },
+ "put dimension": func() error { return backend.PutUnpublished(ctx, gen1, 0, embeddings[:1]) },
+ "put batch": func() error { return backend.PutUnpublished(ctx, gen1, 3, tooMany) },
+ "delete gen": func() error { return backend.DeleteTokens(ctx, 0, []string{"token-a"}) },
+ "delete batch": func() error { return backend.DeleteTokens(ctx, gen1, make([]string, 1001)) },
+ "search gen": func() error {
+ _, err := backend.Search(ctx, 0, 3, []float32{1, 0, 0}, 1)
+ return err
+ },
+ "search dimension": func() error {
+ _, err := backend.Search(ctx, gen1, 0, []float32{1, 0, 0}, 1)
+ return err
+ },
+ "search k zero": func() error {
+ _, err := backend.Search(ctx, gen1, 3, []float32{1, 0, 0}, 0)
+ return err
+ },
+ "search k large": func() error {
+ _, err := backend.Search(ctx, gen1, 3, []float32{1, 0, 0}, 1001)
+ return err
+ },
+ "search nonfinite": func() error {
+ _, err := backend.Search(ctx, gen1, 3, []float32{1, float32(math.Inf(-1)), 0}, 1)
+ return err
+ },
+ } {
+ t.Run("bounds reject "+name, func(t *testing.T) {
+ require.ErrorIs(t, call(), document.ErrInvalidVector)
+ })
+ }
+ _, err = backend.Search(ctx, gen1, 3, []float32{1, 0}, 1)
+ require.ErrorIs(t, err, vector.ErrDimensionMismatch)
+
+ require.NoError(t, backend.DeleteTokens(ctx, gen2, []string{"token-a"}), "wrong generation is an idempotent no-op")
+ assert.Equal(t, 1, tokenCount("token-a"))
+ require.NoError(t, backend.DeleteTokens(ctx, gen1, []string{"token-a"}))
+ require.NoError(t, backend.DeleteTokens(ctx, gen1, []string{"token-a"}), "repeat delete is idempotent")
+ assert.Zero(t, tokenCount("token-a"))
+ hits, err = backend.Search(ctx, gen1, 3, []float32{0, 0, 1}, 10)
+ require.NoError(t, err)
+ assert.NotContains(t, hitTokens(hits), "token-a")
+}
+
+func hitTokens(hits []document.Hit) []string {
+ out := make([]string, len(hits))
+ for i := range hits {
+ out[i] = hits[i].Token
+ }
+ return out
+}
+
+func hitRanks(hits []document.Hit) []int {
+ out := make([]int, len(hits))
+ for i := range hits {
+ out[i] = hits[i].Rank
+ }
+ return out
+}
+
+func TestDocumentVectorMigrationSQLite(t *testing.T) {
+ parent, _ := openDocumentBackendForTest(t)
+ var tableSQL string
+ require.NoError(t, parent.db.QueryRow(
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'document_vector_embeddings'`).Scan(&tableSQL))
+ assert.Contains(t, tableSQL, "token")
+ assert.NotContains(t, tableSQL, "REFERENCES index_generations")
+
+ require.NoError(t, EnsureDocumentVectorTable(context.Background(), parent.db, 4))
+ require.NoError(t, EnsureDocumentVectorTable(context.Background(), parent.db, 4), "lazy table migration is idempotent")
+ require.NoError(t, parent.db.QueryRow(
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`, DocumentVectorTableName(4)).Scan(&tableSQL))
+ assert.Contains(t, tableSQL, "distance_metric=cosine")
+}
diff --git a/internal/vector/sqlitevec/migrate.go b/internal/vector/sqlitevec/migrate.go
index 7b9e6fa0a..5ca8976f3 100644
--- a/internal/vector/sqlitevec/migrate.go
+++ b/internal/vector/sqlitevec/migrate.go
@@ -554,3 +554,26 @@ func EnsureVectorTable(ctx context.Context, db *sql.DB, dim int) error {
func VectorTableName(dim int) string {
return fmt.Sprintf("vectors_vec_d%d", dim)
}
+
+// EnsureDocumentVectorTable lazily creates the dedicated cosine vec0 table
+// for one document-vector dimension. Its row IDs join only to
+// document_vector_embeddings and never to message embeddings.
+func EnsureDocumentVectorTable(ctx context.Context, db *sql.DB, dim int) error {
+ if dim <= 0 {
+ return fmt.Errorf("invalid document vector dimension %d", dim)
+ }
+ q := fmt.Sprintf(`CREATE VIRTUAL TABLE IF NOT EXISTS %s USING vec0(
+ generation_id INTEGER PARTITION KEY,
+ document_vector_id INTEGER PRIMARY KEY,
+ embedding FLOAT[%d] distance_metric=cosine
+ )`, DocumentVectorTableName(dim), dim)
+ if _, err := db.ExecContext(ctx, q); err != nil {
+ return fmt.Errorf("create %s: %w", DocumentVectorTableName(dim), err)
+ }
+ return nil
+}
+
+// DocumentVectorTableName returns the dedicated dimension-specific vec0 name.
+func DocumentVectorTableName(dim int) string {
+ return fmt.Sprintf("document_vectors_vec_d%d", dim)
+}
diff --git a/internal/vector/sqlitevec/schema.sql b/internal/vector/sqlitevec/schema.sql
index 1283af066..7aa4a661b 100644
--- a/internal/vector/sqlitevec/schema.sql
+++ b/internal/vector/sqlitevec/schema.sql
@@ -140,7 +140,6 @@ CREATE TABLE IF NOT EXISTS embedding_document_progress (
reconcile_cursor TEXT NOT NULL DEFAULT '',
journal_cursor TEXT NOT NULL DEFAULT ''
);
-
-- Visual vectors remain opaque in vectors.db. Ownership, source evidence,
-- publication state, and model inputs stay in the authoritative archive.
-- The fixed 1,024 dimension is the voyage-multimodal-3.5 contract.
@@ -153,3 +152,15 @@ CREATE TABLE IF NOT EXISTS visual_vectors (
CREATE VIRTUAL TABLE IF NOT EXISTS visual_vectors_vec USING vec0(
embedding float[1024] distance_metric=cosine
);
+
+-- Independent attachment-document vectors. Publication tokens are globally
+-- unique opaque identities; generation IDs belong to the main document-vector
+-- ledger and intentionally do not reference message index_generations.
+CREATE TABLE IF NOT EXISTS document_vector_embeddings (
+ document_vector_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ token TEXT NOT NULL UNIQUE,
+ generation_id INTEGER NOT NULL,
+ dimension INTEGER NOT NULL CHECK (dimension > 0)
+);
+CREATE INDEX IF NOT EXISTS idx_document_vector_embeddings_generation
+ ON document_vector_embeddings(generation_id, dimension, token);
diff --git a/nix/package.nix b/nix/package.nix
index dd7e87db9..e49c7606b 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -16,7 +16,7 @@ buildGoModule {
src = gitignoreSource ../.;
- vendorHash = "sha256-mywedDZlx89nFjHuZmSqRxLnPMzVVNR4dqM2RwGWYbI=";
+ vendorHash = "sha256-0fb5ejfR9ctrGQCZjw209yHsRZsWyRXs4hVV5MogVkg=";
proxyVendor = true;
# Bun's copyfile backend can install incomplete packages when fetchBunDeps'
diff --git a/pkg/client/generated/client.go b/pkg/client/generated/client.go
index 2c6980d74..9abc8eaa9 100644
--- a/pkg/client/generated/client.go
+++ b/pkg/client/generated/client.go
@@ -339,6 +339,10 @@ type ClientInterface interface {
GetDocumentIndexStatus(ctx context.Context, options *GetDocumentIndexStatusRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetDocumentIndexStatusResponse, error)
GetDocumentIndexStatusWithResponse(ctx context.Context, options *GetDocumentIndexStatusRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetDocumentIndexStatusResp, error)
+ // GetDocumentVectorStatus Get document vector generation, consent, usage, and failure status
+ GetDocumentVectorStatus(ctx context.Context, options *GetDocumentVectorStatusRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetDocumentVectorStatusResponse, error)
+ GetDocumentVectorStatusWithResponse(ctx context.Context, options *GetDocumentVectorStatusRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetDocumentVectorStatusResp, error)
+
// SearchDomains Search analytical domains
SearchDomains(ctx context.Context, options *SearchDomainsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchDomainsResponse, error)
SearchDomainsWithResponse(ctx context.Context, options *SearchDomainsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchDomainsResp, error)
@@ -5550,6 +5554,69 @@ func (c *Client) GetDocumentIndexStatus(ctx context.Context, options *GetDocumen
return responseParser(ctx, resp)
}
+// GetDocumentVectorStatus Get document vector generation, consent, usage, and failure status
+func (c *Client) GetDocumentVectorStatus(ctx context.Context, options *GetDocumentVectorStatusRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetDocumentVectorStatusResponse, error) {
+ var err error
+ reqParams := runtime.RequestOptionsParameters{
+ RequestURL: c.apiClient.GetBaseURL() + "/api/v1/documents/vectors/status",
+ Method: "GET",
+ Options: options,
+ }
+
+ req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...)
+ if err != nil {
+ return nil, fmt.Errorf("error creating request: %w", err)
+ }
+
+ responseParser := func(ctx context.Context, resp *runtime.Response) (*GetDocumentVectorStatusResponse, error) {
+ bodyBytes := resp.Content
+ if resp.StatusCode != 200 {
+ target := new(GetDocumentVectorStatusErrorResponse)
+ // Handle empty error response body gracefully - skip unmarshal if no content
+ if len(bodyBytes) > 0 {
+ if err = json.Unmarshal(bodyBytes, target); err != nil {
+ return nil, &runtime.ResponseDecodeError{
+ StatusCode: resp.StatusCode,
+ ContentType: resp.Headers.Get("Content-Type"),
+ ContentLength: len(bodyBytes),
+ TargetType: "GetDocumentVectorStatusErrorResponse",
+ Body: bodyBytes,
+ Err: err,
+ }
+ }
+ }
+ // Return error with (possibly empty) target
+ if errTarget, ok := any(*target).(error); ok {
+ return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode))
+ }
+ return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target),
+ runtime.WithStatusCode(resp.StatusCode))
+ }
+ target := new(GetDocumentVectorStatusResponse)
+ // Handle empty response body gracefully
+ if len(bodyBytes) == 0 {
+ return target, nil
+ }
+ if err = json.Unmarshal(bodyBytes, target); err != nil {
+ return nil, &runtime.ResponseDecodeError{
+ StatusCode: resp.StatusCode,
+ ContentType: resp.Headers.Get("Content-Type"),
+ ContentLength: len(bodyBytes),
+ TargetType: "GetDocumentVectorStatusResponse",
+ Body: bodyBytes,
+ Err: err,
+ }
+ }
+ return target, nil
+ }
+
+ resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/documents/vectors/status")
+ if err != nil {
+ return nil, fmt.Errorf("error executing request: %w", err)
+ }
+ return responseParser(ctx, resp)
+}
+
// SearchDomains Search analytical domains
func (c *Client) SearchDomains(ctx context.Context, options *SearchDomainsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchDomainsResponse, error) {
var err error
diff --git a/pkg/client/generated/client_options.go b/pkg/client/generated/client_options.go
index 141c6b320..9345e29a3 100644
--- a/pkg/client/generated/client_options.go
+++ b/pkg/client/generated/client_options.go
@@ -2930,6 +2930,50 @@ func (o *GetDocumentIndexStatusRequestOptions) GetHeader() (map[string]string, e
return nil, nil
}
+// GetDocumentVectorStatusRequestOptions is the options needed to make a request to GetDocumentVectorStatus.
+type GetDocumentVectorStatusRequestOptions struct {
+ Query *GetDocumentVectorStatusQuery
+}
+
+// Validate validates all the fields in the options.
+// Use it if fields validation was not run.
+func (o *GetDocumentVectorStatusRequestOptions) Validate() error {
+ var errors runtime.ValidationErrors
+
+ if o.Query != nil {
+ if v, ok := any(o.Query).(runtime.Validator); ok {
+ if err := v.Validate(); err != nil {
+ errors = errors.Append("Query", err)
+ }
+ }
+ }
+ if len(errors) == 0 {
+ return nil
+ }
+
+ return errors
+}
+
+// GetPathParams returns the path params as a map.
+func (o *GetDocumentVectorStatusRequestOptions) GetPathParams() (map[string]any, error) {
+ return nil, nil
+}
+
+// GetQuery returns the query params as a map.
+func (o *GetDocumentVectorStatusRequestOptions) GetQuery() (map[string]any, error) {
+ return runtime.AsMap[any](o.Query)
+}
+
+// GetBody returns the payload in any type that can be marshalled to JSON by the client.
+func (o *GetDocumentVectorStatusRequestOptions) GetBody() any {
+ return nil
+}
+
+// GetHeader returns the headers as a map.
+func (o *GetDocumentVectorStatusRequestOptions) GetHeader() (map[string]string, error) {
+ return nil, nil
+}
+
// SearchDomainsRequestOptions is the options needed to make a request to SearchDomains.
type SearchDomainsRequestOptions struct {
Body *SearchDomainsBody
diff --git a/pkg/client/generated/client_with_response.go b/pkg/client/generated/client_with_response.go
index 7c0931867..828c70de8 100644
--- a/pkg/client/generated/client_with_response.go
+++ b/pkg/client/generated/client_with_response.go
@@ -6447,6 +6447,117 @@ func (c *Client) GetDocumentIndexStatusWithResponse(ctx context.Context, options
}
}
+// GetDocumentVectorStatus Get document vector generation, consent, usage, and failure status
+func (c *Client) GetDocumentVectorStatusWithResponse(ctx context.Context, options *GetDocumentVectorStatusRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetDocumentVectorStatusResp, error) {
+ var err error
+ reqParams := runtime.RequestOptionsParameters{
+ RequestURL: c.apiClient.GetBaseURL() + "/api/v1/documents/vectors/status",
+ Method: "GET",
+ Options: options,
+ }
+
+ req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...)
+ if err != nil {
+ return nil, fmt.Errorf("error creating request: %w", err)
+ }
+
+ resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/documents/vectors/status")
+ if err != nil {
+ return nil, fmt.Errorf("error executing request: %w", err)
+ }
+
+ out := &GetDocumentVectorStatusResp{
+ HTTPResponse: resp.Raw,
+ Body: resp.Content,
+ StatusCode: resp.StatusCode,
+ }
+
+ switch resp.StatusCode {
+ case 200:
+ out.JSON200 = new(GetDocumentVectorStatusResponse)
+ bodyBytes := resp.Content
+ if len(bodyBytes) > 0 {
+ if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil {
+ return out, &runtime.ResponseDecodeError{
+ StatusCode: resp.StatusCode,
+ ContentType: resp.Headers.Get("Content-Type"),
+ ContentLength: len(bodyBytes),
+ TargetType: "GetDocumentVectorStatusResponse",
+ Body: bodyBytes,
+ Err: err,
+ }
+ }
+ }
+ return out, nil
+ case 400:
+ out.JSON400 = new(GetDocumentVectorStatusErrorResponse)
+ bodyBytes := resp.Content
+ if len(bodyBytes) > 0 {
+ if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil {
+ return out, &runtime.ResponseDecodeError{
+ StatusCode: resp.StatusCode,
+ ContentType: resp.Headers.Get("Content-Type"),
+ ContentLength: len(bodyBytes),
+ TargetType: "GetDocumentVectorStatusErrorResponse",
+ Body: bodyBytes,
+ Err: err,
+ }
+ }
+ }
+ return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode))
+ case 403:
+ out.JSON403 = new(GetDocumentVectorStatusErrorResponseJSON)
+ bodyBytes := resp.Content
+ if len(bodyBytes) > 0 {
+ if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil {
+ return out, &runtime.ResponseDecodeError{
+ StatusCode: resp.StatusCode,
+ ContentType: resp.Headers.Get("Content-Type"),
+ ContentLength: len(bodyBytes),
+ TargetType: "GetDocumentVectorStatusErrorResponseJSON",
+ Body: bodyBytes,
+ Err: err,
+ }
+ }
+ }
+ return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode))
+ case 429:
+ out.JSON429 = new(GetDocumentVectorStatusErrorResponseJSON429)
+ bodyBytes := resp.Content
+ if len(bodyBytes) > 0 {
+ if err := json.Unmarshal(bodyBytes, out.JSON429); err != nil {
+ return out, &runtime.ResponseDecodeError{
+ StatusCode: resp.StatusCode,
+ ContentType: resp.Headers.Get("Content-Type"),
+ ContentLength: len(bodyBytes),
+ TargetType: "GetDocumentVectorStatusErrorResponseJSON429",
+ Body: bodyBytes,
+ Err: err,
+ }
+ }
+ }
+ return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode))
+ case 503:
+ out.JSON503 = new(GetDocumentVectorStatusErrorResponseJSON503)
+ bodyBytes := resp.Content
+ if len(bodyBytes) > 0 {
+ if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil {
+ return out, &runtime.ResponseDecodeError{
+ StatusCode: resp.StatusCode,
+ ContentType: resp.Headers.Get("Content-Type"),
+ ContentLength: len(bodyBytes),
+ TargetType: "GetDocumentVectorStatusErrorResponseJSON503",
+ Body: bodyBytes,
+ Err: err,
+ }
+ }
+ }
+ return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode))
+ default:
+ return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode))
+ }
+}
+
// SearchDomains Search analytical domains
func (c *Client) SearchDomainsWithResponse(ctx context.Context, options *SearchDomainsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchDomainsResp, error) {
var err error
diff --git a/pkg/client/generated/queries.go b/pkg/client/generated/queries.go
index 593d16f19..b83d5172f 100644
--- a/pkg/client/generated/queries.go
+++ b/pkg/client/generated/queries.go
@@ -365,6 +365,12 @@ type SearchDocumentsQuery struct {
// Cursor Opaque cursor from the previous document search page
Cursor *string `json:"cursor,omitempty"`
+
+ // Mode Search mode: lexical (default and auto); semantic/hybrid send the query to the embedding provider
+ Mode *string `json:"mode,omitempty"`
+
+ // CandidateLimit Maximum candidates (default/max: lexical 10000; semantic/hybrid 100/1000)
+ CandidateLimit *int64 `json:"candidate_limit,omitempty"`
}
func (s SearchDocumentsQuery) Validate() error {
@@ -389,6 +395,17 @@ func (g GetDocumentIndexStatusQuery) Validate() error {
return runtime.ConvertValidatorError(typesValidator.Struct(g))
}
+type GetDocumentVectorStatusQuery struct {
+ // GenerationID Generation whose bounded failures to inspect
+ GenerationID *int64 `json:"generation_id,omitempty"`
+
+ // AfterToken Stable failure cursor token
+ AfterToken *string `json:"after_token,omitempty"`
+
+ // Limit Maximum failure diagnostics (default 20, max 1000)
+ Limit *int64 `json:"limit,omitempty"`
+}
+
type ListIdentityMatchCandidatesQuery struct {
// State Candidate state filter (candidate, accepted, rejected, conflict); repeat or comma-separate for multiple values
State *string `json:"state,omitempty"`
diff --git a/pkg/client/generated/responses.go b/pkg/client/generated/responses.go
index aec821c74..a507855b5 100644
--- a/pkg/client/generated/responses.go
+++ b/pkg/client/generated/responses.go
@@ -593,6 +593,16 @@ type GetDocumentIndexStatusErrorResponseJSON429 = ErrorResponse
type GetDocumentIndexStatusErrorResponseJSON503 = ErrorResponse
+type GetDocumentVectorStatusResponse = DocumentVectorOperationsResponse
+
+type GetDocumentVectorStatusErrorResponse = ErrorResponse
+
+type GetDocumentVectorStatusErrorResponseJSON = ErrorResponse
+
+type GetDocumentVectorStatusErrorResponseJSON429 = ErrorResponse
+
+type GetDocumentVectorStatusErrorResponseJSON503 = ErrorResponse
+
type SearchDomainsResponse = DomainSearchHTTPResponse
type SearchDomainsErrorResponse = ErrorResponse
@@ -3322,6 +3332,17 @@ type GetDocumentIndexStatusResp struct {
JSON503 *GetDocumentIndexStatusErrorResponseJSON503
}
+type GetDocumentVectorStatusResp struct {
+ HTTPResponse *http.Response
+ Body []byte
+ StatusCode int
+ JSON200 *GetDocumentVectorStatusResponse
+ JSON400 *GetDocumentVectorStatusErrorResponse
+ JSON403 *GetDocumentVectorStatusErrorResponseJSON
+ JSON429 *GetDocumentVectorStatusErrorResponseJSON429
+ JSON503 *GetDocumentVectorStatusErrorResponseJSON503
+}
+
type SearchDomainsResp struct {
HTTPResponse *http.Response
Body []byte
diff --git a/pkg/client/generated/types.go b/pkg/client/generated/types.go
index fd2f6e452..d4e76872d 100644
--- a/pkg/client/generated/types.go
+++ b/pkg/client/generated/types.go
@@ -2105,10 +2105,13 @@ func (d DocumentIndexStatusResponse) Validate() error {
}
type DocumentSearchResponse struct {
- NextCursor *string `json:"next_cursor,omitempty"`
- Results []DocumentSearchResult `json:"results,omitempty" validate:"required"`
- Revision int64 `json:"revision"`
- Truncated *bool `json:"truncated,omitempty"`
+ EffectiveMode *string `json:"effective_mode,omitempty"`
+ NextCursor *string `json:"next_cursor,omitempty"`
+ Results []DocumentSearchResult `json:"results,omitempty" validate:"required"`
+ Revision int64 `json:"revision"`
+ Truncated *bool `json:"truncated,omitempty"`
+ VectorGenerationFingerprint *string `json:"vector_generation_fingerprint,omitempty"`
+ VectorGenerationID *int64 `json:"vector_generation_id,omitempty"`
}
func (d DocumentSearchResponse) Validate() error {
@@ -2127,35 +2130,45 @@ func (d DocumentSearchResponse) Validate() error {
}
type DocumentSearchResult struct {
- AttachmentID int64 `json:"attachment_id"`
- CanonicalBlobHash string `json:"canonical_blob_hash" validate:"required"`
- ChunkKey string `json:"chunk_key" validate:"required"`
- ChunkOrdinal int64 `json:"chunk_ordinal"`
- ContainingTitle *string `json:"containing_title,omitempty"`
- ConversationID int64 `json:"conversation_id"`
- Excerpt string `json:"excerpt" validate:"required"`
- ExtractionID string `json:"extraction_id" validate:"required"`
- Filename *string `json:"filename,omitempty"`
- FirstUnitIndex int64 `json:"first_unit_index"`
- HeadingPath []string `json:"heading_path,omitempty"`
- HighlightEnd int64 `json:"highlight_end"`
- HighlightStart int64 `json:"highlight_start"`
- LastUnitIndex int64 `json:"last_unit_index"`
- MatchedSignals []string `json:"matched_signals,omitempty" validate:"required"`
- MessageID int64 `json:"message_id"`
- MimeType *string `json:"mime_type,omitempty"`
- Model string `json:"model" validate:"required"`
- OccurredAt *time.Time `json:"occurred_at,omitempty"`
- OccurrenceKey string `json:"occurrence_key" validate:"required"`
- OtherLiveCopies int64 `json:"other_live_copies"`
- PersonProvenance *Provenance `json:"person_provenance,omitempty"`
- ProfileID string `json:"profile_id" validate:"required"`
- Provider string `json:"provider" validate:"required"`
- Rank int64 `json:"rank"`
- SourceID int64 `json:"source_id"`
- SourceMessageID *string `json:"source_message_id,omitempty"`
- SourcePartKey *string `json:"source_part_key,omitempty"`
- Truncated bool `json:"truncated"`
+ AttachmentID int64 `json:"attachment_id"`
+ CanonicalBlobHash string `json:"canonical_blob_hash" validate:"required"`
+ ChunkKey string `json:"chunk_key" validate:"required"`
+ ChunkOrdinal int64 `json:"chunk_ordinal"`
+ ContainingTitle *string `json:"containing_title,omitempty"`
+ ConversationID int64 `json:"conversation_id"`
+ Excerpt string `json:"excerpt" validate:"required"`
+ ExtractionID string `json:"extraction_id" validate:"required"`
+ Filename *string `json:"filename,omitempty"`
+ FirstUnitIndex int64 `json:"first_unit_index"`
+ FusionScore *float64 `json:"fusion_score,omitempty"`
+ HeadingPath []string `json:"heading_path,omitempty"`
+ HighlightEnd int64 `json:"highlight_end"`
+ HighlightStart int64 `json:"highlight_start"`
+ LastUnitIndex int64 `json:"last_unit_index"`
+ LexicalRank *int64 `json:"lexical_rank,omitempty"`
+ MatchedSignals []string `json:"matched_signals,omitempty" validate:"required"`
+ MessageID int64 `json:"message_id"`
+ MimeType *string `json:"mime_type,omitempty"`
+ Model string `json:"model" validate:"required"`
+ OccurredAt *time.Time `json:"occurred_at,omitempty"`
+ OccurrenceKey string `json:"occurrence_key" validate:"required"`
+ OtherLiveCopies int64 `json:"other_live_copies"`
+ PersonProvenance *Provenance `json:"person_provenance,omitempty"`
+ ProfileID string `json:"profile_id" validate:"required"`
+ Provider string `json:"provider" validate:"required"`
+ Rank int64 `json:"rank"`
+ SemanticRank *int64 `json:"semantic_rank,omitempty"`
+ SemanticScore *float64 `json:"semantic_score,omitempty"`
+ SourceID int64 `json:"source_id"`
+ SourceMessageID *string `json:"source_message_id,omitempty"`
+ SourcePartKey *string `json:"source_part_key,omitempty"`
+ Truncated bool `json:"truncated"`
+ VectorDimension *int64 `json:"vector_dimension,omitempty"`
+ VectorEmbeddingProfile *string `json:"vector_embedding_profile,omitempty"`
+ VectorGenerationFingerprint *string `json:"vector_generation_fingerprint,omitempty"`
+ VectorGenerationID *int64 `json:"vector_generation_id,omitempty"`
+ VectorModel *string `json:"vector_model,omitempty"`
+ VectorToken *string `json:"vector_token,omitempty"`
}
func (d DocumentSearchResult) Validate() error {
@@ -2200,6 +2213,215 @@ func (d DocumentSearchResult) Validate() error {
return errors
}
+type DocumentVectorConsent struct {
+ ConsentedAt time.Time `json:"consented_at" validate:"required"`
+ Dimension int64 `json:"dimension"`
+ EgressFingerprint string `json:"egress_fingerprint" validate:"required"`
+ EmbeddingProfile string `json:"embedding_profile" validate:"required"`
+ Fingerprint string `json:"fingerprint" validate:"required"`
+ Model string `json:"model" validate:"required"`
+ Purpose string `json:"purpose" validate:"required"`
+ TargetExtractionProfileID string `json:"target_extraction_profile_id" validate:"required"`
+}
+
+func (d DocumentVectorConsent) Validate() error {
+ return runtime.ConvertValidatorError(typesValidator.Struct(d))
+}
+
+type DocumentVectorCoverage struct {
+ Ready int64 `json:"ready"`
+ Required int64 `json:"required"`
+}
+
+type DocumentVectorFailureDiagnostic struct {
+ AttemptCount int64 `json:"attempt_count"`
+ ErrorCode string `json:"error_code" validate:"required"`
+ NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
+ Terminal bool `json:"terminal"`
+ Token string `json:"token" validate:"required"`
+}
+
+func (d DocumentVectorFailureDiagnostic) Validate() error {
+ return runtime.ConvertValidatorError(typesValidator.Struct(d))
+}
+
+type DocumentVectorGeneration struct {
+ ActivatedAt *time.Time `json:"activated_at,omitempty"`
+ CreatedAt time.Time `json:"created_at" validate:"required"`
+ Dimension int64 `json:"dimension"`
+ EmbeddingProfile string `json:"embedding_profile" validate:"required"`
+ Fingerprint string `json:"fingerprint" validate:"required"`
+ ID int64 `json:"id"`
+ Model string `json:"model" validate:"required"`
+ RetiredAt *time.Time `json:"retired_at,omitempty"`
+ State string `json:"state" validate:"required"`
+ TargetExtractionProfileID string `json:"target_extraction_profile_id" validate:"required"`
+}
+
+func (d DocumentVectorGeneration) Validate() error {
+ return runtime.ConvertValidatorError(typesValidator.Struct(d))
+}
+
+type DocumentVectorGenerationSpec struct {
+ Dimension int64 `json:"dimension"`
+ EmbeddingProfile string `json:"embedding_profile" validate:"required"`
+ Fingerprint string `json:"fingerprint" validate:"required"`
+ Model string `json:"model" validate:"required"`
+ TargetExtractionProfileID string `json:"target_extraction_profile_id" validate:"required"`
+}
+
+func (d DocumentVectorGenerationSpec) Validate() error {
+ return runtime.ConvertValidatorError(typesValidator.Struct(d))
+}
+
+type DocumentVectorGenerationStatus struct {
+ Blocked bool `json:"blocked"`
+ CleanupPending int64 `json:"cleanup_pending"`
+ FailureAfterGenerationID *int64 `json:"failure_after_generation_id,omitempty"`
+ FailureAfterToken *string `json:"failure_after_token,omitempty"`
+ Failures []DocumentVectorFailureDiagnostic `json:"failures,omitempty" validate:"required"`
+ FailuresExhausted bool `json:"failures_exhausted"`
+ GenerationID int64 `json:"generation_id"`
+ Pending int64 `json:"pending"`
+ ReadyLive int64 `json:"ready_live"`
+ Retryable int64 `json:"retryable"`
+ StaleObsolete int64 `json:"stale_obsolete"`
+ State string `json:"state" validate:"required"`
+ Terminal int64 `json:"terminal"`
+}
+
+func (d DocumentVectorGenerationStatus) Validate() error {
+ var errors runtime.ValidationErrors
+ for i, item := range d.Failures {
+ if v, ok := any(item).(runtime.Validator); ok {
+ if err := v.Validate(); err != nil {
+ errors = errors.Append(fmt.Sprintf("Failures[%d]", i), err)
+ }
+ }
+ }
+ if err := typesValidator.Var(d.State, "required"); err != nil {
+ errors = errors.Append("State", err)
+ }
+ if len(errors) == 0 {
+ return nil
+ }
+ return errors
+}
+
+type DocumentVectorOperationsResponse struct {
+ Configured bool `json:"configured"`
+ Enabled bool `json:"enabled"`
+ ScheduledRegistrationRequiresRestart *bool `json:"scheduled_registration_requires_restart,omitempty"`
+ Status *DocumentVectorOperationsStatus `json:"status,omitempty"`
+}
+
+func (d DocumentVectorOperationsResponse) Validate() error {
+ var errors runtime.ValidationErrors
+ if d.Status != nil {
+ if v, ok := any(d.Status).(runtime.Validator); ok {
+ if err := v.Validate(); err != nil {
+ errors = errors.Append("Status", err)
+ }
+ }
+ }
+ if len(errors) == 0 {
+ return nil
+ }
+ return errors
+}
+
+type DocumentVectorOperationsStatus struct {
+ Active *DocumentVectorGeneration `json:"active,omitempty"`
+ Building *DocumentVectorGeneration `json:"building,omitempty"`
+ ConfiguredDocumentEgressFingerprint string `json:"configured_document_egress_fingerprint" validate:"required"`
+ ConfiguredQueryEgressFingerprint string `json:"configured_query_egress_fingerprint" validate:"required"`
+ ConfiguredSpec DocumentVectorGenerationSpec `json:"configured_spec"`
+ Coverage *DocumentVectorCoverage `json:"coverage,omitempty"`
+ DocumentConsent *DocumentVectorConsent `json:"document_consent,omitempty"`
+ QueryConsent *DocumentVectorConsent `json:"query_consent,omitempty"`
+ Selected *DocumentVectorGenerationStatus `json:"selected,omitempty"`
+ Usage DocumentVectorProviderUsage `json:"usage"`
+}
+
+func (d DocumentVectorOperationsStatus) Validate() error {
+ var errors runtime.ValidationErrors
+ if d.Active != nil {
+ if v, ok := any(d.Active).(runtime.Validator); ok {
+ if err := v.Validate(); err != nil {
+ errors = errors.Append("Active", err)
+ }
+ }
+ }
+ if d.Building != nil {
+ if v, ok := any(d.Building).(runtime.Validator); ok {
+ if err := v.Validate(); err != nil {
+ errors = errors.Append("Building", err)
+ }
+ }
+ }
+ if err := typesValidator.Var(d.ConfiguredDocumentEgressFingerprint, "required"); err != nil {
+ errors = errors.Append("ConfiguredDocumentEgressFingerprint", err)
+ }
+ if err := typesValidator.Var(d.ConfiguredQueryEgressFingerprint, "required"); err != nil {
+ errors = errors.Append("ConfiguredQueryEgressFingerprint", err)
+ }
+ if v, ok := any(d.ConfiguredSpec).(runtime.Validator); ok {
+ if err := v.Validate(); err != nil {
+ errors = errors.Append("ConfiguredSpec", err)
+ }
+ }
+ if d.Coverage != nil {
+ if v, ok := any(d.Coverage).(runtime.Validator); ok {
+ if err := v.Validate(); err != nil {
+ errors = errors.Append("Coverage", err)
+ }
+ }
+ }
+ if d.DocumentConsent != nil {
+ if v, ok := any(d.DocumentConsent).(runtime.Validator); ok {
+ if err := v.Validate(); err != nil {
+ errors = errors.Append("DocumentConsent", err)
+ }
+ }
+ }
+ if d.QueryConsent != nil {
+ if v, ok := any(d.QueryConsent).(runtime.Validator); ok {
+ if err := v.Validate(); err != nil {
+ errors = errors.Append("QueryConsent", err)
+ }
+ }
+ }
+ if d.Selected != nil {
+ if v, ok := any(d.Selected).(runtime.Validator); ok {
+ if err := v.Validate(); err != nil {
+ errors = errors.Append("Selected", err)
+ }
+ }
+ }
+ if v, ok := any(d.Usage).(runtime.Validator); ok {
+ if err := v.Validate(); err != nil {
+ errors = errors.Append("Usage", err)
+ }
+ }
+ if len(errors) == 0 {
+ return nil
+ }
+ return errors
+}
+
+type DocumentVectorProviderUsage struct {
+ Fingerprint string `json:"fingerprint" validate:"required"`
+ ProviderCalls int64 `json:"provider_calls"`
+ ProviderChunks int64 `json:"provider_chunks"`
+ ProviderDocuments int64 `json:"provider_documents"`
+ ProviderInputChars int64 `json:"provider_input_chars"`
+ UpdatedAt *time.Time `json:"updated_at,omitempty"`
+}
+
+func (d DocumentVectorProviderUsage) Validate() error {
+ return runtime.ConvertValidatorError(typesValidator.Struct(d))
+}
+
type DomainContextSummaryHTTPResponse struct {
CacheRevision string `json:"cache_revision" validate:"required"`
CandidateSnapshotID *string `json:"candidate_snapshot_id,omitempty"`
diff --git a/pkg/client/openapi.yaml b/pkg/client/openapi.yaml
index 67c23a549..a2be356c9 100644
--- a/pkg/client/openapi.yaml
+++ b/pkg/client/openapi.yaml
@@ -2491,6 +2491,8 @@ components:
type: object
DocumentSearchResponse:
properties:
+ effective_mode:
+ type: string
next_cursor:
type: string
results:
@@ -2503,6 +2505,11 @@ components:
type: integer
truncated:
type: boolean
+ vector_generation_fingerprint:
+ type: string
+ vector_generation_id:
+ format: int64
+ type: integer
required:
- results
- revision
@@ -2533,6 +2540,9 @@ components:
first_unit_index:
format: int64
type: integer
+ fusion_score:
+ format: double
+ type: number
heading_path:
items:
type: string
@@ -2547,6 +2557,9 @@ components:
last_unit_index:
format: int64
type: integer
+ lexical_rank:
+ format: int64
+ type: integer
matched_signals:
items:
type: string
@@ -2576,6 +2589,12 @@ components:
rank:
format: int64
type: integer
+ semantic_rank:
+ format: int64
+ type: integer
+ semantic_score:
+ format: double
+ type: number
source_id:
format: int64
type: integer
@@ -2585,6 +2604,20 @@ components:
type: string
truncated:
type: boolean
+ vector_dimension:
+ format: int64
+ type: integer
+ vector_embedding_profile:
+ type: string
+ vector_generation_fingerprint:
+ type: string
+ vector_generation_id:
+ format: int64
+ type: integer
+ vector_model:
+ type: string
+ vector_token:
+ type: string
required:
- attachment_id
- message_id
@@ -2608,6 +2641,245 @@ components:
- truncated
- rank
type: object
+ DocumentVectorConsent:
+ properties:
+ consented_at:
+ format: date-time
+ type: string
+ dimension:
+ format: int64
+ type: integer
+ egress_fingerprint:
+ type: string
+ embedding_profile:
+ type: string
+ fingerprint:
+ type: string
+ model:
+ type: string
+ purpose:
+ type: string
+ target_extraction_profile_id:
+ type: string
+ required:
+ - consented_at
+ - egress_fingerprint
+ - purpose
+ - fingerprint
+ - target_extraction_profile_id
+ - embedding_profile
+ - model
+ - dimension
+ type: object
+ DocumentVectorCoverage:
+ properties:
+ ready:
+ format: int64
+ type: integer
+ required:
+ format: int64
+ type: integer
+ required:
+ - required
+ - ready
+ type: object
+ DocumentVectorFailureDiagnostic:
+ properties:
+ attempt_count:
+ format: int64
+ type: integer
+ error_code:
+ type: string
+ next_retry_at:
+ format: date-time
+ type: string
+ terminal:
+ type: boolean
+ token:
+ type: string
+ required:
+ - token
+ - attempt_count
+ - terminal
+ - error_code
+ type: object
+ DocumentVectorGeneration:
+ properties:
+ activated_at:
+ format: date-time
+ type: string
+ created_at:
+ format: date-time
+ type: string
+ dimension:
+ format: int64
+ type: integer
+ embedding_profile:
+ type: string
+ fingerprint:
+ type: string
+ id:
+ format: int64
+ type: integer
+ model:
+ type: string
+ retired_at:
+ format: date-time
+ type: string
+ state:
+ type: string
+ target_extraction_profile_id:
+ type: string
+ required:
+ - id
+ - state
+ - created_at
+ - fingerprint
+ - target_extraction_profile_id
+ - embedding_profile
+ - model
+ - dimension
+ type: object
+ DocumentVectorGenerationSpec:
+ properties:
+ dimension:
+ format: int64
+ type: integer
+ embedding_profile:
+ type: string
+ fingerprint:
+ type: string
+ model:
+ type: string
+ target_extraction_profile_id:
+ type: string
+ required:
+ - fingerprint
+ - target_extraction_profile_id
+ - embedding_profile
+ - model
+ - dimension
+ type: object
+ DocumentVectorGenerationStatus:
+ properties:
+ blocked:
+ type: boolean
+ cleanup_pending:
+ format: int64
+ type: integer
+ failure_after_generation_id:
+ format: int64
+ type: integer
+ failure_after_token:
+ type: string
+ failures:
+ items:
+ $ref: "#/components/schemas/DocumentVectorFailureDiagnostic"
+ nullable: true
+ type: array
+ failures_exhausted:
+ type: boolean
+ generation_id:
+ format: int64
+ type: integer
+ pending:
+ format: int64
+ type: integer
+ ready_live:
+ format: int64
+ type: integer
+ retryable:
+ format: int64
+ type: integer
+ stale_obsolete:
+ format: int64
+ type: integer
+ state:
+ type: string
+ terminal:
+ format: int64
+ type: integer
+ required:
+ - generation_id
+ - state
+ - blocked
+ - pending
+ - retryable
+ - terminal
+ - ready_live
+ - stale_obsolete
+ - cleanup_pending
+ - failures
+ - failures_exhausted
+ type: object
+ DocumentVectorOperationsResponse:
+ properties:
+ configured:
+ type: boolean
+ enabled:
+ type: boolean
+ scheduled_registration_requires_restart:
+ type: boolean
+ status:
+ $ref: "#/components/schemas/DocumentVectorOperationsStatus"
+ required:
+ - enabled
+ - configured
+ type: object
+ DocumentVectorOperationsStatus:
+ properties:
+ active:
+ $ref: "#/components/schemas/DocumentVectorGeneration"
+ building:
+ $ref: "#/components/schemas/DocumentVectorGeneration"
+ configured_document_egress_fingerprint:
+ type: string
+ configured_query_egress_fingerprint:
+ type: string
+ configured_spec:
+ $ref: "#/components/schemas/DocumentVectorGenerationSpec"
+ coverage:
+ $ref: "#/components/schemas/DocumentVectorCoverage"
+ document_consent:
+ $ref: "#/components/schemas/DocumentVectorConsent"
+ query_consent:
+ $ref: "#/components/schemas/DocumentVectorConsent"
+ selected:
+ $ref: "#/components/schemas/DocumentVectorGenerationStatus"
+ usage:
+ $ref: "#/components/schemas/DocumentVectorProviderUsage"
+ required:
+ - configured_spec
+ - configured_document_egress_fingerprint
+ - configured_query_egress_fingerprint
+ - usage
+ type: object
+ DocumentVectorProviderUsage:
+ properties:
+ fingerprint:
+ type: string
+ provider_calls:
+ format: int64
+ type: integer
+ provider_chunks:
+ format: int64
+ type: integer
+ provider_documents:
+ format: int64
+ type: integer
+ provider_input_chars:
+ format: int64
+ type: integer
+ updated_at:
+ format: date-time
+ type: string
+ required:
+ - fingerprint
+ - provider_calls
+ - provider_documents
+ - provider_chunks
+ - provider_input_chars
+ type: object
DomainContextSummaryHTTPResponse:
properties:
cache_revision:
@@ -13433,6 +13705,17 @@ paths:
name: cursor
schema:
type: string
+ - description: "Search mode: lexical (default and auto); semantic/hybrid send the query to the embedding provider"
+ in: query
+ name: mode
+ schema:
+ type: string
+ - description: "Maximum candidates (default/max: lexical 10000; semantic/hybrid 100/1000)"
+ in: query
+ name: candidate_limit
+ schema:
+ format: int64
+ type: integer
responses:
"200":
content:
@@ -13560,6 +13843,69 @@ paths:
summary: Get extracted document index status
tags:
- API
+ /api/v1/documents/vectors/status:
+ get:
+ operationId: getDocumentVectorStatus
+ parameters:
+ - description: Generation whose bounded failures to inspect
+ in: query
+ name: generation_id
+ schema:
+ format: int64
+ type: integer
+ - description: Stable failure cursor token
+ in: query
+ name: after_token
+ schema:
+ type: string
+ - description: Maximum failure diagnostics (default 20, max 1000)
+ in: query
+ name: limit
+ schema:
+ format: int64
+ type: integer
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DocumentVectorOperationsResponse"
+ description: OK
+ "400":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ description: Error
+ "403":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ description: Error
+ "429":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ description: Error
+ "503":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ description: Error
+ default:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ description: Error
+ security:
+ - apiKey: []
+ summary: Get document vector generation, consent, usage, and failure status
+ tags:
+ - API
/api/v1/domains/search:
post:
operationId: searchDomains
diff --git a/web/src/lib/api/generated/schema.d.ts b/web/src/lib/api/generated/schema.d.ts
index 9aaf9574b..4dd8fe86e 100644
--- a/web/src/lib/api/generated/schema.d.ts
+++ b/web/src/lib/api/generated/schema.d.ts
@@ -1079,6 +1079,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/documents/vectors/status": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get document vector generation, consent, usage, and failure status */
+ get: operations["getDocumentVectorStatus"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/domains/search": {
parameters: {
query?: never;
@@ -4216,11 +4233,15 @@ export interface components {
[key: string]: unknown;
};
DocumentSearchResponse: {
+ effective_mode?: string;
next_cursor?: string;
results: components["schemas"]["DocumentSearchResult"][] | null;
/** Format: int64 */
revision: number;
truncated?: boolean;
+ vector_generation_fingerprint?: string;
+ /** Format: int64 */
+ vector_generation_id?: number;
} & {
[key: string]: unknown;
};
@@ -4239,6 +4260,8 @@ export interface components {
filename?: string;
/** Format: int64 */
first_unit_index: number;
+ /** Format: double */
+ fusion_score?: number;
heading_path?: string[] | null;
/** Format: int64 */
highlight_end: number;
@@ -4246,6 +4269,8 @@ export interface components {
highlight_start: number;
/** Format: int64 */
last_unit_index: number;
+ /** Format: int64 */
+ lexical_rank?: number;
matched_signals: string[] | null;
/** Format: int64 */
message_id: number;
@@ -4262,10 +4287,146 @@ export interface components {
/** Format: int64 */
rank: number;
/** Format: int64 */
+ semantic_rank?: number;
+ /** Format: double */
+ semantic_score?: number;
+ /** Format: int64 */
source_id: number;
source_message_id?: string;
source_part_key?: string;
truncated: boolean;
+ /** Format: int64 */
+ vector_dimension?: number;
+ vector_embedding_profile?: string;
+ vector_generation_fingerprint?: string;
+ /** Format: int64 */
+ vector_generation_id?: number;
+ vector_model?: string;
+ vector_token?: string;
+ } & {
+ [key: string]: unknown;
+ };
+ DocumentVectorConsent: {
+ /** Format: date-time */
+ consented_at: string;
+ /** Format: int64 */
+ dimension: number;
+ egress_fingerprint: string;
+ embedding_profile: string;
+ fingerprint: string;
+ model: string;
+ purpose: string;
+ target_extraction_profile_id: string;
+ } & {
+ [key: string]: unknown;
+ };
+ DocumentVectorCoverage: {
+ /** Format: int64 */
+ ready: number;
+ /** Format: int64 */
+ required: number;
+ } & {
+ [key: string]: unknown;
+ };
+ DocumentVectorFailureDiagnostic: {
+ /** Format: int64 */
+ attempt_count: number;
+ error_code: string;
+ /** Format: date-time */
+ next_retry_at?: string;
+ terminal: boolean;
+ token: string;
+ } & {
+ [key: string]: unknown;
+ };
+ DocumentVectorGeneration: {
+ /** Format: date-time */
+ activated_at?: string;
+ /** Format: date-time */
+ created_at: string;
+ /** Format: int64 */
+ dimension: number;
+ embedding_profile: string;
+ fingerprint: string;
+ /** Format: int64 */
+ id: number;
+ model: string;
+ /** Format: date-time */
+ retired_at?: string;
+ state: string;
+ target_extraction_profile_id: string;
+ } & {
+ [key: string]: unknown;
+ };
+ DocumentVectorGenerationSpec: {
+ /** Format: int64 */
+ dimension: number;
+ embedding_profile: string;
+ fingerprint: string;
+ model: string;
+ target_extraction_profile_id: string;
+ } & {
+ [key: string]: unknown;
+ };
+ DocumentVectorGenerationStatus: {
+ blocked: boolean;
+ /** Format: int64 */
+ cleanup_pending: number;
+ /** Format: int64 */
+ failure_after_generation_id?: number;
+ failure_after_token?: string;
+ failures: components["schemas"]["DocumentVectorFailureDiagnostic"][] | null;
+ failures_exhausted: boolean;
+ /** Format: int64 */
+ generation_id: number;
+ /** Format: int64 */
+ pending: number;
+ /** Format: int64 */
+ ready_live: number;
+ /** Format: int64 */
+ retryable: number;
+ /** Format: int64 */
+ stale_obsolete: number;
+ state: string;
+ /** Format: int64 */
+ terminal: number;
+ } & {
+ [key: string]: unknown;
+ };
+ DocumentVectorOperationsResponse: {
+ configured: boolean;
+ enabled: boolean;
+ scheduled_registration_requires_restart?: boolean;
+ status?: components["schemas"]["DocumentVectorOperationsStatus"];
+ } & {
+ [key: string]: unknown;
+ };
+ DocumentVectorOperationsStatus: {
+ active?: components["schemas"]["DocumentVectorGeneration"];
+ building?: components["schemas"]["DocumentVectorGeneration"];
+ configured_document_egress_fingerprint: string;
+ configured_query_egress_fingerprint: string;
+ configured_spec: components["schemas"]["DocumentVectorGenerationSpec"];
+ coverage?: components["schemas"]["DocumentVectorCoverage"];
+ document_consent?: components["schemas"]["DocumentVectorConsent"];
+ query_consent?: components["schemas"]["DocumentVectorConsent"];
+ selected?: components["schemas"]["DocumentVectorGenerationStatus"];
+ usage: components["schemas"]["DocumentVectorProviderUsage"];
+ } & {
+ [key: string]: unknown;
+ };
+ DocumentVectorProviderUsage: {
+ fingerprint: string;
+ /** Format: int64 */
+ provider_calls: number;
+ /** Format: int64 */
+ provider_chunks: number;
+ /** Format: int64 */
+ provider_documents: number;
+ /** Format: int64 */
+ provider_input_chars: number;
+ /** Format: date-time */
+ updated_at?: string;
} & {
[key: string]: unknown;
};
@@ -11500,6 +11661,10 @@ export interface operations {
limit?: number;
/** @description Opaque cursor from the previous document search page */
cursor?: string;
+ /** @description Search mode: lexical (default and auto); semantic/hybrid send the query to the embedding provider */
+ mode?: string;
+ /** @description Maximum candidates (default/max: lexical 10000; semantic/hybrid 100/1000) */
+ candidate_limit?: number;
};
header?: never;
path?: never;
@@ -11655,6 +11820,78 @@ export interface operations {
};
};
};
+ getDocumentVectorStatus: {
+ parameters: {
+ query?: {
+ /** @description Generation whose bounded failures to inspect */
+ generation_id?: number;
+ /** @description Stable failure cursor token */
+ after_token?: string;
+ /** @description Maximum failure diagnostics (default 20, max 1000) */
+ limit?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["DocumentVectorOperationsResponse"];
+ };
+ };
+ /** @description Error */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ErrorResponse"];
+ };
+ };
+ /** @description Error */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ErrorResponse"];
+ };
+ };
+ /** @description Error */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ErrorResponse"];
+ };
+ };
+ /** @description Error */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ErrorResponse"];
+ };
+ };
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
searchDomains: {
parameters: {
query?: never;