fix: fail closed v3 legacy semantic fallbacks

This commit is contained in:
2026-08-08 16:52:33 +02:00
parent 2f7f923c4d
commit ba1d7b0e78
10 changed files with 172 additions and 48 deletions
+21 -10
View File
@@ -4,7 +4,7 @@ import { buildInstallationContract, type InstallationRole, type InstallationSuff
import { import {
DWH_TRANSPORTS, DWH_TRANSPORTS,
VECTOR_TRANSPORTS, VECTOR_TRANSPORTS,
validateCanonicalWorkspace, validateWorkspaceDescriptor,
type DwhTransport, type DwhTransport,
type VectorTransport, type VectorTransport,
type WorkspaceDescriptor, type WorkspaceDescriptor,
@@ -95,14 +95,20 @@ export function resolveBinding(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
secretRoots: readonly string[], secretRoots: readonly string[],
): ResolvedBinding { ): ResolvedBinding {
const canonical = validateCanonicalWorkspace(workspace); const descriptor = validateWorkspaceDescriptor(workspace);
const contract = buildInstallationContract(canonical); 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 variables = contract.variables.filter((variable) => variable.role === role);
const transportVariable = variables.find((variable) => variable.suffix === "TRANSPORT"); const transportVariable = variables.find((variable) => variable.suffix === "TRANSPORT");
const supported = role === "DWH" const supported = role === "DWH"
? canonical.dwh.supported_transports ? descriptor.dwh.supported_transports
: role === "VECTOR" : 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; : ["rest_api"] as const;
const selectedValue = transportVariable ? env[transportVariable.name] : undefined; const selectedValue = transportVariable ? env[transportVariable.name] : undefined;
const selectedTransport = isTransport(selectedValue) ? selectedValue : supported[0]; const selectedTransport = isTransport(selectedValue) ? selectedValue : supported[0];
@@ -112,7 +118,7 @@ export function resolveBinding(
missing.push(transportVariable.name); missing.push(transportVariable.name);
} }
const required = new Set(requiredSuffixes(canonical, role, selectedTransport)); const required = new Set(requiredSuffixes(descriptor, role, selectedTransport));
const values: Record<string, string> = {}; const values: Record<string, string> = {};
for (const variable of variables) { for (const variable of variables) {
if (variable.suffix === "TRANSPORT") continue; if (variable.suffix === "TRANSPORT") continue;
@@ -136,11 +142,16 @@ export function resolveRuntimeBindings(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
secretRoots: readonly string[], secretRoots: readonly string[],
): RuntimeBindings { ): 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 { return {
dwh: resolveBinding(workspace, "DWH", env, secretRoots), dwh: resolveBinding(descriptor, "DWH", env, secretRoots),
vector: resolveBinding(workspace, "VECTOR", env, secretRoots), vector: resolveBinding(descriptor, "VECTOR", env, secretRoots),
vectorWriter: resolveBinding(workspace, "VECTOR_WRITER", env, secretRoots), vectorWriter: resolveBinding(descriptor, "VECTOR_WRITER", env, secretRoots),
embedding: resolveBinding(workspace, "EMBEDDING", env, secretRoots), embedding: resolveBinding(descriptor, "EMBEDDING", env, secretRoots),
}; };
} }
+21 -15
View File
@@ -1,4 +1,4 @@
import { validateCanonicalWorkspace } from "./schema.js"; import { validateWorkspaceDescriptor } from "./schema.js";
import type { DwhTransport, VectorTransport, WorkspaceDescriptor } from "./schema.js"; import type { DwhTransport, VectorTransport, WorkspaceDescriptor } from "./schema.js";
export type InstallationRole = "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING"; export type InstallationRole = "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING";
@@ -120,23 +120,29 @@ function connectorVariables(
} }
export function buildInstallationContract(workspace: WorkspaceDescriptor): InstallationContract { export function buildInstallationContract(workspace: WorkspaceDescriptor): InstallationContract {
const canonical = validateCanonicalWorkspace(workspace); const descriptor = validateWorkspaceDescriptor(workspace);
const namespace = namespaceFor(canonical); const namespace = namespaceFor(descriptor);
return { return {
workspaceId: canonical.workspace.id, workspaceId: descriptor.workspace.id,
namespace, namespace,
variables: [ variables: [
...connectorVariables(namespace, "DWH", canonical.dwh.supported_transports), ...connectorVariables(namespace, "DWH", descriptor.dwh.supported_transports),
...connectorVariables( ...(descriptor.workspace.schema_version === 2
? connectorVariables(
namespace, namespace,
"VECTOR", "VECTOR",
canonical.semantic_index.vector_store.supported_transports ?? [], "supported_transports" in descriptor.semantic_index.vector_store
), ? descriptor.semantic_index.vector_store.supported_transports
...(canonical.semantic_index.vector_writer : [],
)
: []),
...(descriptor.workspace.schema_version === 2 && descriptor.semantic_index.vector_writer
? [createVariable(namespace, "VECTOR_WRITER", "API_KEY_FILE")] ? [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 } { export function renderWorkspaceDocs(workspace: WorkspaceDescriptor): { envExample: string; markdown: string } {
const canonical = validateCanonicalWorkspace(workspace); const descriptor = validateWorkspaceDescriptor(workspace);
const contract = buildInstallationContract(canonical); const contract = buildInstallationContract(descriptor);
const variablesByRole = new Map<InstallationRole, InstallationVariable[]>(); const variablesByRole = new Map<InstallationRole, InstallationVariable[]>();
for (const variable of contract.variables) { for (const variable of contract.variables) {
const variables = variablesByRole.get(variable.role) ?? []; const variables = variablesByRole.get(variable.role) ?? [];
@@ -158,7 +164,7 @@ export function renderWorkspaceDocs(workspace: WorkspaceDescriptor): { envExampl
} }
const envExample = [ 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.", "# Provide secret file paths only; never paste secret values here.",
...contract.variables.map((variable) => `${variable.name}=`), ...contract.variables.map((variable) => `${variable.name}=`),
"", "",
@@ -167,9 +173,9 @@ export function renderWorkspaceDocs(workspace: WorkspaceDescriptor): { envExampl
const markdown = [ const markdown = [
"# Installation requirements", "# 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.", "Use the following UI fields as installation bindings. Secret fields always contain file paths, never secret values.",
"", "",
+11 -7
View File
@@ -10,9 +10,9 @@ import { buildInstallationContract } from "./contracts.js";
import type { RuntimeBindings } from "./runtime-renderer.js"; import type { RuntimeBindings } from "./runtime-renderer.js";
import { import {
resolveDiagnosticUrl, resolveDiagnosticUrl,
validateCanonicalWorkspace, validateWorkspaceDescriptor,
type CanonicalWorkspace,
type RestDiagnosticRequest, type RestDiagnosticRequest,
type WorkspaceV2,
type WorkspaceDescriptor, type WorkspaceDescriptor,
} from "./schema.js"; } from "./schema.js";
import type { WorkspaceErrorCode } from "./types.js"; import type { WorkspaceErrorCode } from "./types.js";
@@ -542,7 +542,7 @@ function diagnosticError(code: WorkspaceErrorCode, field?: string): Diagnostic {
} }
function bindingName( function bindingName(
workspace: CanonicalWorkspace, workspace: WorkspaceV2,
role: "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING", role: "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING",
suffix: string, suffix: string,
): string { ): string {
@@ -559,7 +559,7 @@ function numericBinding(binding: Record<string, string>, name: string): number |
} }
function diagnosticsForMissingBindings( function diagnosticsForMissingBindings(
workspace: CanonicalWorkspace, workspace: WorkspaceV2,
bindings: RuntimeBindings, bindings: RuntimeBindings,
): Diagnostic[] { ): Diagnostic[] {
const missing = new Set([ const missing = new Set([
@@ -576,7 +576,7 @@ function diagnosticsForMissingBindings(
} }
function connectorRequest( function connectorRequest(
workspace: CanonicalWorkspace, workspace: WorkspaceV2,
role: ConnectorRole, role: ConnectorRole,
bindings: RuntimeBindings, bindings: RuntimeBindings,
timeoutMs: number, timeoutMs: number,
@@ -650,7 +650,7 @@ function connectorRequest(
} }
function tunnelProbeRequest( function tunnelProbeRequest(
workspace: CanonicalWorkspace, workspace: WorkspaceV2,
role: ConnectorRole, role: ConnectorRole,
bindings: RuntimeBindings, bindings: RuntimeBindings,
timeoutMs: number, timeoutMs: number,
@@ -694,7 +694,11 @@ export function createWorkspaceDiagnoser(
bindings: RuntimeBindings, bindings: RuntimeBindings,
options: { writeProbe: boolean }, options: { writeProbe: boolean },
): Promise<WorkspaceDiagnostics> { ): Promise<WorkspaceDiagnostics> {
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); const diagnostics = diagnosticsForMissingBindings(canonical, bindings);
if (diagnostics.length > 0) return { activatable: false, diagnostics }; if (diagnostics.length > 0) return { activatable: false, diagnostics };
+10 -2
View File
@@ -1,6 +1,6 @@
import { stringify } from "yaml"; import { stringify } from "yaml";
import { buildInstallationContract } from "./contracts.js"; 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"; import type { ResolvedBinding, RuntimeBindings } from "./bindings.js";
export type { RuntimeBindings } from "./bindings.js"; export type { RuntimeBindings } from "./bindings.js";
@@ -86,7 +86,15 @@ export function renderRuntimeConfig(
identity?: RuntimeIdentity, identity?: RuntimeIdentity,
installation: RuntimeInstallationOverlay = {}, installation: RuntimeInstallationOverlay = {},
): string { ): 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) { if ([...bindings.dwh.missing, ...bindings.vector.missing, ...bindings.embedding.missing].length > 0) {
throw new Error("runtime configuration requires complete bindings"); throw new Error("runtime configuration requires complete bindings");
} }
-5
View File
@@ -102,11 +102,6 @@ interface QdrantVectorStore {
collection: string; collection: string;
dimensions: 1024; dimensions: 1024;
distance: "cosine"; distance: "cosine";
database?: string;
schema?: string;
port?: number;
timeout_ms?: number;
supported_transports?: VectorTransport[];
} }
export interface WorkspaceV3 extends WorkspaceBase<3, QdrantVectorStore> {} export interface WorkspaceV3 extends WorkspaceBase<3, QdrantVectorStore> {}
+1 -7
View File
@@ -21,18 +21,12 @@ export interface QdrantVectorStore {
collection: string; collection: string;
dimensions: 1024; dimensions: 1024;
distance: "cosine"; distance: "cosine";
database?: string;
schema?: string;
port?: number;
timeout_ms?: number;
supported_transports?: ("pgvector_direct" | "rest_api" | "ssh_tunnel")[];
} }
export interface InternalEmbedding { export interface InternalEmbedding {
provider: "ollama_internal"; provider: "ollama_internal";
model: "qwen3-embedding:0.6b"; model: "qwen3-embedding:0.6b";
dimensions: 1024; dimensions: 1024;
timeout_ms?: number;
} }
export interface WorkspaceV2 { export interface WorkspaceV2 {
@@ -113,5 +107,5 @@ export interface WorkspaceV3 {
embedding: InternalEmbedding; embedding: InternalEmbedding;
}; };
llm_policy: WorkspaceV2["llm_policy"]; llm_policy: WorkspaceV2["llm_policy"];
diagnostics?: WorkspaceV2["diagnostics"]; diagnostics?: Pick<NonNullable<WorkspaceV2["diagnostics"]>, "dwh_rest">;
} }
@@ -30,6 +30,29 @@ semantic_index:
llm_policy: llm_policy:
allowed: [zai/glm-5.2] 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 = { const paths: RuntimePaths = {
sessions: "/data/workspaces/psd-clinical/sessions", sessions: "/data/workspaces/psd-clinical/sessions",
artifacts: "/data/workspaces/psd-clinical/artifacts", 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); 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", () => { test("omits direct TLS fields when binding validation did not retain a file path", () => {
const dwhValues = { ...directBindings.dwh.values }; const dwhValues = { ...directBindings.dwh.values };
const vectorValues = { ...directBindings.vector.values }; const vectorValues = { ...directBindings.vector.values };
+32
View File
@@ -31,6 +31,29 @@ semantic_index:
llm_policy: llm_policy:
allowed: [zai/glm-5.2] 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[] = []; const temporaryRoots: string[] = [];
afterEach(() => { afterEach(() => {
@@ -196,3 +219,12 @@ test("never treats a vector reader credential as the optional writer binding", (
values: {}, 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);
});
+33
View File
@@ -39,6 +39,29 @@ llm_policy:
- zai/glm-5.2 - zai/glm-5.2
- openai/gpt-5 - 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", () => { test("generates stable FILE-based secret requirements from an immutable ID", () => {
const contract = buildInstallationContract(validWorkspace); const contract = buildInstallationContract(validWorkspace);
@@ -278,3 +301,13 @@ test("validates public contract and documentation inputs at runtime", () => {
expect(() => buildInstallationContract(unsafeWorkspace)).toThrow(/id/i); expect(() => buildInstallationContract(unsafeWorkspace)).toThrow(/id/i);
expect(() => renderWorkspaceDocs(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");
});
+12
View File
@@ -128,6 +128,18 @@ test("rejects unknown fields in schema v3 semantic identity", () => {
expect(() => parseWorkspaceYaml(withUnknownField)).toThrow(/unrecognized key/i); 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", () => { test("keeps v1 and v2 descriptors parseable but non-operational", () => {
const v1Yaml = `workspace: const v1Yaml = `workspace:
schema_version: 1 schema_version: 1