diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index 43be047c..db46c031 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -148,6 +148,7 @@ export class ThtRunner { workspace: ReturnType; workspaceId: string; workspaceRevision: string; + revisionContentRoot: string; } { const identity = this.assertWorkspaceSnapshot(path); const fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); @@ -163,7 +164,7 @@ export class ThtRunner { if (workspace.workspace.id !== identity.workspaceId) { throw new Error("workspace snapshot identity does not match its path"); } - return { workspace, ...identity }; + return { workspace, ...identity, revisionContentRoot: dirname(path) }; } finally { closeSync(fd); } diff --git a/backend/src/workspaces/runtime-renderer.ts b/backend/src/workspaces/runtime-renderer.ts index 9c7a32a2..7cf48902 100644 --- a/backend/src/workspaces/runtime-renderer.ts +++ b/backend/src/workspaces/runtime-renderer.ts @@ -1,7 +1,13 @@ +import { basename, join } from "node:path"; import { stringify } from "yaml"; import { buildInstallationContract } from "./contracts.js"; -import { validateWorkspaceDescriptor, type WorkspaceDescriptor, type WorkspaceV2 } from "./schema.js"; -import type { ResolvedBinding, RuntimeBindings } from "./bindings.js"; +import { + validateWorkspaceDescriptor, + type WorkspaceDescriptor, + type WorkspaceV2, + type WorkspaceV3, +} from "./schema.js"; +import type { ResolvedBinding, ResolvedEvidenceBinding, RuntimeBindings } from "./bindings.js"; export type { RuntimeBindings } from "./bindings.js"; export interface RuntimePaths { @@ -15,6 +21,11 @@ export interface RuntimeIdentity { workspaceRevision: string; } +/** Immutable, explicit inputs needed to bind descriptor-relative content to one revision. */ +export interface RuntimeRenderContext extends RuntimeIdentity { + revisionContentRoot: string; +} + export interface RuntimeInstallationOverlay { session_storage?: unknown; profile?: unknown; @@ -80,6 +91,103 @@ function legacyRestEndpoint( return endpoint; } +function exactSeconds(timeoutMs: number): number { + return timeoutMs / 1_000; +} + +function requireRuntimeRenderContext( + identity: RuntimeIdentity | RuntimeRenderContext | undefined, +): RuntimeRenderContext { + if (!identity || !("revisionContentRoot" in identity)) { + throw new Error("runtime Evidence requires an immutable revision content root"); + } + return identity; +} + +function evidenceBindingValue(binding: ResolvedEvidenceBinding, name: string): string | undefined { + return binding.values[name]; +} + +function requireEvidenceBinding(binding: ResolvedEvidenceBinding, name: string): string { + const value = evidenceBindingValue(binding, name); + if (value === undefined) throw new Error(`runtime binding is missing ${name}`); + return value; +} + +function renderEvidence( + workspace: WorkspaceV3, + binding: ResolvedEvidenceBinding, + context: RuntimeRenderContext, + bindingName: (suffix: string) => string, +): { evidence: Record; vector: Record } | undefined { + if (workspace.evidence === undefined) return undefined; + if (binding.missing.length > 0) { + throw new Error("runtime configuration requires complete Evidence bindings"); + } + if (basename(context.revisionContentRoot) !== context.workspaceRevision) { + throw new Error("runtime revision content root does not match workspace revision"); + } + + const source = workspace.evidence.source; + let renderedSource: Record; + if (source.type === "filesystem") { + renderedSource = { + type: "filesystem", + root: join(context.revisionContentRoot, source.uri), + patterns: source.patterns, + max_bytes: source.max_bytes, + }; + } else if (source.type === "http") { + renderedSource = { + type: "http", + ...(source.authentication === "none" + ? { urls: source.uris } + : { + provenance_urls: source.uris, + signed_urls_file: requireEvidenceBinding(binding, bindingName("SIGNED_URLS_FILE")), + }), + connect_timeout: exactSeconds(source.connect_timeout_ms), + read_timeout: exactSeconds(source.read_timeout_ms), + max_bytes: source.max_bytes, + max_redirects: source.max_redirects, + allow_private_hosts: source.allow_private_hosts, + max_cache_bytes: source.max_cache_bytes, + }; + } else { + const uri = new URL(source.uri); + const sessionTokenFile = source.credentials === "static_files" + ? evidenceBindingValue(binding, bindingName("SESSION_TOKEN_FILE")) + : undefined; + renderedSource = { + type: "s3", + bucket: uri.hostname, + prefix: uri.pathname.replace(/^\//, ""), + ...(source.endpoint_url === undefined ? {} : { endpoint_url: source.endpoint_url }), + ...(source.region === undefined ? {} : { region: source.region }), + ...(source.credentials === "ambient" ? {} : { + access_key_file: requireEvidenceBinding(binding, bindingName("ACCESS_KEY_FILE")), + secret_key_file: requireEvidenceBinding(binding, bindingName("SECRET_KEY_FILE")), + ...(sessionTokenFile === undefined ? {} : { session_token_file: sessionTokenFile }), + }), + trusted_endpoint: source.trusted_endpoint, + allow_private_endpoint: source.allow_private_endpoint, + allow_insecure_endpoint: source.allow_insecure_endpoint, + max_bytes: source.max_bytes, + max_objects: source.max_objects, + max_pages: source.max_pages, + page_size: source.page_size, + }; + } + + return { + evidence: { sources: [renderedSource] }, + vector: { + max_chunk_chars: workspace.evidence.policy.max_chunk_chars, + retain_published_generations: workspace.evidence.policy.retain_published_generations, + }, + }; +} + function placeholderConnection(identity: { database: string; schema: string }): Record { return { host: "localhost", @@ -97,13 +205,13 @@ export function renderRuntimeConfig( workspace: WorkspaceDescriptor, bindings: RuntimeBindings, paths: RuntimePaths, - identity?: RuntimeIdentity, + identity?: RuntimeIdentity | RuntimeRenderContext, installation: RuntimeInstallationOverlay = {}, semanticRuntime: SemanticRuntimeConfig = DEFAULT_SEMANTIC_RUNTIME, ): string { const descriptor = validateWorkspaceDescriptor(workspace); const contract = buildInstallationContract(descriptor); - const name = (role: "DWH" | "VECTOR" | "EMBEDDING", suffix: string) => { + const name = (role: "DWH" | "VECTOR" | "EMBEDDING" | "EVIDENCE", suffix: string) => { const variable = contract.variables.find((entry) => entry.role === role && entry.suffix === suffix); if (!variable) throw new Error(`workspace contract is missing ${role}_${suffix}`); return variable.name; @@ -112,6 +220,15 @@ export function renderRuntimeConfig( if (descriptor.workspace.schema_version === 1) { throw new Error("Workspace descriptor requires explicit migration to schema version 2"); } + const canonicalV3 = descriptor as WorkspaceV3; + const renderedEvidence = canonicalV3.evidence === undefined + ? undefined + : renderEvidence( + canonicalV3, + bindings.evidence, + requireRuntimeRenderContext(identity), + (suffix) => name("EVIDENCE", suffix), + ); if (bindings.dwh.missing.length > 0) { throw new Error("runtime configuration requires complete bindings"); } @@ -155,6 +272,7 @@ export function renderRuntimeConfig( }, roots: paths, paths, + ...(renderedEvidence ?? {}), }; if (bindings.dwh.transport === "postgres_direct") { renderedV3.dwh = { type: "postgres_direct", connection: database }; diff --git a/backend/test/workspace-runtime-handoff.test.ts b/backend/test/workspace-runtime-handoff.test.ts index 73b1febe..94ef0684 100644 --- a/backend/test/workspace-runtime-handoff.test.ts +++ b/backend/test/workspace-runtime-handoff.test.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; import { - chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, + chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, + writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -42,6 +43,18 @@ llm_policy: allowed: [zai/glm-5.2] `; +const filesystemWorkspace = `${canonicalWorkspace}evidence: + source: + type: filesystem + uri: workspace-content/psd-clinical/evidence +`; + +function evidenceWorkspace(source: string, policy = ""): string { + return `${canonicalWorkspace}evidence: + source: +${source}${policy}`; +} + const migrationRequiredWorkspace = canonicalWorkspace .replace("schema_version: 3", "schema_version: 2") .replace( @@ -63,7 +76,7 @@ async function git(cwd: string, args: string[]): Promise { return (await runFile("git", args, { cwd })).stdout.trim(); } -async function fixture(workspaceSource = canonicalWorkspace) { +async function fixture(workspaceSource = filesystemWorkspace) { const root = mkdtempSync(join(tmpdir(), "tht-runtime-handoff-")); roots.push(root); const remote = join(root, "remote.git"); @@ -78,14 +91,26 @@ async function fixture(workspaceSource = canonicalWorkspace) { await git(source, ["config", "user.email", "runtime-handoff@example.invalid"]); mkdirSync(join(source, "workspaces")); writeFileSync(join(source, "workspaces", "psd-clinical.yaml"), workspaceSource); - await git(source, ["add", "workspaces/psd-clinical.yaml"]); + const evidenceRoot = join(source, "workspace-content", "psd-clinical", "evidence"); + mkdirSync(evidenceRoot, { recursive: true }); + writeFileSync(join(evidenceRoot, "guide.md"), "# Immutable revision evidence\n"); + await git(source, ["add", "."]); await git(source, ["commit", "-m", "Canonical workspace"]); await git(source, ["remote", "add", "origin", remote]); await git(source, ["push", "origin", "main"]); mkdirSync(secretRoot); - for (const name of ["dwh-password"]) { + const secretContents: Record = { + "dwh-password": "dwh-password-value", + "evidence-signed-urls.json": JSON.stringify([ + "https://evidence.example.test/guide.md?token=SIGNED-HANDOFF-CANARY", + ]), + "evidence-access": "ACCESS-HANDOFF-CANARY", + "evidence-secret": "SECRET-HANDOFF-CANARY", + "evidence-token": "TOKEN-HANDOFF-CANARY", + }; + for (const [name, contents] of Object.entries(secretContents)) { const path = join(secretRoot, name); - writeFileSync(path, `${name}-value`, { mode: 0o600 }); + writeFileSync(path, contents, { mode: 0o600 }); chmodSync(path, 0o600); } mkdirSync(dataRoot); @@ -109,10 +134,14 @@ async function fixture(workspaceSource = canonicalWorkspace) { THT_WS_PSD_CLINICAL_DWH_PORT: "5432", THT_WS_PSD_CLINICAL_DWH_USER: "reader", THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE: join(secretRoot, "dwh-password"), + THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE: join(secretRoot, "evidence-signed-urls.json"), + THT_WS_PSD_CLINICAL_EVIDENCE_ACCESS_KEY_FILE: join(secretRoot, "evidence-access"), + THT_WS_PSD_CLINICAL_EVIDENCE_SECRET_KEY_FILE: join(secretRoot, "evidence-secret"), + THT_WS_PSD_CLINICAL_EVIDENCE_SESSION_TOKEN_FILE: join(secretRoot, "evidence-token"), }; for (const [name, value] of Object.entries(environment)) vi.stubEnv(name, value); vi.stubEnv("THT_HOME", join(root, "home")); - return { root, dataRoot, registry, registryConfig, revision }; + return { root, dataRoot, secretRoot, registry, registryConfig, revision }; } function runnerFor(f: Awaited>): ThtRunner { @@ -148,30 +177,160 @@ test("real schema-v3 registry revision loads through ThtRunner and the harness c expect(readdirSync(join(f.registryConfig.root, "snapshots", "runtime"))).toEqual([]); }); -test("separate runtime leases hand off one stable logical workspace identity", async () => { +test("separate runtime leases hand off byte-identical revision Evidence configs accepted by tht", async () => { const f = await fixture(); const runner = runnerFor(f); const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); const second = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); + const expectedRoot = join( + f.registryConfig.root, + "snapshots", + f.revision.commit, + "workspace-content", + "psd-clinical", + "evidence", + ); try { expect(first.path).not.toBe(second.path); - expect(parse(readFileSync(first.path, "utf8")).runtime_identity).toEqual({ + const firstYaml = readFileSync(first.path, "utf8"); + const secondYaml = readFileSync(second.path, "utf8"); + expect(secondYaml).toBe(firstYaml); + expect(parse(firstYaml).runtime_identity).toEqual({ workspace_id: "psd-clinical", workspace_revision: f.revision.commit, source_identity: "workspace://psd-clinical", }); - expect(parse(readFileSync(second.path, "utf8")).runtime_identity).toEqual({ - workspace_id: "psd-clinical", - workspace_revision: f.revision.commit, - source_identity: "workspace://psd-clinical", + expect(parse(firstYaml).evidence).toEqual({ + sources: [{ + type: "filesystem", + root: expectedRoot, + patterns: ["**/*.md"], + max_bytes: 10_485_760, + }], }); + expect(parse(firstYaml).vector).toEqual({ + max_chunk_chars: 4_000, + retain_published_generations: 3, + }); + expect(expectedRoot).not.toContain(join(f.registryConfig.root, "repo")); + + for (const lease of [first, second]) { + const checked = await runFile(thtBin, ["config", "check", "-c", lease.path], { + cwd: harnessDir, + env: { ...process.env, THT_HOME: join(f.root, "home") }, + }); + expect(`${checked.stdout}${checked.stderr}`).not.toContain("HANDOFF-CANARY"); + } + + first.release(); + expect(existsSync(first.path)).toBe(false); + expect(existsSync(second.path)).toBe(true); + second.release(); + expect(existsSync(second.path)).toBe(false); } finally { first.release(); second.release(); } }); +test("signed HTTP Evidence resolves its file binding and config check never captures its contents", async () => { + const f = await fixture(evidenceWorkspace(` type: http + uris: [https://evidence.example.test/guide.md] + authentication: signed_urls_file + connect_timeout_ms: 1250 + read_timeout_ms: 30001 + max_bytes: 12345 + max_redirects: 2 + allow_private_hosts: false + max_cache_bytes: 67890 +`)); + const runner = runnerFor(f); + const lease = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); + try { + const yaml = readFileSync(lease.path, "utf8"); + expect(parse(yaml).evidence.sources).toEqual([{ + type: "http", + provenance_urls: ["https://evidence.example.test/guide.md"], + signed_urls_file: realpathSync(join(f.secretRoot, "evidence-signed-urls.json")), + connect_timeout: 1.25, + read_timeout: 30.001, + max_bytes: 12_345, + max_redirects: 2, + allow_private_hosts: false, + max_cache_bytes: 67_890, + }]); + expect(yaml).not.toContain("SIGNED-HANDOFF-CANARY"); + + const checked = await runFile(thtBin, ["config", "check", "-c", lease.path], { + cwd: harnessDir, + env: { ...process.env, THT_HOME: join(f.root, "home") }, + }); + expect(`${checked.stdout}${checked.stderr}`).not.toContain("SIGNED-HANDOFF-CANARY"); + } finally { + lease.release(); + } +}); + +test("static S3 Evidence resolves only configured secret-root file paths", async () => { + const f = await fixture(evidenceWorkspace(` type: s3 + uri: s3://clinical-evidence/published/ + endpoint_url: https://s3.example.test/ + region: eu-west-1 + credentials: static_files + trusted_endpoint: true + allow_private_endpoint: true + allow_insecure_endpoint: false + max_bytes: 222 + max_objects: 33 + max_pages: 4 + page_size: 5 +`, ` policy: + max_chunk_chars: 2500 + retain_published_generations: 7 +`)); + const runner = runnerFor(f); + const lease = runner.acquireWorkspaceRuntime(f.revision.snapshotPath); + try { + const yaml = readFileSync(lease.path, "utf8"); + expect(parse(yaml).evidence.sources).toEqual([{ + type: "s3", + bucket: "clinical-evidence", + prefix: "published/", + endpoint_url: "https://s3.example.test/", + region: "eu-west-1", + access_key_file: realpathSync(join(f.secretRoot, "evidence-access")), + secret_key_file: realpathSync(join(f.secretRoot, "evidence-secret")), + session_token_file: realpathSync(join(f.secretRoot, "evidence-token")), + trusted_endpoint: true, + allow_private_endpoint: true, + allow_insecure_endpoint: false, + max_bytes: 222, + max_objects: 33, + max_pages: 4, + page_size: 5, + }]); + expect(parse(yaml).vector).toEqual({ + max_chunk_chars: 2_500, + retain_published_generations: 7, + }); + for (const canary of ["ACCESS-HANDOFF-CANARY", "SECRET-HANDOFF-CANARY", "TOKEN-HANDOFF-CANARY"]) { + expect(yaml).not.toContain(canary); + } + + const checked = await runFile(thtBin, ["config", "check", "-c", lease.path], { + cwd: harnessDir, + env: { ...process.env, THT_HOME: join(f.root, "home") }, + }); + const output = `${checked.stdout}${checked.stderr}`; + for (const canary of ["ACCESS-HANDOFF-CANARY", "SECRET-HANDOFF-CANARY", "TOKEN-HANDOFF-CANARY"]) { + expect(output).not.toContain(canary); + } + } finally { + lease.release(); + } +}); + test("ThtRunner refuses to render a migration-required registry snapshot", async () => { const f = await fixture(migrationRequiredWorkspace); const runner = runnerFor(f); diff --git a/backend/test/workspace-runtime-renderer.test.ts b/backend/test/workspace-runtime-renderer.test.ts index 7bba52a7..9f4f551a 100644 --- a/backend/test/workspace-runtime-renderer.test.ts +++ b/backend/test/workspace-runtime-renderer.test.ts @@ -299,3 +299,278 @@ test("renders REST bindings through the legacy rest sections without secret valu }); expect(yaml).not.toContain("\n api_key: "); }); + +function evidenceWorkspace(source: Record, policy?: Record) { + return parseWorkspaceYaml(`${canonicalEvidenceWorkspace}\nevidence:\n source: ${JSON.stringify(source)}${ + policy === undefined ? "" : `\n policy: ${JSON.stringify(policy)}` + }\n`); +} + +const canonicalEvidenceWorkspace = `workspace: + schema_version: 3 + id: psd-clinical + name: Runtime Evidence + language: en +dwh: + engine: postgres + database: analytics + schema: mart + supported_transports: [postgres_direct] +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 evidenceRevision = "1".repeat(40); +const evidenceContext = { + workspaceId: "psd-clinical", + workspaceRevision: evidenceRevision, + revisionContentRoot: `/srv/registry/snapshots/${evidenceRevision}`, +}; + +function evidenceRender( + source: Record, + evidenceBinding: RuntimeBindings["evidence"] = { missing: [], values: {} }, + policy?: Record, +) { + return renderRuntimeConfig( + evidenceWorkspace(source, policy), + { ...directBindings, evidence: evidenceBinding }, + paths, + evidenceContext, + {}, + semanticRuntime, + ); +} + +test("renders filesystem Evidence below the immutable revision content root with default policy", () => { + const yaml = evidenceRender({ + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + }); + const rendered = parse(yaml); + + expect(rendered.runtime_identity.workspace_revision).toBe(evidenceRevision); + expect(rendered.evidence).toEqual({ + sources: [{ + type: "filesystem", + root: `/srv/registry/snapshots/${evidenceRevision}/workspace-content/psd-clinical/evidence`, + patterns: ["**/*.md"], + max_bytes: 10_485_760, + }], + }); + expect(rendered.vector).toEqual({ + max_chunk_chars: 4_000, + retain_published_generations: 3, + }); + expect(yaml).not.toContain("/srv/registry/repo"); +}); + +test("renders public HTTP Evidence with exact fractional-second timeouts and every policy limit", () => { + const rendered = parse(evidenceRender({ + type: "http", + uris: ["https://evidence.example.test/guide.md"], + authentication: "none", + connect_timeout_ms: 1_001, + read_timeout_ms: 30_001, + max_bytes: 12_345, + max_redirects: 0, + allow_private_hosts: true, + max_cache_bytes: 67_890, + }, undefined, { + max_chunk_chars: 2_501, + retain_published_generations: 7, + })); + + expect(rendered.evidence).toEqual({ + sources: [{ + type: "http", + urls: ["https://evidence.example.test/guide.md"], + connect_timeout: 1.001, + read_timeout: 30.001, + max_bytes: 12_345, + max_redirects: 0, + allow_private_hosts: true, + max_cache_bytes: 67_890, + }], + }); + expect(rendered.vector).toEqual({ + max_chunk_chars: 2_501, + retain_published_generations: 7, + }); +}); + +test("renders signed HTTP Evidence as provenance plus a validated file path only", () => { + const signedFile = "/run/secrets/evidence-signed-urls.json"; + const yaml = evidenceRender({ + type: "http", + uris: [ + "https://evidence.example.test/guide.md", + "https://evidence.example.test/runbook.md", + ], + authentication: "signed_urls_file", + }, { + missing: [], + values: { THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE: signedFile }, + }); + + expect(parse(yaml).evidence.sources).toEqual([{ + type: "http", + provenance_urls: [ + "https://evidence.example.test/guide.md", + "https://evidence.example.test/runbook.md", + ], + signed_urls_file: signedFile, + connect_timeout: 5, + read_timeout: 30, + max_bytes: 10_485_760, + max_redirects: 5, + allow_private_hosts: false, + max_cache_bytes: 67_108_864, + }]); + expect(yaml).not.toContain("SIGNED-URL-CANARY-CONTENT"); +}); + +test("renders ambient S3 Evidence without credential keys", () => { + const rendered = parse(evidenceRender({ + type: "s3", + uri: "s3://clinical-evidence/published/guides/", + credentials: "ambient", + region: "eu-west-1", + })); + + expect(rendered.evidence.sources).toEqual([{ + type: "s3", + bucket: "clinical-evidence", + prefix: "published/guides/", + region: "eu-west-1", + trusted_endpoint: false, + allow_private_endpoint: false, + allow_insecure_endpoint: false, + max_bytes: 10_485_760, + max_objects: 10_000, + max_pages: 100, + page_size: 1_000, + }]); + expect(JSON.stringify(rendered.evidence)).not.toMatch(/access_key|secret_key|session_token/); +}); + +test("renders static S3 Evidence with endpoint policy, limits, and file paths but no contents", () => { + const yaml = evidenceRender({ + type: "s3", + uri: "s3://clinical-evidence/published/", + credentials: "static_files", + endpoint_url: "http://minio.internal:9000/", + region: "eu-central-1", + trusted_endpoint: true, + allow_private_endpoint: true, + allow_insecure_endpoint: true, + max_bytes: 222, + max_objects: 33, + max_pages: 4, + page_size: 5, + }, { + missing: [], + values: { + THT_WS_PSD_CLINICAL_EVIDENCE_ACCESS_KEY_FILE: "/run/secrets/evidence-access", + THT_WS_PSD_CLINICAL_EVIDENCE_SECRET_KEY_FILE: "/run/secrets/evidence-secret", + THT_WS_PSD_CLINICAL_EVIDENCE_SESSION_TOKEN_FILE: "/run/secrets/evidence-token", + }, + }); + + expect(parse(yaml).evidence.sources).toEqual([{ + type: "s3", + bucket: "clinical-evidence", + prefix: "published/", + endpoint_url: "http://minio.internal:9000/", + region: "eu-central-1", + access_key_file: "/run/secrets/evidence-access", + secret_key_file: "/run/secrets/evidence-secret", + session_token_file: "/run/secrets/evidence-token", + trusted_endpoint: true, + allow_private_endpoint: true, + allow_insecure_endpoint: true, + max_bytes: 222, + max_objects: 33, + max_pages: 4, + page_size: 5, + }]); + expect(yaml).not.toContain("ACCESS-CANARY-CONTENT"); + expect(yaml).not.toContain("SECRET-CANARY-CONTENT"); + expect(yaml).not.toContain("TOKEN-CANARY-CONTENT"); +}); + +test("omits Evidence configuration and policy when the descriptor has no Evidence", () => { + const rendered = parse(renderRuntimeConfig( + workspaceV3, + directBindings, + paths, + evidenceContext, + {}, + semanticRuntime, + )); + + expect(rendered).not.toHaveProperty("evidence"); + expect(rendered).not.toHaveProperty("vector"); +}); + +test.each([ + { + source: { + type: "http", uris: ["https://evidence.example.test/guide.md"], + authentication: "signed_urls_file", + }, + missing: "THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE", + }, + { + source: { + type: "s3", uri: "s3://clinical-evidence/published/", credentials: "static_files", + }, + missing: "THT_WS_PSD_CLINICAL_EVIDENCE_SECRET_KEY_FILE", + }, +])("rejects missing required Evidence binding $missing before rendering", ({ source, missing }) => { + expect(() => evidenceRender(source, { missing: [missing], values: {} })).toThrow( + "runtime configuration requires complete Evidence bindings", + ); +}); + +test("is byte deterministic and revision-bound for descriptor-identical content-only commits", () => { + const source = { + type: "filesystem", + uri: "workspace-content/psd-clinical/evidence", + }; + const first = evidenceRender(source); + expect(evidenceRender(source)).toBe(first); + + const nextRevision = "2".repeat(40); + const next = renderRuntimeConfig( + evidenceWorkspace(source), + directBindings, + paths, + { + workspaceId: "psd-clinical", + workspaceRevision: nextRevision, + revisionContentRoot: `/srv/registry/snapshots/${nextRevision}`, + }, + {}, + semanticRuntime, + ); + const firstParsed = parse(first); + const nextParsed = parse(next); + + expect(next).not.toBe(first); + expect(nextParsed.runtime_identity.workspace_revision).toBe(nextRevision); + expect(nextParsed.evidence.sources[0].root).toBe( + `/srv/registry/snapshots/${nextRevision}/workspace-content/psd-clinical/evidence`, + ); + expect(nextParsed.evidence.sources[0].root).not.toBe(firstParsed.evidence.sources[0].root); +});