feat: activate commit-addressed Evidence materialization with an integrity chain (P6)

This commit is contained in:
2026-08-13 12:34:56 +02:00
parent 0c9e614100
commit bb2eabceb6
4 changed files with 164 additions and 8 deletions
+5
View File
@@ -252,6 +252,11 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
maxImportBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_BYTES, 10 * 1024 * 1024), maxImportBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_BYTES, 10 * 1024 * 1024),
maxImportEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_ENTRIES, 32), maxImportEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_ENTRIES, 32),
dataRoot: env.THT_DATA_ROOT, 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 settingsFile = env.SETTINGS_FILE ?? "data/settings.json";
const internalQdrantUrl = internalServiceUrl( const internalQdrantUrl = internalServiceUrl(
+38 -8
View File
@@ -1,10 +1,11 @@
import { createHash, randomUUID } from "node:crypto"; 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 { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { isAbsolute, join } from "node:path"; import { isAbsolute, join } from "node:path";
import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js"; import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js";
import { parseAnnotationsYaml } from "./annotations.js"; import { parseAnnotationsYaml } from "./annotations.js";
import { syncAnnotations } from "./annotations-sync.js"; import { syncAnnotations } from "./annotations-sync.js";
import { materializeEvidenceTree } from "./evidence-materialization.js";
import { assertCatalogMatchesDescriptor, parseWorkspaceCatalogYaml, type WorkspaceCatalog, type WorkspaceCatalogEntry } from "./catalog.js"; import { assertCatalogMatchesDescriptor, parseWorkspaceCatalogYaml, type WorkspaceCatalog, type WorkspaceCatalogEntry } from "./catalog.js";
import { import {
GitWorkspaceRepository, GitWorkspaceRepository,
@@ -640,6 +641,16 @@ export class WorkspaceRegistry {
await writeFile(join(staging, docsName), docs.markdown, { encoding: "utf8", mode: 0o400 }); await writeFile(join(staging, docsName), docs.markdown, { encoding: "utf8", mode: 0o400 });
files[envName] = digest(docs.envExample); files[envName] = digest(docs.envExample);
files[docsName] = digest(docs.markdown); 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 }), { await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions, catalog, files }), {
encoding: "utf8", mode: 0o400, encoding: "utf8", mode: 0o400,
@@ -822,7 +833,7 @@ export class WorkspaceRegistry {
|| JSON.stringify(manifest.catalog ?? null) !== JSON.stringify(state.catalog ?? null)) { || JSON.stringify(manifest.catalog ?? null) !== JSON.stringify(state.catalog ?? null)) {
throw new Error("manifest state does not match active state"); 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); await this.assertSnapshotEvidenceContexts(state);
} catch (error) { } catch (error) {
if (error instanceof WorkspaceRegistryError) throw error; if (error instanceof WorkspaceRegistryError) throw error;
@@ -830,12 +841,31 @@ export class WorkspaceRegistry {
} }
} }
private expectedSnapshotFiles(state: ActiveState): string[] { private expectedSnapshotFiles(state: ActiveState, directory: string): string[] {
// P1 snapshots only descriptors and derived public docs. P6 owns revision-pinned return state.revisions.flatMap((revision) => {
// workspace-content materialization and its recursive containment checks. const names = [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`];
return state.revisions.flatMap((revision) => [ const workspace = parseWorkspaceYaml(readFileSync(join(directory, `${revision.id}.yaml`), "utf8"));
`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`, 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( private async assertManifestFiles(
+6
View File
@@ -10,6 +10,12 @@ export interface WorkspaceRegistryConfig {
maxImportEntries: number; maxImportEntries: number;
/** Absolute runtime data root; when set, activation also syncs curated annotations per revision. */ /** Absolute runtime data root; when set, activation also syncs curated annotations per revision. */
dataRoot?: string; 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 = export type WorkspaceErrorCode =
+115
View File
@@ -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<string> {
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" });
});