/*
RFC-0707: nachweis.validate command handler — validates PBP trust entities and enforces publication gate.
- Does not modify any state — read-only validation.
- Does not validate PBP schema conformance — that is the PBP compiler's job.
- RFC-0872: replace evaluateGate with policy-driven evaluateGateV2, add technical-assessment validation, locale drift check.
- RFC-0880: add NACHWEIS-SLUG-01 check for mandatory slug in Nachweis evidence records.
- RFC-0886: add NACHWEIS-DISPLAY-CONSENT-01 check for display↔consent consistency (warning, not hard failure).
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
RFC-0707, RFC-0715, RFC-0871
*/
import path from "node:path";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import { executeKernelCommand } from "@warpgogol/werkstatt-engine/kernel";
import {
snapshotCanonicalJsonObjectV1,
canonicalJsonHashV1,
} from "@warpgogol/werkstatt-engine/fingerprint";
import { parseMarkdownFrontmatter, loadSystemManifest } from "@warpgogol/werkstatt-shared/content";
import { readBordbuch } from "../bordbuch/bordbuch-io.ts";
import {
isNachweisEntitled,
makeSkipResult,
resolveNachweisCachePath,
resolvePbpEntityDir,
resolveDefaultLang,
evaluateGateV2,
isValidSha256Hex,
type NachweisPublicationGateV2,
type NachweisValidateResult,
type NachweisViolation,
} from "./nachweis-io.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
const NACHWEIS_EVIDENCE_KINDS = new Set([
"client-statement",
"project-confirmation",
"certificate",
"operational-evidence",
// RFC-0872: technical assessment evidence type
"technical-assessment",
]);
const BCP47_PATTERN = /^[a-z]{2,3}(-[A-Z][a-zA-Z]{3})?(-[A-Z]{2})?$/;
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
interface PbpEntityRecord {
id: string;
type: string;
data: Record;
}
async function readPbpEntitiesByType(
cachePath: string,
entityType: string,
lang: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const dir = resolvePbpEntityDir(cachePath, lang, entityType);
if (!(await io.exists(dir))) return [];
const entries = await io.readdir(dir);
const records: PbpEntityRecord[] = [];
for (const entry of entries) {
if (!entry.isFile) continue;
if (!entry.name.endsWith(".md") && !entry.name.endsWith(".yaml")) continue;
const filePath = path.join(dir, entry.name);
const raw = await io.readFile(filePath);
if (entry.name.endsWith(".md")) {
const { data } = parseMarkdownFrontmatter(raw);
records.push({ id: entry.name.replace(/\.(md|yaml)$/, ""), type: entityType, data });
} else {
try {
const parsed = JSON.parse(raw) as Record;
records.push({ id: entry.name.replace(/\.yaml$/, ""), type: entityType, data: parsed });
} catch {
// skip invalid YAML
}
}
}
return records;
}
function evaluateGateV2Local(
slug: string,
kind: string,
evidenceRecord: PbpEntityRecord | undefined,
consentRecord: PbpEntityRecord | undefined,
bordbuchEntries: { kind: string; metadata?: Record | null; summary: string }[],
): NachweisPublicationGateV2 {
return evaluateGateV2(slug, kind, {
evidenceData: (evidenceRecord?.data ?? {}) as Record,
consentData: consentRecord?.data as Record | undefined,
bordbuchEntries,
});
}
async function resolveSupportedLangs(cachePath: string): Promise {
const contentDir = path.join(cachePath, "src", "content");
const { manifest } = await loadSystemManifest(contentDir);
const i18n = manifest.i18n as
{ default?: string; supported?: Record } | undefined;
if (!i18n?.default) return [];
const langs = Object.keys(i18n.supported ?? { [i18n.default]: true });
return langs.length > 0 ? langs : [i18n.default];
}
export async function runNachweisValidate(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot } = context;
const systemId = flagString(input, "system") ?? context.site?.name;
if (!systemId) throw new Error("[nachweis.validate] --system is required");
const entitled = await isNachweisEntitled(workspaceRoot, systemId);
if (!entitled) {
return makeSkipResult(
"nachweis.validate",
systemId,
) as unknown as KernelCommandResult;
}
const cachePath = await resolveNachweisCachePath(workspaceRoot, systemId);
const lang = await resolveDefaultLang(cachePath);
const evidenceSources = await readPbpEntitiesByType(cachePath, "evidence-source", lang);
const consents = await readPbpEntitiesByType(cachePath, "consent", lang);
const claims = await readPbpEntitiesByType(cachePath, "claim", lang);
const violations: NachweisViolation[] = [];
// Check EvidenceSource entities with Nachweis kinds have sha256 in items
for (const es of evidenceSources) {
const kind = es.data.kind as string | undefined;
if (!kind || !NACHWEIS_EVIDENCE_KINDS.has(kind)) continue;
// RFC-0880: NACHWEIS-SLUG-01 — mandatory slug in frontmatter
const slug = (es.data as Record).slug as string | undefined;
if (typeof slug !== "string" || slug.trim() === "") {
violations.push({
rule: "NACHWEIS-SLUG-01",
message: `EvidenceSource '${es.id}' has kind '${kind}' but no frontmatter slug. Add a slug field to the frontmatter.`,
recordId: es.id,
});
}
const items = es.data.items as Record | undefined;
if (!items) {
violations.push({
rule: "evidence-missing-sha256",
message: `EvidenceSource '${es.id}' has kind '${kind}' but no items[]`,
recordId: es.id,
});
continue;
}
for (const [itemKey, item] of Object.entries(items)) {
if (!isValidSha256Hex(item.sha256)) {
violations.push({
rule: "evidence-missing-sha256",
message: `EvidenceSource '${es.id}' item '${itemKey}' is missing or has invalid sha256`,
recordId: es.id,
});
}
}
}
// RFC-0885: Check Consent entities with consentScope.document.status granted have grantedAt set
for (const c of consents) {
const scope = c.data.consentScope as
{ document?: { status?: string; grantedAt?: string | null } } | undefined;
const docStatus = scope?.document?.status;
if (docStatus === "granted") {
const grantedAt = scope?.document?.grantedAt;
if (grantedAt == null || grantedAt === "") {
violations.push({
rule: "consent-granted-without-timestamp",
message: `Consent '${c.id}' has consentScope.document.status 'granted' but grantedAt is null`,
recordId: c.id,
});
}
}
}
// Check Claim entities for valid BCP 47 statementLang tags
for (const cl of claims) {
const statementLang = cl.data.statementLang as string | undefined;
if (statementLang && !BCP47_PATTERN.test(statementLang)) {
violations.push({
rule: "claim-invalid-statement-lang",
message: `Claim '${cl.id}' has invalid statementLang '${statementLang}' (not BCP 47)`,
recordId: cl.id,
});
}
}
// Delegate bordbuch hash-chain validation
const bordbuchResult = await executeKernelCommand({
workspaceRoot,
commandName: "bordbuch.validate",
siteName: systemId,
argv: [`--system=${systemId}`],
});
const bordbuchReport = Array.isArray(bordbuchResult) ? bordbuchResult[0] : bordbuchResult;
if (bordbuchReport.exitCode !== 0) {
violations.push({
rule: "bordbuch-hash-chain",
message: `bordbuch.validate reported violations: ${bordbuchReport.summary ?? "unknown"}`,
});
}
// Publication gate evaluation per record
const bordbuchEntries = await readBordbuch(workspaceRoot, systemId);
const nachweisEntries = bordbuchEntries.filter(
(e) =>
e.kind === "nachweis-record" ||
e.kind === "nachweis-consent" ||
e.kind === "nachweis-signed" ||
e.kind === "nachweis-timestamped",
);
const gateResults: NachweisPublicationGateV2[] = [];
const slugs = new Set();
for (const es of evidenceSources) {
const kind = es.data.kind as string | undefined;
if (!kind || !NACHWEIS_EVIDENCE_KINDS.has(kind)) continue;
const slug = ((es.data as Record).slug as string | undefined) ?? es.id;
slugs.add(slug);
}
for (const slug of slugs) {
const evidenceRecord = evidenceSources.find(
(e) =>
((e.data as Record).slug as string | undefined) === slug || e.id === slug,
);
const consentRecord = consents.find(
(c) => (c.data as Record).slug === slug || c.id === slug,
);
const kind = (evidenceRecord?.data as Record)?.kind as string | undefined;
if (!kind) continue;
const gate = evaluateGateV2Local(slug, kind, evidenceRecord, consentRecord, nachweisEntries);
gateResults.push(gate);
// Check if record is published but gate not passed
const isPublished =
(evidenceRecord?.data as Record)?.recordStatus === "published";
if (isPublished && !gate.allPassed) {
const failedConditions = gate.conditions
.filter((c) => c.required && c.status !== "pass")
.map((c) => c.id);
violations.push({
rule: "publication-gate-violation",
message: `Record '${slug}' is published but gate conditions not met (policy: ${gate.policyId}, failed: ${failedConditions.join(", ")})`,
recordId: slug,
});
}
// RFC-0872: technical-assessment-specific validation
if (kind === "technical-assessment") {
const assessment = (evidenceRecord?.data as Record)?.assessment as
Record | undefined;
if (!assessment) {
violations.push({
rule: "TECHNICAL_ASSESSMENT_METADATA_REQUIRED",
message: `EvidenceSource '${slug}' has kind 'technical-assessment' but no assessment field`,
recordId: slug,
});
}
const items = (evidenceRecord?.data as Record)?.items as
Record | undefined;
const hasCanonicalRaw =
items != null &&
Object.values(items).some((item) => item.role === "raw-result" && item.canonical === true);
if (!hasCanonicalRaw) {
violations.push({
rule: "TECHNICAL_ASSESSMENT_CANONICAL_RAW_REQUIRED",
message: `EvidenceSource '${slug}' has no canonical raw-result artifact`,
recordId: slug,
});
}
if (items != null && hasCanonicalRaw) {
for (const [itemKey, item] of Object.entries(items)) {
if (
item.role === "raw-result" &&
item.canonical === true &&
!isValidSha256Hex(item.sha256)
) {
violations.push({
rule: "TECHNICAL_ASSESSMENT_HASH_REQUIRED",
message: `EvidenceSource '${slug}' canonical raw-result item '${itemKey}' is missing sha256`,
recordId: slug,
});
}
}
// RFC-0872 section 2: screenshot canonical invariant — if any item has
// role: screenshot and canonical: true, there MUST also be a canonical raw-result
const hasCanonicalScreenshot = Object.values(items).some(
(item) => item.role === "screenshot" && item.canonical === true,
);
if (hasCanonicalScreenshot && !hasCanonicalRaw) {
violations.push({
rule: "TECHNICAL_ASSESSMENT_CANONICAL_SCREENSHOT_WITHOUT_RAW",
message: `EvidenceSource '${slug}' has canonical screenshot but no canonical raw-result artifact (screenshots cannot be the sole canonical artifact)`,
recordId: slug,
});
}
// RFC-0872 section 2: no two canonical artifacts may reuse the same
// logical item key for different hashes
const canonicalHashes = new Map();
for (const [itemKey, item] of Object.entries(items)) {
if (item.canonical === true && isValidSha256Hex(item.sha256)) {
const existing = canonicalHashes.get(item.sha256);
if (existing !== undefined && existing !== itemKey) {
violations.push({
rule: "TECHNICAL_ASSESSMENT_DUPLICATE_CANONICAL_HASH",
message: `EvidenceSource '${slug}' canonical items '${existing}' and '${itemKey}' share the same sha256 '${item.sha256}'`,
recordId: slug,
});
}
canonicalHashes.set(item.sha256, itemKey);
}
}
}
// Check authorization basis
if (
assessment &&
(assessment.authorizationBasis == null || assessment.authorizationBasis === "")
) {
violations.push({
rule: "ASSESSMENT_AUTHORIZATION_REQUIRED",
message: `EvidenceSource '${slug}' technical assessment lacks authorizationBasis`,
recordId: slug,
});
}
}
// RFC-0872: assessment field must be absent for non-technical kinds
if (kind !== "technical-assessment") {
const assessment = (evidenceRecord?.data as Record)?.assessment;
if (assessment != null) {
violations.push({
rule: "assessment-on-non-technical-kind",
message: `EvidenceSource '${slug}' has kind '${kind}' but has an assessment field (only technical-assessment allows assessment)`,
recordId: slug,
});
}
}
// RFC-0886: NACHWEIS-DISPLAY-CONSENT-01 — display↔consent consistency
// For each visible display aspect, the corresponding consentScope aspect must have status "granted".
// This is a warning (does not fail the command), reported in the result.
const display = (evidenceRecord?.data as Record)?.display as
Record | undefined;
if (display) {
const consentScope = (consentRecord?.data as Record)?.consentScope as
Record | undefined;
const aspects = ["document", "screenshot", "websiteLink"];
for (const aspect of aspects) {
if (display[aspect] === "visible") {
const aspectConsent = consentScope?.[aspect]?.status;
if (aspectConsent !== "granted") {
violations.push({
rule: "NACHWEIS-DISPLAY-CONSENT-01",
message: `EvidenceSource '${slug}' has display.${aspect} 'visible' but consentScope.${aspect}.status is '${aspectConsent ?? "not_requested"}' (expected 'granted')`,
recordId: slug,
severity: "warning",
});
}
}
}
}
// RFC-0715: N3 artifact check — published N3 records must have nachweis-signed and nachweis-timestamped entries
if (isPublished && gate.conditions.find((c) => c.id === "n3-met")?.status === "pass") {
const hasSigned = nachweisEntries.some(
(e) => e.kind === "nachweis-signed" && e.metadata?.slug === slug,
);
const hasTimestamped = nachweisEntries.some(
(e) => e.kind === "nachweis-timestamped" && e.metadata?.slug === slug,
);
if (!hasSigned) {
violations.push({
rule: "n3-missing-signature",
message: `Record '${slug}' is published at N3 but has no nachweis-signed Bordbuch entry. Run nachweis.sign first.`,
recordId: slug,
});
}
if (!hasTimestamped) {
violations.push({
rule: "n3-missing-timestamp",
message: `Record '${slug}' is published at N3 but has no nachweis-timestamped Bordbuch entry. Run nachweis.timestamp first.`,
recordId: slug,
});
}
// RFC-0871: eidas-qualified assurance requires qualificationEvidenceRef
if (hasTimestamped) {
const timestampedEntry = nachweisEntries.find(
(e) => e.kind === "nachweis-timestamped" && e.metadata?.slug === slug,
);
const assurance = timestampedEntry?.metadata?.timestampAssurance as string | undefined;
const evidenceRef = timestampedEntry?.metadata?.qualificationEvidenceRef as
string | undefined;
if (assurance === "eidas-qualified" && !evidenceRef) {
violations.push({
rule: "n3-timestamp-qualification-evidence-missing",
message: `Record '${slug}' has timestampAssurance 'eidas-qualified' but no qualificationEvidenceRef in Bordbuch metadata. Provide --qualification-evidence-ref when running nachweis.timestamp.`,
recordId: slug,
});
}
}
}
}
// RFC-0872: locale drift check for technical-assessment assessment field
const supportedLangs = await resolveSupportedLangs(cachePath);
if (supportedLangs.length > 1) {
const assessmentByObsId = new Map>();
for (const lang of supportedLangs) {
const langEntities = await readPbpEntitiesByType(cachePath, "evidence-source", lang);
for (const es of langEntities) {
const kind = es.data.kind as string | undefined;
if (kind !== "technical-assessment") continue;
const assessment = es.data.assessment as Record | undefined;
if (!assessment) continue;
const observationId = assessment.observationId as string | undefined;
if (!observationId) continue;
const snapshot = snapshotCanonicalJsonObjectV1(assessment);
if (!snapshot.ok) continue;
const hash = canonicalJsonHashV1(snapshot.value);
let langMap = assessmentByObsId.get(observationId);
if (!langMap) {
langMap = new Map();
assessmentByObsId.set(observationId, langMap);
}
langMap.set(lang, hash);
}
}
for (const [observationId, langMap] of assessmentByObsId) {
if (langMap.size < 2) continue;
const hashes = [...langMap.values()];
const allMatch = hashes.every((h) => h === hashes[0]);
if (!allMatch) {
violations.push({
rule: "TECHNICAL_ASSESSMENT_LOCALE_DRIFT",
message: `Technical assessment observationId '${observationId}' has locale drift — assessment values differ across locales: ${[...langMap.entries()].map(([l, h]) => `${l}=${h.slice(0, 16)}`).join(", ")}`,
});
}
}
}
const hasViolations = violations.some((v) => v.severity !== "warning");
const publishedCount = gateResults.filter((g) => g.allPassed).length;
return {
data: {
systemId,
records: slugs.size,
violations,
gateResults,
},
exitCode: hasViolations ? 1 : 0,
summary: `[nachweis.validate] ${systemId}: ${slugs.size} records, ${violations.length} violations, ${publishedCount} published`,
nextSteps: hasViolations
? [
{
action: `Fix the ${violations.length} violation${violations.length === 1 ? "" : "s"} above, then re-run: pnpm exec werkstatt run nachweis.validate --site ${systemId}`,
kind: "required",
},
]
: undefined,
};
}