/*
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}`);
}
}