/* RFC-0354 §7.3: sternsystem.validate — validate registry invariants and bundle contract. Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1254: drop unused io-migration leftovers RFC-1284: ACCESS-01/02 diagnostics in sternsystem.validate RFC-1284 step 5: checkAccessProtection compares desired accessPin to observed channel answers — a proven non-401 is a violation, transport failures downgrade to warnings, suspended systems skip the probe, and stale markers (>1h) warn via ACCESS-02. RFC-1284: fix review REVIEW-CODE-2026-10-15-02: RFC-1284 — single-store marker, flag=false proceed, surfaced reconcile F1: marker store unified on the git-tracked cache clone — access commands, certify entry check, and deploy reconcile all use non-smart readSystemState/writeSystemState (decisions Q-6); the mission-window blindness and the DNA-106 false-resume are gone. F2: --auto-manage-pin=false + suspension at entry skips entry-resume and proceeds on the open channel (decisions Q-7) — RFC/AC-8 text amended. F3: accessReconcile surfaced in data + summary of dev-deploy/propagate. F4: CHANGE_SUMMARY items on all touched files. F5: sternsystem.validate declares ACCESS-01/02 + system-state.yaml reads. F6: isProtectedLiveTarget exported and covered (AC-12 probe repointed). F7: fresh-read ordering proven — suspension opened mid-deploy suppresses the re-assert. RFC-1297: LIVE-01/02 live-channel drift checks alongside ACCESS-01/02 — same transport-failure-warns semantics. ADR-0082, ADR-0088, ADR-0091, DRIFT-01, DRIFT-02, RFC-0354, RFC-0480, RFC-0520, RFC-0561, RFC-0648, RFC-0792, RFC-0822, RFC-0870, RFC-0902, RFC-0966, RFC-0968, RFC-1097, RFC-1149 */ import path from "node:path"; import { type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import { parse as parseYaml } from "yaml"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { StarCatalog } from "@warpgogol/werkstatt-shared/ontology/cosmic"; import { systemPinSchema, type SystemConfig, type SystemState, } from "@warpgogol/werkstatt-engine/schemas"; import { discoverSystems, hasAppsCollision, resolveMirrors, resolveMirrorPath, resolveWorkpiecePath, readSystemState, isGitAccessible, } from "./registry-io.ts"; import { evaluateExternalEditGate } from "./external-edit-guard.ts"; import { collectExternalEditInputs, bordbuchFileExists, bordbuchPathFor, } from "./external-edit-collector.ts"; import { collectEnvFiles } from "../mission/env-persist.ts"; import { hasTldSuffix } from "@warpgogol/werkstatt-shared/ontology/operations"; import { readPassport } from "./registry-io.ts"; import { readLiveChannelStates, liveDriftDiagnostics } from "./live-drift.ts"; import { verifyPassport, buildPassportPayload, computePassportHash } from "./passport.ts"; import { isBordbuchDriftBookkeepingOnly, dropTrailingBookkeepingEntries, } from "./passport-staleness.ts"; import { readAuthorization, isAuthorizationExpired } from "./handover.ts"; import { readBordbuch } from "../bordbuch/bordbuch-io.ts"; import { readJournal } from "../journal/jsonl.ts"; import type { JournalRecord } from "../journal/types.ts"; import { probeAccessProtection } from "@warpgogol/werkstatt-shared/node/access-probe"; // RFC-1284: suspension is a transient measured in minutes — a marker older // than this is almost certainly orphaned (ACCESS-02 warning threshold). const ACCESS_SUSPENSION_STALE_MS = 60 * 60 * 1000; export interface SternsystemValidateData { validated: number; violations: SternsystemViolation[]; warnings: Array<{ systemId: string; field: string; message: string }>; withOwner: number; withoutOwner: number; } type SternsystemViolation = { systemId: string; rule: string; message: string }; function flagString(input: KernelCommandInput, key: string): string | undefined { const v = input.flags[key]; return typeof v === "string" ? v : undefined; } export const FORBIDDEN_PATTERNS = [ "dist", "node_modules", "packages", "package.json", "pnpm-lock.yaml", "astro.config.mjs", "astro.config.ts", "tsconfig.json", "wrangler.toml", "wrangler.jsonc", ]; // RFC-0870: Committed generated manifest paths checked in cache clone HEAD. // These are registry-only generated files committed to git for drift detection. // Only checked when tracked in git — new systems without manifests are not flagged. // Source of truth: toOwnershipEntries(context.ownershipMap ?? []) in packages/werkstatt-site/src/checks/generator-ownership.ts // (filter for markerPolicy="registry-only", conditional=true, path starts with "src/"). // This list is hardcoded here (not dynamically imported) to respect DNA-64 — // the engine must not statically import from the site plugin. // When adding a new registry-only conditional manifest to toOwnershipEntries(context.ownershipMap ?? []), // also add it here. const COMMITTED_MANIFEST_PATHS = [ "src/image-variants.generated.yaml", "src/video-manifest.generated.yaml", "src/live-video-manifest.generated.yaml", ]; // Generated files allowed to be committed but NOT required to persist in HEAD. // Unlike COMMITTED_MANIFEST_PATHS (registry-only manifests that must stay // tracked once committed — enforced by STERN-MANIFEST-01), these are migration // artifacts that may be deleted once their purpose is fulfilled: the // shrink-only list-punctuation baseline is removed when all LP violations are // fixed. const COMMITTED_GENERATED_ALLOWED = ["list-punctuation.baseline.generated.yaml"]; /** * RFC-0870: Check that committed generated manifests are present in the cache * clone git HEAD. Uses `git ls-tree HEAD -- ` to determine if a file is * tracked. Only files that ARE tracked but missing from HEAD trigger * STERN-MANIFEST-01 — untracked files (new systems) are not flagged. */ async function checkManifestPresence( cacheDir: string, systemId: string, io: WorkspaceIO, ): Promise { const violations: SternsystemViolation[] = []; for (const manifestPath of COMMITTED_MANIFEST_PATHS) { try { // Check if the file is tracked in git history (any commit) const trackedResult = await io.exec("git", ["log", "--oneline", "-1", "--", manifestPath], { cwd: cacheDir, }); if (trackedResult.exitCode !== 0 || !trackedResult.stdout.trim()) continue; // Not tracked — new system, skip // Check if the file exists in HEAD const headResult = await io.exec("git", ["ls-tree", "HEAD", "--", manifestPath], { cwd: cacheDir, }); if (headResult.exitCode === 0 && !headResult.stdout.trim()) { violations.push({ systemId, rule: "STERN-MANIFEST-01", message: `Committed generated manifest ${manifestPath} is missing from cache clone HEAD — run the generating command in a mission workpiece and commit via mission.git.commit`, }); } } catch { // Git command failed — skip this manifest } } return violations; } /** * ADR-0088: JRN-01 — every `op-started` record in the cache clone's * `operations/*.jsonl` journals MUST carry a non-empty `missionId` so the * ADR-0084 close-time sweep can attribute the operation to a mission. * Unreadable journal files produce a warning — they may hide violations. */ async function checkJournalAttribution( cacheDir: string, systemId: string, warnings: Array<{ systemId: string; field: string; message: string }>, io: WorkspaceIO, ): Promise { const violations: SternsystemViolation[] = []; const operationsDir = path.join(cacheDir, "operations"); if (!(await io.exists(operationsDir))) return violations; let files: string[]; try { files = (await io.readdir(operationsDir)) .map((e) => e.name) .filter((f) => f.endsWith(".jsonl")); } catch (err) { warnings.push({ systemId, field: "JRN-01", message: `operations/: cannot list journal directory — attribution check skipped (${(err as Error).message})`, }); return violations; } for (const file of files) { const journalPath = path.join(operationsDir, file); let records: JournalRecord[]; try { records = await readJournal(journalPath); } catch (err) { warnings.push({ systemId, field: "JRN-01", message: `operations/${file}: unreadable journal — cannot verify mission attribution (${(err as Error).message})`, }); continue; } for (const rec of records) { if (rec.kind === "op-started" && (!rec.missionId || rec.missionId.trim() === "")) { violations.push({ systemId, rule: "JRN-01", message: `operations/${file}: op-started '${rec.opId}' (${rec.op}) has empty missionId — cache-clone journal ops must be mission-attributed (ADR-0088)`, }); } } } return violations; } async function checkBundleContract( cacheDir: string, systemId: string, io: WorkspaceIO, ): Promise { const violations: SternsystemViolation[] = []; if (!(await io.exists(cacheDir))) return violations; let trackedFiles: string[] = []; const lsResult = await io.exec("git", ["ls-files"], { cwd: cacheDir }); if (lsResult.exitCode === 0) { trackedFiles = lsResult.stdout.split("\n").filter(Boolean); } else { return violations; } for (const rel of trackedFiles) { const name = path.basename(rel); if (FORBIDDEN_PATTERNS.includes(name)) { violations.push({ systemId, rule: "bundle-contract", message: `${systemId}: cache clone contains forbidden file: ${rel}`, }); } if ( name.includes(".generated.") && !COMMITTED_MANIFEST_PATHS.includes(rel) && !COMMITTED_GENERATED_ALLOWED.includes(rel) ) { violations.push({ systemId, rule: "bundle-contract", message: `${systemId}: cache clone contains generated file: ${rel}`, }); } } return violations; } async function validateYamlFiles( cacheDir: string, systemId: string, io: WorkspaceIO, ): Promise { const violations: SternsystemViolation[] = []; if (!(await io.exists(cacheDir))) return violations; const entries = await io.readdir(cacheDir); for (const entry of entries) { if (!entry.isFile) continue; if (!entry.name.endsWith(".yaml") && !entry.name.endsWith(".yml")) continue; const filePath = path.join(cacheDir, entry.name); try { const raw = await io.readFile(filePath); parseYaml(raw); } catch (err) { violations.push({ systemId, rule: "yaml-syntax-error", message: `${entry.name}: YAML syntax error: ${(err as Error).message}`, }); } } return violations; } /** * RFC-1149 phase 1b: route generated.drift.validate diagnostics — DRIFT-02 * (generator lacks dryRun support) is a blocking violation now that all * DRIFT-02 generators are migrated to context.io; DRIFT-01 (content drift) * and other rules stay warnings per the ADR-0082 cleanup caveat. */ export function routeDriftDiagnostics( diagnostics: Array<{ ruleId?: string; file?: string; message?: string }>, systemId: string, violations: SternsystemViolation[], warnings: Array<{ systemId: string; field: string; message: string }>, ): void { for (const diag of diagnostics) { const message = diag.message ?? `cache clone generated drift in ${diag.file ?? "unknown file"}`; if (diag.ruleId === "DRIFT-02") { violations.push({ systemId, rule: "DRIFT-02", message }); } else { warnings.push({ systemId, field: diag.ruleId ?? "DRIFT", message }); } } } /** * RFC-1284: ACCESS-01/02 — compare desired protection (system-state.yaml * accessPin) to observed channel behavior. * * ACCESS-01 — protection drift: pin declared and no suspension marker → every * configured dev/alt channel URL must answer 401 to an anonymous request. A * proven non-401 response is a violation; transport failures are warnings, * never violations (probe inconclusive). Suspended systems skip the probe. * ACCESS-02 — stale suspension: a marker older than ACCESS_SUSPENSION_STALE_MS * is almost certainly orphaned → warning. */ export async function checkAccessProtection( entry: SystemConfig, accessState: Pick, violations: SternsystemViolation[], warnings: Array<{ systemId: string; field: string; message: string }>, probe: typeof probeAccessProtection = probeAccessProtection, ): Promise { const desiredPin = accessState.accessPin; const suspension = accessState.accessPinSuspended ?? null; if (suspension !== null) { const suspendedAt = Date.parse(suspension.at); if (Number.isNaN(suspendedAt)) { warnings.push({ systemId: entry.id, field: "ACCESS-02", message: `accessPinSuspended.at is unparseable ("${suspension.at}", by ${suspension.by}) — age unknown; run: pnpm exec werkstatt run leitstand.access.resume --site ${entry.id}`, }); } else if (Date.now() - suspendedAt > ACCESS_SUSPENSION_STALE_MS) { warnings.push({ systemId: entry.id, field: "ACCESS-02", message: `accessPinSuspended since ${suspension.at} (by ${suspension.by}) — stale > 1h; run: pnpm exec werkstatt run leitstand.access.resume --site ${entry.id}`, }); } } if (desiredPin !== null && suspension === null) { for (const channelName of ["dev", "alt"] as const) { const channelUrl = entry.deployment?.channels[channelName]?.url; if (!channelUrl) continue; const result = await probe(channelUrl); if (result.outcome === "transport-error") { warnings.push({ systemId: entry.id, field: "ACCESS-01", message: `access protection probe inconclusive for ${channelUrl}: ${result.error}`, }); } else if (result.status !== 401) { violations.push({ systemId: entry.id, rule: "ACCESS-01", message: `accessPin declared but ${channelUrl} returned ${result.status} (expected 401) — run: pnpm exec werkstatt run leitstand.access.resume --site ${entry.id}`, }); } } } } export async function runSternsystemValidate( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const filterId = flagString(input, "id"); const { systems: allSystems, errors: discoveryErrors } = await discoverSystems(workspaceRoot); const violations: SternsystemViolation[] = []; const warnings: Array<{ systemId: string; field: string; message: string }> = []; if (discoveryErrors.length > 0) { for (const err of discoveryErrors) { violations.push({ systemId: err.id, rule: "discovery-error", message: `Failed to read system-config.yaml for '${err.id}': ${err.error}`, }); } } const systems = filterId ? allSystems.filter((s) => s.id === filterId) : allSystems; if (filterId && systems.length === 0) { throw new Error(`[sternsystem.validate] id '${filterId}' not found in ../systems-cache/`); } const seenIds = new Set(); const seenStars = new Set(); let withOwner = 0; let withoutOwner = 0; for (const entry of systems) { // Registry invariants if (seenIds.has(entry.id)) { violations.push({ systemId: entry.id, rule: "unique-id", message: `duplicate id '${entry.id}' in registry`, }); } seenIds.add(entry.id); if (entry.owner) { withOwner++; } else { withoutOwner++; warnings.push({ systemId: entry.id, field: "owner", message: "owner field not set; Studio Gate cannot verify ownership for this site", }); } if (entry.status !== "archived" && seenStars.has(entry.cosmicStar)) { violations.push({ systemId: entry.id, rule: "unique-cosmicStar", message: `cosmicStar '${entry.cosmicStar}' is used by multiple active/registered systems`, }); } seenStars.add(entry.cosmicStar); if (!(StarCatalog as readonly string[]).includes(entry.cosmicStar)) { violations.push({ systemId: entry.id, rule: "valid-cosmicStar", message: `cosmicStar '${entry.cosmicStar}' is not in StarCatalog`, }); } // RFC-0902: TLD suffix check if (hasTldSuffix(entry.id)) { violations.push({ systemId: entry.id, rule: "STERN-ID-TLD", message: `Sternsystem ID '${entry.id}' ends in a TLD suffix — use the business ID without domain TLD (e.g. 'warpgogol' not 'warpgogol-com')`, }); } // apps/ collision if (await hasAppsCollision(workspaceRoot, entry.id, context.io)) { violations.push({ systemId: entry.id, rule: "apps-collision", message: `id '${entry.id}' matches existing apps/${entry.id}/ — extraction incomplete`, }); } // Bundle contract (if cache clone exists) const cacheDir = resolveMirrors(workspaceRoot, entry).cachePath; const bundleViolations = await checkBundleContract(cacheDir, entry.id, context.io); violations.push(...bundleViolations); // Pin file validation (if cache clone exists) const pinPath = path.join(cacheDir, "system.pin.json"); if (await context.io.exists(pinPath)) { try { const raw = await context.io.readFile(pinPath); const parsed = JSON.parse(raw); systemPinSchema.parse(parsed); const pin = parsed as { platform: { version: string }; systemId: string }; if (pin.platform.version !== entry.pinnedPlatform) { violations.push({ systemId: entry.id, rule: "pin-version-mismatch", message: `system.pin.json platform.version '${pin.platform.version}' does not match registry pinnedPlatform '${entry.pinnedPlatform}'`, }); } if (pin.systemId !== entry.id) { violations.push({ systemId: entry.id, rule: "pin-id-mismatch", message: `system.pin.json systemId '${pin.systemId}' does not match registry id '${entry.id}'`, }); } } catch (err) { violations.push({ systemId: entry.id, rule: "pin-parse", message: `system.pin.json parse failed: ${(err as Error).message}`, }); } } else if (entry.status === "active") { violations.push({ systemId: entry.id, rule: "pin-missing", message: `active system '${entry.id}' has no system.pin.json in cache clone`, }); } // RFC-0666: secretsFile field is removed — reject if still present in any channel if (entry.deployment?.channels) { for (const [ch, chConfig] of Object.entries(entry.deployment.channels)) { if (chConfig?.secretsFile) { violations.push({ systemId: entry.id, rule: "secretsFile-removed", message: `channel '${ch}' still contains 'secretsFile' field — remove it. See RFC-0666.`, }); } } } // RFC-0574: validate mirror topology — mirrors[0] is cache, mirrors[1] is bare, mirrors[2+] are external if (entry.mirrors.length < 1) { violations.push({ systemId: entry.id, rule: "mirrors-empty", message: `system '${entry.id}' has no mirrors — at least 1 mirror (cache clone) is required`, }); } if (entry.mirrors.length >= 1 && entry.mirrors[0].storageType !== "non-bare") { violations.push({ systemId: entry.id, rule: "cache-must-be-non-bare", message: `mirrors[0] must have storageType 'non-bare' (got '${entry.mirrors[0].storageType}') — cache clone must be a working tree for git push`, }); } for (let i = 0; i < entry.mirrors.length; i++) { const m = entry.mirrors[i]; if (m.storageType === "bundle" && isGitAccessible(m.path)) { violations.push({ systemId: entry.id, rule: "bundle-no-git-protocol", message: `mirrors[${i}] has storageType 'bundle' but uses git-accessible protocol '${m.path}' — bundle mirrors must not use git protocols`, }); } } if (entry.mirrors.length > 1) { const bareMirror = entry.mirrors[1]; const bareRepoPath = resolveMirrorPath(workspaceRoot, bareMirror.path); if (await context.io.exists(bareRepoPath)) { try { // sternsystem.sync names the remote for mirrors[2] `mirror-0` // (mirror- per external mirror); older systems also carry a // legacy `mirror` remote — accept either, prefer the sync name. let remoteResult = await context.io.exec("git", ["remote", "get-url", "mirror-0"], { cwd: bareRepoPath, }); if (remoteResult.exitCode !== 0) { remoteResult = await context.io.exec("git", ["remote", "get-url", "mirror"], { cwd: bareRepoPath, }); } if (remoteResult.exitCode !== 0) throw new Error(remoteResult.stderr.trim()); const remoteUrl = remoteResult.stdout.trim(); if (entry.mirrors.length > 2 && remoteUrl !== entry.mirrors[2].path) { violations.push({ systemId: entry.id, rule: "mirror-remote-mismatch", message: `mirror remote URL '${remoteUrl}' does not match registry mirrors[2] '${entry.mirrors[2].path}'`, }); } } catch { violations.push({ systemId: entry.id, rule: "mirror-remote-missing", message: `mirror remote not configured in bare repo at ${bareRepoPath}`, }); } } if (entry.mirrors.length > 2) { const externalMirror = entry.mirrors[2].path; if (/https:\/\/[^:]+:[^@]+@/.test(externalMirror)) { violations.push({ systemId: entry.id, rule: "mirror-credentials", message: `external mirror URL contains embedded credentials — use SSH URL instead`, }); } } } // RFC-0648: branch-convention rule — cache clone and bare repo must use 'main' branch const cacheGitDir = path.join(cacheDir, ".git"); if (await context.io.exists(cacheGitDir)) { try { const branchResult = await context.io.exec("git", ["symbolic-ref", "HEAD"], { cwd: cacheDir, }); if (branchResult.exitCode !== 0) throw new Error(branchResult.stderr.trim()); const cacheBranch = branchResult.stdout.replace("refs/heads/", "").trim(); if (cacheBranch !== "main") { violations.push({ systemId: entry.id, rule: "branch-convention", message: `cache clone branch is '${cacheBranch}', expected 'main' — run: git -C ${cacheDir} branch -m ${cacheBranch} main`, }); } } catch { violations.push({ systemId: entry.id, rule: "branch-convention", message: `cache clone at ${cacheDir} has no resolvable HEAD (detached or corrupt) — expected branch 'main'`, }); } } if (entry.mirrors.length > 1) { const bareMirror = entry.mirrors[1]; const bareRepoPath = resolveMirrorPath(workspaceRoot, bareMirror.path); if (await context.io.exists(bareRepoPath)) { try { const bareBranchResult = await context.io.exec("git", ["symbolic-ref", "HEAD"], { cwd: bareRepoPath, }); if (bareBranchResult.exitCode !== 0) throw new Error(bareBranchResult.stderr.trim()); const bareBranch = bareBranchResult.stdout.replace("refs/heads/", "").trim(); if (bareBranch !== "main") { violations.push({ systemId: entry.id, rule: "branch-convention", message: `bare repo branch is '${bareBranch}', expected 'main' — run: git -C ${bareRepoPath} symbolic-ref HEAD refs/heads/main`, }); } } catch { violations.push({ systemId: entry.id, rule: "branch-convention", message: `bare repo at ${bareRepoPath} has no resolvable HEAD (detached or corrupt) — expected branch 'main'`, }); } } } // RFC-0520: Bordbuch-vs-git-log check delegated to evaluateExternalEditGate if (await bordbuchFileExists(cacheDir, context.io)) { try { const bordbuchPath = bordbuchPathFor(cacheDir); const { bordbuchEntries, rangeShas, gitLogShas, gitLogSubjects, externalEditBaselineSha } = await collectExternalEditInputs(cacheDir, bordbuchPath, context.io); const guardResult = evaluateExternalEditGate({ systemId: entry.id, bordbuchEntries, gitLogShas, gitLogSubjects, rangeShas, externalEditBaselineSha, }); if (guardResult.verdict === "fail") { for (const v of guardResult.violations) { violations.push({ systemId: v.systemId!, rule: v.rule, message: v.message }); } } } catch { // Bordbuch read failed — skip } } // RFC-0792: YAML syntax checking for all top-level YAML files in cache clone const yamlViolations = await validateYamlFiles(cacheDir, entry.id, context.io); violations.push(...yamlViolations); // RFC-0870: Manifest presence check — committed generated manifests must exist in cache clone HEAD const manifestViolations = await checkManifestPresence(cacheDir, entry.id, context.io); violations.push(...manifestViolations); // ADR-0088: JRN-01 — journal mission attribution in cache clone operations/ const journalViolations = await checkJournalAttribution( cacheDir, entry.id, warnings, context.io, ); violations.push(...journalViolations); // ADR-0082: cache-clone generated drift — committed generated files in the // cache clone are drift-checked via generated.drift.validate --target=cache-clone. // RFC-1149 phase 1b: DRIFT-02 (generator lacks dryRun support) is blocking — // all DRIFT-02 generators are migrated to context.io. DRIFT-01 (content // drift) stays a warning: pre-existing drift needs a one-time cleanup pass // before it can go blocking (see ADR consequences). if (await context.io.exists(cacheDir)) { try { const { executeKernelCommand } = await import("@warpgogol/werkstatt-engine/kernel"); const driftReport = (await executeKernelCommand({ workspaceRoot, commandName: "generated.drift.validate", argv: [`--site=${entry.id}`, "--target=cache-clone"], outputFormat: "json", })) as { data?: { diagnostics?: Array<{ ruleId?: string; file?: string; message?: string; }>; }; }; const driftDiagnostics = driftReport?.data?.diagnostics ?? []; routeDriftDiagnostics(driftDiagnostics, entry.id, violations, warnings); } catch { // Non-fatal — drift check skipped (site plugin or generators unavailable) } } // RFC-0822: ENV-PERSIST-01 warning — cache clone lacks .env* but workpiece has them try { const cacheEnvFiles = await collectEnvFiles(cacheDir); if (cacheEnvFiles.length === 0) { const state = await readSystemState(workspaceRoot, entry.id); if (state.currentMission) { const workpieceDir = resolveWorkpiecePath(workspaceRoot, state.currentMission); if (await context.io.exists(workpieceDir)) { const workpieceEnvFiles = await collectEnvFiles(workpieceDir); if (workpieceEnvFiles.length > 0) { warnings.push({ systemId: entry.id, field: "ENV-PERSIST-01", message: `Cache clone for system '${entry.id}' has no .env files but workpiece has ${workpieceEnvFiles.length} — run mission.close to persist secrets`, }); } } } } } catch { // Non-fatal — env check skipped } // RFC-0966: PASSPORT-01/02/03 — signed site passport checks try { const passportDoc = await readPassport(workspaceRoot, entry.id); if (!passportDoc) { const state = await readSystemState(workspaceRoot, entry.id); if (state.passportRequired) { violations.push({ systemId: entry.id, rule: "PASSPORT-01", message: `passport.json missing but passportRequired is true — run: pnpm exec werkstatt run sternsystem.passport.generate --id ${entry.id}`, }); } else { warnings.push({ systemId: entry.id, field: "PASSPORT-01", message: `passport.json not found — run: pnpm exec werkstatt run sternsystem.passport.generate --id ${entry.id}`, }); } } else { const { valid, errors } = await verifyPassport(passportDoc); if (!valid) { for (const err of errors) { violations.push({ systemId: entry.id, rule: "PASSPORT-02", message: err, }); } } // PASSPORT-03: resource drift check (non-fatal warning) try { const freshPayload = await buildPassportPayload({ systemId: entry.id, werkstattRoot: workspaceRoot, creatorIdentity: passportDoc.payload.creator.identity, creatorPublicKey: passportDoc.payload.creator.publicKey, }); freshPayload.provenance.createdAt = passportDoc.payload.provenance.createdAt; freshPayload.provenance.generatedAt = passportDoc.payload.provenance.generatedAt; const freshHash = computePassportHash(freshPayload); if (freshHash !== passportDoc.passportHash) { let isToleratedDrift = false; try { const entries = await readBordbuch(workspaceRoot, entry.id); const recordedHead = passportDoc.payload.provenance.bordbuchHead; // ADR-0092: a head drift caused only by bookkeeping entries // (mirror-sync — propagation of already-committed state) does not // stale the passport. Reproducing the signed hash with the recorded // head pinned proves nothing else drifted alongside. if ( freshPayload.provenance.bordbuchHead !== recordedHead && isBordbuchDriftBookkeepingOnly(entries, recordedHead) ) { const tolerated = { ...freshPayload, provenance: { ...freshPayload.provenance, bordbuchHead: recordedHead, }, }; isToleratedDrift = computePassportHash(tolerated) === passportDoc.passportHash; } if (!isToleratedDrift) { // Tolerate stale bordbuchHead right after a handover — the passport is // generated before the handover bordbuch entry is appended, so the head // legitimately doesn't include it. Check if the only difference is the // bordbuch head pointing to the entry before the latest handover event. // ADR-0092: trailing bookkeeping entries (mirror-sync) are stripped // before locating the handover, so a sync after handover still counts. const significant = dropTrailingBookkeepingEntries(entries); if ( significant.length > 0 && significant[significant.length - 1].kind === "handover" ) { // The latest state-significant bordbuch entry is a handover event — // check if the passport's bordbuchHead matches the entry just before it const prevEntry = significant[significant.length - 2]; const expectedHead = prevEntry ? prevEntry.hash : ""; if (recordedHead === expectedHead) { isToleratedDrift = true; } } } } catch { // Bordbuch read failed — can't determine, treat as real drift } if (!isToleratedDrift) { warnings.push({ systemId: entry.id, field: "PASSPORT-03", message: `passport content drift detected — bordbuchHead or resources changed since last generation. Run: pnpm exec werkstatt run sternsystem.passport.generate --id ${entry.id}`, }); } } } catch { // Non-fatal — drift check skipped } } } catch { // Non-fatal — passport check skipped } // RFC-0967: OWNERSHIP-01 — fleet ownership registry check try { const registryUrl = process.env.FLEET_OWNERSHIP_REGISTRY_URL; if (!registryUrl) { // Registry not configured — skip } else { const { verifyOwnership, OwnershipError } = await import("../fleet/ownership-registry.ts"); try { const result = await verifyOwnership({ systemId: entry.id, werkstattRoot: workspaceRoot, registryUrl, }); if (!result.registered) { const state = await readSystemState(workspaceRoot, entry.id); if (state.ownershipRequired) { violations.push({ systemId: entry.id, rule: "OWNERSHIP-01", message: `Site not registered in fleet ownership registry but ownershipRequired is true — run: pnpm exec werkstatt run fleet.ownership.register --id ${entry.id}`, }); } else { warnings.push({ systemId: entry.id, field: "OWNERSHIP-01", message: `Site not registered in fleet ownership registry — run: pnpm exec werkstatt run fleet.ownership.register --id ${entry.id}`, }); } } else if (result.conflictsWith) { warnings.push({ systemId: entry.id, field: "OWNERSHIP-01", message: `Ownership conflict: site registered to instance ${result.conflictsWith}, not this instance (OWNERSHIP-03)`, }); } } catch (err) { if (err instanceof OwnershipError) { warnings.push({ systemId: entry.id, field: "OWNERSHIP-01", message: `Registry check skipped (${err.code}): ${err.message}`, }); } } } } catch { // Non-fatal — ownership check skipped } // RFC-0968: HANDOVER-01/02/03/04/05 — sternsystem handover protocol try { const authorization = await readAuthorization(workspaceRoot, entry.id); if (authorization) { // HANDOVER-01: pending handover blocks missions violations.push({ systemId: entry.id, rule: "HANDOVER-01", message: `pending handover — missions blocked; run sternsystem.handover.complete or sternsystem.handover.cancel`, }); // HANDOVER-02: authorization expired if (isAuthorizationExpired(authorization.payload.expiresAt)) { violations.push({ systemId: entry.id, rule: "HANDOVER-02", message: `authorization expired at ${authorization.payload.expiresAt} — sender must re-issue`, }); } // HANDOVER-03: passport hash mismatch const currentPassport = await readPassport(workspaceRoot, entry.id); if (currentPassport) { if (authorization.payload.passportHash !== currentPassport.passportHash) { violations.push({ systemId: entry.id, rule: "HANDOVER-03", message: `passport changed after authorization — sender must re-issue`, }); } } // HANDOVER-04: recipient key mismatch (warning, not blocking) const privateKeyEnv = process.env.SIGNING_PRIVATE_KEY; const privateKeyPath = process.env.SIGNING_PRIVATE_KEY_PATH; if (privateKeyEnv || privateKeyPath) { try { const { loadPrivateKey } = await import("@warpgogol/werkstatt-engine/signing"); const { derivePublicKey } = await import("./passport.ts"); let privateKeyBytes: Uint8Array; if (privateKeyEnv) { privateKeyBytes = await loadPrivateKey({ pem: privateKeyEnv }); } else { privateKeyBytes = await loadPrivateKey({ filePath: privateKeyPath!, encoding: "pem", }); } const derivedPubKey = await derivePublicKey(privateKeyBytes); if (authorization.payload.recipient.publicKey !== derivedPubKey) { warnings.push({ systemId: entry.id, field: "HANDOVER-04", message: `recipient key mismatch — this instance is not the authorized recipient`, }); } } catch { // Non-fatal — key derivation failed } } } else { // HANDOVER-05: no authorization file but passport creator doesn't match bordbuch's latest handover event try { const entries = await readBordbuch(workspaceRoot, entry.id); const handoverEntries = entries.filter((e) => e.kind === "handover"); if (handoverEntries.length > 0) { const latestHandover = handoverEntries[handoverEntries.length - 1]; const currentPassport = await readPassport(workspaceRoot, entry.id); if (currentPassport) { const metadata = latestHandover.metadata as Record | undefined; const expectedNewPassportHash = metadata?.newPassportHash as string | undefined; if ( expectedNewPassportHash && expectedNewPassportHash !== currentPassport.passportHash ) { violations.push({ systemId: entry.id, rule: "HANDOVER-05", message: `incomplete handover — passport regenerated but bordbuch event references a different passport hash; re-run sternsystem.handover.complete`, }); } } } } catch { // Non-fatal — bordbuch read failed } } } catch { // Non-fatal — handover check skipped } // RFC-1284: ACCESS-01/02 — desired-vs-live access protection checks. try { const accessState = await readSystemState(workspaceRoot, entry.id, context.io); await checkAccessProtection(entry, accessState, violations, warnings); } catch { // Non-fatal — access protection checks skipped } // RFC-1297: LIVE-01/02 — recorded (system-state.yaml lastPropagated) vs // live (build-identity.json) release drift. Same transport-failure // semantics as ACCESS-01: inconclusive probes warn, never violate. try { const liveStates = await readLiveChannelStates({ workspaceRoot, systemId: entry.id, io: context.io, }); for (const d of liveDriftDiagnostics(liveStates, entry.id)) { if (d.severity === "error") { violations.push({ systemId: entry.id, rule: d.ruleId, message: d.fixHint ? `${d.message} — ${d.fixHint}` : d.message, }); } else { warnings.push({ systemId: entry.id, field: d.ruleId, message: d.message }); } } } catch { // Non-fatal — live drift checks skipped } // RFC-1034: STERN-CONFIG-RESOLVE-01 — check if cache clone's kernel.config.ts can be loaded try { const cachePath = resolveMirrors(workspaceRoot, entry).cachePath; if (cachePath) { const configPath = path.join(cachePath, "tools", "kernel.config.ts"); if (await context.io.exists(configPath)) { try { const { loadAppRuntime } = await import("../kernel/runtime/registry.ts"); await loadAppRuntime(workspaceRoot, { name: entry.id, directory: cachePath, configPath: "tools/kernel.config.ts", toolsDirectory: path.join(cachePath, "tools"), }); } catch (configErr) { warnings.push({ systemId: entry.id, field: "STERN-CONFIG-RESOLVE-01", message: `cache clone kernel.config.ts failed to load: ${configErr instanceof Error ? configErr.message : String(configErr)} — run pnpm install in the cache clone or re-materialize`, }); } } } } catch { // Non-fatal — config resolution check skipped } } const validated = systems.length; if (violations.length === 0) { logger.success( `[sternsystem.validate] ${validated} system${validated === 1 ? "" : "s"} validated, 0 violations`, ); } else { logger.error( `[sternsystem.validate] ${validated} system${validated === 1 ? "" : "s"} validated, ${violations.length} violation${violations.length === 1 ? "" : "s"}`, ); for (const v of violations) { logger.error(` [${v.rule}] ${v.systemId}: ${v.message}`); } } for (const w of warnings) { logger.warn(` [${w.field}] ${w.systemId}: ${w.message}`); } return { data: { validated, violations, warnings, withOwner, withoutOwner }, exitCode: violations.length > 0 ? 1 : 0, summary: `[sternsystem.validate] ${validated} system${validated === 1 ? "" : "s"} validated, ${violations.length} violation${violations.length === 1 ? "" : "s"}, ${withOwner} with owner, ${withoutOwner} without owner`, nextSteps: violations.length > 0 ? [ { action: `Fix the ${violations.length} validation violation${violations.length === 1 ? "" : "s"} above, then re-run: pnpm exec werkstatt run sternsystem.validate`, kind: "required", }, ] : undefined, }; }