/** Original, synthetic lending model. No database, clock, provider or client data. */ export type LendingRequest = Readonly<{ key: string; member: string; kit: string }>; export type LendingStation = "request" | "store" | "notice"; export type LendingScenarioId = "normal" | "duplicate" | "timeout"; export type LendingCommand = | { type: "receive"; request: LendingRequest } | { type: "commit" } | { type: "respond" } | { type: "lose-response" } | { type: "send" } | { type: "timeout" } | { type: "acknowledge" }; type Reservation = { id: string; request: LendingRequest }; type OutboxIntent = { id: string; reservationId: string; status: "pending" | "sending" | "uncertain" | "accepted"; attempts: number; }; type Reply = | { kind: "reserved"; reservationId: string; reused: boolean } | { kind: "key-conflict" | "unavailable" }; export type LendingState = { request: LendingRequest | null; requestAttempts: number; reservation: Reservation | null; outbox: OutboxIntent | null; reply: Reply | null; client: "idle" | "waiting" | "uncertain" | "confirmed" | "rejected"; }; export const LENDING_REQUEST: LendingRequest = Object.freeze({ key: "loan-017", member: "member-17", kit: "TELESCOPE-01", }); export function initialLendingState(): LendingState { return { request: null, requestAttempts: 0, reservation: null, outbox: null, reply: null, client: "idle" }; } /** * Commands are serialized. `commit` models one indivisible transaction containing * the inventory check, unique request receipt, reservation and outbox intent. * This is an executable invariant, not a concurrency or durability test. */ export function applyLendingCommand(state: LendingState, command: LendingCommand): LendingState { switch (command.type) { case "receive": if (state.client === "waiting") throw new Error("Resolve the current request before receiving another."); return { ...state, request: { ...command.request }, requestAttempts: state.requestAttempts + 1, reply: null, client: "waiting" }; case "commit": { if (!state.request || state.client !== "waiting" || state.reply) throw new Error("A received, unprocessed request is required."); const request = state.request; // Look up the receipt before stock. A replay still succeeds when stock is zero. if (state.reservation?.request.key === request.key) { const original = state.reservation.request; const matches = original.member === request.member && original.kit === request.kit; return { ...state, reply: matches ? { kind: "reserved", reservationId: state.reservation.id, reused: true } : { kind: "key-conflict" } }; } if (state.reservation || request.kit !== LENDING_REQUEST.kit) return { ...state, reply: { kind: "unavailable" } }; const reservation = { id: "R-001", request: { ...request } }; return { ...state, reservation, outbox: { id: "N-001", reservationId: reservation.id, status: "pending", attempts: 0 }, reply: { kind: "reserved", reservationId: reservation.id, reused: false }, }; } case "respond": case "lose-response": if (!state.reply || state.client !== "waiting") throw new Error("A committed reply is required."); return { ...state, client: command.type === "lose-response" ? "uncertain" : state.reply.kind === "reserved" ? "confirmed" : "rejected" }; case "send": if (!state.outbox || !["pending", "uncertain"].includes(state.outbox.status)) throw new Error("A pending or uncertain notification is required."); return { ...state, outbox: { ...state.outbox, status: "sending", attempts: state.outbox.attempts + 1 } }; case "timeout": case "acknowledge": if (state.outbox?.status !== "sending") throw new Error("A notification attempt must be in flight."); return { ...state, outbox: { ...state.outbox, status: command.type === "timeout" ? "uncertain" : "accepted" } }; } } type LendingEvent = { title: string; detail: string; station: LendingStation; command: LendingCommand }; type LendingScenario = { label: string; question: string; takeaway: string; events: readonly LendingEvent[] }; const receive: LendingEvent = { title: "A member asks for the telescope", detail: "The request includes loan-017, a key the caller keeps for any retry of this same request.", station: "request", command: { type: "receive", request: LENDING_REQUEST } }; const commit: LendingEvent = { title: "One commit records the reservation", detail: "The model reserves the kit, saves the request key and queues notification N-001 together. No provider call runs inside this transaction.", station: "store", command: { type: "commit" } }; const respond: LendingEvent = { title: "The caller receives R-001", detail: "The reservation is confirmed. Notification has its own state and is not a condition for holding the telescope.", station: "request", command: { type: "respond" } }; const send: LendingEvent = { title: "A worker attempts the notification", detail: "The worker reads the committed outbox intent. The reservation already exists; this attempt cannot reserve another kit.", station: "notice", command: { type: "send" } }; const acknowledge: LendingEvent = { title: "The provider acknowledges acceptance", detail: "The model records acceptance of the notification. This is not proof of delivery to an inbox, and it does not change the reservation.", station: "notice", command: { type: "acknowledge" } }; export const LENDING_SCENARIOS: Record = { normal: { label: "Normal request", question: "Which work needs to commit together?", takeaway: "The reservation, request receipt and outbox intent form one atomic write. Sending the notification is separate work.", events: [receive, commit, respond, send, acknowledge], }, duplicate: { label: "Duplicate request", question: "The reply disappears. Is retrying safe?", takeaway: "The same key and payload return the existing reservation. Reuse is checked before availability, so zero remaining stock does not turn a successful retry into a rejection.", events: [receive, commit, { title: "The response is lost", detail: "The caller cannot tell whether the write succeeded. The stored reservation and outbox intent still exist.", station: "request", command: { type: "lose-response" } }, { ...receive, title: "The caller retries the same request", detail: "Attempt two carries the original key and payload. A fresh key would represent a different request." }, { title: "The saved result is reused", detail: "The key lookup finds R-001 before checking stock. It creates neither a second reservation nor a second notification intent.", station: "store", command: { type: "commit" } }, respond, send, acknowledge], }, timeout: { label: "Notification timeout", question: "A dependency times out. What stays true?", takeaway: "Retry the notification intent, not the reservation. A timeout means acceptance is unknown. A retry can send a duplicate unless the provider also honors an idempotency key.", events: [receive, commit, respond, send, { title: "No acknowledgement arrives", detail: "The provider may have accepted the message. The model marks the attempt uncertain and keeps the reservation confirmed.", station: "notice", command: { type: "timeout" } }, { title: "Retry the same notification intent", detail: "Attempt two uses N-001. The worker does not rerun the reservation write. This model does not promise exactly-once notification delivery.", station: "notice", command: { type: "send" } }, acknowledge], }, }; export function lendingSnapshot(scenarioId: LendingScenarioId, requestedStep: number) { const scenario = LENDING_SCENARIOS[scenarioId]; const step = Math.max(0, Math.min(scenario.events.length, Number.isFinite(requestedStep) ? Math.floor(requestedStep) : 0)); const state = scenario.events.slice(0, step).reduce((current, event) => applyLendingCommand(current, event.command), initialLendingState()); const event = step > 0 ? scenario.events[step - 1] : null; return { state, step, event, complete: step === scenario.events.length, total: scenario.events.length }; }