Changes
diff --git a/designs/design-0-architecture.md b/designs/design-0-architecture.md
new file mode 100644
index 0000000..fb47daa
--- /dev/null
+++ b/designs/design-0-architecture.md
@@ -0,0 +1,1352 @@
+# Doctor Boring Architecture
+
+Status: Proposed
+Version: 0.1.0
+Last updated: 2026-08-27
+PRD: [`../prd.md`](../prd.md)
+
+## 1. Purpose
+
+This document defines the implementation design for Doctor Boring, an
+Emacs Lisp conversation mode that preserves the interaction style of
+`M-x doctor` while sending each conversation turn to an
+OpenAI-compatible Chat Completions endpoint.
+
+The design is intentionally complete enough to guide implementation
+without requiring additional product decisions. It specifies the
+package surface, buffer representation, asynchronous request lifecycle,
+HTTP and JSON handling, error rollback, cleanup, and automated tests.
+
+The compatibility baseline is GNU Emacs 30.2. That version was the
+latest stable Emacs release when this design was written. The package
+does not promise compatibility with earlier Emacs releases.
+
+## 2. Goals
+
+The implementation must provide all of the following behavior:
+
+1. `M-x doctor-boring` creates or returns to `*doctor-boring*`.
+2. A first-time buffer contains a configurable, fixed greeting.
+3. The user submits at the end of the buffer with two presses of `RET`
+ or one press of `C-j`.
+4. API work is asynchronous and does not block editor interaction.
+5. The complete, currently visible conversation is sent on every turn.
+6. Editing a previous message changes the next request's history.
+7. Only one request may be active, and the conversation is read-only
+ while that request is active.
+8. A failed turn is rolled back into editable input beneath a concise
+ local error.
+9. No third-party package is required. An installed `markdown-mode` is
+ used as an optional presentation enhancement.
+10. Core behavior is covered by deterministic ERT tests that never use
+ the network.
+
+## 3. Non-goals
+
+The initial release does not implement:
+
+- token streaming or Server-Sent Events;
+- more than one active request;
+- a queue of pending user messages;
+- a command that clears or resets the conversation;
+- persistence across buffer deletion or Emacs restarts;
+- provider discovery or model listing;
+- arbitrary provider-specific request fields;
+- tool calls, images, audio, or multimodal content;
+- Markdown transformation or sanitization;
+- authentication mechanisms other than an optional bearer token;
+- compatibility with the Responses API;
+- medical advice, crisis handling, or a general-purpose assistant.
+
+These exclusions are architectural boundaries, not merely deferred UI
+details. In particular, accepting a second message while a request is
+active would require queue state and would make rollback ambiguous.
+The read-only waiting state prevents that ambiguity.
+
+## 4. External contracts and references
+
+The implementation relies only on APIs shipped with Emacs 30.2:
+
+- [`url-retrieve`](https://www.gnu.org/software/emacs/manual/html_node/url/Retrieving-URLs.html)
+ performs an asynchronous retrieval and invokes a callback after the
+ response has been completely retrieved. This behavior is why the
+ initial version is asynchronous but non-streaming.
+- [`json-serialize` and
+ `json-parse-string`](https://www.gnu.org/software/emacs/manual/html_node/elisp/Parsing-JSON.html)
+ encode the request and decode the response.
+- [Emacs timers](https://www.gnu.org/software/emacs/manual/html_node/elisp/Timers.html)
+ provide the 300-second request deadline.
+- [markers](https://www.gnu.org/software/emacs/manual/html_node/elisp/Markers.html)
+ track editable message boundaries as buffer text changes.
+- [ERT](https://www.gnu.org/software/emacs/manual/ert.html) provides the
+ test framework included with Emacs.
+- The HTTP payload follows the OpenAI [Chat Completions create
+ operation](https://platform.openai.com/docs/api-reference/chat/create).
+ Compatibility means using the common `model`, `temperature`, and
+ `messages` request fields and reading
+ `choices[0].message.content` from the response.
+
+The package targets the common subset of OpenAI-compatible servers.
+Compatibility does not imply that every server implements every OpenAI
+extension or returns identical error objects.
+
+## 5. High-level architecture
+
+Doctor Boring has four internal layers:
+
+```text
+Interactive command and key bindings
+ |
+ v
+Buffer model and conversation state
+ |
+ v
+Request coordinator and lifecycle guard
+ |
+ v
+Built-in URL, JSON, and timer adapters
+```
+
+The layers have deliberately narrow responsibilities:
+
+- The interaction layer decides whether a key inserts a newline or
+ submits the current input.
+- The buffer layer owns visible text, message boundaries, and history
+ reconstruction.
+- The coordinator owns the single active request and all state
+ transitions.
+- The adapters translate between plain Lisp values and Emacs's URL,
+ JSON, process, and timer APIs.
+
+No callback should directly implement key behavior, and no key command
+should parse raw HTTP. This separation keeps failure rollback testable
+without a real network operation.
+
+## 6. Source files and package metadata
+
+The implementation consists of two files:
+
+### 6.1 `doctor-boring.el`
+
+This is the installable package. Its header must include:
+
+```elisp
+;;; doctor-boring.el --- An LLM Doctor -*- lexical-binding: t; -*-
+
+;; Author: MetroWind <chris.corsair@gmail.com>
+;; Version: 0.1.0
+;; Package-Requires: ((emacs "30.2"))
+;; Keywords: games
+;; SPDX-License-Identifier: WTFPL
+```
+
+The file must end with:
+
+```elisp
+(provide 'doctor-boring)
+;;; doctor-boring.el ends here
+```
+
+The package loads these built-in libraries explicitly:
+
+```elisp
+(require 'cl-lib)
+(require 'json)
+(require 'subr-x)
+(require 'url)
+(require 'url-http)
+```
+
+`markdown-mode` is never a package requirement. It is attempted only
+during first-time buffer initialization.
+
+Lexical binding is required because asynchronous callbacks retain
+request objects after the submitting function returns. Explicit
+request objects are still preferred to callbacks that close over many
+locals because named state is easier to inspect and test.
+
+### 6.2 `doctor-boring-test.el`
+
+This file contains ERT tests and requires both `ert` and
+`doctor-boring`. It must not start a real URL retrieval. Tests replace
+the internal retrieval function and manually deliver success or failure
+results.
+
+The batch command is:
+
+```sh
+emacs -Q --batch -L . -l doctor-boring-test.el \
+ -f ert-run-tests-batch-and-exit
+```
+
+## 7. Emacs Lisp conventions
+
+This package follows normal Emacs Lisp naming conventions:
+
+- public symbols use the `doctor-boring-` prefix;
+- private symbols use the `doctor-boring--` prefix;
+- multiword Lisp symbols use hyphens;
+- every public command, option, mode, and face has a docstring;
+- private functions also receive docstrings when their contract is not
+ obvious from their name;
+- source lines should remain within 80 columns where practical.
+
+This is an Emacs Lisp-specific exception to generic cross-language file
+and function naming rules.
+
+## 8. Public package surface
+
+The initial public surface is intentionally small.
+
+### 8.1 Interactive command
+
+```elisp
+(doctor-boring)
+```
+
+`doctor-boring` is interactive. It obtains
+`*doctor-boring*` with `get-buffer-create`.
+
+- If the buffer is new, it initializes the presentation mode,
+ interaction mode, buffer-local state, greeting, and input marker.
+- If the buffer already exists, it does not rerun initialization and
+ does not modify text or state.
+- It finishes with `switch-to-buffer`, matching the navigation behavior
+ of the original Doctor command.
+
+The new-buffer check must be explicit. Calling a major mode again on an
+existing buffer would erase buffer-local state and violate the PRD.
+
+### 8.2 Interaction minor mode
+
+```elisp
+(doctor-boring-mode &optional arg)
+```
+
+`doctor-boring-mode` is a buffer-local minor mode. It supplies the
+Doctor-specific `RET` and `C-j` bindings without replacing the selected
+presentation major mode.
+
+Using a minor mode resolves the optional parent-mode requirement:
+
+- when `markdown-mode` can be loaded, it remains the major mode;
+- otherwise `text-mode` remains the major mode;
+- Doctor Boring's keys and state work identically in either case.
+
+The minor mode is not intended to be enabled manually in arbitrary
+buffers. Its docstring should say that users normally enter it through
+`M-x doctor-boring`.
+
+### 8.3 Customization group
+
+```elisp
+(defgroup doctor-boring ...)
+```
+
+All options belong to this group.
+
+### 8.4 Customization options
+
+The exact initial options are:
+
+```elisp
+doctor-boring-endpoint
+doctor-boring-api-key
+doctor-boring-model
+doctor-boring-temperature
+doctor-boring-system-prompt
+doctor-boring-greeting
+doctor-boring-request-timeout
+```
+
+Their types and defaults are:
+
+```text
+Option Type Default
+doctor-boring-endpoint string ""
+doctor-boring-api-key string ""
+doctor-boring-model string ""
+doctor-boring-temperature number 1.0
+doctor-boring-system-prompt string package default
+doctor-boring-greeting string package default
+doctor-boring-request-timeout integer 300
+```
+
+The endpoint docstring must state that the value ends at the API
+version, for example `https://example.com/v1`, rather than at
+`/chat/completions`.
+
+The API key docstring must state that Customize can save a string value
+in the user's customization file. This documentation is appropriate
+even though the conversation buffer itself contains no privacy or
+security warning.
+
+The model may be empty because some compatible local servers ignore
+its value. The key may be empty because local servers often do not
+authenticate. The endpoint may not be empty at submission time because
+there is no destination to contact.
+
+Suggested defaults for the two prose options are:
+
+```text
+System prompt:
+You are Doctor Boring, a playful conversational partner inspired by
+the classic Emacs Doctor. Respond briefly in plain prose. Ask curious
+questions, reflect the user's wording, and occasionally be mildly
+repetitive or literal. Do not behave like a general-purpose assistant.
+Keep the exchange light and conversational.
+
+Greeting:
+I am Doctor Boring. Tell me what is on your mind, then press RET twice.
+```
+
+The final strings may be polished during implementation without an
+architecture change, provided their intent remains consistent with the
+PRD.
+
+## 9. Buffer presentation and initialization
+
+### 9.1 Presentation mode selection
+
+Initialization performs these steps exactly once:
+
+1. Try `(require 'markdown-mode nil t)` inside `condition-case`.
+2. If it succeeds, call `markdown-mode`.
+3. Otherwise, call `text-mode`.
+4. Enable `doctor-boring-mode`.
+5. Enable Auto Fill with `(auto-fill-mode 1)`.
+6. Install the buffer-local kill hook.
+7. Insert and record the greeting.
+8. Insert two newlines and create the current input marker at
+ `point-max`.
+9. Leave the buffer writable with point at the input marker.
+
+The optional `require` must use `noerror`, and `condition-case` must
+also catch a load-time error from an installed but broken optional
+package. Either case falls back to `text-mode`.
+
+Mode selection occurs only at first creation. Installing or removing
+`markdown-mode` while `*doctor-boring*` already exists does not change
+that buffer's major mode.
+
+### 9.2 Visible layout
+
+The buffer contains no visible role labels. A normal successful
+conversation looks like:
+
+```text
+I am Doctor Boring. Tell me what is on your mind, then press RET twice.
+
+I keep putting off a small task.
+
+What makes this particular small task so easy to postpone?
+
+|
+```
+
+The vertical bar represents point and is not inserted text. Exactly
+two newline characters separate completed messages and the current
+input. The implementation owns these separators, but users may edit
+them while no request is active.
+
+The implementation must not add `User:`, `Assistant:`, prompts,
+spinners, Markdown fences, or other permanent presentation elements.
+
+### 9.3 Keymap
+
+The minor mode keymap binds:
+
+```elisp
+RET -> doctor-boring--return
+C-j -> doctor-boring--submit-immediately
+```
+
+Both commands are private implementation functions even though they
+are interactive.
+
+`doctor-boring--return` follows this decision sequence:
+
+1. If point is before the current input marker, insert a newline.
+2. If point is not at `point-max`, insert a newline.
+3. If the character before point is not a newline, insert a newline.
+4. Otherwise, submit the current input.
+
+This means the first `RET` at the end inserts a newline and the second
+`RET` submits. A blank line typed while editing history remains a blank
+line and never submits.
+
+`doctor-boring--submit-immediately` behaves as follows:
+
+1. If point is before the current input marker, insert a newline.
+2. Otherwise, submit the complete range from the input marker through
+ `point-max`, regardless of point's location within that range.
+
+When the buffer is read-only, normal Emacs read-only enforcement blocks
+both insertion and submission. The package does not queue the key
+event.
+
+## 10. Conversation data model
+
+### 10.1 Why visible text is authoritative
+
+The PRD requires edits to earlier messages to affect future requests.
+Therefore, a copied list of old strings cannot be the authoritative
+history. It would become stale as soon as the user changed the buffer.
+
+The package instead stores only role and boundary metadata. Immediately
+before a request, it reads each message's current text directly from
+the buffer.
+
+### 10.2 Message record
+
+Define a private structure:
+
+```elisp
+(cl-defstruct doctor-boring--message
+ role
+ start
+ end)
+```
+
+Fields have these meanings:
+
+- `role` is exactly the symbol `user` or `assistant`.
+- `start` is a marker at the first character of the message.
+- `end` is a marker immediately after the last character.
+
+The start marker uses insertion type `nil`. Text inserted at its exact
+position remains after the marker and is therefore included. The end
+marker uses insertion type `t`. Text inserted at its exact position
+moves the marker forward and is also included. Together, these choices
+make normal edits at either edge part of the message.
+
+Separating newlines are outside both message markers. Consequently,
+changing separator whitespace does not change message content.
+
+### 10.3 Buffer-local variables
+
+Each conversation buffer owns:
+
+```elisp
+doctor-boring--messages
+doctor-boring--input-start
+doctor-boring--active-request
+doctor-boring--request-sequence
+doctor-boring--normalizing-boundaries
+```
+
+Their invariants are:
+
+- `doctor-boring--messages` is a chronological list of message
+ records. The first record is the greeting with role `assistant`.
+- Every live record's markers belong to the conversation buffer.
+- Message ranges do not overlap.
+- `doctor-boring--input-start` is a live marker at or after the end of
+ the final completed message.
+- The input marker has insertion type `nil`, so normal typing at the
+ marker remains after it and belongs to the unsent draft.
+- Text from `doctor-boring--input-start` through `point-max` is the
+ unsent draft.
+- `doctor-boring--active-request` is either `nil` or one request
+ object.
+- A non-`nil` active request implies `buffer-read-only` is non-`nil`.
+- `doctor-boring--request-sequence` increases for each attempted
+ request and is never reused within the buffer.
+
+### 10.4 Boundary normalization
+
+Arbitrary deletion can collapse several markers onto one position.
+Subsequent insertion at that position could otherwise make adjacent
+message ranges overlap. An `after-change-functions` handler enforces
+the non-overlap invariant whenever the user edits completed history.
+
+The normalization algorithm is:
+
+1. Return immediately when the internal normalization guard is set.
+2. Walk message records in chronological order.
+3. Ensure each record's end is not before its start. If it is, move the
+ end to the start.
+4. If one record's end is after the next record's start, move the
+ earlier record's end back to the later record's start.
+5. Apply the same rule between the final message end and the current
+ input marker, assigning ambiguous new text to the current input.
+6. Remove records whose markers no longer belong to the buffer.
+
+The later region wins an ambiguous overlap. This rule avoids sending
+the same visible text twice. Empty completed records remain harmless;
+history construction skips them after trimming.
+
+Internal insertions bind the guard to non-`nil` and establish their
+markers explicitly. This avoids repeated normalization while a success
+or rollback transaction is only partially inserted.
+
+### 10.5 Local errors
+
+Local errors do not receive message records. They are ordinary visible
+text inserted outside all message boundaries. Since history is built
+only from recorded message ranges, error text cannot reach the API.
+
+An optional private text property such as
+`doctor-boring-local-error` may be placed on error text to support a
+distinct face or future cleanup. History exclusion must not depend on
+that property; absence from `doctor-boring--messages` is the decisive
+rule.
+
+## 11. History reconstruction
+
+`doctor-boring--build-messages` produces the API message sequence.
+
+The algorithm is:
+
+1. Start with one system message whose content is the current value of
+ `doctor-boring-system-prompt`.
+2. Walk `doctor-boring--messages` in chronological order.
+3. For each record, verify that both markers are live and belong to the
+ current buffer.
+4. Read the marker-bounded text with
+ `buffer-substring-no-properties`.
+5. Apply `string-trim`.
+6. Skip the record if the resulting string is empty.
+7. Convert the role symbol to the string `"user"` or `"assistant"`.
+8. Append an alist containing `role` and `content`.
+
+The result before JSON serialization is conceptually:
+
+```elisp
+(((role . "system")
+ (content . "Configured system prompt"))
+ ((role . "assistant")
+ (content . "Configured greeting"))
+ ((role . "user")
+ (content . "Edited user message")))
+```
+
+The configured system prompt is read on every submission. Changing the
+option during a conversation therefore affects the next request. The
+greeting is different: its value is inserted only when the buffer is
+created, after which the visible, editable greeting is authoritative.
+
+The message currently being submitted is committed as a `user` record
+before history construction. It therefore appears exactly once in the
+request.
+
+## 12. Submission transaction
+
+Submission is implemented as a transaction so that failure can restore
+a stable earlier state.
+
+### 12.1 Empty input
+
+The function reads from `doctor-boring--input-start` through
+`point-max`, applies `string-trim`, and checks the result.
+
+If it is empty:
+
+1. Delete whitespace in the input range.
+2. Move the input marker and point to `point-max`.
+3. Keep the buffer writable.
+4. Do not allocate a request, start a timer, or contact the endpoint.
+
+This behavior applies to both two-`RET` submission and `C-j`.
+
+### 12.2 Non-empty input
+
+For non-empty input:
+
+1. Save the trimmed input string in the pending request object.
+2. Replace the draft range with the trimmed string. This removes the
+ submission newline and other edge whitespace from the visible
+ message as well as from API content.
+3. Create and append a `user` message record around the trimmed text.
+4. Insert two separator newlines after its end marker.
+5. Move the input marker to `point-max`.
+6. Build history from the buffer.
+7. Allocate a unique request identifier.
+8. Set the buffer state to requesting and make it read-only.
+9. Validate the endpoint.
+10. Serialize and start the request, or invoke normal failure rollback
+ if local validation or serialization fails.
+
+Committing before validation is deliberate. It gives local endpoint
+errors the same rollback path and visual result as network errors.
+
+## 13. Request data model
+
+Define a private structure:
+
+```elisp
+(cl-defstruct doctor-boring--request
+ id
+ conversation-buffer
+ submitted-message
+ submitted-text
+ retrieval-buffer
+ timer
+ completed)
+```
+
+Field contracts are:
+
+- `id` is the buffer's monotonically increasing request number.
+- `conversation-buffer` identifies where success or rollback applies.
+- `submitted-message` is the provisional user message record.
+- `submitted-text` is the trimmed text needed for rollback.
+- `retrieval-buffer` is the buffer returned by `url-retrieve`, if one
+ remains live.
+- `timer` is the one-shot timeout timer.
+- `completed` prevents duplicate terminal handling.
+
+The conversation buffer also points to this object through
+`doctor-boring--active-request`. A completion is valid only if all of
+the following are true:
+
+1. The conversation buffer is live.
+2. The request is not marked completed.
+3. The buffer's active request is `eq` to the callback's request.
+
+This identity check is the main defense against late callbacks after a
+timeout, cancellation, or buffer deletion.
+
+## 14. Endpoint and headers
+
+### 14.1 URL construction
+
+`doctor-boring--chat-completions-url` removes all trailing slash
+characters from `doctor-boring-endpoint` and appends exactly:
+
+```text
+/chat/completions
+```
+
+Examples:
+
+```text
+https://api.example/v1 -> https://api.example/v1/chat/completions
+http://localhost:8080/v1/ -> http://localhost:8080/v1/chat/completions
+```
+
+An endpoint that becomes empty after trimming whitespace is a local
+configuration error. Other URL validation is delegated to the built-in
+URL library so unusual but valid schemes or host forms are not rejected
+by incomplete package logic.
+
+### 14.2 Headers
+
+Every request sends:
+
+```text
+Content-Type: application/json
+Accept: application/json
+```
+
+If the API key is non-empty after trimming, it also sends:
+
+```text
+Authorization: Bearer <exact configured key>
+```
+
+The trim check determines whether the header is present, but the header
+uses the trimmed key to avoid accidental surrounding whitespace. The
+key must never appear in routine progress or error messages.
+
+Cookies are disabled by passing the `no-cookies` argument to
+`url-retrieve`.
+
+## 15. JSON request encoding
+
+The top-level request object is:
+
+```json
+{
+ "model": "",
+ "temperature": 1.0,
+ "messages": [
+ {"role": "system", "content": "..."},
+ {"role": "assistant", "content": "..."},
+ {"role": "user", "content": "..."}
+ ]
+}
+```
+
+`model` is always present, even when empty. This produces predictable
+payloads and lets a server ignore the value. `temperature` is always
+present. No `stream`, token limit, tool, or provider-specific fields
+are included.
+
+Use `json-serialize` rather than manually constructing JSON. The
+`messages` collection must be a vector so that an empty or non-empty
+Lisp list cannot be mistaken for a JSON object. Each message may be an
+alist with symbol keys.
+
+Serialization errors are treated like request failures. The detailed
+Lisp error is logged, and the input is rolled back.
+
+## 16. Asynchronous transport
+
+### 16.1 Retrieval seam
+
+Define a private variable:
+
+```elisp
+(defvar doctor-boring--retrieve-function #'url-retrieve)
+```
+
+Production code calls this variable with the normal `url-retrieve`
+signature. Tests dynamically bind it to a fake function that records
+arguments and returns a synthetic retrieval buffer.
+
+The dynamic URL variables are bound around the call:
+
+```elisp
+url-request-method
+url-request-extra-headers
+url-request-data
+```
+
+Their values are `"POST"`, the constructed header alist, and the
+serialized JSON bytes, respectively.
+
+The call is conceptually:
+
+```elisp
+(funcall doctor-boring--retrieve-function
+ url
+ #'doctor-boring--url-callback
+ (list request)
+ t
+ t)
+```
+
+The two final arguments request silent operation and disable cookies.
+
+### 16.2 Start ordering
+
+Startup order must tolerate an unusually fast or mocked callback:
+
+1. Store the request as the conversation buffer's active request.
+2. Create and store the timeout timer.
+3. Call the retrieval function.
+4. If the request is still active when the call returns, store its
+ returned retrieval buffer.
+5. If the callback already completed it, dispose of any returned live
+ buffer instead of reattaching it.
+
+This ordering prevents a synchronous test double from completing a
+request before the coordinator considers it active.
+
+## 17. HTTP response handling
+
+The URL callback runs with the retrieval buffer current. It must not
+assume the user still views the conversation buffer.
+
+### 17.1 Callback sequence
+
+The callback performs:
+
+1. Capture the retrieval buffer.
+2. Check request identity and liveness.
+3. If stale, clean up the retrieval buffer and return.
+4. Cancel and clear the request timer.
+5. Inspect the URL status plist for `:error`.
+6. Inspect `url-http-response-status` for the HTTP status code.
+7. Locate the body after `url-http-end-of-headers`.
+8. Decode body bytes as UTF-8.
+9. For a 2xx status, parse and validate the success body.
+10. For any other status, create an HTTP failure containing the status
+ and detailed body.
+11. Complete the request exactly once.
+12. Kill the retrieval buffer in an `unwind-protect` cleanup path.
+
+HTTP status values from 200 through 299 count as transport success.
+The JSON shape must still validate before the conversation succeeds.
+
+### 17.2 Success body
+
+Parse with:
+
+```elisp
+(json-parse-string body
+ :object-type 'plist
+ :array-type 'list)
+```
+
+Then validate each level:
+
+1. The top-level value is a plist-like object.
+2. `:choices` is a non-empty list.
+3. The first choice contains `:message`.
+4. The message contains string-valued `:content`.
+5. `string-trim` of the content is non-empty.
+
+The trimmed string becomes the assistant response. A missing choice,
+`null` content, non-string content, invalid JSON, or an empty response
+is a malformed-response failure. The initial package does not fall back
+to `text`, `reasoning_content`, delta events, or provider extensions.
+
+### 17.3 Error detail
+
+Detailed logs should include:
+
+- request identifier;
+- failure category;
+- HTTP status when available;
+- URL-library error data when available;
+- response body when available;
+- JSON parsing or validation error when applicable.
+
+They must not include the authorization header or API key. Request
+message content need not be repeated in `*Messages*` because it is
+already visible in the conversation buffer.
+
+## 18. Request state machine
+
+The conversation has two live states:
+
+```text
+READY --submit non-empty input--> REQUESTING
+ ^ |
+ | |
+ +-----------success---------------+
+ |
+ +-----------failure and rollback--+
+```
+
+Buffer deletion is a terminal exit from either state.
+
+### 18.1 READY invariants
+
+- `doctor-boring--active-request` is `nil`.
+- `buffer-read-only` is `nil`.
+- The input marker is live.
+- Point may be anywhere, but submission is recognized only in the
+ current input region.
+
+### 18.2 REQUESTING invariants
+
+- `doctor-boring--active-request` is a live request object.
+- `buffer-read-only` is non-`nil`.
+- The submitted user record is the final completed message.
+- There is no editable draft and no queued message.
+- Exactly one timeout timer belongs to the request unless completion is
+ already being processed.
+
+### 18.3 Terminal guard
+
+Both success and failure call a shared function that atomically:
+
+1. Checks that the request is still active.
+2. Marks the request completed.
+3. Clears the active-request slot before modifying network resources.
+4. Cancels and clears the timer.
+
+Clearing active state first is essential. Killing a retrieval process
+or buffer can trigger additional sentinels or callbacks; those events
+must observe a stale request and do nothing to the conversation.
+
+## 19. Successful completion
+
+Success applies only after response validation.
+
+Within the live conversation buffer and with
+`inhibit-read-only` bound:
+
+1. Go to `point-max`.
+2. Insert the trimmed assistant content.
+3. Create an `assistant` message record around exactly that content.
+4. Append the record to `doctor-boring--messages`.
+5. Insert two separator newlines outside the record.
+6. Move the input marker and buffer point to `point-max`.
+7. Set `buffer-read-only` to `nil`.
+8. Run fontification normally; do not transform response text.
+
+The callback must use `with-current-buffer`, not `switch-to-buffer`,
+`pop-to-buffer`, or window-selection functions. Therefore, a response
+updates the conversation in place without stealing focus.
+
+If the conversation is visible in an unselected window, normal Emacs
+redisplay shows the inserted text. The implementation does not select
+that window or force-scroll it.
+
+## 20. Failure and rollback
+
+All terminal failures converge on one rollback function. Categories
+include:
+
+- empty endpoint;
+- JSON serialization failure;
+- immediate URL setup failure;
+- URL transport error;
+- timeout;
+- non-2xx HTTP response;
+- malformed or empty success response;
+- unexpected internal callback error.
+
+### 20.1 Visible result
+
+If the failed submitted input was `Hey`, the final buffer tail is:
+
+```text
+[Doctor Boring error: HTTP request failed.]
+
+Hey|
+```
+
+The error wording should be concise and category-specific. It should
+not print a backtrace or full provider body in the conversation.
+
+### 20.2 Rollback algorithm
+
+With `inhibit-read-only` bound:
+
+1. Locate the provisional submitted message record.
+2. Delete from that record's start through `point-max`. No later user
+ or assistant messages can exist because the buffer was read-only.
+3. Remove the provisional record from `doctor-boring--messages`.
+4. Detach its markers with `set-marker` and a `nil` buffer.
+5. Insert the concise local error and two newline characters.
+6. Set `doctor-boring--input-start` immediately after the error
+ separator.
+7. Insert the saved trimmed user input with no message record.
+8. Move point to `point-max`.
+9. Set `buffer-read-only` to `nil`.
+
+The error is above the draft, and the draft is again ready for either
+two presses of `RET` or `C-j`.
+
+### 20.3 Detailed logging
+
+Call `message` once with a detailed diagnostic. Emacs records messages
+in `*Messages*`. Logging should use a stable prefix such as:
+
+```text
+Doctor Boring request 3 failed: ...
+```
+
+The concise buffer error and detailed message log serve different
+purposes. The former preserves a pleasant conversation; the latter
+supports diagnosis.
+
+## 21. Timeout behavior
+
+`doctor-boring-request-timeout` defaults to 300 seconds to accommodate
+slow local inference.
+
+A positive timeout schedules a one-shot timer with `run-at-time`. The
+Customize type should require a positive integer, so zero and negative
+values are invalid configuration rather than special cases.
+
+When the timer fires:
+
+1. Check whether the request remains active.
+2. Mark and detach the request through the shared terminal guard.
+3. Stop the associated retrieval process if it is live.
+4. Kill the retrieval buffer without an interactive process query.
+5. Log a timeout diagnostic.
+6. Roll back with a concise timeout error.
+
+A callback arriving after the timeout fails the identity check and only
+cleans up its own retrieval buffer.
+
+Timers run when Emacs can process events, so a timeout can be delayed if
+Emacs itself is busy. It is a deadline for asynchronous waiting, not a
+hard real-time guarantee.
+
+## 22. Buffer deletion and cancellation
+
+The conversation buffer installs a buffer-local `kill-buffer-hook`.
+
+If there is an active request, the hook:
+
+1. Clears the active-request slot.
+2. Marks the request completed.
+3. Cancels its timer.
+4. Finds and disables exit queries on the retrieval process.
+5. Deletes the process if it is live.
+6. Kills the retrieval buffer if it is distinct and live.
+7. Detaches all message and input markers.
+
+It does not insert an error or attempt rollback because the target
+buffer is being destroyed. A later callback sees a dead conversation
+buffer or a stale request and exits safely.
+
+Killing `*doctor-boring*` is the supported way to end a conversation.
+The next invocation creates a fully new session from current
+customization values.
+
+## 23. Internal function decomposition
+
+The following function set is recommended. Exact private names may
+change during implementation, but responsibilities should remain
+separate.
+
+### 23.1 Buffer and interaction
+
+```text
+doctor-boring
+ Create or select the conversation buffer.
+
+doctor-boring--initialize-buffer
+ Select the base mode and create the first conversation state.
+
+doctor-boring--insert-message
+ Insert content and return a marker-bounded message record.
+
+doctor-boring--return
+ Implement contextual RET behavior.
+
+doctor-boring--submit-immediately
+ Implement contextual C-j behavior.
+
+doctor-boring--submit
+ Validate and commit the current input transaction.
+
+doctor-boring--normalize-boundaries
+ Restore marker ordering after user edits.
+
+doctor-boring--kill-buffer
+ Cancel resources and detach metadata during buffer deletion.
+```
+
+### 23.2 History and request encoding
+
+```text
+doctor-boring--build-messages
+ Read current message text and create role/content alists.
+
+doctor-boring--chat-completions-url
+ Normalize the base endpoint and append the operation path.
+
+doctor-boring--request-headers
+ Construct content negotiation and optional authentication headers.
+
+doctor-boring--serialize-request
+ Create the JSON body from model, temperature, and messages.
+```
+
+### 23.3 Lifecycle and transport
+
+```text
+doctor-boring--start-request
+ Allocate timer and invoke the asynchronous retrieval seam.
+
+doctor-boring--url-callback
+ Convert a URL retrieval result into success or failure.
+
+doctor-boring--parse-response
+ Validate a 2xx Chat Completions response and return content.
+
+doctor-boring--timeout
+ Terminate a still-active request after its deadline.
+
+doctor-boring--complete-success
+ Insert and record a validated assistant message.
+
+doctor-boring--complete-failure
+ Log details and perform visible rollback.
+
+doctor-boring--claim-request
+ Atomically accept one terminal event and reject stale events.
+
+doctor-boring--dispose-retrieval
+ Stop and clean up a URL process and response buffer.
+```
+
+Parsing, URL joining, header building, and JSON serialization should be
+pure functions where possible. Pure functions make most tests short and
+eliminate dependence on global editor state.
+
+## 24. Error containment
+
+Asynchronous callbacks must not allow an unexpected Lisp error to leave
+the conversation permanently read-only.
+
+The URL callback should wrap response extraction and success handling
+in `condition-case`. If an unexpected error occurs while the request is
+still active, it becomes an internal failure and uses normal rollback.
+Cleanup of the retrieval buffer belongs in `unwind-protect` so it runs
+whether parsing succeeds, fails normally, or signals unexpectedly.
+
+The rollback function should itself minimize operations that can fail.
+Before modifying the buffer it verifies:
+
+- the conversation buffer is live;
+- the submitted record has live markers in that buffer;
+- the record is still the final record in the list.
+
+If these invariants are unexpectedly broken, the function should still
+clear read-only state and log the invariant violation. It may place the
+saved submitted text at `point-max` as a last-resort recovery rather
+than losing user input.
+
+## 25. Security and privacy considerations
+
+The user explicitly configures the remote endpoint, so the
+conversation buffer contains no transmission warning. Nevertheless,
+the implementation must observe these rules:
+
+- Never log the API key or complete request headers.
+- Omit `Authorization` entirely for an empty key.
+- Disable URL-library cookies for API calls.
+- Do not persist conversation text independently of the Emacs buffer.
+- Do not write response bodies to files.
+- Mention in the endpoint and key option docstrings that messages are
+ sent to the configured endpoint and that Customize may persist the
+ key.
+- Treat provider response text as text only. Never evaluate it as Lisp,
+ interpret it as a local variable form, or execute embedded commands.
+
+Using `markdown-mode` provides fontification, not trusted rendering.
+Model output remains literal buffer text.
+
+## 26. Test architecture
+
+Tests use ERT and isolate every test's buffers, variables, timers, and
+window state. The ERT guidance on [test
+environments](https://www.gnu.org/software/emacs/manual/html_node/ert/Tests-and-Their-Environment.html)
+recommends binding customization variables and cleaning up temporary
+state; this suite follows that approach.
+
+### 26.1 Fixture
+
+A private test helper should:
+
+1. Bind every Doctor Boring option to deterministic values.
+2. Bind hooks that could be affected by user configuration when
+ necessary.
+3. Ensure any old `*doctor-boring*` buffer is absent.
+4. Create the conversation buffer.
+5. Run the test body.
+6. In `unwind-protect`, cancel live timers and kill all test buffers.
+
+Do not define a broad mocking framework. Small fake functions and
+`cl-letf` or dynamic bindings are sufficient.
+
+### 26.2 Pure unit tests
+
+Cover at least:
+
+1. Endpoint joining without a trailing slash.
+2. Endpoint joining with one or several trailing slashes.
+3. Empty endpoint rejection after whitespace trimming.
+4. Authorization header present for a non-empty key.
+5. Authorization header absent for an empty key.
+6. Empty model retained in serialized JSON.
+7. Temperature retained as a JSON number.
+8. Messages encoded as an array in chronological order.
+9. UTF-8 request and response content.
+10. Valid extraction from `choices[0].message.content`.
+11. Missing choices rejected.
+12. Missing message rejected.
+13. `null`, non-string, and whitespace-only content rejected.
+14. Invalid JSON rejected.
+
+### 26.3 Buffer and editing tests
+
+Cover:
+
+1. First invocation inserts one greeting and creates one assistant
+ record.
+2. A second invocation selects the same buffer without inserting a
+ greeting or resetting state.
+3. Killing and invoking again creates a fresh greeting and history.
+4. The current greeting value is captured only at buffer creation.
+5. Editing greeting text changes reconstructed history.
+6. Editing old user and assistant text changes reconstructed history.
+7. Local error text is absent from reconstructed history.
+8. Leading and trailing message whitespace is trimmed.
+9. Internal whitespace is preserved.
+10. Deleted empty records are skipped.
+11. Cross-boundary deletion does not create overlapping ranges.
+12. Point before the input marker makes `RET` insert normally.
+13. Point before the input marker makes `C-j` insert normally.
+14. One `RET` at input end inserts a newline.
+15. The next `RET` submits.
+16. `C-j` submits without inserting a newline.
+17. Whitespace-only input is cleared and not submitted.
+
+### 26.4 Mode-selection tests
+
+Test the two branches independently:
+
+- Stub optional loading as unavailable and assert `text-mode` plus
+ `doctor-boring-mode` and Auto Fill.
+- When the test environment provides `markdown-mode`, or when a minimal
+ test stub simulates it, assert that it is selected and the Doctor
+ minor mode remains active.
+
+The test suite must not require the real third-party `markdown-mode`.
+
+### 26.5 Asynchronous lifecycle tests
+
+Bind `doctor-boring--retrieve-function` to a fake that captures:
+
+- URL;
+- callback;
+- callback arguments;
+- dynamic request method;
+- headers;
+- request bytes.
+
+Then manually invoke the captured callback in a synthetic response
+buffer. Cover:
+
+1. Submission makes the buffer read-only before retrieval begins.
+2. Exactly one retrieval is started.
+3. A valid response appends an assistant record and unlocks the buffer.
+4. Completion does not change the selected buffer.
+5. A transport failure logs detail, inserts a concise error, restores
+ input, and unlocks the buffer.
+6. A non-2xx response follows the same rollback path.
+7. A malformed 2xx body follows the same rollback path.
+8. The restored input can be submitted again.
+9. A stale callback after completion changes nothing.
+10. A stale callback after timeout changes nothing.
+11. Killing the conversation cancels the timer and retrieval resource.
+
+### 26.6 Timeout tests
+
+Do not wait 300 seconds. Either bind the timeout to a very short value
+and use ERT event waiting, or more deterministically capture and invoke
+the timeout function directly with the request object.
+
+Assert that timeout:
+
+- claims the request only once;
+- disposes of the retrieval resource;
+- cancels or clears the timer;
+- rolls back the draft;
+- logs detail;
+- makes a subsequent callback harmless.
+
+### 26.7 Static checks
+
+In addition to ERT:
+
+```sh
+emacs -Q --batch -L . \
+ --eval '(byte-compile-file "doctor-boring.el")'
+
+emacs -Q --batch -L . \
+ -l doctor-boring.el \
+ --eval '(checkdoc-file "doctor-boring.el")'
+```
+
+Byte compilation must produce no warnings attributable to the package.
+Checkdoc findings should be fixed unless a package-header convention
+requires a narrow suppression.
+
+## 27. Implementation sequence
+
+Implement in the following order so each stage can be tested before
+network integration:
+
+1. Add package headers, requirements, group, and options.
+2. Define message and request structures.
+3. Implement buffer creation, base-mode selection, greeting insertion,
+ and buffer reuse.
+4. Implement markers, history reconstruction, and normalization.
+5. Implement contextual `RET`, `C-j`, trimming, and provisional commit.
+6. Implement endpoint, headers, and JSON serialization as pure helpers.
+7. Add the retrieval seam and request state transition.
+8. Implement response parsing with synthetic buffers.
+9. Implement success insertion.
+10. Implement unified failure rollback and detailed logging.
+11. Add timeout, stale-callback guard, and kill-buffer cleanup.
+12. Complete ERT coverage for both success and every failure category.
+13. Run byte compilation, Checkdoc, and the full batch test suite.
+14. Manually exercise one local compatible endpoint with an empty API
+ key and model.
+
+The manual network exercise is validation, not part of the automated
+test suite.
+
+## 28. Acceptance criteria
+
+The implementation is complete when all of these statements are true:
+
+- `M-x doctor-boring` creates `*doctor-boring*` with one greeting.
+- Reinvocation preserves text and live conversation state.
+- Buffer deletion is the only reset mechanism.
+- `RET`, `C-j`, history editing, and Auto Fill behave as specified.
+- Markdown mode is optional and Text mode fallback requires no setup.
+- A request contains the current system prompt, greeting, all non-empty
+ completed messages, and the newly submitted user message.
+- The endpoint path and optional bearer header are correct.
+- Emacs remains interactive while the response is pending.
+- The buffer is read-only during the pending interval.
+- Valid output is trimmed, inserted unchanged, and recorded as an
+ assistant message.
+- Completion never selects a different buffer or window.
+- Every failure restores the submitted input beneath a local error.
+- Local errors never appear in API history.
+- Timeout and buffer deletion release timers, processes, and retrieval
+ buffers.
+- Late callbacks cannot alter the conversation.
+- Tests use no real API and pass under Emacs 30.2.
+- Byte compilation is clean.
+
+## 29. Alternatives considered
+
+### 29.1 Store copied message strings
+
+Rejected because previous-message edits would not affect later
+requests. Making visible text authoritative is a direct product
+requirement.
+
+### 29.2 Parse roles from visible labels
+
+Rejected because role labels would change the original Doctor-like
+appearance. Parsing unlabeled prose heuristically would be ambiguous.
+Marker-bounded records preserve invisible roles without altering text.
+
+### 29.3 Make Doctor Boring a dedicated derived major mode
+
+Rejected because a major mode can have only one parent. Choosing
+between `markdown-mode` and `text-mode` at runtime would complicate mode
+definition and package loading. A small minor mode layers interaction
+behavior cleanly over either presentation major mode.
+
+### 29.4 Use synchronous retrieval inside a thread
+
+Rejected because Emacs Lisp thread and buffer interactions would add
+complexity without benefit. `url-retrieve` already provides a
+documented asynchronous callback interface.
+
+### 29.5 Implement streaming with a network process
+
+Rejected for version 0.1.0. Correct streaming would require incremental
+HTTP body handling, chunked transfer support, SSE framing, partial JSON
+events, cancellation, and incremental buffer updates. The built-in
+complete-response callback meets the non-blocking requirement with far
+less protocol code.
+
+### 29.6 Queue input while waiting
+
+Rejected because a failure would have to decide how to position the
+failed draft relative to later queued drafts. Making the buffer
+read-only produces a simple, reversible single-request transaction.
+
+### 29.7 Put local errors in conversation history
+
+Rejected because provider and transport failures are not statements by
+the assistant. Sending them back would distort the conversation and
+could cause the model to discuss implementation details.
+
+## 30. Future-compatible extension points
+
+The design leaves narrow seams for later work without implementing it
+now:
+
+- A streaming transport can replace the retrieval adapter while
+ preserving request identity and terminal guards.
+- Additional compatible request fields can be added in the serializer.
+- A cancel command can call the same terminal and rollback primitives
+ as timeout.
+- Alternative providers can translate their payloads behind the
+ transport boundary.
+- A visible status indicator can be represented as local, unrecorded
+ text or an overlay.
+
+Any such extension must preserve the core invariants: one authoritative
+visible conversation, no local metadata in model history, no focus
+stealing, and exactly one terminal outcome per request.
diff --git a/prd.md b/prd.md
new file mode 100644
index 0000000..93dd1ec
--- /dev/null
+++ b/prd.md
@@ -0,0 +1,105 @@
+# Doctor Boring
+
+Doctor Boring is an evolution of Emacs' `M-x doctor`, backed by an
+LLM. It is intended to be a fun side activity rather than a serious
+assistant or medical tool.
+
+## User experience
+
+- Doctor Boring provides a new interactive command, `M-x doctor-boring`.
+- The command switches to a buffer named `*doctor-boring*`.
+- If that buffer already exists, the command switches to it without
+ resetting the conversation. Users start a new conversation by
+ killing the buffer and invoking the command again.
+- When first created, the buffer displays a fixed greeting. The
+ greeting is included as the first assistant message in conversation
+ history.
+- The buffer behaves like the original Doctor:
+ - The user types a message and presses `RET` twice to submit it.
+ - `C-j` submits the current message immediately.
+ - Empty or whitespace-only messages are ignored.
+ - Auto Fill is enabled.
+- Leading and trailing whitespace is removed from user messages and
+ model responses. Whitespace within a message is preserved.
+- Submission is available only from the current input area at the end
+ of the buffer. When editing earlier messages, `RET` inserts a newline
+ normally.
+- Earlier user and assistant messages remain editable. The history for
+ each request is reconstructed from the current buffer, so edits affect
+ future requests.
+- Invisible buffer metadata distinguishes user messages, assistant
+ messages, local errors, and the current input without changing the
+ visible Doctor-style presentation.
+- If `markdown-mode` is available when the conversation buffer is
+ created, it is used automatically. Otherwise, the buffer falls back
+ to `text-mode`. Model output is inserted without additional
+ processing in either mode.
+
+## Request behavior
+
+- Requests use an OpenAI-compatible Chat Completions API.
+- The configured endpoint is a base URL ending at the API version, such
+ as `https://example.com/v1`. Doctor Boring appends
+ `/chat/completions`, handling a trailing slash in the configured URL.
+- The complete conversation history is sent with every request,
+ preceded by the configured system prompt.
+- Requests are asynchronous and must not block Emacs.
+- Streaming responses are out of scope for the initial version.
+- Only one request may be active at a time. While a request is active,
+ the conversation buffer is read-only and no messages can be queued.
+- A response arriving while the user is viewing another buffer updates
+ `*doctor-boring*` without changing the selected buffer or window.
+- Requests have a configurable timeout that defaults to 300 seconds.
+- Killing the conversation buffer while a request is active cancels or
+ safely abandons that request.
+
+## Error behavior
+
+- An empty endpoint is detected locally when the user submits a
+ message. An empty API key or model is allowed.
+- Network, timeout, HTTP, and malformed-response failures insert a
+ concise local error above the failed user input.
+- Detailed error information is written to `*Messages*`.
+- Local error text is excluded from conversation history.
+- After a failure, Doctor Boring rolls back the submission: the failed
+ user text is restored as the current input, point is placed at its
+ end, and the buffer is writable. The user can retry by submitting it
+ again.
+
+## Configuration
+
+The following options are exposed as `defcustom` variables:
+
+- API endpoint, empty by default
+- API key, empty by default; when empty, the `Authorization` header is
+ omitted
+- Model, empty by default
+- Temperature, defaulting to `1.0`
+- System prompt, with a sensible default that makes Doctor Boring
+ brief, curious, mildly repetitive, playful, and inclined to respond
+ in plain prose
+- Initial greeting, with a sensible fixed default
+- Request timeout, defaulting to 300 seconds
+
+No arbitrary additional request fields are required in the initial
+version.
+
+## Implementation constraints
+
+- The implementation is pure Emacs Lisp.
+- It may use libraries bundled with the latest Emacs release.
+- It must not require third-party Emacs packages or external
+ executables. `markdown-mode` support is optional and must degrade
+ gracefully when it is absent.
+- The initial implementation consists of one installable package file,
+ `doctor-boring.el`, and an ERT test file,
+ `doctor-boring-test.el`.
+- Tests must not contact a real API. They should mock networking and
+ cover at least request construction, buffer-to-history conversion,
+ submission behavior, asynchronous state, and failure rollback.
+
+## Package metadata
+
+- Version: `0.1.0`
+- Author: `MetroWind <chris.corsair@gmail.com>`
+- License: WTFPL