From ba1d7b0e788fb3379a0de1875f45ba4144bed2d9 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 8 Aug 2026 16:52:33 +0200 Subject: [PATCH] fix: fail closed v3 legacy semantic fallbacks --- backend/src/workspaces/bindings.ts | 31 +++++++++----- backend/src/workspaces/contracts.ts | 40 +++++++++++-------- backend/src/workspaces/diagnostics.ts | 18 +++++---- backend/src/workspaces/runtime-renderer.ts | 12 +++++- backend/src/workspaces/schema.ts | 5 --- backend/src/workspaces/types.ts | 8 +--- .../test/workspace-runtime-renderer.test.ts | 29 ++++++++++++++ backend/test/workspaces-bindings.test.ts | 32 +++++++++++++++ backend/test/workspaces-contracts.test.ts | 33 +++++++++++++++ backend/test/workspaces-schema.test.ts | 12 ++++++ 10 files changed, 172 insertions(+), 48 deletions(-) diff --git a/backend/src/workspaces/bindings.ts b/backend/src/workspaces/bindings.ts index 33ca0106..a4a18f17 100644 --- a/backend/src/workspaces/bindings.ts +++ b/backend/src/workspaces/bindings.ts @@ -4,7 +4,7 @@ import { buildInstallationContract, type InstallationRole, type InstallationSuff import { DWH_TRANSPORTS, VECTOR_TRANSPORTS, - validateCanonicalWorkspace, + validateWorkspaceDescriptor, type DwhTransport, type VectorTransport, type WorkspaceDescriptor, @@ -95,14 +95,20 @@ export function resolveBinding( env: NodeJS.ProcessEnv, secretRoots: readonly string[], ): ResolvedBinding { - const canonical = validateCanonicalWorkspace(workspace); - const contract = buildInstallationContract(canonical); + const descriptor = validateWorkspaceDescriptor(workspace); + if (descriptor.workspace.schema_version === 3 && role !== "DWH") { + throw new Error("Schema version 3 semantic bindings are not supported by the legacy installation contract"); + } + + const contract = buildInstallationContract(descriptor); const variables = contract.variables.filter((variable) => variable.role === role); const transportVariable = variables.find((variable) => variable.suffix === "TRANSPORT"); const supported = role === "DWH" - ? canonical.dwh.supported_transports + ? descriptor.dwh.supported_transports : role === "VECTOR" - ? (canonical.semantic_index.vector_store.supported_transports ?? []) + ? ("supported_transports" in descriptor.semantic_index.vector_store + ? descriptor.semantic_index.vector_store.supported_transports + : []) : ["rest_api"] as const; const selectedValue = transportVariable ? env[transportVariable.name] : undefined; const selectedTransport = isTransport(selectedValue) ? selectedValue : supported[0]; @@ -112,7 +118,7 @@ export function resolveBinding( missing.push(transportVariable.name); } - const required = new Set(requiredSuffixes(canonical, role, selectedTransport)); + const required = new Set(requiredSuffixes(descriptor, role, selectedTransport)); const values: Record = {}; for (const variable of variables) { if (variable.suffix === "TRANSPORT") continue; @@ -136,11 +142,16 @@ export function resolveRuntimeBindings( env: NodeJS.ProcessEnv, secretRoots: readonly string[], ): RuntimeBindings { + const descriptor = validateWorkspaceDescriptor(workspace); + if (descriptor.workspace.schema_version === 3) { + throw new Error("Schema version 3 semantic runtime bindings are not supported before the internal Qdrant/Ollama runtime lands"); + } + return { - dwh: resolveBinding(workspace, "DWH", env, secretRoots), - vector: resolveBinding(workspace, "VECTOR", env, secretRoots), - vectorWriter: resolveBinding(workspace, "VECTOR_WRITER", env, secretRoots), - embedding: resolveBinding(workspace, "EMBEDDING", env, secretRoots), + dwh: resolveBinding(descriptor, "DWH", env, secretRoots), + vector: resolveBinding(descriptor, "VECTOR", env, secretRoots), + vectorWriter: resolveBinding(descriptor, "VECTOR_WRITER", env, secretRoots), + embedding: resolveBinding(descriptor, "EMBEDDING", env, secretRoots), }; } diff --git a/backend/src/workspaces/contracts.ts b/backend/src/workspaces/contracts.ts index ff239275..05c04391 100644 --- a/backend/src/workspaces/contracts.ts +++ b/backend/src/workspaces/contracts.ts @@ -1,4 +1,4 @@ -import { validateCanonicalWorkspace } from "./schema.js"; +import { validateWorkspaceDescriptor } from "./schema.js"; import type { DwhTransport, VectorTransport, WorkspaceDescriptor } from "./schema.js"; export type InstallationRole = "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING"; @@ -120,23 +120,29 @@ function connectorVariables( } export function buildInstallationContract(workspace: WorkspaceDescriptor): InstallationContract { - const canonical = validateCanonicalWorkspace(workspace); - const namespace = namespaceFor(canonical); + const descriptor = validateWorkspaceDescriptor(workspace); + const namespace = namespaceFor(descriptor); return { - workspaceId: canonical.workspace.id, + workspaceId: descriptor.workspace.id, namespace, variables: [ - ...connectorVariables(namespace, "DWH", canonical.dwh.supported_transports), - ...connectorVariables( - namespace, - "VECTOR", - canonical.semantic_index.vector_store.supported_transports ?? [], - ), - ...(canonical.semantic_index.vector_writer + ...connectorVariables(namespace, "DWH", descriptor.dwh.supported_transports), + ...(descriptor.workspace.schema_version === 2 + ? connectorVariables( + namespace, + "VECTOR", + "supported_transports" in descriptor.semantic_index.vector_store + ? descriptor.semantic_index.vector_store.supported_transports + : [], + ) + : []), + ...(descriptor.workspace.schema_version === 2 && descriptor.semantic_index.vector_writer ? [createVariable(namespace, "VECTOR_WRITER", "API_KEY_FILE")] : []), - ...EMBEDDING_SUFFIXES.map((suffix) => createVariable(namespace, "EMBEDDING", suffix)), + ...(descriptor.workspace.schema_version === 2 + ? EMBEDDING_SUFFIXES.map((suffix) => createVariable(namespace, "EMBEDDING", suffix)) + : []), ], }; } @@ -148,8 +154,8 @@ function localizedIntroduction(workspace: WorkspaceDescriptor): string { } export function renderWorkspaceDocs(workspace: WorkspaceDescriptor): { envExample: string; markdown: string } { - const canonical = validateCanonicalWorkspace(workspace); - const contract = buildInstallationContract(canonical); + const descriptor = validateWorkspaceDescriptor(workspace); + const contract = buildInstallationContract(descriptor); const variablesByRole = new Map(); for (const variable of contract.variables) { const variables = variablesByRole.get(variable.role) ?? []; @@ -158,7 +164,7 @@ export function renderWorkspaceDocs(workspace: WorkspaceDescriptor): { envExampl } const envExample = [ - `# Generated installation bindings for ${canonical.workspace.id}`, + `# Generated installation bindings for ${descriptor.workspace.id}`, "# Provide secret file paths only; never paste secret values here.", ...contract.variables.map((variable) => `${variable.name}=`), "", @@ -167,9 +173,9 @@ export function renderWorkspaceDocs(workspace: WorkspaceDescriptor): { envExampl const markdown = [ "# Installation requirements", "", - `**Workspace:** ${canonical.workspace.name}`, + `**Workspace:** ${descriptor.workspace.name}`, "", - localizedIntroduction(canonical), + localizedIntroduction(descriptor), "", "Use the following UI fields as installation bindings. Secret fields always contain file paths, never secret values.", "", diff --git a/backend/src/workspaces/diagnostics.ts b/backend/src/workspaces/diagnostics.ts index aea16f25..70e16ae9 100644 --- a/backend/src/workspaces/diagnostics.ts +++ b/backend/src/workspaces/diagnostics.ts @@ -10,9 +10,9 @@ import { buildInstallationContract } from "./contracts.js"; import type { RuntimeBindings } from "./runtime-renderer.js"; import { resolveDiagnosticUrl, - validateCanonicalWorkspace, - type CanonicalWorkspace, + validateWorkspaceDescriptor, type RestDiagnosticRequest, + type WorkspaceV2, type WorkspaceDescriptor, } from "./schema.js"; import type { WorkspaceErrorCode } from "./types.js"; @@ -542,7 +542,7 @@ function diagnosticError(code: WorkspaceErrorCode, field?: string): Diagnostic { } function bindingName( - workspace: CanonicalWorkspace, + workspace: WorkspaceV2, role: "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING", suffix: string, ): string { @@ -559,7 +559,7 @@ function numericBinding(binding: Record, name: string): number | } function diagnosticsForMissingBindings( - workspace: CanonicalWorkspace, + workspace: WorkspaceV2, bindings: RuntimeBindings, ): Diagnostic[] { const missing = new Set([ @@ -576,7 +576,7 @@ function diagnosticsForMissingBindings( } function connectorRequest( - workspace: CanonicalWorkspace, + workspace: WorkspaceV2, role: ConnectorRole, bindings: RuntimeBindings, timeoutMs: number, @@ -650,7 +650,7 @@ function connectorRequest( } function tunnelProbeRequest( - workspace: CanonicalWorkspace, + workspace: WorkspaceV2, role: ConnectorRole, bindings: RuntimeBindings, timeoutMs: number, @@ -694,7 +694,11 @@ export function createWorkspaceDiagnoser( bindings: RuntimeBindings, options: { writeProbe: boolean }, ): Promise { - const canonical = validateCanonicalWorkspace(workspace); + const descriptor = validateWorkspaceDescriptor(workspace); + if (descriptor.workspace.schema_version !== 2) { + return { activatable: false, diagnostics: [diagnosticError("workspace_not_activatable")] }; + } + const canonical = descriptor as WorkspaceV2; const diagnostics = diagnosticsForMissingBindings(canonical, bindings); if (diagnostics.length > 0) return { activatable: false, diagnostics }; diff --git a/backend/src/workspaces/runtime-renderer.ts b/backend/src/workspaces/runtime-renderer.ts index 7f42d25d..cc9e1542 100644 --- a/backend/src/workspaces/runtime-renderer.ts +++ b/backend/src/workspaces/runtime-renderer.ts @@ -1,6 +1,6 @@ import { stringify } from "yaml"; import { buildInstallationContract } from "./contracts.js"; -import { validateCanonicalWorkspace, type WorkspaceDescriptor } from "./schema.js"; +import { validateWorkspaceDescriptor, type WorkspaceDescriptor, type WorkspaceV2 } from "./schema.js"; import type { ResolvedBinding, RuntimeBindings } from "./bindings.js"; export type { RuntimeBindings } from "./bindings.js"; @@ -86,7 +86,15 @@ export function renderRuntimeConfig( identity?: RuntimeIdentity, installation: RuntimeInstallationOverlay = {}, ): string { - const canonical = validateCanonicalWorkspace(workspace); + const descriptor = validateWorkspaceDescriptor(workspace); + if (descriptor.workspace.schema_version !== 2) { + if (descriptor.workspace.schema_version === 1) { + throw new Error("Workspace descriptor requires explicit migration to schema version 2"); + } + throw new Error("Schema version 3 runtime rendering is unsupported until the internal semantic runtime is implemented"); + } + + const canonical = descriptor as WorkspaceV2; if ([...bindings.dwh.missing, ...bindings.vector.missing, ...bindings.embedding.missing].length > 0) { throw new Error("runtime configuration requires complete bindings"); } diff --git a/backend/src/workspaces/schema.ts b/backend/src/workspaces/schema.ts index fc530055..b8962e88 100644 --- a/backend/src/workspaces/schema.ts +++ b/backend/src/workspaces/schema.ts @@ -102,11 +102,6 @@ interface QdrantVectorStore { collection: string; dimensions: 1024; distance: "cosine"; - database?: string; - schema?: string; - port?: number; - timeout_ms?: number; - supported_transports?: VectorTransport[]; } export interface WorkspaceV3 extends WorkspaceBase<3, QdrantVectorStore> {} diff --git a/backend/src/workspaces/types.ts b/backend/src/workspaces/types.ts index c0384e73..574caacb 100644 --- a/backend/src/workspaces/types.ts +++ b/backend/src/workspaces/types.ts @@ -21,18 +21,12 @@ export interface QdrantVectorStore { collection: string; dimensions: 1024; distance: "cosine"; - database?: string; - schema?: string; - port?: number; - timeout_ms?: number; - supported_transports?: ("pgvector_direct" | "rest_api" | "ssh_tunnel")[]; } export interface InternalEmbedding { provider: "ollama_internal"; model: "qwen3-embedding:0.6b"; dimensions: 1024; - timeout_ms?: number; } export interface WorkspaceV2 { @@ -113,5 +107,5 @@ export interface WorkspaceV3 { embedding: InternalEmbedding; }; llm_policy: WorkspaceV2["llm_policy"]; - diagnostics?: WorkspaceV2["diagnostics"]; + diagnostics?: Pick, "dwh_rest">; } diff --git a/backend/test/workspace-runtime-renderer.test.ts b/backend/test/workspace-runtime-renderer.test.ts index 8f18dd26..5a1c9781 100644 --- a/backend/test/workspace-runtime-renderer.test.ts +++ b/backend/test/workspace-runtime-renderer.test.ts @@ -30,6 +30,29 @@ semantic_index: llm_policy: allowed: [zai/glm-5.2] `); +const workspaceV3 = parseWorkspaceYaml(`workspace: + schema_version: 3 + id: psd-clinical + name: Policlinico San Donato + language: it +dwh: + engine: postgres + database: postgres + schema: datawarehouse + supported_transports: [postgres_direct, rest_api] +semantic_index: + vector_store: + engine: qdrant + collection: psd-clinical + dimensions: 1024 + distance: cosine + embedding: + provider: ollama_internal + model: qwen3-embedding:0.6b + dimensions: 1024 +llm_policy: + allowed: [zai/glm-5.2] +`); const paths: RuntimePaths = { sessions: "/data/workspaces/psd-clinical/sessions", artifacts: "/data/workspaces/psd-clinical/artifacts", @@ -149,6 +172,12 @@ test("refuses to render a v1 descriptor until an explicit migration creates v2", expect(() => renderRuntimeConfig(legacyWorkspace, directBindings, paths)).toThrow(/migrat/i); }); +test("fails closed for v3 runtime rendering and session support", () => { + expect(supportsSessionRuntime(directBindings)).toBe(true); + expect(() => renderRuntimeConfig(workspaceV3, directBindings, paths)) + .toThrow(/unsupported|schema version 3|qdrant|ollama_internal/i); +}); + test("omits direct TLS fields when binding validation did not retain a file path", () => { const dwhValues = { ...directBindings.dwh.values }; const vectorValues = { ...directBindings.vector.values }; diff --git a/backend/test/workspaces-bindings.test.ts b/backend/test/workspaces-bindings.test.ts index feee8c0e..741b532f 100644 --- a/backend/test/workspaces-bindings.test.ts +++ b/backend/test/workspaces-bindings.test.ts @@ -31,6 +31,29 @@ semantic_index: llm_policy: allowed: [zai/glm-5.2] `); +const workspaceV3 = parseWorkspaceYaml(`workspace: + schema_version: 3 + id: psd-clinical + name: Policlinico San Donato + language: it +dwh: + engine: postgres + database: postgres + schema: datawarehouse + supported_transports: [postgres_direct, rest_api] +semantic_index: + vector_store: + engine: qdrant + collection: psd-clinical + dimensions: 1024 + distance: cosine + embedding: + provider: ollama_internal + model: qwen3-embedding:0.6b + dimensions: 1024 +llm_policy: + allowed: [zai/glm-5.2] +`); const temporaryRoots: string[] = []; afterEach(() => { @@ -196,3 +219,12 @@ test("never treats a vector reader credential as the optional writer binding", ( values: {}, }); }); + +test("fails closed for v3 external semantic bindings", () => { + expect(() => resolveBinding(workspaceV3, "VECTOR", {}, ["/run/secrets"])) + .toThrow(/unsupported|schema version 3|semantic/i); + expect(() => resolveBinding(workspaceV3, "EMBEDDING", {}, ["/run/secrets"])) + .toThrow(/unsupported|schema version 3|semantic/i); + expect(() => resolveRuntimeBindings(workspaceV3, {}, ["/run/secrets"])) + .toThrow(/unsupported|schema version 3|semantic/i); +}); diff --git a/backend/test/workspaces-contracts.test.ts b/backend/test/workspaces-contracts.test.ts index 357fd499..92f91009 100644 --- a/backend/test/workspaces-contracts.test.ts +++ b/backend/test/workspaces-contracts.test.ts @@ -39,6 +39,29 @@ llm_policy: - zai/glm-5.2 - openai/gpt-5 `); +const workspaceV3 = parseWorkspaceYaml(`workspace: + schema_version: 3 + id: psd-clinical + name: Policlinico San Donato + language: it +dwh: + engine: postgres + database: postgres + schema: datawarehouse + supported_transports: [postgres_direct, rest_api] +semantic_index: + vector_store: + engine: qdrant + collection: psd-clinical + dimensions: 1024 + distance: cosine + embedding: + provider: ollama_internal + model: qwen3-embedding:0.6b + dimensions: 1024 +llm_policy: + allowed: [zai/glm-5.2] +`); test("generates stable FILE-based secret requirements from an immutable ID", () => { const contract = buildInstallationContract(validWorkspace); @@ -278,3 +301,13 @@ test("validates public contract and documentation inputs at runtime", () => { expect(() => buildInstallationContract(unsafeWorkspace)).toThrow(/id/i); expect(() => renderWorkspaceDocs(unsafeWorkspace)).toThrow(/id/i); }); + +test("v3 installation contract omits external vector and embedding bindings", () => { + const contract = buildInstallationContract(workspaceV3); + const names = contract.variables.map((variable) => variable.name); + + expect(names).toContain("THT_WS_PSD_CLINICAL_DWH_TRANSPORT"); + expect(names.some((name) => name.includes("_VECTOR_"))).toBe(false); + expect(names.some((name) => name.includes("_EMBEDDING_"))).toBe(false); + expect(renderWorkspaceDocs(workspaceV3).markdown).not.toContain("Embedding service"); +}); diff --git a/backend/test/workspaces-schema.test.ts b/backend/test/workspaces-schema.test.ts index ffc549a1..6f8f580f 100644 --- a/backend/test/workspaces-schema.test.ts +++ b/backend/test/workspaces-schema.test.ts @@ -128,6 +128,18 @@ test("rejects unknown fields in schema v3 semantic identity", () => { expect(() => parseWorkspaceYaml(withUnknownField)).toThrow(/unrecognized key/i); }); +test("rejects legacy semantic connector fields and diagnostics in schema v3", () => { + expect(() => parseWorkspaceYaml(validYaml.replace( + " collection: psd-clinical\n", + " collection: psd-clinical\n database: postgres\n", + ))).toThrow(/unrecognized key|database/i); + + expect(() => parseWorkspaceYaml(validYaml.replace( + "llm_policy:\n", + "diagnostics:\n vector_rest:\n metadata:\n method: GET\n path: /metadata\n auth: bearer\n response:\n collection: collection\n dimensions: dimensions\n distance: distance\nllm_policy:\n", + ))).toThrow(/unrecognized key|vector_rest/i); +}); + test("keeps v1 and v2 descriptors parseable but non-operational", () => { const v1Yaml = `workspace: schema_version: 1