/** * Mailbox router: route validation, authority revalidation, quota admission, * and fenced-queue semantics. */ import { createHash, randomUUID } from "node:crypto"; import { MESSAGE_PROVENANCE_VERSION, normalizeMessageProvenanceV1, type MessageProvenanceKind, type MessageProvenanceV1, type VerifiedMessageProvenanceV1, } from "../../shared/types.ts"; import { MailboxFileStore } from "./file-store.ts"; import { QuotaAdmission } from "./gc.ts"; import { type MailboxDeliveryMode, type MailboxEnvelope, type MailboxEnqueueResult, type MailboxMessageKind, type MailboxPriority, CORRELATION_ID_PATTERN, MAILBOX_CAPABILITY_PATTERN, MAILBOX_SCHEMA_VERSION, MAX_FROZEN_CAPABILITIES, MAX_PAYLOAD_BYTES, MESSAGE_ID_PATTERN, priorityForKind, SAFE_ID_PATTERN, TTL_NORMAL_MS, TTL_STEER_MS, } from "./types.ts"; // --- Authority Context --- /** Injected authority checks for route and lease validation. */ export interface MailboxAuthority { /** Validate that the sender can route to the recipient. */ canRoute(senderId: string, recipientCorrelationId: string, mode: MailboxDeliveryMode): { allowed: boolean; reason?: string }; /** Current session generation for revalidation. */ currentGeneration(): number; /** Current lease epoch for the recipient (unbound when no recipient). */ currentLeaseEpoch(recipientCorrelationId?: string): number; /** Current lease nonce for the recipient (unbound when no recipient). */ currentLeaseNonce(recipientCorrelationId?: string): string; /** Whether the recipient agent is fenced (queued but not dispatched). */ isFenced(recipientCorrelationId: string): boolean; /** Whether the recipient agent is stale/unauthorized (should dead-letter). */ isStaleUnauthorized(recipientCorrelationId: string): boolean; /** Whether this host instance owns the recipient (local activeRuns). */ managesRecipient(recipientCorrelationId: string): boolean; } // --- Enqueue Request --- export interface MailboxEnqueueRequest { /** Stable caller-selected UUID for retry/receipt reconciliation. */ messageId?: string; workspaceId: string; teamId: string; senderId: string; recipientId: string; recipientCorrelationId: string; kind: MailboxMessageKind; mode: MailboxDeliveryMode; /** Route capabilities frozen into the immutable envelope. Defaults to [mode]. */ capabilities?: readonly string[]; payload: string; provenance?: MessageProvenanceV1; requestId?: string; correlationId?: string; } // --- TTL Resolution --- function ttlForKind(kind: MailboxMessageKind): number { switch (kind) { case "steer": case "interrupt": case "control": return TTL_STEER_MS; default: return TTL_NORMAL_MS; } } function provenanceKindForMailbox(kind: MailboxMessageKind): MessageProvenanceKind { switch (kind) { case "task": return "task"; case "result": return "result"; case "lifecycle": return "lifecycle"; case "control": return "control"; case "steer": case "interrupt": case "follow_up": return "message"; } } // --- Router --- export interface MailboxRouterOptions { store: MailboxFileStore; authority: MailboxAuthority; quota: QuotaAdmission; /** Workspace this router belongs to; enqueue requests from other workspaces are rejected. */ workspaceId?: string; now?: () => number; } export class MailboxRouter { readonly #store: MailboxFileStore; readonly #authority: MailboxAuthority; readonly #quota: QuotaAdmission; readonly #workspaceId: string | undefined; readonly #now: () => number; #senderSeqBySender = new Map(); constructor(options: MailboxRouterOptions) { this.#store = options.store; this.#authority = options.authority; this.#quota = options.quota; this.#workspaceId = options.workspaceId; this.#now = options.now ?? Date.now; } /** * Enqueue a message through the full authority + quota pipeline. * Returns a result indicating success (message in ready state) or failure code. */ async enqueue(request: MailboxEnqueueRequest): Promise { const now = this.#now(); const messageId = request.messageId ?? randomUUID(); const capabilities = Object.freeze([...(request.capabilities ?? [request.mode])]); const capturedRequest: MailboxEnqueueRequest = Object.freeze({ ...request, capabilities, ...(request.provenance === undefined ? {} : { provenance: normalizeMessageProvenanceV1(request.provenance) }), }); // Freeze caller-owned routing inputs before the first async boundary. if (!MESSAGE_ID_PATTERN.test(messageId)) { return { ok: false, code: "route_invalid", message: "invalid messageId" }; } if (capabilities.length < 1 || capabilities.length > MAX_FROZEN_CAPABILITIES || capabilities.some((capability) => !MAILBOX_CAPABILITY_PATTERN.test(capability)) || new Set(capabilities).size !== capabilities.length || !capabilities.includes(capturedRequest.mode)) { return { ok: false, code: "route_invalid", message: "invalid frozen capabilities" }; } // 0. Workspace isolation: reject cross-workspace enqueue attempts. if (this.#workspaceId !== undefined && capturedRequest.workspaceId !== this.#workspaceId) { return { ok: false, code: "route_invalid", message: "workspace mismatch: message from another workspace" }; } // 0.5 Validate identifiers before any routing or path construction. senderId // and workspaceId are joined into file paths and authority decisions, so // reject anything unsafe (traversal, separators, empty). "caller" (the root // tool identity) already matches SAFE_ID_PATTERN. if (!SAFE_ID_PATTERN.test(capturedRequest.senderId)) { return { ok: false, code: "route_invalid", message: "invalid senderId" }; } if (!SAFE_ID_PATTERN.test(capturedRequest.workspaceId)) { return { ok: false, code: "route_invalid", message: "invalid workspaceId" }; } if (!CORRELATION_ID_PATTERN.test(capturedRequest.recipientCorrelationId)) { return { ok: false, code: "route_invalid", message: "invalid recipientCorrelationId" }; } // 1. Validate route const route = this.#authority.canRoute( capturedRequest.senderId, capturedRequest.recipientCorrelationId, capturedRequest.mode, ); if (!route.allowed) { return { ok: false, code: "route_invalid", message: route.reason ?? "route validation failed" }; } // 2. Check payload size const payloadBytes = Buffer.byteLength(capturedRequest.payload, "utf8"); if (payloadBytes > MAX_PAYLOAD_BYTES) { return { ok: false, code: "payload_too_large", message: `payload exceeds ${MAX_PAYLOAD_BYTES} bytes` }; } // 3. Resolve priority and check quota const priority: MailboxPriority = priorityForKind(capturedRequest.kind); const admission = await this.#quota.check(priority); if (!admission.allowed) { return { ok: false, code: "quota_exceeded", message: `quota exceeded (live: ${admission.live})` }; } // 4. Build envelope const generatedProvenance: VerifiedMessageProvenanceV1 = { version: MESSAGE_PROVENANCE_VERSION, messageId, source: "mailbox", messageKind: provenanceKindForMailbox(capturedRequest.kind), deliveryMode: capturedRequest.mode, confidence: "verified", sender: capturedRequest.senderId === "caller" ? { kind: "root-agent", ownerId: capturedRequest.teamId, label: "caller" } : { kind: "system", ownerId: capturedRequest.senderId, label: capturedRequest.senderId }, }; const provenance = capturedRequest.provenance === undefined ? generatedProvenance : capturedRequest.provenance; const ttlMs = ttlForKind(capturedRequest.kind); const senderSeq = (this.#senderSeqBySender.get(capturedRequest.senderId) ?? 0) + 1; this.#senderSeqBySender.set(capturedRequest.senderId, senderSeq); const envelopeBase: Omit = { messageId, schemaVersion: MAILBOX_SCHEMA_VERSION, workspaceId: capturedRequest.workspaceId, teamId: capturedRequest.teamId, senderId: capturedRequest.senderId, recipientId: capturedRequest.recipientId, recipientCorrelationId: capturedRequest.recipientCorrelationId, kind: capturedRequest.kind, mode: capturedRequest.mode, capabilities, priority, senderSeq, createdAt: now, expiresAt: now + ttlMs, ttlMs, sessionGeneration: this.#authority.currentGeneration(), leaseEpoch: this.#authority.currentLeaseEpoch(capturedRequest.recipientCorrelationId), leaseNonce: this.#authority.currentLeaseNonce(capturedRequest.recipientCorrelationId), payload: capturedRequest.payload, provenance, ...(capturedRequest.requestId ? { requestId: capturedRequest.requestId } : {}), ...(capturedRequest.correlationId ? { correlationId: capturedRequest.correlationId } : {}), }; // Compute hash const { computeEnvelopeHash } = await import("./file-store.ts"); const hash = computeEnvelopeHash(envelopeBase); const envelope: MailboxEnvelope = { ...envelopeBase, hash }; // 5. Deduplication is a durable prepare transaction containing the request // hash, immutable messageId and full envelope. A crash after prepare but // before publication is repaired from that record by this call or startup // recovery; the prepare is never unmarked merely because the caller lost a // response. const dedupKey = capturedRequest.requestId ?? capturedRequest.correlationId; if (dedupKey) { const requestHash = computeMailboxRequestHash(capturedRequest); const prepared = await this.#store.prepareEnqueue(dedupKey, requestHash, envelope); if (prepared.status !== "prepared") { const detail = prepared.status === "conflict" ? " with conflicting immutable request data" : ""; return { ok: false, code: "duplicate", message: `message ${dedupKey} already processed${detail}`, messageId: prepared.messageId, }; } return { ok: true, messageId: prepared.messageId, state: "ready" }; } // 6. Requests without a stable caller id still publish through an immutable // staging file and the journalled staging→ready transition. try { await this.#store.writeStaging(envelope); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes("payload exceeds")) { return { ok: false, code: "payload_too_large", message }; } if (message.includes("envelope exceeds")) { return { ok: false, code: "envelope_too_large", message }; } throw error; } const promoted = await this.#store.promoteToReady(messageId); if (!promoted) return { ok: false, code: "route_invalid", message: "failed to promote staging to ready" }; return { ok: true, messageId, state: "ready" }; } /** Whether this host's authority owns the recipient (consumer "*" filtering). */ managesRecipient(recipientCorrelationId: string): boolean { return this.#authority.managesRecipient(recipientCorrelationId); } /** * Revalidate authority for a message before dispatch. * Called by the consumer before injecting into the child. * Returns true if dispatch is allowed, false if blocked. */ async revalidateForDispatch(envelope: MailboxEnvelope): Promise<{ allowed: boolean; action: "dispatch" | "dead" | "hold"; reason?: string }> { // Workspace isolation: a message from another workspace can never dispatch here. if (this.#workspaceId !== undefined && envelope.workspaceId !== this.#workspaceId) { return { allowed: false, action: "dead", reason: "workspace mismatch on dispatch" }; } // Frozen capabilities cannot be widened or narrowed by a later advertisement. if (envelope.capabilities !== undefined && !envelope.capabilities.includes(envelope.mode)) { return { allowed: false, action: "dead", reason: "frozen capability snapshot does not permit the delivery mode" }; } // Check generation const currentGen = this.#authority.currentGeneration(); if (envelope.sessionGeneration !== currentGen) { return { allowed: false, action: "dead", reason: `generation mismatch (envelope: ${envelope.sessionGeneration}, current: ${currentGen})` }; } // Check lease epoch + nonce (bound to the recipient's current SessionLease) const currentEpoch = this.#authority.currentLeaseEpoch(envelope.recipientCorrelationId); const currentNonce = this.#authority.currentLeaseNonce(envelope.recipientCorrelationId); if (envelope.leaseEpoch !== currentEpoch || envelope.leaseNonce !== currentNonce) { return { allowed: false, action: "dead", reason: "lease epoch/nonce mismatch" }; } // Check stale unauthorized if (this.#authority.isStaleUnauthorized(envelope.recipientCorrelationId)) { return { allowed: false, action: "dead", reason: "recipient is stale/unauthorized" }; } // Check fenced — allow queue but block dispatch if (this.#authority.isFenced(envelope.recipientCorrelationId)) { return { allowed: false, action: "hold", reason: "recipient is fenced" }; } // Re-validate route const route = this.#authority.canRoute(envelope.senderId, envelope.recipientCorrelationId, envelope.mode); if (!route.allowed) { return { allowed: false, action: "dead", reason: route.reason ?? "route no longer valid" }; } return { allowed: true, action: "dispatch" }; } } /** Hash only caller-controlled logical request data, never generated timestamps/messageIds. */ export function computeMailboxRequestHash(request: MailboxEnqueueRequest): string { const canonical = canonicalRequestValue({ workspaceId: request.workspaceId, teamId: request.teamId, messageId: request.messageId, senderId: request.senderId, recipientId: request.recipientId, recipientCorrelationId: request.recipientCorrelationId, kind: request.kind, mode: request.mode, capabilities: request.capabilities, payload: request.payload, provenance: request.provenance, requestId: request.requestId, correlationId: request.correlationId, }); return createHash("sha256").update(JSON.stringify(canonical), "utf8").digest("hex"); } function canonicalRequestValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonicalRequestValue); if (!value || typeof value !== "object") return value; return Object.fromEntries(Object.entries(value as Record) .filter(([, entry]) => entry !== undefined) .sort(([left], [right]) => left.localeCompare(right, "en")) .map(([key, entry]) => [key, canonicalRequestValue(entry)])); }