/* RFC-0968: sternsystem.handover.complete — the receiving instance verifies the handover authorization, regenerates the passport with the new creator identity, appends a handover event to the bordbuch, updates the fleet ownership registry (RFC-0967), and removes the authorization file. Does not fetch the authorization from a remote source — the caller must ensure the authorization file is present in the cache clone (via mirror sync or --source-locator fetch). Does not manage key lifecycle or rotation. RFC-0968: initial handover complete command handler. RFC-0988: add --dry-run and --authorization-data flags — verify authorization in memory, return preview data, skip all writes. RFC-1237: resolve dry-run via context.dryRun fallback — consumeCommonFlags strips --dry-run into context.dryRun before dispatch, so a flags-only read was permanently false under werkstatt run (enforced by KERNEL-FLAG-10). RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt */ import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { join } from "node:path"; import { loadPrivateKey } from "@warpgogol/werkstatt-engine/signing"; import { resolveActor } from "../mission/actor-identity.ts"; import { readPassport, writePassport } from "./registry-io.ts"; import { appendBordbuchEntry } from "../bordbuch/bordbuch-io.ts"; import { readAuthorization, removeAuthorization, verifyAuthorization, isAuthorizationExpired, resolveAuthorizationPath, parseAuthorizationData, type HandoverCompleteResult, type HandoverEventMetadata, type SignedHandoverAuthorization, } from "./handover.ts"; import { buildPassportPayload, signPassport, derivePublicKey, type SignedSitePassport, } from "./passport.ts"; import { acquireLock, releaseLock, generateOperationId } from "../werkstatt/index.ts"; export interface SternsystemHandoverCompleteData extends HandoverCompleteResult { command: "sternsystem.handover.complete"; dryRun: boolean; newPassportHashPreview?: string; bordbuchEventPreview?: { kind: "handover"; metadata: { newPassportHash: string; authorizationHash: string }; }; registryTransferPreview?: { systemId: string; previousOwner: string; newOwner: string; }; } function flagString(input: KernelCommandInput, key: string): string | undefined { const v = input.flags[key]; return typeof v === "string" ? v : undefined; } function flagBool(input: KernelCommandInput, key: string): boolean { return input.flags[key] === true; } function filterEnv(env: Record): Record { const result: Record = {}; for (const [key, value] of Object.entries(env)) { if (value !== undefined) { result[key] = value; } } return result; } export async function runSternsystemHandoverComplete( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const systemId = flagString(input, "id"); const dryRun = context.dryRun === true || flagBool(input, "dry-run"); const authorizationDataJson = flagString(input, "authorization-data"); const sourceLocator = flagString(input, "source-locator"); if (!systemId) { throw new Error("[sternsystem.handover.complete] requires --id "); } if (dryRun && sourceLocator) { throw new Error( "[sternsystem.handover.complete] --source-locator is incompatible with --dry-run", ); } if (dryRun && !authorizationDataJson) { throw new Error( "[sternsystem.handover.complete] --authorization-data is required in dry-run mode", ); } const recipientIdentity = resolveActor(input); const env = { ...filterEnv(process.env) }; const privateKeyEnv = env["SIGNING_PRIVATE_KEY"]; const privateKeyPath = env["SIGNING_PRIVATE_KEY_PATH"]; if (!privateKeyEnv && !privateKeyPath) { throw new Error( "[sternsystem.handover.complete] No signing key configured. Set SIGNING_PRIVATE_KEY or SIGNING_PRIVATE_KEY_PATH env var.", ); } let privateKeyBytes: Uint8Array; try { if (privateKeyEnv) { privateKeyBytes = await loadPrivateKey({ pem: privateKeyEnv }); } else { privateKeyBytes = await loadPrivateKey({ filePath: privateKeyPath!, encoding: "pem" }); } } catch (err) { throw new Error( `[sternsystem.handover.complete] Failed to load private key: ${err instanceof Error ? err.message : String(err)}`, ); } const recipientPublicKey = await derivePublicKey(privateKeyBytes); let authorization: SignedHandoverAuthorization; if (dryRun && authorizationDataJson) { authorization = parseAuthorizationData(authorizationDataJson); } else { const read = await readAuthorization(workspaceRoot, systemId); if (!read) { throw new Error( `[sternsystem.handover.complete] No handover-authorization.json found for ${systemId}. Ensure the sender has prepared and synced the authorization.`, ); } authorization = read; } // HANDOVER-02: check expiry if (isAuthorizationExpired(authorization.payload.expiresAt)) { throw new Error( `[sternsystem.handover.complete] HANDOVER-02: authorization expired at ${authorization.payload.expiresAt}. Sender must re-issue.`, ); } // Read the sender's passport to get the sender's public key for signature verification const senderPassport = await readPassport(workspaceRoot, systemId); if (!senderPassport) { throw new Error( `[sternsystem.handover.complete] No passport found for ${systemId}. The sender's passport must be present to verify the authorization signature.`, ); } // Verify the authorization signature using the sender's public key from the passport const senderPublicKey = senderPassport.payload.creator.publicKey; const verification = await verifyAuthorization(authorization, senderPublicKey); if (!verification.valid) { throw new Error( `[sternsystem.handover.complete] Authorization signature verification failed: ${verification.errors.join("; ")}`, ); } // HANDOVER-03: check passport hash match if (authorization.payload.passportHash !== senderPassport.passportHash) { throw new Error( `[sternsystem.handover.complete] HANDOVER-03: passport hash mismatch — authorization references ${authorization.payload.passportHash} but current passport is ${senderPassport.passportHash}. The site changed after authorization; sender must re-issue.`, ); } // HANDOVER-04: verify recipient key possession if (authorization.payload.recipient.publicKey !== recipientPublicKey) { throw new Error( `[sternsystem.handover.complete] HANDOVER-04: recipient key mismatch — SIGNING_PRIVATE_KEY derives public key ${recipientPublicKey} but authorization expects ${authorization.payload.recipient.publicKey}. This instance is not the authorized recipient.`, ); } // Regenerate passport with new creator identity const newPayload = await buildPassportPayload({ systemId, werkstattRoot: workspaceRoot, creatorIdentity: authorization.payload.recipient.identity, creatorPublicKey: recipientPublicKey, }); // Preserve original createdAt from sender's passport newPayload.provenance.createdAt = senderPassport.payload.provenance.createdAt; const { passportHash: newPassportHash, signature: newSignature } = await signPassport( newPayload, privateKeyBytes, ); const newPassport: SignedSitePassport = { payload: newPayload, passportHash: newPassportHash, signature: newSignature, }; const operationId = generateOperationId(); await acquireLock( workspaceRoot, `system:${systemId}`, operationId, "sternsystem.handover.complete", recipientIdentity, ); try { if (dryRun) { logger.success( `[sternsystem.handover.complete] ${systemId} dry-run: handover verified for ${authorization.payload.recipient.identity} — no files written`, ); return { data: { command: "sternsystem.handover.complete", systemId, previousCreator: authorization.payload.sender.identity, newCreator: authorization.payload.recipient.identity, newPassportHash: newPassportHash, bordbuchEventHash: "", ownershipRegistryUpdated: false, dryRun: true, newPassportHashPreview: newPassportHash, bordbuchEventPreview: { kind: "handover", metadata: { newPassportHash, authorizationHash: authorization.authorizationHash, }, }, registryTransferPreview: { systemId, previousOwner: authorization.payload.sender.identity, newOwner: authorization.payload.recipient.identity, }, }, exitCode: 0, summary: `[sternsystem.handover.complete] ${systemId} dry-run: handover verified (no files written)`, }; } // Write new passport await writePassport(workspaceRoot, systemId, newPassport); logger.info( `[sternsystem.handover.complete] ${systemId} passport regenerated for ${authorization.payload.recipient.identity} (hash: ${newPassportHash})`, ); // Append bordbuch handover event const metadata: HandoverEventMetadata = { from: authorization.payload.sender.identity, to: authorization.payload.recipient.identity, authorizationHash: authorization.authorizationHash, newPassportHash, }; const entry = await appendBordbuchEntry( workspaceRoot, systemId, "handover", `handover from ${authorization.payload.sender.identity} to ${authorization.payload.recipient.identity}`, authorization.payload.recipient.identity, { missionId: null, releaseId: null, metadata: metadata as unknown as Record, }, ); logger.info( `[sternsystem.handover.complete] ${systemId} bordbuch handover event appended (hash: ${entry.hash})`, ); // RFC-1124: publish the handover claim to the fleet claims repo (authoritative). // Signed by the recipient key (the new owner) — the only key available here — // and carries authorizationHash binding it to the bordbuch handover event. // The sender-side transfer claim (signed by the outgoing key) is published by // the sender's workshop via sternsystem.sync when the event propagates back. // Non-fatal: workshops without werkstatt.fleet.json skip silently. try { const { loadFleetClaimsConfig, publishClaim } = await import("../fleet/claims-repo.ts"); const claimsConfig = await loadFleetClaimsConfig(workspaceRoot); if (claimsConfig) { const { toHex } = await import("@warpgogol/werkstatt-engine/signing"); await publishClaim({ systemId, werkstattRoot: workspaceRoot, signerPrivateKeyHex: toHex(privateKeyBytes), signerPublicKey: recipientPublicKey, authorizationHash: authorization.authorizationHash, }); logger.info(`[sternsystem.handover.complete] ${systemId} fleet claim published`); } } catch (err) { logger.warn( `[sternsystem.handover.complete] fleet claim publish failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`, ); } // Update fleet ownership registry (RFC-0967) — best-effort accelerator/cache, // never the authority (RFC-1124). On failure: warn + journaled retry entry in // the cache clone's operations/journal/ — the handover itself succeeds. let ownershipRegistryUpdated = false; try { const { transferOwnership } = await import("../fleet/ownership-registry.ts"); const authPath = resolveAuthorizationPath(workspaceRoot, systemId); await transferOwnership({ systemId, werkstattRoot: workspaceRoot, authorizationPath: authPath, io: context.io, }); ownershipRegistryUpdated = true; logger.info(`[sternsystem.handover.complete] ${systemId} ownership registry updated`); } catch (err) { logger.warn( `[sternsystem.handover.complete] fleet.ownership.transfer failed (non-fatal, journaled for retry): ${err instanceof Error ? err.message : String(err)}`, ); try { const { appendRecord } = await import("../journal/jsonl.ts"); const { resolveCacheClonePath } = await import("./registry-io.ts"); const journalDir = join( resolveCacheClonePath(workspaceRoot, systemId), "operations", "journal", ); await context.io.mkdir(journalDir); const opId = `ownership-transfer-retry-${systemId}-${Date.now()}`; const journalPath = join(journalDir, `${opId}.jsonl`); const at = new Date().toISOString(); await appendRecord(journalPath, { kind: "op-started", opId, op: "fleet.ownership.transfer", missionId: "none", at, platformVersion: "unknown", }); await appendRecord(journalPath, { kind: "step-failed", opId, step: "fleet.ownership.transfer", seq: 1, at, error: err instanceof Error ? err.message : String(err), }); await appendRecord(journalPath, { kind: "op-abandoned", opId, at, reason: "worker-unreachable — retry via fleet.ownership.transfer", }); // Follow-up (RFC-1124 Phase 2): a retry command should consume these // journals and re-attempt the Worker transfer; until then entries // accumulate harmlessly — the claims repo is the authority. } catch (journalErr) { logger.warn( `[sternsystem.handover.complete] retry journal write failed (non-fatal): ${journalErr instanceof Error ? journalErr.message : String(journalErr)}`, ); } } // Remove authorization file await removeAuthorization(workspaceRoot, systemId); logger.success( `[sternsystem.handover.complete] ${systemId} handover complete: ${authorization.payload.sender.identity} → ${authorization.payload.recipient.identity}`, ); const result: HandoverCompleteResult = { systemId, previousCreator: authorization.payload.sender.identity, newCreator: authorization.payload.recipient.identity, newPassportHash: newPassportHash, bordbuchEventHash: entry.hash, ownershipRegistryUpdated, }; return { data: { command: "sternsystem.handover.complete", ...result, dryRun: false, }, exitCode: 0, summary: `[sternsystem.handover.complete] ${systemId} handed over from ${result.previousCreator} to ${result.newCreator}, passport regenerated, bordbuch updated, ownership registry synced`, }; } finally { await releaseLock(workspaceRoot, `system:${systemId}`); } }