/* RFC-1124: Git-native fleet claims registry — pure functions for the per-fleet claims repository. Manages the local clone (.werkstatt/fleet-claims), builds and publishes signed claim files, verifies claims offline (self-claim and transfer-claim legitimacy), resolves conflicts via LWW + owner-signature precedence ported from kernel/dht/register.ts, and syncs with git remotes. No kernel command types — thin handlers live in claims-commands.ts. No auto-merge of diverged claims histories — non-fast-forward is a manual-resolution diagnostic. No Worker calls — the RFC-0967 Worker is an optional accelerator, never consulted here. No network requirement for verify/status — only publish push and sync fetch touch remotes. RFC-1124: initial claims repository — config, clone management, build/publish/verify/sync/status, transfer-claim legitimacy via bordbuch handover events. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1159: step 3 — io.exec exit-code sweep via execOrThrow */ import path from "node:path"; import { createDefaultIO, execOrThrow, type WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import { signClaim, verifyClaim as verifyClaimSignature, type FleetClaim, type FleetClaimData, } from "@warpgogol/werkstatt-shared/passport/claim-sign"; import { readBordbuch } from "../bordbuch/bordbuch-io.ts"; import { readPassport } from "../sternsystem/registry-io.ts"; import { derivePublicKey } from "../sternsystem/passport.ts"; import { deriveInstanceId } from "./ownership-registry.ts"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** Workshop-local config: werkstatt.fleet.json (created by fleet.claims.init). */ export interface FleetClaimsConfig { /** Remote URL(s) of the per-fleet claims repository. */ remotes: string[]; /** Local clone path relative to the werkstatt root. */ localPath: string; /** Optional RFC-0967 Worker URL — accelerator only, never required. */ workerUrl?: string; } export interface ClaimsVerifyResult { systemId: string; claim: FleetClaim | null; /** * "valid" | "no-claim" | "invalid-signature" | "stale". * "stale" = tombstone (supersededBy set) OR passportHash drift vs the local * systems-cache passport when that passport is available. */ verdict: "valid" | "no-claim" | "invalid-signature" | "stale"; /** Losing claims observed on diverged remote refs at the last sync. */ conflicts: Array<{ claim: FleetClaim; reason: string }>; } export interface PublishClaimResult { claim: FleetClaim; claimPath: string; commit: string; pushed: boolean; pushErrors: string[]; } export interface SyncClaimsResult { fetched: string[]; fastForwarded: boolean; pushed: boolean; diverged: Array<{ remote: string; files: string[] }>; invalidClaims: string[]; errors: string[]; } export interface ClaimsStatusResult { configured: boolean; clonePath: string | null; remotes: string[]; claims: number; unpushedCommits: number; behindRemotes: Array<{ remote: string; behind: number }>; lastSyncAt: string | null; } const DEFAULT_LOCAL_PATH = ".werkstatt/fleet-claims"; const STATE_FILE = ".werkstatt/fleet-claims-state.json"; const CONFIG_FILE = "werkstatt.fleet.json"; // --------------------------------------------------------------------------- // Config + clone management // --------------------------------------------------------------------------- export async function loadFleetClaimsConfig( werkstattRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const configPath = path.join(werkstattRoot, CONFIG_FILE); if (!(await io.exists(configPath))) return null; const raw = await io.readFile(configPath); const parsed = JSON.parse(raw) as Partial; return { remotes: parsed.remotes ?? [], localPath: parsed.localPath ?? DEFAULT_LOCAL_PATH, workerUrl: parsed.workerUrl, }; } export function claimsClonePath(werkstattRoot: string, config: FleetClaimsConfig): string { return path.isAbsolute(config.localPath) ? config.localPath : path.join(werkstattRoot, config.localPath); } async function git(clonePath: string, args: string[], io: WorkspaceIO): Promise { const result = await io.exec("git", ["-C", clonePath, ...args]); if (result.exitCode !== 0) { throw new Error( `git ${args[0] ?? ""} failed (exit ${result.exitCode}): ${result.stderr.trim()}`, ); } return result.stdout.trim(); } async function gitOk(clonePath: string, args: string[], io: WorkspaceIO): Promise { const result = await io.exec("git", ["-C", clonePath, ...args]); return result.exitCode === 0; } /** * Open or create the local claims clone. Clones the first configured remote * when the directory is absent; falls back to `git init` when the remote is * empty or does not exist yet (first publish creates the initial commit). */ export async function ensureClaimsClone( werkstattRoot: string, config: FleetClaimsConfig, io: WorkspaceIO = createDefaultIO().io, ): Promise { const clonePath = claimsClonePath(werkstattRoot, config); if (await io.exists(path.join(clonePath, ".git"))) { // Keep remote list in sync with config (idempotent re-init). await syncGitRemotes(clonePath, config.remotes, io); return clonePath; } await io.mkdir(path.dirname(clonePath)); let cloned = false; if (config.remotes.length > 0) { const cloneResult = await io.exec("git", ["clone", config.remotes[0], clonePath]); cloned = cloneResult.exitCode === 0; // remote missing/empty/unreachable — init locally } if (!cloned) { await io.mkdir(clonePath); await execOrThrow(io, "git", ["-C", clonePath, "init"]); } await syncGitRemotes(clonePath, config.remotes, io); return clonePath; } /** * Named remotes: first = "origin", rest = "remote1..N". Only managed names * (origin, remoteN) are removed/re-added — operator-added remotes with other * names are preserved. */ async function syncGitRemotes( clonePath: string, remotes: string[], io: WorkspaceIO, ): Promise { const managed = new Set(remotes.map((_, i) => remoteName(i))); const existing = (await gitOk(clonePath, ["remote"], io)) ? (await git(clonePath, ["remote"], io)).split("\n").filter(Boolean) : []; for (const name of existing) { if (!managed.has(name) && name !== "origin" && !/^remote\d+$/.test(name)) continue; await execOrThrow(io, "git", ["-C", clonePath, "remote", "remove", name]); } for (const [i, url] of remotes.entries()) { const name = remoteName(i); await execOrThrow(io, "git", ["-C", clonePath, "remote", "add", name, url]); } } function remoteName(index: number): string { return index === 0 ? "origin" : `remote${index}`; } /** * Load the signing key pair from the standard env vars * (SIGNING_PRIVATE_KEY / SIGNING_PRIVATE_KEY_PATH) and derive the public key. * Shared by all fleet.claims.* handlers and the lifecycle call sites * (mission.close, sternsystem.register, sternsystem.sync, handover.complete). * Returns null when no key is configured — callers skip silently. */ export async function loadSigningKeyFromEnv(): Promise<{ privateKeyHex: string; publicKeyHex: string; } | null> { const privateKeyEnv = process.env.SIGNING_PRIVATE_KEY; const privateKeyPath = process.env.SIGNING_PRIVATE_KEY_PATH; if (!privateKeyEnv && !privateKeyPath) return null; const { loadPrivateKey, toHex } = await import("@warpgogol/werkstatt-engine/signing"); const keyBytes = privateKeyEnv ? await loadPrivateKey({ pem: privateKeyEnv }) : await loadPrivateKey({ filePath: privateKeyPath!, encoding: "pem" }); return { privateKeyHex: toHex(keyBytes), publicKeyHex: await derivePublicKey(keyBytes), }; } // --------------------------------------------------------------------------- // Claim build + publish // --------------------------------------------------------------------------- export interface BuildClaimInput { systemId: string; werkstattRoot: string; /** 32-byte Ed25519 private key as hex (SIGNING_PRIVATE_KEY material). */ signerPrivateKeyHex: string; /** Hex public key matching the private key (passport.creator.publicKey format). */ signerPublicKey: string; /** Transfer claims only: sha256 of handover-authorization.json. */ authorizationHash?: string | null; } /** * Build a signed claim for a system from its passport + bordbuch tip. * The claim describes the CURRENT passport's creator — for transfer claims the * caller signs with the outgoing key and passes authorizationHash. */ export async function buildClaim(input: BuildClaimInput): Promise { const { systemId, werkstattRoot, signerPrivateKeyHex, signerPublicKey } = input; const passport = await readPassport(werkstattRoot, systemId); if (!passport) { throw new Error( `[fleet.claims] passport.json not found for ${systemId} — run: pnpm exec werkstatt run sternsystem.passport.generate --id ${systemId}`, ); } let bordbuchHead = passport.payload.provenance.bordbuchHead ?? ""; try { const entries = await readBordbuch(werkstattRoot, systemId); if (entries.length > 0) bordbuchHead = entries[entries.length - 1].hash; } catch { // bordbuch missing — fall back to passport provenance } const data: FleetClaimData = { passportHash: passport.passportHash, systemId, creatorIdentity: passport.payload.creator.identity, instanceId: deriveInstanceId(passport.payload.creator.publicKey), claimedAt: new Date().toISOString(), bordbuchHead, supersededBy: null, signerPublicKey, authorizationHash: input.authorizationHash ?? null, }; const signature = await signClaim(data, signerPrivateKeyHex); return { ...data, signature }; } export interface PublishClaimInput extends BuildClaimInput { io?: WorkspaceIO; /** Push to all configured remotes after committing (default true). */ push?: boolean; } /** * Write claims/.json into the local clone, commit, and push * (best-effort — push failure never throws; unpushed state is visible via * claimsStatus and retried by syncClaims / sternsystem.sync). */ export async function publishClaim(input: PublishClaimInput): Promise { const { werkstattRoot, systemId } = input; const config = await loadFleetClaimsConfig(werkstattRoot); if (!config) { throw new Error( `[fleet.claims] ${CONFIG_FILE} not found — run: pnpm exec werkstatt run fleet.claims.init --remote `, ); } const io = input.io ?? createDefaultIO().io; const clonePath = await ensureClaimsClone(werkstattRoot, config, io); const claim = await buildClaim(input); const claimsDir = path.join(clonePath, "claims"); await io.mkdir(claimsDir); const claimPath = path.join(claimsDir, `${systemId}.json`); await io.writeFile(claimPath, JSON.stringify(claim, null, 2) + "\n"); await git(clonePath, ["add", `claims/${systemId}.json`], io); const hasStaged = !(await gitOk(clonePath, ["diff", "--cached", "--quiet"], io)); let commit = (await gitOk(clonePath, ["rev-parse", "HEAD"], io)) ? await git(clonePath, ["rev-parse", "HEAD"], io) : ""; if (hasStaged) { await git( clonePath, [ "-c", "user.name=werkstatt-fleet-claims", "-c", "user.email=fleet-claims@werkstatt.local", "commit", "-m", `claim: ${systemId} ${claim.passportHash.slice(0, 16)}`, ], io, ); commit = await git(clonePath, ["rev-parse", "HEAD"], io); } const pushErrors: string[] = []; if (input.push !== false) { for (let i = 0; i < config.remotes.length; i++) { try { await git(clonePath, ["push", remoteName(i), "HEAD:refs/heads/main"], io); } catch (err) { pushErrors.push( `${remoteName(i)}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`, ); } } } return { claim, claimPath, commit, pushed: pushErrors.length === 0 && config.remotes.length > 0 && input.push !== false, pushErrors, }; } /** * Sender-side transfer claim (RFC-1124): after a handover completes on the * recipient's side and the bordbuch event + new passport propagate back to the * sender's clone, the OUTGOING creator publishes a transfer claim — signed by * the old key, describing the new owner, bound to the authorization via * authorizationHash. This completes the registry transfer even when the * recipient's workshop never publishes (offline, no claims config). * * Returns null when there is nothing to do: no handover event, the current * claim already covers the new passport, the local passport has not caught up, * or our key is not the outgoing creator's. */ export async function publishPendingTransferClaim(input: { systemId: string; werkstattRoot: string; signerPrivateKeyHex: string; signerPublicKey: string; push?: boolean; io?: WorkspaceIO; }): Promise { const { systemId, werkstattRoot, signerPublicKey } = input; const io = input.io ?? createDefaultIO().io; const config = await loadFleetClaimsConfig(werkstattRoot, io); if (!config) return null; const clonePath = claimsClonePath(werkstattRoot, config); if (!(await io.exists(path.join(clonePath, ".git")))) return null; // Latest handover event in the system bordbuch. const entries = await readBordbuch(werkstattRoot, systemId).catch(() => []); const handover = [...entries].reverse().find((e) => e.kind === "handover"); if (!handover) return null; const meta = handover.metadata as Record | undefined; const newPassportHash = meta?.newPassportHash as string | undefined; const authorizationHash = meta?.authorizationHash as string | undefined; if (!newPassportHash || !authorizationHash) return null; // The new passport must be visible locally — otherwise buildClaim would // describe the OLD passport and produce a self-claim refresh, not a transfer. const passport = await readPassport(werkstattRoot, systemId).catch(() => null); if (!passport || passport.passportHash !== newPassportHash) return null; // Current local claim must exist, must be ours (we are the outgoing // creator), and must not already cover the new passport. const local = await readClaimFile(path.join(clonePath, "claims", `${systemId}.json`), io); if (!local) return null; if (local.passportHash === newPassportHash) return null; // already claimed if (local.instanceId !== deriveInstanceId(signerPublicKey)) return null; // not outgoing creator return publishClaim({ ...input, authorizationHash }); } // --------------------------------------------------------------------------- // Verify (offline) // --------------------------------------------------------------------------- async function readClaimFile(filePath: string, io: WorkspaceIO): Promise { if (!(await io.exists(filePath))) return null; try { return JSON.parse(await io.readFile(filePath)) as FleetClaim; } catch { return null; } } async function claimAtRef( clonePath: string, ref: string, systemId: string, io: WorkspaceIO, ): Promise { try { const raw = await git(clonePath, ["show", `${ref}:claims/${systemId}.json`], io); return JSON.parse(raw) as FleetClaim; } catch { return null; } } /** Previous version of the claim file on the given ref (second commit back). */ async function previousClaimAtRef( clonePath: string, ref: string, systemId: string, io: WorkspaceIO, ): Promise { try { const commits = ( await git(clonePath, ["log", "--format=%H", ref, "--", `claims/${systemId}.json`], io) ) .split("\n") .filter(Boolean); if (commits.length < 2) return null; const raw = await git(clonePath, ["show", `${commits[1]}:claims/${systemId}.json`], io); return JSON.parse(raw) as FleetClaim; } catch { return null; } } async function remoteRefs(clonePath: string, io: WorkspaceIO): Promise { if (!(await gitOk(clonePath, ["for-each-ref", "--format=%(refname)", "refs/remotes/"], io))) return []; return (await git(clonePath, ["for-each-ref", "--format=%(refname)", "refs/remotes/"], io)) .split("\n") .filter(Boolean); } /** * LWW + owner-signature precedence, ported from kernel/dht/register.ts * resolveConflict: newer claimedAt wins; on equal timestamps a self-claim * (owner signature) beats a transfer claim; otherwise the existing claim wins * (conservative, no flapping). */ export function resolveClaimConflict( existing: FleetClaim, candidate: FleetClaim, ): { winner: FleetClaim; reason: "newer" | "self-claim" | "equal-existing" } { const existingTime = new Date(existing.claimedAt).getTime(); const candidateTime = new Date(candidate.claimedAt).getTime(); if (candidateTime > existingTime) { return { winner: candidate, reason: "newer" }; } if (candidateTime === existingTime) { const existingIsSelf = deriveInstanceId(existing.signerPublicKey) === existing.instanceId; const candidateIsSelf = deriveInstanceId(candidate.signerPublicKey) === candidate.instanceId; if (candidateIsSelf && !existingIsSelf) { return { winner: candidate, reason: "self-claim" }; } } return { winner: existing, reason: "equal-existing" }; } async function isLegitimateClaim( claim: FleetClaim, previous: FleetClaim | null, werkstattRoot: string, ): Promise { const signerInstance = deriveInstanceId(claim.signerPublicKey); if (signerInstance === claim.instanceId) return true; // self-claim // Transfer claim: signer must be the previous claim's creator AND a bordbuch // handover event must link authorizationHash → newPassportHash. if (!previous || previous.instanceId !== signerInstance) return false; if (!claim.authorizationHash) return false; try { const entries = await readBordbuch(werkstattRoot, claim.systemId); return entries.some( (e) => e.kind === "handover" && (e.metadata as Record | undefined)?.authorizationHash === claim.authorizationHash && (e.metadata as Record | undefined)?.newPassportHash === claim.passportHash, ); } catch { return false; } } /** * Resolve the current claim for a system entirely offline: local claim file + * diverged claims on remote-tracking refs → signature check → legitimacy → * LWW → verdict. Never fetches, never writes. */ export async function verifyClaimOffline(input: { systemId: string; werkstattRoot: string; io?: WorkspaceIO; }): Promise { const { systemId, werkstattRoot } = input; const result: ClaimsVerifyResult = { systemId, claim: null, verdict: "no-claim", conflicts: [], }; const io = input.io ?? createDefaultIO().io; const config = await loadFleetClaimsConfig(werkstattRoot, io); if (!config) return result; const clonePath = claimsClonePath(werkstattRoot, config); if (!(await io.exists(path.join(clonePath, ".git")))) return result; // Candidates: local file + every diverged remote-ref version. const candidates: Array<{ claim: FleetClaim; ref: string }> = []; const local = await readClaimFile(path.join(clonePath, "claims", `${systemId}.json`), io); if (local) candidates.push({ claim: local, ref: "HEAD" }); const seen = new Set(local ? [JSON.stringify(local)] : []); for (const ref of await remoteRefs(clonePath, io)) { const remote = await claimAtRef(clonePath, ref, systemId, io); if (remote && !seen.has(JSON.stringify(remote))) { seen.add(JSON.stringify(remote)); candidates.push({ claim: remote, ref }); } } if (candidates.length === 0) return result; // Signature + legitimacy filter. const valid: Array<{ claim: FleetClaim; ref: string }> = []; for (const c of candidates) { const sigOk = await verifyClaimSignature(c.claim).catch(() => false); if (!sigOk) continue; const previous = await previousClaimAtRef(clonePath, c.ref, systemId, io); if (await isLegitimateClaim(c.claim, previous, werkstattRoot)) { valid.push(c); } } if (valid.length === 0) { result.claim = local ?? candidates[0].claim; result.verdict = "invalid-signature"; return result; } // LWW across valid candidates. let winner = valid[0].claim; for (const c of valid.slice(1)) { const res = resolveClaimConflict(winner, c.claim); if (res.winner !== winner) { result.conflicts.push({ claim: winner, reason: `lost-lww:${res.reason}` }); winner = res.winner; } else { result.conflicts.push({ claim: c.claim, reason: `lost-lww:${res.reason}` }); } } result.claim = winner; // Verdict on the winner. if (winner.supersededBy !== null) { result.verdict = "stale"; return result; } const passport = await readPassport(werkstattRoot, systemId).catch(() => null); if (passport && passport.passportHash !== winner.passportHash) { result.verdict = "stale"; return result; } result.verdict = "valid"; return result; } // --------------------------------------------------------------------------- // Sync + status // --------------------------------------------------------------------------- /** * Fetch every configured remote into remote-tracking refs, fast-forward the * local clone when possible, record diverged claim files (never auto-merge), * verify all local claim signatures, and push pending commits. */ export async function syncClaims(werkstattRoot: string): Promise { const result: SyncClaimsResult = { fetched: [], fastForwarded: false, pushed: false, diverged: [], invalidClaims: [], errors: [], }; const config = await loadFleetClaimsConfig(werkstattRoot); if (!config) { result.errors.push(`${CONFIG_FILE} not found — run fleet.claims.init first`); return result; } const io = createDefaultIO().io; const clonePath = await ensureClaimsClone(werkstattRoot, config, io); // Fetch all remotes. for (let i = 0; i < config.remotes.length; i++) { const name = remoteName(i); try { await git(clonePath, ["fetch", name, `+refs/heads/*:refs/remotes/${name}/*`], io); result.fetched.push(name); } catch (err) { result.errors.push( `fetch ${name}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`, ); } } // Fast-forward local main to the best remote state; record divergence. const hasHead = await gitOk(clonePath, ["rev-parse", "HEAD"], io); for (const ref of await remoteRefs(clonePath, io)) { if (!hasHead) { // No local commits — adopt the remote ref wholesale. await git(clonePath, ["checkout", "-B", "main", ref], io); result.fastForwarded = true; break; } if (await gitOk(clonePath, ["merge-base", "--is-ancestor", "HEAD", ref], io)) { await git(clonePath, ["merge", "--ff-only", ref], io); result.fastForwarded = true; } else if (!(await gitOk(clonePath, ["merge-base", "--is-ancestor", ref, "HEAD"], io))) { const files = (await gitOk( clonePath, ["diff", "--name-only", "HEAD", ref, "--", "claims/"], io, )) ? (await git(clonePath, ["diff", "--name-only", "HEAD", ref, "--", "claims/"], io)) .split("\n") .filter(Boolean) : []; result.diverged.push({ remote: ref, files }); } // ref is ancestor of HEAD — local ahead, nothing to pull. } // Verify all local claim signatures. const claimsDir = path.join(clonePath, "claims"); if (await io.exists(claimsDir)) { for (const entry of await io.readdir(claimsDir)) { const file = entry.name; if (!file.endsWith(".json")) continue; const claim = await readClaimFile(path.join(claimsDir, file), io); if (!claim || !(await verifyClaimSignature(claim).catch(() => false))) { result.invalidClaims.push(file); } } } // Push pending commits. let pushFailed = false; for (let i = 0; i < config.remotes.length; i++) { try { await git(clonePath, ["push", remoteName(i), "HEAD:refs/heads/main"], io); } catch (err) { pushFailed = true; result.errors.push( `push ${remoteName(i)}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`, ); } } result.pushed = config.remotes.length > 0 && !pushFailed; await io.writeFile( path.join(werkstattRoot, STATE_FILE), JSON.stringify({ lastSyncAt: new Date().toISOString() }, null, 2) + "\n", ); return result; } /** Local clone vs remote state, pending unpushed claims, last sync. */ export async function claimsStatus( werkstattRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const result: ClaimsStatusResult = { configured: false, clonePath: null, remotes: [], claims: 0, unpushedCommits: 0, behindRemotes: [], lastSyncAt: null, }; const config = await loadFleetClaimsConfig(werkstattRoot); if (!config) return result; result.configured = true; result.remotes = config.remotes; const clonePath = claimsClonePath(werkstattRoot, config); if (!(await io.exists(path.join(clonePath, ".git")))) return result; result.clonePath = clonePath; const claimsDir = path.join(clonePath, "claims"); if (await io.exists(claimsDir)) { result.claims = (await io.readdir(claimsDir)).filter((f) => f.name.endsWith(".json")).length; } const refs = await remoteRefs(clonePath, io); for (const ref of refs) { const behind = (await gitOk(clonePath, ["rev-list", "--count", `HEAD..${ref}`], io)) ? Number.parseInt(await git(clonePath, ["rev-list", "--count", `HEAD..${ref}`], io), 10) : 0; const ahead = (await gitOk(clonePath, ["rev-list", "--count", `${ref}..HEAD`], io)) ? Number.parseInt(await git(clonePath, ["rev-list", "--count", `${ref}..HEAD`], io), 10) : 0; if (behind > 0) result.behindRemotes.push({ remote: ref, behind }); result.unpushedCommits = Math.max(result.unpushedCommits, ahead); } // No remote refs yet — count all local commits as unpushed. if (refs.length === 0 && (await gitOk(clonePath, ["rev-list", "--count", "HEAD"], io))) { result.unpushedCommits = Number.parseInt( await git(clonePath, ["rev-list", "--count", "HEAD"], io), 10, ); } const statePath = path.join(werkstattRoot, STATE_FILE); if (await io.exists(statePath)) { try { const state = JSON.parse(await io.readFile(statePath)) as { lastSyncAt?: string }; result.lastSyncAt = state.lastSyncAt ?? null; } catch { // corrupt state file — ignore } } return result; }