/* Maintains packages/os/site-kernel-handoff/src/bordbuch/bordbuch-io.ts as an authored site-kernel-handoff authored module so agents can evolve it without rediscovering local boundaries. Does not define mission lifecycle — that lives in the mission module. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1223: gate the push-preceding rebase on real divergence — fetch, compare merge-base to remote head, skip rebase when origin is an ancestor or absent, use rebase --rebase-merges on true divergence so merge-commit topology survives the push. RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1295 RFC-1297: leitstand actor gains deployment-rolled-back + live-state-reconciled kinds. CONTRACT-02, CONTRACT-03, CS-07, PURPOSE-02, RFC-0355, RFC-0473, RFC-0477, RFC-0574, RFC-0580, RFC-0583, RFC-0706, RFC-0715, RFC-0724, RFC-0994, RFC-1097 */ import path from "node:path"; import { createHash } from "node:crypto"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import { bordbuchEntrySchema, type BordbuchEntry, type BordbuchEntryKind, } from "@warpgogol/werkstatt-engine/schemas"; import { atomicWriteFile } from "../werkstatt/atomic.ts"; import { resolveCacheClonePath, resolveActiveWorkpieceDir } from "../sternsystem/registry-io.ts"; import { gitExec } from "../werkstatt/git-exec.ts"; import { cacheCloneCommit } from "../mission/mission-git-commit.ts"; const BORDBUCH_PATH = path.join("bordbuch", "events.ndjson"); export async function resolveBordbuchPath( workspaceRoot: string, systemId: string, ): Promise { const cachePath = resolveCacheClonePath(workspaceRoot, systemId); return path.join(cachePath, BORDBUCH_PATH); } /** * Workpiece-aware: during an active mission, bordbuch projections (bordbuch.json, * bordbuch/index.html, status.generated.yaml) must be written to the workpiece * directory so that astro build includes them in the site output. Without this, * projections land in the cache clone and are invisible to the build — the same * circular dependency pattern as resolveNachweisCachePath. * * The bordbuch ledger itself (events.ndjson) stays in the cache clone — it is * an append-only artifact, not a workpiece file. Only the generated projections * need to be workpiece-aware. */ export async function resolveBordbuchProjectionDir( workspaceRoot: string, systemId: string, ): Promise { const workpieceDir = await resolveActiveWorkpieceDir(workspaceRoot, systemId); if (workpieceDir) return workpieceDir; return resolveCacheClonePath(workspaceRoot, systemId); } const WRITER_ROLE_KINDS: Record = { mission: [ "mission-open", "mission-close", "mission-abort", "mission-open-rolled-back", "mission-reconcile", "preflight-skipped", ], release: ["release-ready", "release-rolled-back"], sternsystem: ["pin-update"], leitstand: ["deployment", "deployment-rolled-back", "live-state-reconciled"], notausgang: ["notausgang-export"], operator: ["operator-note", "erratum", "editorial-signoff"], runtime: ["pseo", "indexnow.submit"], // RFC-0706 / ADR-0028: Nachweisregister trust lifecycle // RFC-0715: N3 cryptographic verification (operator signature + RFC 3161 timestamp) // RFC-0888: Sichtpass lifecycle audit trail nachweis: [ "nachweis-record", "nachweis-consent", "nachweis-signed", "nachweis-timestamped", "sichtpass", ], // RFC-1031: Evolution controller candidate lifecycle evolution: ["candidate"], // RFC-1037: External-effect compensation verification effect: ["effect"], }; export function validateWriterRole(writerRole: string, kind: BordbuchEntryKind): boolean { const allowed = WRITER_ROLE_KINDS[writerRole]; return allowed ? allowed.includes(kind) : false; } /** * RFC-0724: Forward-only migration map for deprecated bordbuch entry kinds. * When a kind is renamed (e.g. release-published -> release-ready), old entries * on disk still carry the deprecated value. readBordbuch normalizes them * before Zod parsing so validation doesn't crash on historical data. */ export const DEPRECATED_KIND_MIGRATIONS: Record = { "release-published": "release-ready", }; /** Reverse map: current kind -> list of deprecated kinds that map to it. */ const KIND_MIGRATION_REVERSE: Record = Object.entries( DEPRECATED_KIND_MIGRATIONS, ).reduce>((acc, [oldKind, newKind]) => { (acc[newKind] ??= []).push(oldKind); return acc; }, {}); /** Normalize a deprecated kind to its current equivalent. Returns the kind unchanged if not deprecated. */ export function migrateDeprecatedKind(kind: string): string { return DEPRECATED_KIND_MIGRATIONS[kind] ?? kind; } const SENSITIVE_PATTERNS: RegExp[] = [ /password/i, /secret/i, /token/i, /credential/i, /api[_-]?key/i, /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/, /(? p.test(text)); } export function computeEntryHash(entry: Omit): string { const stable = JSON.stringify(entry, Object.keys(entry).sort()); return `sha256:${createHash("sha256").update(stable).digest("hex")}`; } function nextEventId(entries: BordbuchEntry[]): string { const maxNum = entries.reduce((max, e) => { const m = e.id.match(/^event-(\d{6})$/); return m ? Math.max(max, parseInt(m[1], 10)) : max; }, 0); return `event-${String(maxNum + 1).padStart(6, "0")}`; } export async function readBordbuch( workspaceRoot: string, systemId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const filePath = await resolveBordbuchPath(workspaceRoot, systemId); if (!(await io.exists(filePath))) return []; const raw = await io.readFile(filePath); const lines = raw.split("\n").filter((l) => l.trim().length > 0); const entries: BordbuchEntry[] = []; for (const line of lines) { const raw = JSON.parse(line) as Record; if (typeof raw.kind === "string") { raw.kind = migrateDeprecatedKind(raw.kind); } const parsed = bordbuchEntrySchema.parse(raw); entries.push(parsed); } return entries; } export async function appendBordbuchEntry( workspaceRoot: string, systemId: string, kind: BordbuchEntryKind, summary: string, actor: string, options?: { missionId?: string | null; releaseId?: string | null; writerRole?: string; metadata?: Record; status?: BordbuchEntry["status"]; erratumOf?: string; }, io: WorkspaceIO = createDefaultIO().io, ): Promise { const filePath = await resolveBordbuchPath(workspaceRoot, systemId); const dir = path.dirname(filePath); if (!(await io.exists(dir))) { await io.mkdir(dir); } const redactedSummary = redactForBordbuch(summary); if (containsSensitivePayload(redactedSummary)) { throw new Error( "[bordbuch.append] sensitive payload detected in summary — redact before appending", ); } const entries = await readBordbuch(workspaceRoot, systemId); if ((kind === "mission-close" || kind === "mission-abort") && options?.missionId) { const existing = entries.find( (e) => e.missionId === options.missionId && (e.kind === "mission-close" || e.kind === "mission-abort"), ); if (existing) { throw new Error( `[bordbuch.append] ${kind} already exists for mission '${options.missionId}' (event ${existing.id}) — duplicate mission lifecycle close is not allowed`, ); } } // RFC-0968: duplicate handover rejection by authorizationHash if (kind === "handover" && options?.metadata) { const newAuthHash = (options.metadata as Record).authorizationHash as string | undefined; if (newAuthHash) { const existing = entries.find( (e) => e.kind === "handover" && (e.metadata as Record | undefined)?.authorizationHash === newAuthHash, ); if (existing) { throw new Error( `[bordbuch.append] handover already exists for authorizationHash '${newAuthHash}' (event ${existing.id}) — duplicate handover is not allowed`, ); } } } const previousHash = entries.length > 0 ? entries[entries.length - 1].hash : null; const id = nextEventId(entries); const entryWithoutHash: Omit = { schemaVersion: "1.0.0", id, systemId, occurredAt: new Date().toISOString(), kind, status: options?.status ?? "done", missionId: options?.missionId ?? null, releaseId: options?.releaseId ?? null, actor, summary: redactedSummary, metadata: options?.metadata, previousHash, erratumOf: options?.erratumOf, }; const hash = computeEntryHash(entryWithoutHash); const entry: BordbuchEntry = { ...entryWithoutHash, hash }; bordbuchEntrySchema.parse(entry); const line = JSON.stringify(entry) + "\n"; const existingContent = (await io.exists(filePath)) ? await io.readFile(filePath) : ""; const separator = existingContent.length > 0 && !existingContent.endsWith("\n") ? "\n" : ""; await atomicWriteFile(filePath, `${existingContent}${separator}${line}`); return entry; } export interface BordbuchViolation { rule: string; message: string; eventId?: string; } export async function validateBordbuch( workspaceRoot: string, systemId: string, ): Promise<{ entries: number; violations: BordbuchViolation[] }> { const entries = await readBordbuch(workspaceRoot, systemId); const violations: BordbuchViolation[] = []; let prevHash: string | null = null; let expectedId = 1; const openMissions = new Set(); const allMissionIds = new Set(); for (const entry of entries) { // Event id sequence const expectedIdStr = `event-${String(expectedId).padStart(6, "0")}`; if (entry.id !== expectedIdStr) { violations.push({ rule: "event-id-gap", message: `expected id '${expectedIdStr}', got '${entry.id}'`, eventId: entry.id, }); } expectedId++; // previousHash chain if (entry.previousHash !== prevHash) { violations.push({ rule: "hash-chain-gap", message: `expected previousHash '${prevHash}', got '${entry.previousHash}'`, eventId: entry.id, }); } // hash verification const { hash: _hash, ...entryWithoutHash } = entry; const computedHash = computeEntryHash(entryWithoutHash); // RFC-0724: also accept hashes computed with deprecated kind values const deprecatedAliases = KIND_MIGRATION_REVERSE[entry.kind] ?? []; const deprecatedHashes = deprecatedAliases.map((oldKind) => computeEntryHash({ ...entryWithoutHash, kind: oldKind as BordbuchEntryKind }), ); if (entry.hash !== computedHash && !deprecatedHashes.includes(entry.hash)) { violations.push({ rule: "hash-mismatch", message: `hash mismatch for '${entry.id}'`, eventId: entry.id, }); } prevHash = entry.hash; // Mission lifecycle pairing if (entry.kind === "mission-open") { if (entry.missionId && allMissionIds.has(entry.missionId)) { violations.push({ rule: "duplicate-mission-id", message: `duplicate mission-open for '${entry.missionId}'`, eventId: entry.id, }); } if (entry.missionId) { openMissions.add(entry.missionId); allMissionIds.add(entry.missionId); } } else if ( entry.kind === "mission-close" || entry.kind === "mission-abort" || entry.kind === "mission-open-rolled-back" ) { if (entry.missionId && !openMissions.has(entry.missionId)) { violations.push({ rule: "orphan-mission-close", message: `${entry.kind} for '${entry.missionId}' has no preceding mission-open`, eventId: entry.id, }); } if (entry.missionId) { openMissions.delete(entry.missionId); } } // Sensitive payload guard (with git URL redaction) if (containsSensitivePayload(redactForBordbuch(entry.summary))) { violations.push({ rule: "sensitive-payload", message: `sensitive payload detected in '${entry.id}'`, eventId: entry.id, }); } } // Unmatched open missions — skip the currently active mission (state.currentMission) let currentMission: string | null = null; try { const { readSystemState } = await import("../sternsystem/registry-io.ts"); const state = await readSystemState(workspaceRoot, systemId); currentMission = state.currentMission ?? null; } catch { // State not available — check all missions } for (const openId of openMissions) { if (openId === currentMission) continue; violations.push({ rule: "unmatched-mission-open", message: `mission '${openId}' has mission-open but no mission-close or mission-abort`, }); } return { entries: entries.length, violations }; } export function deriveNextMissionNumber(entries: BordbuchEntry[]): number { let maxNum = 0; for (const entry of entries) { if (entry.kind === "mission-open" && entry.missionId) { const m = entry.missionId.match(/-m(\d{6})$/); if (m) { maxNum = Math.max(maxNum, parseInt(m[1], 10)); } } } return maxNum + 1; } /** * Derive the next mission number, considering both Bordbuch entries AND * existing mission directories on disk. This prevents reusing a mission ID * from an aborted mission that was never recorded in the Bordbuch. */ export async function deriveNextMissionNumberSafe( entries: BordbuchEntry[], workspaceRoot: string, systemId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { let maxNum = 0; for (const entry of entries) { if (entry.kind === "mission-open" && entry.missionId) { const m = entry.missionId.match(/-m(\d{6})$/); if (m) { maxNum = Math.max(maxNum, parseInt(m[1], 10)); } } } // Also scan existing mission directories on disk const missionsPath = path.join(workspaceRoot, "missions"); if (await io.exists(missionsPath)) { const dirs = await io.readdir(missionsPath); for (const d of dirs) { if (d.name === "archive") continue; if (!d.isDirectory) continue; if (!d.name.startsWith(`${systemId}-m`)) continue; const m = d.name.match(/-m(\d{6})$/); if (m) { maxNum = Math.max(maxNum, parseInt(m[1], 10)); } } } return maxNum + 1; } export interface CommitAndPushResult { commitSha: string | null; pushed: boolean; error: string | null; } export async function commitAndPushBordbuch( systemDir: string, message: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const bordbuchPath = path.join("bordbuch", "events.ndjson"); const statusPath = path.join("bordbuch", "status.generated.yaml"); try { await gitExec(systemDir, `add ${bordbuchPath}`); // Also commit bordbuch/status.generated.yaml if it exists (RFC-0597 fix: prevent dirty cache clone) if (await io.exists(path.join(systemDir, statusPath))) { try { await gitExec(systemDir, `add ${statusPath}`); } catch { // status.generated.yaml may not exist or may be gitignored — non-fatal } } } catch { return { commitSha: null, pushed: false, error: null }; } let commitSha: string | null = null; try { await cacheCloneCommit(systemDir, message); commitSha = await gitExec(systemDir, "rev-parse HEAD"); } catch { return { commitSha: null, pushed: false, error: null }; } // RFC-0987 Fix 4: resolve branch via symbolic-ref instead of rev-parse --abbrev-ref. // In detached HEAD, rev-parse returns "HEAD" which produces invalid refspecs. // Try symbolic-ref --short HEAD first (current branch when not detached), // then refs/remotes/origin/HEAD (remote default for detached HEAD), then main. let branch = "main"; try { const ref = await gitExec(systemDir, "symbolic-ref --short HEAD", { allowNonZero: true }); branch = ref.trim() || "main"; } catch { // Detached HEAD — try remote default try { const ref = await gitExec(systemDir, "symbolic-ref --short refs/remotes/origin/HEAD", { allowNonZero: true, }); branch = ref.replace("origin/", "").trim() || "main"; } catch { // No remote HEAD set — default to main } } let stashed = false; try { try { await gitExec(systemDir, "stash push -m bordbuch-pull-rebase"); stashed = true; } catch { // No changes to stash — proceed with pull } // RFC-1223: fetch without a refspec — `fetch origin ` fatals // with "couldn't find remote ref" on a fresh mirror lacking the branch. await gitExec(systemDir, "fetch origin"); // gitExec returns "" under allowNonZero, so observe the remote ref via // stdout: empty means `origin/` does not exist yet (first push). const remoteHead = await gitExec(systemDir, `rev-parse --verify -q origin/${branch}`, { allowNonZero: true, }); // merge-base == origin head ⇔ origin is an ancestor of HEAD. An empty // merge-base (unrelated histories) counts as diverged — the rebase decides. const mergeBase = remoteHead ? await gitExec(systemDir, `merge-base origin/${branch} HEAD`, { allowNonZero: true }) : ""; if (remoteHead && mergeBase !== remoteHead) { // Genuine divergence — rebase replaying merge commits (equivalent to // `pull --rebase=merges`, minus pull's redundant second fetch). await gitExec(systemDir, `rebase --rebase-merges origin/${branch}`); } // No remote ref yet or remote strictly behind → nothing to integrate → // straight to push; merge topology preserved. if (stashed) { try { await gitExec(systemDir, "stash pop"); stashed = false; } catch { // Stash pop failed — drop the stash to prevent accumulation. // The stashed changes are lost, but they were auto-generated // (bordbuch/status files) and will be recreated on next commit. try { await gitExec(systemDir, "stash drop"); stashed = false; } catch { // Stash may have been already popped — ignore } } } // RFC-0987 Fix 5: use --force-with-lease to safely overwrite diverged bare repo // history. The cache clone is the source of truth (RFC-0480); --force-with-lease // rejects the push if the remote HEAD changed unexpectedly. await gitExec(systemDir, `push --force-with-lease origin ${branch}`); return { commitSha, pushed: true, error: null }; } catch (err) { // RFC-0987 Fix 1: abort any in-progress rebase to prevent stale rebase-merge // state from blocking subsequent git operations (e.g. mission.open bordbuch.repair). try { await gitExec(systemDir, "rebase --abort"); } catch { // If git rebase --abort fails, manually remove the rebase state directories try { await io.rm(path.join(systemDir, ".git", "rebase-merge"), { recursive: true }); } catch {} try { await io.rm(path.join(systemDir, ".git", "rebase-apply"), { recursive: true }); } catch {} } // Clean up stash on push failure too if (stashed) { try { await gitExec(systemDir, "stash drop"); } catch { // Ignore — stash may not exist } } const stderr = (err as Error).message; return { commitSha, pushed: false, error: stderr }; } }