From bb2eabceb6983d63240c4e66dc26e4acad25077f Mon Sep 17 00:00:00 2001 From: mptyl Date: Thu, 13 Aug 2026 12:34:56 +0200 Subject: [PATCH] feat: activate commit-addressed Evidence materialization with an integrity chain (P6) --- backend/src/config.ts | 5 ++ backend/src/workspaces/registry.ts | 46 ++++++++-- backend/src/workspaces/types.ts | 6 ++ backend/test/registry-evidence.test.ts | 115 +++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 backend/test/registry-evidence.test.ts diff --git a/backend/src/config.ts b/backend/src/config.ts index 87160468..597d521b 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -252,6 +252,11 @@ export function loadConfig(env: Record): AppConfig { maxImportBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_BYTES, 10 * 1024 * 1024), maxImportEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_ENTRIES, 32), dataRoot: env.THT_DATA_ROOT, + maxEvidenceEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_ENTRIES, 4096), + maxEvidenceBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_BYTES, 64 * 1024 * 1024), + maxEvidenceFileBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_FILE_BYTES, 8 * 1024 * 1024), + maxEvidencePathBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_PATH_BYTES, 4096), + maxEvidenceManifestBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_MANIFEST_BYTES, 1024 * 1024), }; const settingsFile = env.SETTINGS_FILE ?? "data/settings.json"; const internalQdrantUrl = internalServiceUrl( diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index 82e71e64..a9bbdd1c 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -1,10 +1,11 @@ import { createHash, randomUUID } from "node:crypto"; -import { lstatSync } from "node:fs"; +import { lstatSync, readFileSync } from "node:fs"; import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js"; import { parseAnnotationsYaml } from "./annotations.js"; import { syncAnnotations } from "./annotations-sync.js"; +import { materializeEvidenceTree } from "./evidence-materialization.js"; import { assertCatalogMatchesDescriptor, parseWorkspaceCatalogYaml, type WorkspaceCatalog, type WorkspaceCatalogEntry } from "./catalog.js"; import { GitWorkspaceRepository, @@ -640,6 +641,16 @@ export class WorkspaceRegistry { await writeFile(join(staging, docsName), docs.markdown, { encoding: "utf8", mode: 0o400 }); files[envName] = digest(docs.envExample); files[docsName] = digest(docs.markdown); + if (snapshot.workspace.evidence?.source.type === "filesystem") { + const materialized = await materializeEvidenceTree({ + repository: this.repository, + revision: safeHead, + id: snapshot.id, + targetDirectory: join(staging, snapshot.id), + limits: this.evidenceMaterializationLimits(), + }); + files[`${snapshot.id}/evidence.manifest.json`] = materialized.manifestDigest; + } } await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions, catalog, files }), { encoding: "utf8", mode: 0o400, @@ -822,7 +833,7 @@ export class WorkspaceRegistry { || JSON.stringify(manifest.catalog ?? null) !== JSON.stringify(state.catalog ?? null)) { throw new Error("manifest state does not match active state"); } - await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state)); + await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state, directory)); await this.assertSnapshotEvidenceContexts(state); } catch (error) { if (error instanceof WorkspaceRegistryError) throw error; @@ -830,12 +841,31 @@ export class WorkspaceRegistry { } } - private expectedSnapshotFiles(state: ActiveState): string[] { - // P1 snapshots only descriptors and derived public docs. P6 owns revision-pinned - // workspace-content materialization and its recursive containment checks. - return state.revisions.flatMap((revision) => [ - `${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`, - ]); + private expectedSnapshotFiles(state: ActiveState, directory: string): string[] { + return state.revisions.flatMap((revision) => { + const names = [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`]; + const workspace = parseWorkspaceYaml(readFileSync(join(directory, `${revision.id}.yaml`), "utf8")); + if (workspace.evidence?.source.type === "filesystem") { + names.push(`${revision.id}/evidence.manifest.json`); + } + return names; + }); + } + + private evidenceMaterializationLimits(): Partial<{ + maxEntries: number; + maxTotalBytes: number; + maxFileBytes: number; + maxPathBytes: number; + maxManifestBytes: number; + }> { + return { + ...(this.config.maxEvidenceEntries === undefined ? {} : { maxEntries: this.config.maxEvidenceEntries }), + ...(this.config.maxEvidenceBytes === undefined ? {} : { maxTotalBytes: this.config.maxEvidenceBytes }), + ...(this.config.maxEvidenceFileBytes === undefined ? {} : { maxFileBytes: this.config.maxEvidenceFileBytes }), + ...(this.config.maxEvidencePathBytes === undefined ? {} : { maxPathBytes: this.config.maxEvidencePathBytes }), + ...(this.config.maxEvidenceManifestBytes === undefined ? {} : { maxManifestBytes: this.config.maxEvidenceManifestBytes }), + }; } private async assertManifestFiles( diff --git a/backend/src/workspaces/types.ts b/backend/src/workspaces/types.ts index b703ae8b..0e82d83b 100644 --- a/backend/src/workspaces/types.ts +++ b/backend/src/workspaces/types.ts @@ -10,6 +10,12 @@ export interface WorkspaceRegistryConfig { maxImportEntries: number; /** Absolute runtime data root; when set, activation also syncs curated annotations per revision. */ dataRoot?: string; + /** P6 Evidence materialization bounds; defaults are applied by the materializer. */ + maxEvidenceEntries?: number; + maxEvidenceBytes?: number; + maxEvidenceFileBytes?: number; + maxEvidencePathBytes?: number; + maxEvidenceManifestBytes?: number; } export type WorkspaceErrorCode = diff --git a/backend/test/registry-evidence.test.ts b/backend/test/registry-evidence.test.ts new file mode 100644 index 00000000..66e17e19 --- /dev/null +++ b/backend/test/registry-evidence.test.ts @@ -0,0 +1,115 @@ +import { execFile } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, test } from "vitest"; +import { WorkspaceRegistry } from "../src/workspaces/registry.js"; +import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; + +const runFile = promisify(execFile); +const temporaryRoots: string[] = []; + +afterEach(() => { + temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); +}); + +async function git(cwd: string, args: string[]): Promise { + const { stdout } = await runFile("git", args, { cwd }); + return stdout.trim(); +} + +const descriptor = `workspace: + schema_version: 3 + id: research + name: Research + language: en +dwh: + engine: postgres + database: analytics + schema: mart + supported_transports: [postgres_direct] +semantic_index: + vector_store: + engine: qdrant + collection: research + dimensions: 1024 + distance: cosine + embedding: + provider: ollama_internal + model: qwen3-embedding:0.6b + dimensions: 1024 +llm_policy: + allowed: [zai/glm-5.2] +evidence: + source: + type: filesystem + uri: research/evidence +`; + +function config(root: string, remoteUrl: string): WorkspaceRegistryConfig { + return { + root, + remoteUrl, + branch: "main", + gitAuthorName: "Evidence Registry Test", + gitAuthorEmail: "evidence-registry@example.invalid", + installationId: "test", + secretRoots: [], + maxImportBytes: 1024, + maxImportEntries: 1, + }; +} + +async function fixture(): Promise<{ root: string; remote: string; commit: string }> { + const root = mkdtempSync(join(tmpdir(), "thoth-registry-evidence-")); + temporaryRoots.push(root); + const remote = join(root, "remote.git"); + const source = join(root, "source"); + await git(root, ["init", "--bare", "--initial-branch=main", remote]); + mkdirSync(source); + await git(source, ["init", "--initial-branch=main"]); + await git(source, ["config", "user.name", "Evidence Registry Test"]); + await git(source, ["config", "user.email", "evidence-registry@example.invalid"]); + writeFileSync(join(source, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces:\n - id: research\n name: Research\n"); + mkdirSync(join(source, "research", "evidence"), { recursive: true }); + writeFileSync(join(source, "research", "workspace.yaml"), descriptor); + writeFileSync(join(source, "research", "evidence", "guide.md"), "# guide\n"); + await git(source, ["add", "-A"]); + await git(source, ["commit", "-m", "initial"]); + await git(source, ["remote", "add", "origin", remote]); + await git(source, ["push", "origin", "main"]); + const commit = await git(source, ["rev-parse", "HEAD"]); + return { root, remote, commit }; +} + +test("activation materializes filesystem Evidence and chains its manifest into snapshot.json", async () => { + const fixtureValue = await fixture(); + const registryRoot = join(fixtureValue.root, "registry"); + const registry = new WorkspaceRegistry(config(registryRoot, fixtureValue.remote)); + + await registry.bootstrap(); + + const evidence = join(registryRoot, "snapshots", fixtureValue.commit, "research", "evidence"); + const manifestPath = join(registryRoot, "snapshots", fixtureValue.commit, "research", "evidence.manifest.json"); + expect(readFileSync(join(evidence, "guide.md"), "utf8")).toBe("# guide\n"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + expect(manifest).toMatchObject({ schemaVersion: 1, workspace: "research", commit: fixtureValue.commit, entryCount: 1 }); + const snapshotManifest = JSON.parse(readFileSync(join(registryRoot, "snapshots", fixtureValue.commit, "snapshot.json"), "utf8")); + expect(snapshotManifest.files["research/evidence.manifest.json"]).toMatch(/^[0-9a-f]{64}$/); + + // Re-activation verifies the existing materialized root. + await expect(registry.bootstrap()).resolves.toMatchObject({ degraded: false }); +}); + +test("activation fails closed when the materialized Evidence manifest is tampered", async () => { + const fixtureValue = await fixture(); + const registryRoot = join(fixtureValue.root, "registry"); + const registry = new WorkspaceRegistry(config(registryRoot, fixtureValue.remote)); + await registry.bootstrap(); + + const manifestPath = join(registryRoot, "snapshots", fixtureValue.commit, "research", "evidence.manifest.json"); + writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, workspace: "research", commit: fixtureValue.commit, tree: "0".repeat(40), entryCount: 0, totalBytes: 0, files: {} })}\n`); + + await expect(registry.bootstrap()).rejects.toMatchObject({ code: "workspace_invalid" }); +});