/* RFC-1210: remediation.signal.ingest — the closed-loop remediation intake. Normalizes an external signal (alert, probe result, validator finding, operator submission) into a RemediationSignal, enforces the site's remediation.signalSources trust boundary, applies per-(site,class) dedup and per-day rate caps, then routes: - autoFixable class + autonomy: bounded → internal openRemediationMission step (composes mission.open — NOT a registered command) + remediation-mission bordbuch bookkeeping entry + signal evidence file in the mission dir. - everything else → action-pack route (existing RFC-0850/DNA-81 path). - autonomy: off (default) → suppressed-autonomy-off. The dedup ledger lives at systems-cache//operations/remediation-signals.jsonl (swept to mirrors by commitCacheCloneIfDirty). The loop closes at mission OPEN — reconcile/validate/close stay inside the normal mission lifecycle (DNA-46). Does not mutate deployed production or the cache clone content — only the operations ledger, bordbuch, and missions/ (all through the mission pipeline). Does not close or reconcile missions — v1 terminates at mission open. Does not accept ad-hoc classes or undeclared sources. RFC-1210: initial ingest — trust boundary, dedup window, daily rate cap, route decision, bounded mission open via executeKernelCommand, remediation-mission bordbuch entry, JSONL signal ledger. RFC-1210: closed-loop signal ingest to bounded missions (RFC-1210) */ import path from "node:path"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel"; import { createDefaultIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import { lookupRemediationClass, remediationSignalSchema, DEFAULT_REMEDIATION_SIGNAL_SOURCES, type RemediationSignal, type RemediationAutonomyConfig, } from "@warpgogol/werkstatt-shared/remediation/classes"; import { resolveCacheClonePath, readSystemState } from "../sternsystem/registry-io.ts"; import { readMissionManifest } from "../mission/mission-io.ts"; import { appendBordbuchEntry } from "../bordbuch/bordbuch-io.ts"; import { diagnosticsResult } from "@warpgogol/werkstatt-shared/checks/result-helpers"; const COMMAND = "remediation.signal.ingest"; const LEDGER_REL = "operations/remediation-signals.jsonl"; const DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000; type IngestRoute = "auto-mission" | "action-pack" | "suppressed-autonomy-off" | "deduped" | "rate-capped"; interface LedgerRecord { signal: RemediationSignal; route: IngestRoute; missionId?: string; ingestedAt: string; } export interface SignalIngestData { route: IngestRoute; class: string; missionId?: string; deduped: boolean; signal: RemediationSignal; } /** Injectable for tests — defaults to the real kernel mission.open call. */ export interface IngestDeps { openMission?: ( workspaceRoot: string, systemId: string, brief: string, actor: string, ) => Promise<{ exitCode: number; missionId?: string; summary?: string }>; } function flagString(input: KernelCommandInput, key: string): string | undefined { const v = input.flags[key]; return typeof v === "string" ? v : undefined; } function fail( ruleId: string, message: string, nextStep: string, ): KernelCommandResult { return diagnosticsResult( COMMAND, [{ ruleId, severity: "error", message }], [{ action: nextStep, kind: "required" }], ) as unknown as KernelCommandResult; } async function readLedger( workspaceRoot: string, systemId: string, io: WorkspaceIO, ): Promise { const filePath = path.join(resolveCacheClonePath(workspaceRoot, systemId), LEDGER_REL); if (!(await io.exists(filePath))) return []; const raw = await io.readFile(filePath); const records: LedgerRecord[] = []; for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; try { records.push(JSON.parse(trimmed) as LedgerRecord); } catch { // tolerate a torn tail line — ledger is append-only best-effort } } return records; } async function appendLedger( workspaceRoot: string, systemId: string, record: LedgerRecord, io: WorkspaceIO, ): Promise { const filePath = path.join(resolveCacheClonePath(workspaceRoot, systemId), LEDGER_REL); await io.mkdir(path.dirname(filePath)); const existing = (await io.exists(filePath)) ? await io.readFile(filePath) : ""; const next = existing.endsWith("\n") || existing === "" ? existing : `${existing}\n`; await io.writeFile(filePath, `${next}${JSON.stringify(record)}\n`); } /** true when a prior auto-mission for (site,class) is still open. */ async function openAutoMissionExists( workspaceRoot: string, systemId: string, className: string, ledger: LedgerRecord[], io: WorkspaceIO, ): Promise { const candidates = ledger.filter( (r) => r.signal.siteId === systemId && r.signal.class === className && r.route === "auto-mission" && r.missionId, ); for (const rec of candidates.reverse()) { try { const manifest = await readMissionManifest(workspaceRoot, rec.missionId!, io); if (manifest.state === "open") return rec.missionId!; } catch { // mission dir gone or unreadable → treat as resolved, keep scanning } } return null; } async function defaultOpenMission( workspaceRoot: string, systemId: string, brief: string, actor: string, ): Promise<{ exitCode: number; missionId?: string; summary?: string }> { const { executeKernelCommand } = await import("@warpgogol/werkstatt-engine/kernel"); const result = (await executeKernelCommand({ workspaceRoot, commandName: "mission.open", argv: [`--system=${systemId}`, `--brief=${brief}`, `--actor=${actor}`], })) as { exitCode?: number; data?: { missionId?: string }; summary?: string; }; return { exitCode: result.exitCode ?? 1, missionId: result.data?.missionId, summary: result.summary, }; } export async function runRemediationSignalIngest( input: KernelCommandInput, context: KernelRuntimeContext, deps: IngestDeps = {}, ): Promise> { const { workspaceRoot } = context; const io = context.io ?? createDefaultIO().io; const siteId = flagString(input, "site") ?? flagString(input, "system"); const className = flagString(input, "class"); const source = flagString(input, "source"); const detectedBy = flagString(input, "detected-by") ?? "manual"; const evidenceFlag = flagString(input, "evidence"); if (!siteId) { return fail("SIG-00", "--site is required", "Pass --site ."); } if (!className) { return fail("SIG-00", "--class is required", "Pass --class ."); } if (!source) { return fail("SIG-00", "--source is required", "Pass --source ."); } // AUDIT-RFC-1210-01: trust boundary FIRST — the declared --source must be in // the site's remediation.signalSources allowlist before anything else runs. let autonomy: RemediationAutonomyConfig; try { const state = await readSystemState(workspaceRoot, siteId, io); autonomy = state.remediation ?? { autonomy: "off", signalSources: [...DEFAULT_REMEDIATION_SIGNAL_SOURCES], }; } catch (err) { return fail( "SIG-04", `cannot read system-state.yaml for '${siteId}': ${err instanceof Error ? err.message : String(err)}`, `Verify the site id and that systems-cache/${siteId}/system-state.yaml parses.`, ); } if (!autonomy.signalSources.includes(source)) { return fail( "SIG-02", `source '${source}' is not in ${siteId}'s remediation.signalSources allowlist — undeclared invokers cannot trigger any route`, `Add '${source}' to remediation.signalSources in system-state.yaml, or submit the signal through a declared source.`, ); } const classEntry = lookupRemediationClass(className); if (!classEntry) { return fail( "SIG-01", `unknown remediation class '${className}' — the catalog is closed; new classes require a follow-up RFC`, "Choose a class from REMEDIATION_CLASSES in werkstatt-shared/remediation/classes, or route the finding manually.", ); } // Normalize the signal (evidence flag may be a JSON array or a path/uri). let evidenceRefs: string[] = []; if (evidenceFlag) { const trimmed = evidenceFlag.trim(); if (trimmed.startsWith("[")) { try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) evidenceRefs = parsed.map(String); } catch { return fail( "SIG-03", "--evidence is not valid JSON array or plain reference", "Pass --evidence '' or a JSON array string.", ); } } else { evidenceRefs = [trimmed]; } } const signalParse = remediationSignalSchema.safeParse({ schema: "gogol.remediation-signal/1", siteId, class: className, source, detectedBy, evidenceRefs, detectedAt: new Date().toISOString(), }); if (!signalParse.success) { return fail( "SIG-03", `signal failed schema: ${signalParse.error.issues.map((i) => i.message).join("; ")}`, "Fix the flag values to match the RemediationSignal contract.", ); } const signal = signalParse.data; const ingestedAt = signal.detectedAt; const ledger = await readLedger(workspaceRoot, siteId, io); const record = async (route: IngestRoute, missionId?: string) => { const rec: LedgerRecord = { signal, route, ingestedAt }; if (missionId) rec.missionId = missionId; await appendLedger(workspaceRoot, siteId, rec, io); }; const ok = ( data: Omit, ): KernelCommandResult => ({ data: { ...data, class: className, signal }, exitCode: 0, summary: `[${COMMAND}] route=${data.route} class=${className}${data.missionId ? ` mission=${data.missionId}` : ""}`, }); // autonomy: off — ingest still records the signal, never opens missions. if (autonomy.autonomy !== "bounded") { await record("suppressed-autonomy-off"); return ok({ route: "suppressed-autonomy-off", deduped: false }); } if (!classEntry.autoFixable) { await record("action-pack"); return ok({ route: "action-pack", deduped: false }); } // Dedup: one open auto-mission per (siteId, class). const openMissionId = await openAutoMissionExists(workspaceRoot, siteId, className, ledger, io); if (openMissionId) { await record("deduped", openMissionId); return ok({ route: "deduped", missionId: openMissionId, deduped: true }); } // Rate cap: maxAutoMissionsPerDay per rolling day — degrades to action-pack, // never silently drops (RFC-1210 failure modes). const windowStart = Date.now() - DEDUP_WINDOW_MS; const recentAuto = ledger.filter( (r) => r.signal.siteId === siteId && r.signal.class === className && r.route === "auto-mission" && Date.parse(r.ingestedAt) >= windowStart, ).length; if (recentAuto >= classEntry.maxAutoMissionsPerDay) { await record("rate-capped"); const res = ok({ route: "rate-capped", deduped: false }); res.nextSteps = [ { action: `Rate cap reached for class '${className}' (${classEntry.maxAutoMissionsPerDay}/day) — signal degrades to action-pack; escalate to the operator alert path.`, kind: "optional", }, ]; return res; } // Route: auto-mission — internal openRemediationMission composition step. const fixCommand = classEntry.fixCommand!; const brief = `remediation(${className}): signal-triggered bounded mission (RFC-1210). ` + `Deterministic fix: run '${fixCommand}' in the mission workpiece, then mission.validate. ` + `Source: ${source}; detectedBy: ${signal.detectedBy}; ` + `evidence: ${evidenceRefs.join(", ") || "(none)"}.`; const openMission = deps.openMission ?? defaultOpenMission; const opened = await openMission(workspaceRoot, siteId, brief, `remediation:${source}`); if (opened.exitCode !== 0 || !opened.missionId) { return fail( "SIG-05", `mission.open failed for auto-mission (exit ${opened.exitCode}): ${opened.summary ?? "no summary"}`, "Open the remediation mission manually via mission.open, or resolve the reported blocker.", ); } // Signal evidence lives inside the mission dir — bounded scope artifact. const missionId = opened.missionId; const evidencePath = path.join(workspaceRoot, "missions", missionId, "remediation-signal.json"); try { await io.writeFile(evidencePath, `${JSON.stringify(signal, null, 2)}\n`); } catch { // evidence write is best-effort — the bordbuch entry below carries the refs } await appendBordbuchEntry( workspaceRoot, siteId, "remediation-mission", `remediation signal '${className}' opened bounded mission ${missionId} (source: ${source}, fix: ${fixCommand})`, `remediation:${source}`, { missionId, metadata: { signalClass: className, source, detectedBy: signal.detectedBy, evidenceRefs: signal.evidenceRefs, fixCommand, }, }, io, ); await record("auto-mission", missionId); return ok({ route: "auto-mission", missionId, deduped: false }); }