feat: define workspace evidence descriptor contract
This commit is contained in:
@@ -104,7 +104,52 @@ interface QdrantVectorStore {
|
||||
distance: "cosine";
|
||||
}
|
||||
|
||||
export interface WorkspaceV3 extends WorkspaceBase<3, QdrantVectorStore> {}
|
||||
export interface EvidencePolicy {
|
||||
max_chunk_chars: number;
|
||||
retain_published_generations: number;
|
||||
}
|
||||
|
||||
export type EvidenceSource =
|
||||
| {
|
||||
type: "filesystem";
|
||||
uri: string;
|
||||
patterns: string[];
|
||||
max_bytes: number;
|
||||
}
|
||||
| {
|
||||
type: "http";
|
||||
uris: string[];
|
||||
authentication: "none" | "signed_urls_file";
|
||||
connect_timeout_ms: number;
|
||||
read_timeout_ms: number;
|
||||
max_bytes: number;
|
||||
max_redirects: number;
|
||||
allow_private_hosts: boolean;
|
||||
max_cache_bytes: number;
|
||||
}
|
||||
| {
|
||||
type: "s3";
|
||||
uri: string;
|
||||
endpoint_url?: string;
|
||||
region?: string;
|
||||
credentials: "ambient" | "static_files";
|
||||
trusted_endpoint: boolean;
|
||||
allow_private_endpoint: boolean;
|
||||
allow_insecure_endpoint: boolean;
|
||||
max_bytes: number;
|
||||
max_objects: number;
|
||||
max_pages: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
export interface WorkspaceEvidence {
|
||||
source: EvidenceSource;
|
||||
policy: EvidencePolicy;
|
||||
}
|
||||
|
||||
export interface WorkspaceV3 extends WorkspaceBase<3, QdrantVectorStore> {
|
||||
evidence?: WorkspaceEvidence;
|
||||
}
|
||||
export interface WorkspaceV2 extends WorkspaceBase<2, VectorStore & { database: string; schema: string }> {}
|
||||
|
||||
/** A readable, non-operational v1 descriptor. It must be explicitly migrated before use. */
|
||||
@@ -218,6 +263,147 @@ const llmPolicySchema = z.object({
|
||||
allowed: z.array(modelReference).min(1),
|
||||
}).strict();
|
||||
|
||||
const positiveSafeInteger = z.number().int().safe().positive();
|
||||
const nonnegativeSafeInteger = z.number().int().safe().nonnegative();
|
||||
|
||||
function isSafeEvidencePattern(value: string): boolean {
|
||||
const parts = value.split("/");
|
||||
return value.length > 0
|
||||
&& !value.startsWith("/")
|
||||
&& !value.includes("\\")
|
||||
&& !/[\u0000-\u001f\u007f]/u.test(value)
|
||||
&& parts.every((part) => part !== "" && part !== "." && part !== "..");
|
||||
}
|
||||
|
||||
function parsePublicHttpUri(value: string): URL | undefined {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (
|
||||
!["http:", "https:"].includes(parsed.protocol)
|
||||
|| parsed.hostname.length === 0
|
||||
|| parsed.username !== ""
|
||||
|| parsed.password !== ""
|
||||
|| parsed.search !== ""
|
||||
|| parsed.hash !== ""
|
||||
) return undefined;
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalPublicHttpUri(value: string): string | undefined {
|
||||
return parsePublicHttpUri(value)?.href;
|
||||
}
|
||||
|
||||
function isSafeS3Uri(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "s3:"
|
||||
&& parsed.hostname.length > 0
|
||||
&& parsed.username === ""
|
||||
&& parsed.password === ""
|
||||
&& parsed.search === ""
|
||||
&& parsed.hash === "";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeS3Endpoint(value: string): boolean {
|
||||
const parsed = parsePublicHttpUri(value);
|
||||
return parsed !== undefined && (parsed.pathname === "/" || parsed.pathname === "");
|
||||
}
|
||||
|
||||
const evidencePattern = z.string().refine(isSafeEvidencePattern, {
|
||||
message: "evidence patterns must be normalized relative globs",
|
||||
});
|
||||
const filesystemEvidenceSourceSchema = z.object({
|
||||
type: z.literal("filesystem"),
|
||||
uri: z.string(),
|
||||
patterns: z.array(evidencePattern).min(1).default(["**/*.md"]),
|
||||
max_bytes: positiveSafeInteger.default(10 * 1024 * 1024),
|
||||
}).strict().superRefine((source, context) => {
|
||||
if (new Set(source.patterns).size !== source.patterns.length) {
|
||||
context.addIssue({ code: "custom", path: ["patterns"], message: "evidence patterns must not repeat" });
|
||||
}
|
||||
});
|
||||
const httpEvidenceUri = z.string().refine((value) => parsePublicHttpUri(value) !== undefined, {
|
||||
message: "HTTP evidence URIs must be public http(s) identities without credentials, query, or fragment",
|
||||
});
|
||||
const httpEvidenceSourceSchema = z.object({
|
||||
type: z.literal("http"),
|
||||
uris: z.array(httpEvidenceUri).min(1),
|
||||
authentication: z.enum(["none", "signed_urls_file"]).default("none"),
|
||||
connect_timeout_ms: positiveSafeInteger.default(5_000),
|
||||
read_timeout_ms: positiveSafeInteger.default(30_000),
|
||||
max_bytes: positiveSafeInteger.default(10 * 1024 * 1024),
|
||||
max_redirects: nonnegativeSafeInteger.default(5),
|
||||
allow_private_hosts: z.boolean().default(false),
|
||||
max_cache_bytes: positiveSafeInteger.default(64 * 1024 * 1024),
|
||||
}).strict().superRefine((source, context) => {
|
||||
const canonical = source.uris.map(canonicalPublicHttpUri);
|
||||
if (new Set(canonical).size !== canonical.length) {
|
||||
context.addIssue({ code: "custom", path: ["uris"], message: "HTTP evidence URIs must not repeat" });
|
||||
}
|
||||
});
|
||||
const s3EvidenceSourceSchema = z.object({
|
||||
type: z.literal("s3"),
|
||||
uri: z.string().refine(isSafeS3Uri, {
|
||||
message: "S3 evidence URI must use s3:// without credentials, query, or fragment",
|
||||
}),
|
||||
endpoint_url: z.string().refine(isSafeS3Endpoint, {
|
||||
message: "S3 endpoint must be an origin-only http(s) URL without credentials",
|
||||
}).optional(),
|
||||
region: z.string().trim().min(1).optional(),
|
||||
credentials: z.enum(["ambient", "static_files"]).default("ambient"),
|
||||
trusted_endpoint: z.boolean().default(false),
|
||||
allow_private_endpoint: z.boolean().default(false),
|
||||
allow_insecure_endpoint: z.boolean().default(false),
|
||||
max_bytes: positiveSafeInteger.default(10 * 1024 * 1024),
|
||||
max_objects: positiveSafeInteger.default(10_000),
|
||||
max_pages: positiveSafeInteger.default(100),
|
||||
page_size: positiveSafeInteger.max(1_000).default(1_000),
|
||||
}).strict().superRefine((source, context) => {
|
||||
if (source.endpoint_url === undefined) {
|
||||
if (source.trusted_endpoint || source.allow_private_endpoint || source.allow_insecure_endpoint) {
|
||||
context.addIssue({
|
||||
code: "custom", path: ["endpoint_url"],
|
||||
message: "S3 endpoint policy requires endpoint_url",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!source.trusted_endpoint) {
|
||||
context.addIssue({
|
||||
code: "custom", path: ["trusted_endpoint"],
|
||||
message: "custom S3 endpoints must be explicitly trusted",
|
||||
});
|
||||
}
|
||||
if (source.endpoint_url.startsWith("http:") && !source.allow_insecure_endpoint) {
|
||||
context.addIssue({
|
||||
code: "custom", path: ["allow_insecure_endpoint"],
|
||||
message: "HTTP S3 endpoints require an explicit insecure opt-in",
|
||||
});
|
||||
}
|
||||
});
|
||||
const evidenceSourceSchema = z.discriminatedUnion("type", [
|
||||
filesystemEvidenceSourceSchema,
|
||||
httpEvidenceSourceSchema,
|
||||
s3EvidenceSourceSchema,
|
||||
]);
|
||||
const evidencePolicySchema = z.object({
|
||||
max_chunk_chars: positiveSafeInteger.default(4_000),
|
||||
retain_published_generations: positiveSafeInteger.default(3),
|
||||
}).strict();
|
||||
const workspaceEvidenceSchema = z.object({
|
||||
source: evidenceSourceSchema,
|
||||
policy: evidencePolicySchema.default({
|
||||
max_chunk_chars: 4_000,
|
||||
retain_published_generations: 3,
|
||||
}),
|
||||
}).strict();
|
||||
|
||||
function unique<T>(values: readonly T[], context: z.RefinementCtx, path: PropertyKey[]) {
|
||||
if (new Set(values).size !== values.length) {
|
||||
context.addIssue({ code: "custom", path, message: "supported transports must not repeat" });
|
||||
@@ -235,6 +421,17 @@ function workspaceInvariants(workspace: any, context: z.RefinementCtx): void {
|
||||
}
|
||||
unique(workspace.llm_policy.allowed, context, ["llm_policy", "allowed"]);
|
||||
|
||||
if (workspace.evidence?.source.type === "filesystem") {
|
||||
const expected = `workspace-content/${workspace.workspace.id}/evidence`;
|
||||
if (workspace.evidence.source.uri !== expected) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["evidence", "source", "uri"],
|
||||
message: "filesystem evidence URI must be the canonical workspace Evidence root",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (workspace.semantic_index.vector_store.dimensions !== workspace.semantic_index.embedding.dimensions) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
@@ -303,6 +500,7 @@ const WorkspaceV2Schema = z.object({
|
||||
const WorkspaceV3Schema = z.object({
|
||||
dwh: dwhSchema,
|
||||
llm_policy: llmPolicySchema,
|
||||
evidence: workspaceEvidenceSchema.optional(),
|
||||
diagnostics: z.object({
|
||||
dwh_rest: dwhRestDiagnostic.optional(),
|
||||
}).strict().optional(),
|
||||
|
||||
Reference in New Issue
Block a user