/*
external-edit-guard — pure external-edit guard extracted from the sternsystem.validate inline block (RFC-0520).
- Does not read Bordbuch or run git rev-list — the caller gathers I/O and passes results.
- RFC-0520 follow-up: read bordbuch `kind` field (the `type` field never existed — guard was silently dead) and whitelist pipeline bookkeeping subject prefixes so machine-authored commits pass.
- ADR-0093: trace metadata.mergedShas[] from mission-reconcile entries — every merged workpiece commit counts as pipeline-authored, not just the merge head in commitSha.
- 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.
- ADR-0110: implement ADR-0110 — classify post-reconcile divergence by PIPELINE_SUBJECT_PREFIXES; sync remedy, never reset --hard; plus ADR-0096 bootstrapCommittedSnapshot on mission.build path (SNAP-02)
- ADR-0093: treat the register birth commit as pipeline-authored in the external-edit guard
ADR-0086, ADR-0091
*/
import type { GuardResult } from "../handoff/guards.ts";
/**
* Commit subjects the pipeline authors itself in the cache clone — bordbuch
* ledger commits, state/pin/materialization bookkeeping, merges, rescues.
* These can never be traced by a forward-referencing entry (the entry's own
* commit SHA cannot be known before the entry is hash-chained and written),
* so they are whitelisted by convention. A human/external edit with any other
* subject still flags.
*/
export const PIPELINE_SUBJECT_PREFIXES = [
"Bordbuch:",
"chore:",
"system-state:",
"state:",
"materialize from pin",
"reconcile mission",
"rescue:",
"restore cache-clone",
"cache-clone:",
"deployment-operations:",
"gate-decisions:",
"journal:",
// The birth commit sternsystem.register writes before any Bordbuch entry exists.
"sternsystem.register:",
];
export interface ExternalEditGuardInput {
systemId: string;
bordbuchEntries: Array<{
kind?: string;
metadata?: { commitSha?: string; preReconcileSha?: string; mergedShas?: string[] };
}>;
gitLogShas: string[];
gitLogSubjects?: string[];
rangeShas: string[];
// ADR-0091: optional baseline SHA from system-state.yaml — commits
// at-or-older-than this SHA in rev-list order are grandfathered.
externalEditBaselineSha?: string;
}
export interface ExternalEditGuardResult extends GuardResult {
metadata?: {
unexpectedCount?: number;
firstUnexpectedSha?: string;
};
}
export function evaluateExternalEditGate(input: ExternalEditGuardInput): ExternalEditGuardResult {
const { systemId, bordbuchEntries, gitLogShas, rangeShas, externalEditBaselineSha } = input;
// ADR-0091: positional grandfathering — git rev-list output is newest-first,
// so the baseline SHA itself and every SHA after it (older commits) are
// excluded from the check. A baseline that is absent from the rev-list must
// never silently disable the guard — emit a distinct violation instead.
let effectiveGitLogShas = gitLogShas;
if (externalEditBaselineSha) {
const baselineIndex = gitLogShas.indexOf(externalEditBaselineSha);
if (baselineIndex === -1) {
return {
verdict: "fail",
violations: [
{
rule: "external-edit-baseline-missing",
systemId,
message: `externalEditBaselineSha '${externalEditBaselineSha.slice(0, 12)}' is set in system-state.yaml but absent from git rev-list — fix the baseline SHA or remove the field (a wrong baseline must never disable the external-edit check unnoticed)`,
},
],
summary: `external-edit baseline SHA missing from git history for ${systemId}`,
};
}
effectiveGitLogShas = gitLogShas.slice(0, baselineIndex);
}
const expectedShas = new Set();
for (const entry of bordbuchEntries) {
if (entry.kind !== "mission-reconcile") continue;
if (entry.metadata?.commitSha) {
expectedShas.add(entry.metadata.commitSha);
}
// ADR-0093: every workpiece commit folded into the cache clone by
// reconcile is pipeline-authored — trace each SHA, not just the merge
// head. Legacy entries without mergedShas fall back to commitSha only.
for (const sha of entry.metadata?.mergedShas ?? []) {
expectedShas.add(sha);
}
}
for (const sha of rangeShas) {
expectedShas.add(sha);
}
// Pipeline bookkeeping commits carry machine-authored subjects — whitelist
// them so the guard only flags commits with foreign (human/tool) subjects.
if (input.gitLogSubjects) {
for (let i = 0; i < effectiveGitLogShas.length; i++) {
const sha = gitLogShas.indexOf(effectiveGitLogShas[i]!);
const subject = sha === -1 ? "" : (input.gitLogSubjects[sha] ?? "");
if (PIPELINE_SUBJECT_PREFIXES.some((prefix) => subject.startsWith(prefix))) {
expectedShas.add(effectiveGitLogShas[i]!);
}
}
}
const unexpectedShas = effectiveGitLogShas.filter((sha) => !expectedShas.has(sha));
if (unexpectedShas.length === 0) {
return {
verdict: "pass",
violations: [],
summary: `No external edits detected for ${systemId}`,
};
}
return {
verdict: "fail",
violations: [
{
rule: "external-edit-detected",
systemId,
message: `${unexpectedShas.length} commit(s) in git log not traced to any Bordbuch reconcile entry. External edits detected — consider demoting system to 'paused'. First unexpected SHA: ${unexpectedShas[0]!.slice(0, 12)}`,
},
],
summary: `${unexpectedShas.length} external edit(s) detected for ${systemId}`,
metadata: {
unexpectedCount: unexpectedShas.length,
firstUnexpectedSha: unexpectedShas[0],
},
};
}