BareGit
# 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.