diff --git a/.abcd/development/decisions/adrs/2610040749545010-the-gateway-may-refuse-a-request-whose-prompt-or-messages.md b/.abcd/development/decisions/adrs/2610040749545010-the-gateway-may-refuse-a-request-whose-prompt-or-messages.md
index 7a9ae48f..f47ec7b7 100644
--- a/.abcd/development/decisions/adrs/2610040749545010-the-gateway-may-refuse-a-request-whose-prompt-or-messages.md
+++ b/.abcd/development/decisions/adrs/2610040749545010-the-gateway-may-refuse-a-request-whose-prompt-or-messages.md
@@ -1,10 +1,10 @@
---
id: adr-2610040749545010
slug: the-gateway-may-refuse-a-request-whose-prompt-or-messages
-status: accepted
+status: accepted; superseded in part by adr-2610042021365934 (a /v1/completions prompt that is not a JSON string is refused too, reading its kind)
date: 2026-10-04
supersedes: [adr-2609201008470380, adr-2609061610102325]
-superseded_by: null
+superseded_by: adr-2610042021365934
related_intents: []
related_rfcs: []
related_adrs: [adr-2609201008470380, adr-2609061610102325]
diff --git a/.abcd/development/decisions/adrs/2610042021365934-the-gateway-may-refuse-a-completions-prompt-that-is-not-a.md b/.abcd/development/decisions/adrs/2610042021365934-the-gateway-may-refuse-a-completions-prompt-that-is-not-a.md
new file mode 100644
index 00000000..71c29aa7
--- /dev/null
+++ b/.abcd/development/decisions/adrs/2610042021365934-the-gateway-may-refuse-a-completions-prompt-that-is-not-a.md
@@ -0,0 +1,116 @@
+---
+id: adr-2610042021365934
+slug: the-gateway-may-refuse-a-completions-prompt-that-is-not-a
+status: accepted
+date: 2026-10-04
+supersedes: [adr-2610040749545010]
+superseded_by: null
+related_intents: []
+related_rfcs: []
+related_adrs: [adr-2610040749545010, adr-2609201008470380, adr-2609061610102325]
+---
+
+# ADR-2610042021365934: The gateway may refuse a completions prompt that is not a JSON string, reading its kind and nothing else
+
+**Supersedes in part:**
+[adr-2610040749545010](2610040749545010-the-gateway-may-refuse-a-request-whose-prompt-or-messages.md),
+on two of its sentences, and for a `/v1/completions` prompt only: that the
+check "reads whether the field is empty and nothing else", and that "a prompt
+or messages value of another type ... [is] left to the model server". The
+check now also reads the prompt's JSON kind. Every other decision of that
+record stands: what counts as an empty string prompt, the refusal of empty
+`messages` on the network and bridge paths, a missing field left to the model
+server, keeping nothing, and the one file the check lives in.
+
+## Context
+
+adr-2610040749545010 granted the gateway one bit about a request's prompt:
+whether it is empty. Its adversarial review found that a `/v1/completions`
+prompt that is an array of strings reaches the generation thread of mlx-lm
+0.32.0 outside its `try` (iss-2610040805353721). The tokenizer treats a list
+of strings as a batch, so `["hi"]` tokenizes to `[[72, 73]]` and `[""]` to
+`[[]]`, and the nested list raises in the prompt cache's search and in the
+batch generator. The emptiness check refused only an empty array and let
+`[""]` and `["hi"]` through, because telling them apart from a string reads
+more than emptiness.
+
+The maintainer set the condition on 2026-10-03: reproduce it on the Mac first,
+and refuse it if it freezes or crashes the server. On 2026-10-04, on Dessau
+v0.9.3 with mlx-lm 0.32.0 and a 30B mixture-of-experts 4-bit model, each prompt
+on a fresh load:
+
+- `["hi"]` and `[""]` each got no answer, and the ordinary prompt sent after
+ each got none either: the model server was frozen, as the empty prompt had
+ left it.
+- `[72,73]`, an array of token ids, was refused by mlx-lm with a 404 ("text
+ input must be of type `str`"), and the ordinary prompt after it was
+ answered.
+
+So on the pinned server no prompt that is not a string yields a generation: a
+token array is refused upstream, and an array of strings freezes the model for
+every client. The health watch on main turns such a freeze into a crash and a
+restart (iss-2610040752568866), which still fails every request in flight on
+that model.
+
+## Decision
+
+We will refuse, with a 400 and before a model is resolved or loaded, a
+`/v1/completions` request whose `prompt` is present and is not a JSON string:
+an array of any kind (of strings, of token ids, or empty), a number, an
+object, a boolean or `null`. The refusal names the field and its required
+kind, `"prompt" must be a string`, and never the value.
+
+The check reads the prompt's JSON kind, from the first byte of its raw value,
+and nothing else: it never looks inside an array or an object, never counts
+elements, and decodes a value only when it is already a string, to apply the
+emptiness test adr-2610040749545010 grants. It keeps nothing. A missing
+`prompt` is still left to the model server, whose check for it runs on its
+handler thread. The chat path and the bridge's `Ask` path are unchanged: they
+read `messages`, whose emptiness is all they read.
+
+The check stays in `internal/gateway/emptyprompt.go`, in the same function as
+the emptiness check, and its entry on the prompt-content readers' list in
+`internal/archtest/prompt_content_test.go` names the wider reading.
+
+We will treat any further reading of prompt content by the gateway as a new
+decision that supersedes this record, as the records before it said.
+
+## Alternatives Considered
+
+1. **Refuse any prompt that is not a JSON string (chosen).** It closes a
+ reproduced freeze that any client on the network can cause, and refuses
+ nothing that works on the pinned server. The grant grows from one bit to
+ the value's JSON kind, read from its first byte.
+2. **Refuse only an array whose elements include a string.** It is the
+ narrowest refusal of the reproduced freeze, but it reads inside the array,
+ element by element, which is more of the prompt than its kind. A token
+ array and a number would still reach the model server, which refuses them
+ with a 404 that says nothing a client can act on. Rejected as a wider read
+ for a smaller result.
+3. **Leave it to the health watch.** The watch restarts a frozen model server
+ within a tick, but every request in flight on that model fails, and any
+ client can repeat the request at will. Rejected as the only defence, as
+ adr-2610040749545010 rejected it for the empty prompt.
+4. **Accept arrays and fan them out as one request per element, as OpenAI
+ does.** That reads every element, writes new requests from client text and
+ adds batching the gateway does not otherwise have. Rejected as a feature
+ nobody has asked for, and a far wider grant than the defect calls for.
+
+## Consequences
+
+- adr-2610040749545010 changes only its status fields, to say it is
+ superseded in part by this record; its text is not edited.
+- A client that sends an array, number, object, boolean or `null` prompt gets
+ a 400 naming the field. Before, an array of strings froze the model for
+ everyone and the rest were refused by the model server with a 404. That is
+ a breaking change on the wire, recorded as `impact: breaking`, though no
+ such request produced an answer before.
+- A client written for OpenAI's batched or tokenized `prompt` forms cannot use
+ them through Dessau. It could not before either.
+- If a later mlx-lm pin accepts a list prompt and generates from it, this
+ refusal would withhold a working feature. Moving the pin is the moment to
+ check, and lifting the refusal is a new decision.
+- Obligations: the adversarial security review of the change that lands this,
+ the gateway being a trust boundary; a test that each non-string kind is
+ refused before the pool or the model server is reached, and that a string
+ prompt is not; and the readers' list entry updated to name the kind read.
diff --git a/.abcd/development/decisions/adrs/README.md b/.abcd/development/decisions/adrs/README.md
index 05c09ab2..5bb2c48a 100644
--- a/.abcd/development/decisions/adrs/README.md
+++ b/.abcd/development/decisions/adrs/README.md
@@ -43,4 +43,5 @@ hand edit.
| [adr-2609201008477513](2609201008477513-a-deliberately-invoked-per-model-diagnostic-may-write-prompt.md) | A deliberately invoked per-model diagnostic may write prompts and answers to the operator's own log, and the client is not told | accepted | 2026-09-20 |
| [adr-2609202200197975](2609202200197975-who-counts-as-one-client-for-scheduling-a-paired-client-is-o.md) | Who counts as one client for scheduling: a paired client is one person by its pairing, unpaired clients are one class served last, and what is written down is the contention, never the client | accepted | 2026-09-20 |
| [adr-2610030906462776](2610030906462776-dessau-serves-from-one-macos-account-the-machine-wide-shared.md) | Dessau serves from one macOS account; the machine-wide shared model cache is retired | accepted | 2026-10-03 |
-| [adr-2610040749545010](2610040749545010-the-gateway-may-refuse-a-request-whose-prompt-or-messages.md) | The gateway may refuse a request whose prompt or messages are empty, reading emptiness and nothing else | accepted | 2026-10-04 |
+| [adr-2610040749545010](2610040749545010-the-gateway-may-refuse-a-request-whose-prompt-or-messages.md) | The gateway may refuse a request whose prompt or messages are empty, reading emptiness and nothing else | superseded in part by [adr-2610042021365934](2610042021365934-the-gateway-may-refuse-a-completions-prompt-that-is-not-a.md) on the prompt's kind | 2026-10-04 |
+| [adr-2610042021365934](2610042021365934-the-gateway-may-refuse-a-completions-prompt-that-is-not-a.md) | The gateway may refuse a completions prompt that is not a JSON string, reading its kind and nothing else | accepted | 2026-10-04 |
diff --git a/.abcd/development/intents/drafts/itd-2610041336141168-a-dessau-agent-that-selects-the-most-appropriate-model-for-a.md b/.abcd/development/intents/drafts/itd-2610041336141168-a-dessau-agent-that-selects-the-most-appropriate-model-for-a.md
new file mode 100644
index 00000000..2ed234a6
--- /dev/null
+++ b/.abcd/development/intents/drafts/itd-2610041336141168-a-dessau-agent-that-selects-the-most-appropriate-model-for-a.md
@@ -0,0 +1,43 @@
+---
+id: itd-2610041336141168
+slug: a-dessau-agent-that-selects-the-most-appropriate-model-for-a
+spec_id: null
+kind: null
+suggested_kind: null
+reclassification_history: []
+builds_on: []
+severity: minor
+impact: additive
+origin: researcher-authored
+production_mode: hand-written
+---
+
+# Dessau picks the model that answers each request
+
+## Press Release
+
+> A 'Dessau' agent that selects the most appropriate model for a given response. It parses the question, decides, answers, parses the answer and only then returns it, depending on the client (e.g. Discord: in chunks; Dessau Chat: whole with thinking; etc.). This agent can later be expanded, e.g. by using the Brave search API.
+
+## Why This Matters
+
+> _Why this matters to the user — replace before planning._
+
+## Mechanism
+
+> _Prompted (the claim-recording gradient): why the authors expect this to work, as a falsifiable "we expect X because Y" — not the outcome restated. Replace this line with the claim, or with the exact token `None stated.` alone on its line to record the claim as considered and declined._
+
+## Scope Conditions
+
+> _Required (the claim-recording gradient): the population, platform, scale, or assumptions this claim holds under, one per top-level bullet — `abcd intent plan` stamps each with a persistent identity. Replace this line with those bullets, or with the exact token `None stated.` alone on its line._
+
+## Acceptance Criteria
+
+> _Required (the itd-1 discipline): add at least one Given-When-Then bullet describing the verifiable bar for "shipped" before this draft can be planned._
+
+## Open Questions
+
+_None recorded yet._
+
+## Audit Notes
+
+_Empty. Populated by intent-auditor when intent moves to shipped/._
diff --git a/.abcd/development/intents/planned/itd-2610030857275099-dessau-tells-alice-when-a-model-she-downloaded-has-a-newer.md b/.abcd/development/intents/planned/itd-2610030857275099-dessau-tells-alice-when-a-model-she-downloaded-has-a-newer.md
deleted file mode 100644
index 067d5000..00000000
--- a/.abcd/development/intents/planned/itd-2610030857275099-dessau-tells-alice-when-a-model-she-downloaded-has-a-newer.md
+++ /dev/null
@@ -1,130 +0,0 @@
----
-id: itd-2610030857275099
-slug: dessau-tells-alice-when-a-model-she-downloaded-has-a-newer
-spec_id: spc-2610030929021692
-kind: standalone
-suggested_kind: null
-reclassification_history: []
-builds_on: [itd-2610030656210408, itd-2609081259493890, itd-2609100519003748]
-severity: minor
-impact: additive
-origin: researcher-authored
-production_mode: hand-written
----
-
-# Dessau checks downloaded models for newer versions, and updates them with one click
-
-## Press Release
-
-Dessau tells Alice when a model she downloaded has a newer version, and updates
-it with one click.
-
-Alice turns on update checks in Settings and picks how often, once a day to
-start with. From then on, at every start and every interval, Dessau asks
-HuggingFace whether the files of each model she downloaded have changed, and
-the control panel marks the ones that have. One click on Update fetches the new
-version beside the old one and swaps it in only once every file checks out, so
-Carol's chat never meets a half-updated model. Nothing is asked until Alice
-turns checks on, and a decision model is never marked.
-
-## Why This Matters
-
-Downloads take a repository's latest files once and are never revisited. Fixes
-to chat templates, configs and quantisations land on HuggingFace silently, so
-Alice serves a stale build and finds out only when a client misbehaves. And the
-one way to update today, downloading again, can leave a model that is half the
-old version and half the new (iss-2610030913179523). Seeded 2026-10-03 from the
-maintainer's request.
-
-## Mechanism
-
-- We expect Alice to update models she would otherwise leave stale, because the
- mark appears in the panel she already uses. Shown wrong if marked models stay
- un-updated release after release.
-- We expect Alice to act on marks because they appear only when a file Dessau
- actually uses has changed, never for a README edit. Shown wrong if a mark
- appears for an upstream change that touches no file Dessau reads.
-
-## Scope Conditions
-
-- Only when Alice has turned checks on; a Mac whose operator never does sends
- nothing.
-- A model is marked only if Dessau recorded which version it downloaded; older
- downloads show "version unknown" until updated once.
-- Models from HuggingFace; public ones are checked without the token, private
- ones with it only when an anonymous request is refused.
-- The Mac is online (offline, marks stay as they were); a decision model is
- checked, but offered an update only once a Dessau release has reviewed that
- version.
-
-## Acceptance Criteria
-
-- **Given** checks are off, **when** Dessau runs, **then** no check leaves the
- Mac, and saving any other setting never turns checks on.
-- **Given** checks are on, **when** Dessau starts (if the last check is older
- than the interval) and each interval passes, **then** every eligible model is
- checked and changed ones are marked.
-- **Given** an upstream change that touches only files Dessau does not use (a
- README edit), **when** a check runs, **then** no mark appears.
-- **Given** a model whose version Dessau never recorded, **when** Alice opens
- its card, **then** it says "version unknown", shows no mark, and offers
- Update.
-- **Given** a token in Settings and a public model, **when** a check runs,
- **then** the request carries no token; a private model gets it only after an
- anonymous request is refused.
-- **Given** HuggingFace cannot be reached, **when** a check runs, **then** marks
- stay as they were, one line is logged, and serving is not slowed.
-- **Given** a marked model, **when** Alice clicks Update, **then** the new
- version downloads beside the old one at that exact upstream version, every
- file is checked, and it is swapped in only when complete; if anything fails
- the old one keeps serving, so Carol's chat never meets a half-updated model.
-- **Given** a newer version that ships its own code, **when** it is checked,
- **then** the mark says Dessau will not run that version and offers no Update.
-- **Given** a decision model whose upstream version changed, **when** Alice
- opens its card, **then** it says a newer version exists and will be offered
- once reviewed, with no Update; Update appears when a Dessau release ships the
- reviewed version.
-- **Given** the setting (on or off; an interval from 1 hour to 30 days, daily by
- default), **when** it is read from Go, the panel or `config.json`, **then** all
- three agree; an out-of-range value in `config.json` is repaired on load, and
- saving an unrelated setting is never refused over it.
-- **Given** a model or file name from HuggingFace containing markup, **when** the
- panel shows a mark, **then** the name appears as plain text.
-
-## Decisions at the planning interview (2026-10-03)
-
-1. On an update: a mark plus a one-click update. The safe update (staged at the
- exact upstream version, every file checked, atomic swap, the old copy kept
- until the new one is in) is part of this intent; the reviews had proposed it
- as a separate one.
-2. When: off until the operator turns checks on. Under the 2026-09-08
- precedent this needs no supersession of adr-2609201008476813;
- adr-2610030857208746 records the rule for this check.
-3. The existing startup category lookup counts as part of fetching models
- (recorded in adr-2610030857208746); it stays always on.
-4. The token: sent only when an anonymous request is refused.
-5. Models with no recorded version: "version unknown" plus Update.
-6. Interval: daily by default, 1 hour to 30 days.
-7. Decision models: checked, with no Update until a Dessau release has
- reviewed the version (the maintainer asked why they should differ; the
- answer is itd-2610030656210408's "reviewed version only").
-
-Typed links: builds on itd-2610030656210408 (one recorded-revision field;
-"reviewed version only"), itd-2609081259493890 (three surfaces) and
-itd-2609100519003748 (where the panel shows a model's state). Resolves, when
-built, iss-2610030913179523 (a re-download can leave a mixed model), since the
-update path replaces it.
-
-## Open Questions
-
-_None open._ Settled in spc-2610030929021692: the ordinary re-download moves
-onto the same staged path as Update (its step 3), which resolves
-iss-2610030913179523.
-
-## Audit Notes
-
-_Empty. Populated by intent-auditor when intent moves to shipped/._
-
-## Grounds
-
-- pursued: fixes to chat templates, settings and quantisations land on HuggingFace silently, so Alice serves stale builds without knowing; shown wrong if, over a month of daily checks, no model she uses changes in a file Dessau reads. And the only way to update today, downloading again, can leave a half-old, half-new model, which a safe one-click update fixes; shown wrong if no one ever re-downloads a changed model.
diff --git a/.abcd/development/intents/planned/itd-2610031024247803-a-program-that-finished-with-a-model-can-tell-dessau-to.md b/.abcd/development/intents/planned/itd-2610031024247803-a-program-that-finished-with-a-model-can-tell-dessau-to.md
deleted file mode 100644
index fa2ba7cc..00000000
--- a/.abcd/development/intents/planned/itd-2610031024247803-a-program-that-finished-with-a-model-can-tell-dessau-to.md
+++ /dev/null
@@ -1,118 +0,0 @@
----
-id: itd-2610031024247803
-slug: a-program-that-finished-with-a-model-can-tell-dessau-to
-spec_id: spc-2610031153301961
-kind: standalone
-suggested_kind: null
-reclassification_history: []
-builds_on: [itd-2609081259493890, itd-2609061441241254, itd-2609061441285238, itd-2609211335097114]
-severity: minor
-origin: researcher-authored
-production_mode: hand-written
-related_issues: [iss-2610031018046897]
-impact: additive
----
-
-# A program can ask Dessau to unload a model it has finished with
-
-## Press Release
-
-A program that has finished with a model can give its memory back.
-
-Bob's test script runs a round of requests against one of Alice's models, then
-tells Dessau it is done with it, and the memory is free for the next round
-without anyone opening the control panel. A model Alice has pinned, or one
-someone has just used, stays where it is, and a device Dessau does not trust is
-refused. Alice can turn this off in Settings.
-
-## Why This Matters
-
-Today the only ways to free a model's memory are the panel's Unload button and
-an undocumented control-panel route that answers only on this Mac. A program
-that tests several models in turn waits for someone to click Unload (it
-happened on 2026-10-03, when a test tool had to ask the maintainer to), and on
-one Mac a model left loaded after a test blocks the next. Seeded 2026-10-03
-from the maintainer's request.
-
-## Mechanism
-
-- We expect models to sit loaded for less time after a test round, because the
- program that used a model can say it is done the moment it is. Shown wrong if
- models stay loaded after batches as long as before.
-- We expect nobody else to lose a model they are using, because pinned, busy
- and just-used models are refused. Shown wrong if Carol's chat ever has to wait
- for a reload because a program unloaded her model.
-
-## Scope Conditions
-
-- Trusted callers only: programs on this Mac, holders of the API key, and
- paired clients; other devices on a network without a key, never.
-- Only a loaded model that no one is using, that is not pinned and is not in
- its protected time after use.
-- A trusted program may unload any such model, not only ones it used; Dessau
- does not track who used what.
-- Unloading only: programs cannot load, delete or change models this way.
-
-## Acceptance Criteria
-
-- **Given** an install without a key, **when** a program on this Mac asks to
- unload a loaded model that is idle and not pinned, **then** it is unloaded and
- the answer says so.
-- **Given** an install without a key, **when** another device on the network asks
- to unload a model, **then** it is refused whatever the switch says, the model
- stays loaded, and the answer reveals nothing about what is loaded.
-- **Given** an install with an API key, **when** a program on another machine asks
- with the key, **then** it may unload; without the key it is refused. A paired
- client may unload.
-- **Given** a web page open in a browser on this Mac, **when** it tries to unload a
- model, **then** it is refused and nothing changes.
-- **Given** a model Alice has pinned, **when** a trusted program asks to unload it,
- **then** it is refused and the model stays.
-- **Given** a model that is answering a request, loading, or in its protected
- time after use, **when** a trusted program asks to unload it, **then** it is
- refused at once and the request finishes.
-- **Given** the switch is off, **when** any program asks to unload through the API,
- **then** it is refused, and the Unload button in the control panel still works.
-- **Given** the switch, **when** it is read from Go, the panel or `config.json`,
- **then** all three agree; a `config.json` that does not mention it means on, and
- saving an unrelated setting is never refused over it.
-- **Given** a request to delete a model through the API (the way OpenAI's API
- deletes models), **when** any client sends it, **then** no file is deleted.
-- **Given** a program unloaded a model, **when** Alice reads the statistics or the
- log, **then** they say a program unloaded it and what kind of caller it was
- (this Mac, an API key, a paired client), and never show the key.
-- **Given** the documentation, **when** a script author reads it, **then** it names
- the API unload and the control panel's own unload, who can use each, and when
- each is refused.
-
-## Decisions at the planning interview (2026-10-03)
-
-1. No switch on the control panel's own unload route (it is the panel's Unload
- button too, and a script is indistinguishable from it); the route is
- documented, and who can use it follows the panel-access setting of
- itd-2610031004535845. Only the API route has a switch, on by default.
-2. Trusted callers only: exactly the callers the gateway already entitles.
-3. Pinned models are refused (the pin promise of itd-2609061441241254 holds).
-4. Models in their eviction-grace window are refused.
-5. Under itd-2610031004535845's "this account only", the API unload keeps the
- API's own rule; other accounts' trusted programs can still unload.
-6. Recorded without a question (one defensible answer): the first
- state-changing verb on `/v1` is settled by adr-2610031153127219:
- its route, access rule and refusals.
-
-Typed links: refines itd-2609061441241254 (pins hold), itd-2609061441285238
-(grace holds), itd-2609211335097114 (unload behaviour) and itd-2610031004535845
-(the API unload stays under API rules); builds on itd-2609081259493890 (three
-surfaces); resolves the documentation half of iss-2610031018046897.
-
-## Open Questions
-
-_None open._
-
-## Audit Notes
-
-_Empty. Populated by intent-auditor when intent moves to shipped/._
-
-## Grounds
-
-- pursued: programs that test several models in turn have to wait for someone to click Unload, as happened on 2026-10-03; shown wrong if test tools never use it. And on one Mac a model left loaded after a test blocks the next, so freeing it promptly lets more models be tried; shown wrong if test rounds are no faster with it.
diff --git a/.abcd/development/intents/shipped/itd-2610030857275099-dessau-tells-alice-when-a-model-she-downloaded-has-a-newer.md b/.abcd/development/intents/shipped/itd-2610030857275099-dessau-tells-alice-when-a-model-she-downloaded-has-a-newer.md
new file mode 100644
index 00000000..1a656a05
--- /dev/null
+++ b/.abcd/development/intents/shipped/itd-2610030857275099-dessau-tells-alice-when-a-model-she-downloaded-has-a-newer.md
@@ -0,0 +1,256 @@
+---
+id: itd-2610030857275099
+slug: dessau-tells-alice-when-a-model-she-downloaded-has-a-newer
+spec_id: spc-2610030929021692
+kind: standalone
+suggested_kind: null
+reclassification_history: []
+builds_on: [itd-2610030656210408, itd-2609081259493890, itd-2609100519003748]
+severity: minor
+impact: additive
+origin: researcher-authored
+production_mode: hand-written
+---
+
+# Dessau checks downloaded models for newer versions, and updates them with one click
+
+## Press Release
+
+Dessau tells Alice when a model she downloaded has a newer version, and updates
+it with one click.
+
+Alice turns on update checks in Settings and picks how often, once a day to
+start with. From then on, at every start and every interval, Dessau asks
+HuggingFace whether the files of each model she downloaded have changed, and
+the control panel marks the ones that have. One click on Update fetches the new
+version beside the old one and swaps it in only once every file checks out, so
+Carol's chat never meets a half-updated model. Nothing is asked until Alice
+turns checks on, and a decision model is never marked.
+
+## Why This Matters
+
+Downloads take a repository's latest files once and are never revisited. Fixes
+to chat templates, configs and quantisations land on HuggingFace silently, so
+Alice serves a stale build and finds out only when a client misbehaves. And the
+one way to update today, downloading again, can leave a model that is half the
+old version and half the new (iss-2610030913179523). Seeded 2026-10-03 from the
+maintainer's request.
+
+## Mechanism
+
+- We expect Alice to update models she would otherwise leave stale, because the
+ mark appears in the panel she already uses. Shown wrong if marked models stay
+ un-updated release after release.
+- We expect Alice to act on marks because they appear only when a file Dessau
+ actually uses has changed, never for a README edit. Shown wrong if a mark
+ appears for an upstream change that touches no file Dessau reads.
+
+## Scope Conditions
+
+- Only when Alice has turned checks on; a Mac whose operator never does sends
+ nothing.
+- A model is marked only if Dessau recorded which version it downloaded; older
+ downloads show "version unknown" until updated once.
+- Models from HuggingFace; public ones are checked without the token, private
+ ones with it only when an anonymous request is refused.
+- The Mac is online (offline, marks stay as they were); a decision model is
+ checked, but offered an update only once a Dessau release has reviewed that
+ version.
+
+## Acceptance Criteria
+
+- **Given** checks are off, **when** Dessau runs, **then** no check leaves the
+ Mac, and saving any other setting never turns checks on.
+- **Given** checks are on, **when** Dessau starts (if the last check is older
+ than the interval) and each interval passes, **then** every eligible model is
+ checked and changed ones are marked.
+- **Given** an upstream change that touches only files Dessau does not use (a
+ README edit), **when** a check runs, **then** no mark appears.
+- **Given** a model whose version Dessau never recorded, **when** Alice opens
+ its card, **then** it says "version unknown", shows no mark, and offers
+ Update.
+- **Given** a token in Settings and a public model, **when** a check runs,
+ **then** the request carries no token; a private model gets it only after an
+ anonymous request is refused.
+- **Given** HuggingFace cannot be reached, **when** a check runs, **then** marks
+ stay as they were, one line is logged, and serving is not slowed.
+- **Given** a marked model, **when** Alice clicks Update, **then** the new
+ version downloads beside the old one at that exact upstream version, every
+ file is checked, and it is swapped in only when complete; if anything fails
+ the old one keeps serving, so Carol's chat never meets a half-updated model.
+- **Given** a newer version that ships its own code, **when** it is checked,
+ **then** the mark says Dessau will not run that version and offers no Update.
+- **Given** a decision model whose upstream version changed, **when** Alice
+ opens its card, **then** it says a newer version exists and will be offered
+ once reviewed, with no Update; Update appears when a Dessau release ships the
+ reviewed version.
+- **Given** the setting (on or off; an interval from 1 hour to 30 days, daily by
+ default), **when** it is read from Go, the panel or `config.json`, **then** all
+ three agree; an out-of-range value in `config.json` is repaired on load, and
+ saving an unrelated setting is never refused over it.
+- **Given** a model or file name from HuggingFace containing markup, **when** the
+ panel shows a mark, **then** the name appears as plain text.
+
+## Decisions at the planning interview (2026-10-03)
+
+1. On an update: a mark plus a one-click update. The safe update (staged at the
+ exact upstream version, every file checked, atomic swap, the old copy kept
+ until the new one is in) is part of this intent; the reviews had proposed it
+ as a separate one.
+2. When: off until the operator turns checks on. Under the 2026-09-08
+ precedent this needs no supersession of adr-2609201008476813;
+ adr-2610030857208746 records the rule for this check.
+3. The existing startup category lookup counts as part of fetching models
+ (recorded in adr-2610030857208746); it stays always on.
+4. The token: sent only when an anonymous request is refused.
+5. Models with no recorded version: "version unknown" plus Update.
+6. Interval: daily by default, 1 hour to 30 days.
+7. Decision models: checked, with no Update until a Dessau release has
+ reviewed the version (the maintainer asked why they should differ; the
+ answer is itd-2610030656210408's "reviewed version only").
+
+Typed links: builds on itd-2610030656210408 (one recorded-revision field;
+"reviewed version only"), itd-2609081259493890 (three surfaces) and
+itd-2609100519003748 (where the panel shows a model's state). Resolves, when
+built, iss-2610030913179523 (a re-download can leave a mixed model), since the
+update path replaces it.
+
+## Open Questions
+
+_None open._ Settled in spc-2610030929021692: the ordinary re-download moves
+onto the same staged path as Update (its step 3), which resolves
+iss-2610030913179523.
+
+## Audit Notes
+
+
+Fidelity review — receipt rcp-c60839654fc3 (verifier intent-auditor claude-opus-5-5).
+
+Provenance: intent-auditor@claude-opus-5-5 · rubric_hash sha256:effa65b3e9e88ff29433b443ec2be159522a8b0b71cf1434526514aa61edb13e · prompt_hash sha256:8830a4a65ccddbb174917335c967ed5442132dcfa29d8d8b0659894d567e1196
+Input attestations: pr:intentdriven/Dessau#149 merge 8e4d2cc4806865dbfb371b11911225c40b649caa (step 1: record the downloaded version)@-; pr:intentdriven/Dessau#152 merge 864042b98223b6f33b1e632b925c9100612a79f4 (step 2: the check and its setting)@-; pr:intentdriven/Dessau#162 merge 8c088a4f3ebed7a2fdfdafb584965a1b04108f88 (step 3: the staged update)@-; pr:intentdriven/Dessau#165 merge 63790e21038bdd36bfb4d32b471fb7a3f0fb4b9f (step 4: the panel)@-; tree:integrate/0.10.0 at dd929d8a7dbe0446792423b43594d8b84583561e; go test -count=1 over internal/{app,hub,config,ui,archtest,registry,gateway} all ok@-;
+
+Acceptance rollup: MET 7 · MET_WITH_CONCERNS 3 · NOT_MET 1 · INCONCLUSIVE 0
+
+Per-criterion verdicts:
+- ac-1 — MET: The schedule returns before anything is due when the switch is off, and the round re-reads the switch before every model. A week of ticks with checks off sends no request. The switch is off by default, and an unrelated settings save leaves it off. The only callers of Latest, FilesAt and SmallFileAt are in the check.
+ evidence: internal/app/updatecheck.go:88 — "if !a.Config().UpdateCheck {"
+ evidence: internal/app/updatecheck.go:160 — "if !a.Config().UpdateCheck {"
+ evidence: internal/app/updatecheck_test.go:162 — "func TestUpdateChecksOffSendNothingOverAWeek(t *testing.T) {"
+ evidence: internal/config/updatecheck_test.go:12 — "func TestUpdateChecksAreOffByDefaultAndDailyWhenOn(t *testing.T) {"
+ evidence: internal/gateway/version_test.go:46 — "func TestAnUnrelatedSaveLeavesTheUpdateCheckAsItWas(t *testing.T) {"
+- ac-2 — MET: main starts the schedule. The first tick comes after a 30-60 s jittered delay, then one tick a minute compares wall-clock time with each model's persisted CheckedAt. Every ready model with a known version that is past the interval is checked, and a changed one is recorded as available. Tests cover a check at the first tick, 7-8 rounds over a week of daily checks, and no request at a start within the interval.
+ evidence: cmd/dessau/main.go:363 — "a.StartCheckingForUpdates()"
+ evidence: internal/app/updatecheck.go:74 — "if m.Update != nil && now.Sub(m.Update.CheckedAt) < interval {"
+ evidence: internal/app/updatecheck_test.go:180 — "func TestUpdateChecksOnRunAtStartAndEachInterval(t *testing.T) {"
+ evidence: internal/app/updatecheck_test.go:204 — "func TestAStartWithinTheIntervalAsksNothing(t *testing.T) {"
+ evidence: internal/app/updatecheck_test.go:248 — "func TestAChangedWeightMarksTheModel(t *testing.T) {"
+- ac-3 — MET: compareVersions skips documentation (Markdown and licence or notice files) on both sides of the comparison. A test where only README.md and LICENSE changed upstream records the model as current and never reads config.json.
+ evidence: internal/app/updatecheck.go:293 — "if isDocumentation(f.Path) {"
+ evidence: internal/app/updatecheck.go:332 — "func isDocumentation(p string) bool {"
+ evidence: internal/app/updatecheck_test.go:230 — "func TestAReadmeOnlyChangeMarksNothing(t *testing.T) {"
+- ac-4 — MET: A model with no recorded commit has VersionKnown() false and is never checked, so it carries no mark. The card says 'Version unknown'. updateMark returns no pill without a commit, and updateOffered offers Update when there is no commit. Update then fetches the current version. Registry, app and panel tests hold each part.
+ evidence: internal/registry/registry.go:245 — "func (m Model) VersionKnown() bool { return m.Commit != "" }"
+ evidence: internal/ui/static/app.js:577 — "if (!m.commit) return 'Version unknown: Dessau did not record which version it downloaded. Update fetches the current one.';"
+ evidence: internal/ui/static/app.js:606 — "return !m.commit || (m.update || {}).status === 'available';"
+ evidence: internal/ui/update_test.go:27 — "{`{"state":"ready"}`, `Version unknown`, \`\`, true},"
+ evidence: internal/app/updatecheck_test.go:216 — "func TestAModelOfUnknownVersionIsNotChecked(t *testing.T) {"
+ evidence: internal/registry/version_test.go:58 — "func TestAModelWithNoRecordedCommitIsVersionUnknown(t *testing.T) {"
+- ac-5 — MET: Latest makes the commit lookup with an empty token, so newTokenRequest sets no Authorization header. It retries with the token only on a 401 or 403 when a token is set, and the listing and config.json then use the same choice. Hub tests check that a public repository gets no Authorization on any request, that the sequence for a private one is anonymous then Bearer, and that a refusal with no token makes no second request.
+ evidence: internal/hub/hub.go:355 — "func (c *Client) Latest(ctx context.Context, repoID string) (Upstream, error) {"
+ evidence: internal/hub/hub.go:360 — "if !IsAuthRequired(err) || token == "" {"
+ evidence: internal/hub/check_test.go:72 — "func TestACheckOfAPublicRepositorySendsNoToken(t *testing.T) {"
+ evidence: internal/hub/check_test.go:98 — "func TestACheckSendsTheTokenOnlyAfterARefusal(t *testing.T) {"
+- ac-6 — MET: A model the Hub does not answer for keeps its record, and a round that reaches no model logs exactly one info line. The tests check that a closed Hub leaves an existing mark identical and writes one log line, and that while the Hub stalls, registry, pool and download reads finish in under 200 ms because the round holds no serving lock and each request is bounded.
+ evidence: internal/app/updatecheck.go:198 — "a.Log.Info("could not reach HuggingFace to check models for newer versions; marks stay as they were","
+ evidence: internal/app/updatecheck_test.go:304 — "func TestAnUnreachableHubLeavesMarksAsTheyWere(t *testing.T) {"
+ evidence: internal/app/updatecheck_test.go:331 — "func TestAStalledHubDoesNotSlowServing(t *testing.T) {"
+- ac-7 — MET_WITH_CONCERNS: Update fetches the marked commit into a staging folder, hash-checks every file and runs validateModelDir and Precheck before swapping. Tests check the staged swap, the exact marked commit, a hash mismatch, a mid-download failure, a version that ships code and no disk space, all of which leave the old version serving. Concern 1: spec step 3 says the old copy is removed only after the new one passes readiness. DECISIONS 2026-10-03 reads readiness as the directory checks, so the old copy is removed as soon as the new record is published (app.go:1759). A version that passes Precheck but fails at its first load has no old copy to fall back to, which narrows 'if anything fails the old one keeps serving'. Concern 2: while the swap drains the model, new requests for it are refused with 503 (ErrUpdating) rather than served by the old version.
+ evidence: internal/app/staged.go:254 — "func (a *App) Update(repoID string) error {"
+ evidence: internal/app/staged_test.go:211 — "func TestAnUpdateStagesTheNewVersionAndSwapsItInWhole(t *testing.T) {"
+ evidence: internal/app/staged_test.go:242 — "func TestAnUpdateFetchesTheMarkedCommit(t *testing.T) {"
+ evidence: internal/app/staged_test.go:263 — "func TestAFailedUpdateLeavesTheOldVersionServing(t *testing.T) {"
+ evidence: internal/app/staged_test.go:323 — "func TestTheSwapDrainsTheModelFirst(t *testing.T) {"
+ evidence: internal/app/app.go:1759 — "a.removeAside(repoID, st.aside)"
+ evidence: .abcd/work/DECISIONS.md:407 — "The spec's "the old one removed after the new one passes readiness" is read as the directory checks a launch makes"
+ evidence: .abcd/work/DECISIONS.md:410 — "an Acquire for a drained model, loaded or not, is refused with ErrUpdating (503, counted as busy)"
+- ac-8 — MET_WITH_CONCERNS: When the newer config.json changed and names a non-null model_file, the check records runs_own_code. The card says the version 'ships its own code, which Dessau will not run, so it is not offered', Update is not offered, and Update() refuses that status. Concern: when the repository keeps config.json in LFS (served from the content CDN), the check cannot read it and records the version as available, with Update offered. Only the update's own Precheck refuses it later, so the mark does not say 'will not run' in that case.
+ evidence: internal/app/updatecheck.go:251 — "return registry.UpdateCheck{Status: registry.UpdateRunsOwnCode, Commit: up.Commit}, nil"
+ evidence: internal/ui/static/app.js:582 — "case 'runs_own_code': return `A newer version (${short}) ships its own code, which Dessau will not run, so it is not offered.`;"
+ evidence: internal/app/updatecheck_test.go:264 — "func TestANewerVersionThatShipsCodeIsMarkedAsOneDessauWillNotRun(t *testing.T) {"
+ evidence: internal/app/updatecheck.go:238 — "case errors.Is(err, hub.ErrCrossOrigin):"
+ evidence: internal/app/updatecheck_test.go:519 — "func TestAConfigOnTheContentCDNDoesNotStopTheCheck(t *testing.T) {"
+- ac-9 — NOT_MET: Promised: a decision model whose upstream changed shows 'will be offered once reviewed' with no Update, and Update appears when a Dessau release ships the reviewed version. Delivered: the gate is a ReviewedBuilds seam (Options.Reviewed), and 'Nil means no model is a decision model'. The shipped binary calls app.New without Reviewed and ships no reviewed manifest; that manifest is itd-2610030656210408 step 2, which has not landed. In the delivered build, no model is ever marked awaiting_review. A changed decision model would be marked 'available' with Update offered, and no release can make Update appear by shipping a reviewed version. The awaiting_review path exists only in tests that inject a.reviewed by hand.
+ evidence: cmd/dessau/main.go:338 — "a, err := app.New(app.Options{Paths: paths, Config: cfg, Log: log, Bind: plan, LogLevel: appLog.Level})"
+ evidence: internal/app/app.go:242 — "// Nil means no model is a decision model."
+ evidence: internal/app/updatecheck.go:255 — "if a.reviewed != nil {"
+ evidence: internal/app/updatecheck_test.go:395 — "a.reviewed = func(id string) (bool, []string) {"
+ evidence: .abcd/work/CONTEXT.md:97 — "Criterion 9 waits on itd-2610030656210408 step 2."
+- ac-10 — MET: Go holds update_check_enabled (off by default) and update_check_interval_hours, bounded 1 to 720 with 24 as the default. Load repairs an out-of-range interval and names the repair. The panel fills from and posts both keys, and its interval control is held to the configuration's bounds. An unrelated save keeps the setting and is not refused over it. Tests cover the config, gateway, ui and archtest parts.
+ evidence: internal/config/config.go:1223 — "DefaultUpdateCheckIntervalHours = 24"
+ evidence: internal/config/config.go:1244 — "func (c *Config) sanitizeUpdateCheck() []string {"
+ evidence: internal/config/updatecheck_test.go:49 — "func TestAnOutOfRangeIntervalInTheFileIsRepairedOnLoad(t *testing.T) {"
+ evidence: internal/ui/updatecheck_test.go:11 — "func TestThePaneIsWiredToTheUpdateCheckSettings(t *testing.T) {"
+ evidence: internal/ui/controls_test.go:138 — "{"setUpdateCheckInterval", true, func(c *config.Config, v float64) { c.UpdateCheckIntervalHours = int(v) }},"
+ evidence: internal/gateway/version_test.go:46 — "func TestAnUnrelatedSaveLeavesTheUpdateCheckAsItWas(t *testing.T) {"
+- ac-11 — MET_WITH_CONCERNS: The card puts the repository name, the mark and the version line into innerHTML through escapeHtml, which goes through textContent, and only in text positions. The commit shown is held to a commit id by the registry. Concern: the test only pattern-matches the source for escapeHtml calls. It is not the rendered test with markup in a repository and a file name that the spec planned. The card shows no file names at all, so the file-name half is never exercised.
+ evidence: internal/ui/static/app.js:475 — "if (mark) pill += `${escapeHtml(mark)}`;"
+ evidence: internal/ui/static/app.js:484 — "${update ? `
${escapeHtml(update)}
` : ''}"
+ evidence: internal/ui/static/app.js:2840 — "function escapeHtml(s) {"
+ evidence: internal/ui/update_test.go:52 — "func TestTheVersionLinesAreEscaped(t *testing.T) {"
+
+Gap audit:
+- honoured:
+ - Nothing is asked until Alice turns checks on
+ evidence: internal/app/updatecheck.go:88 — "if !a.Config().UpdateCheck {"
+ evidence: internal/app/updatecheck_test.go:162 — "func TestUpdateChecksOffSendNothingOverAWeek(t *testing.T) {"
+ - At every start and every interval Dessau asks whether files of each downloaded model changed, and marks only changes to files Dessau uses
+ evidence: internal/app/updatecheck.go:278 — "func (a *App) compareVersions(m registry.Model, files []hub.File) (changed, configChanged bool) {"
+ evidence: internal/app/updatecheck_test.go:230 — "func TestAReadmeOnlyChangeMarksNothing(t *testing.T) {"
+ - The token is sent only when an anonymous request is refused
+ evidence: internal/hub/check_test.go:98 — "func TestACheckSendsTheTokenOnlyAfterARefusal(t *testing.T) {"
+ - One click fetches the new version beside the old one and swaps it in only once every file checks out; the ordinary re-download takes the same path (resolves iss-2610030913179523)
+ evidence: internal/app/staged_test.go:211 — "func TestAnUpdateStagesTheNewVersionAndSwapsItInWhole(t *testing.T) {"
+ evidence: internal/app/staged_test.go:361 — "func TestARedownloadNeverLeavesAMixedModel(t *testing.T) {"
+ - Setting held on Go, panel and config.json, with repair on load
+ evidence: internal/config/updatecheck_test.go:49 — "func TestAnOutOfRangeIntervalInTheFileIsRepairedOnLoad(t *testing.T) {"
+ evidence: internal/archtest/update_check_egress_test.go:17 — "func TestTheUpdateCheckSwitchCarriesTheEgressSentence(t *testing.T) {"
+- diverged:
+ - Old copy kept until the new one passes readiness: the delivery reads 'readiness' as the directory checks and removes the old copy once the new record is published, so a load failure after the swap has no fallback
+ evidence: .abcd/work/DECISIONS.md:407 — "is read as the directory checks a launch makes (validateModelDir and the launcher's Precheck, again in place after the rename), not as starting a model server"
+ evidence: internal/app/app.go:1759 — "a.removeAside(repoID, st.aside)"
+ - Carol's chat is served by the old version until the swap: during the drain and swap, requests for the model are refused with 503 instead
+ evidence: .abcd/work/DECISIONS.md:410 — "A swap refuses new requests for the model while it drains"
+ - A version that ships its own code is marked 'will not run': when its config.json is stored in LFS, it is marked available and offered instead
+ evidence: internal/app/updatecheck.go:238 — "case errors.Is(err, hub.ErrCrossOrigin):"
+ - Press release says 'a decision model is never marked'; the acceptance criteria and delivery give it a mark ('newer version awaiting review')
+ evidence: internal/ui/static/app.js:596 — "case 'awaiting_review': return 'newer version awaiting review';"
+- missing:
+ - Decision models wait for a reviewed version: no reviewed manifest is shipped and app.New is called without Options.Reviewed, so the gate never fires in the delivered binary (depends on itd-2610030656210408 step 2)
+ evidence: cmd/dessau/main.go:338 — "a, err := app.New(app.Options{Paths: paths, Config: cfg, Log: log, Bind: plan, LogLevel: appLog.Level})"
+ evidence: internal/app/app.go:242 — "// Nil means no model is a decision model."
+ - The spec's rendered UI test with markup in a repository name and a file name: delivered as a pattern match on the source instead
+ evidence: internal/ui/update_test.go:52 — "func TestTheVersionLinesAreEscaped(t *testing.T) {"
+
+Scope-condition dispositions:
+- cond-2610030929026824 — survived: Every request the check makes is gated on the switch, which is off by default. A week of ticks with it off reaches the Hub zero times, and turning it off mid-round stops the round.
+ evidence: internal/app/updatecheck.go:88 — "if !a.Config().UpdateCheck {"
+ evidence: internal/app/updatecheck_test.go:162 — "func TestUpdateChecksOffSendNothingOverAWeek(t *testing.T) {"
+ evidence: internal/app/updatecheck_test.go:428 — "func TestTurningChecksOffStopsARoundInFlight(t *testing.T) {"
+- cond-2610030929020636 — survived: Only models with a recorded commit are due a check. A model without one reads 'Version unknown' and is offered Update. Every download records the commit and file hashes it fetched, so one update makes the version known.
+ evidence: internal/app/updatecheck.go:71 — "if !m.VersionKnown() {"
+ evidence: internal/ui/static/app.js:577 — "if (!m.commit) return 'Version unknown: Dessau did not record which version it downloaded. Update fetches the current one.';"
+ evidence: internal/app/version_test.go:45 — "func TestDownloadRecordsTheVersionItFetched(t *testing.T) {"
+- cond-2610030929022838 — survived: The check asks the HuggingFace API anonymously and adds the token only after a 401 or 403. Hub tests hold both the public and the private sequence.
+ evidence: internal/hub/hub.go:355 — "func (c *Client) Latest(ctx context.Context, repoID string) (Upstream, error) {"
+ evidence: internal/hub/check_test.go:72 — "func TestACheckOfAPublicRepositorySendsNoToken(t *testing.T) {"
+ evidence: internal/hub/check_test.go:98 — "func TestACheckSendsTheTokenOnlyAfterARefusal(t *testing.T) {"
+- cond-2610030929025733 — narrowed: The offline clause holds: an unreachable Hub leaves marks as they were. The decision-model clause holds only behind the ReviewedBuilds seam. The shipped binary passes no Reviewed function and no reviewed manifest exists, so no model is treated as a decision model, and gating to a reviewed version is exercised only by tests that inject one.
+ narrowing: The offline clause holds as stated. 'A decision model is offered an update only once a Dessau release has reviewed that version' holds only when a ReviewedBuilds manifest is supplied to app.New. The delivered binary supplies none (pending itd-2610030656210408 step 2), so in the shipped build no model is distinguished as a decision model.
+ evidence: internal/app/updatecheck_test.go:304 — "func TestAnUnreachableHubLeavesMarksAsTheyWere(t *testing.T) {"
+ evidence: internal/app/updatecheck_test.go:386 — "func TestADecisionModelWaitsForAReviewedVersion(t *testing.T) {"
+ evidence: cmd/dessau/main.go:338 — "a, err := app.New(app.Options{Paths: paths, Config: cfg, Log: log, Bind: plan, LogLevel: appLog.Level})"
+
+
+## Grounds
+
+- pursued: fixes to chat templates, settings and quantisations land on HuggingFace silently, so Alice serves stale builds without knowing; shown wrong if, over a month of daily checks, no model she uses changes in a file Dessau reads. And the only way to update today, downloading again, can leave a half-old, half-new model, which a safe one-click update fixes; shown wrong if no one ever re-downloads a changed model.
diff --git a/.abcd/development/intents/shipped/itd-2610031024247803-a-program-that-finished-with-a-model-can-tell-dessau-to.md b/.abcd/development/intents/shipped/itd-2610031024247803-a-program-that-finished-with-a-model-can-tell-dessau-to.md
new file mode 100644
index 00000000..ba97965c
--- /dev/null
+++ b/.abcd/development/intents/shipped/itd-2610031024247803-a-program-that-finished-with-a-model-can-tell-dessau-to.md
@@ -0,0 +1,218 @@
+---
+id: itd-2610031024247803
+slug: a-program-that-finished-with-a-model-can-tell-dessau-to
+spec_id: spc-2610031153301961
+kind: standalone
+suggested_kind: null
+reclassification_history: []
+builds_on: [itd-2609081259493890, itd-2609061441241254, itd-2609061441285238, itd-2609211335097114]
+severity: minor
+origin: researcher-authored
+production_mode: hand-written
+related_issues: [iss-2610031018046897]
+impact: additive
+---
+
+# A program can ask Dessau to unload a model it has finished with
+
+## Press Release
+
+A program that has finished with a model can give its memory back.
+
+Bob's test script runs a round of requests against one of Alice's models, then
+tells Dessau it is done with it, and the memory is free for the next round
+without anyone opening the control panel. A model Alice has pinned, or one
+someone has just used, stays where it is, and a device Dessau does not trust is
+refused. Alice can turn this off in Settings.
+
+## Why This Matters
+
+Today the only ways to free a model's memory are the panel's Unload button and
+an undocumented control-panel route that answers only on this Mac. A program
+that tests several models in turn waits for someone to click Unload (it
+happened on 2026-10-03, when a test tool had to ask the maintainer to), and on
+one Mac a model left loaded after a test blocks the next. Seeded 2026-10-03
+from the maintainer's request.
+
+## Mechanism
+
+- We expect models to sit loaded for less time after a test round, because the
+ program that used a model can say it is done the moment it is. Shown wrong if
+ models stay loaded after batches as long as before.
+- We expect nobody else to lose a model they are using, because pinned, busy
+ and just-used models are refused. Shown wrong if Carol's chat ever has to wait
+ for a reload because a program unloaded her model.
+
+## Scope Conditions
+
+- Trusted callers only: programs on this Mac, holders of the API key, and
+ paired clients; other devices on a network without a key, never.
+- Only a loaded model that no one is using, that is not pinned and is not in
+ its protected time after use.
+- A trusted program may unload any such model, not only ones it used; Dessau
+ does not track who used what.
+- Unloading only: programs cannot load, delete or change models this way.
+
+## Acceptance Criteria
+
+- **Given** an install without a key, **when** a program on this Mac asks to
+ unload a loaded model that is idle and not pinned, **then** it is unloaded and
+ the answer says so.
+- **Given** an install without a key, **when** another device on the network asks
+ to unload a model, **then** it is refused whatever the switch says, the model
+ stays loaded, and the answer reveals nothing about what is loaded.
+- **Given** an install with an API key, **when** a program on another machine asks
+ with the key, **then** it may unload; without the key it is refused. A paired
+ client may unload.
+- **Given** a web page open in a browser on this Mac, **when** it tries to unload a
+ model, **then** it is refused and nothing changes.
+- **Given** a model Alice has pinned, **when** a trusted program asks to unload it,
+ **then** it is refused and the model stays.
+- **Given** a model that is answering a request, loading, or in its protected
+ time after use, **when** a trusted program asks to unload it, **then** it is
+ refused at once and the request finishes.
+- **Given** the switch is off, **when** any program asks to unload through the API,
+ **then** it is refused, and the Unload button in the control panel still works.
+- **Given** the switch, **when** it is read from Go, the panel or `config.json`,
+ **then** all three agree; a `config.json` that does not mention it means on, and
+ saving an unrelated setting is never refused over it.
+- **Given** a request to delete a model through the API (the way OpenAI's API
+ deletes models), **when** any client sends it, **then** no file is deleted.
+- **Given** a program unloaded a model, **when** Alice reads the statistics or the
+ log, **then** they say a program unloaded it and what kind of caller it was
+ (this Mac, an API key, a paired client), and never show the key.
+- **Given** the documentation, **when** a script author reads it, **then** it names
+ the API unload and the control panel's own unload, who can use each, and when
+ each is refused.
+
+## Decisions at the planning interview (2026-10-03)
+
+1. No switch on the control panel's own unload route (it is the panel's Unload
+ button too, and a script is indistinguishable from it); the route is
+ documented, and who can use it follows the panel-access setting of
+ itd-2610031004535845. Only the API route has a switch, on by default.
+2. Trusted callers only: exactly the callers the gateway already entitles.
+3. Pinned models are refused (the pin promise of itd-2609061441241254 holds).
+4. Models in their eviction-grace window are refused.
+5. Under itd-2610031004535845's "this account only", the API unload keeps the
+ API's own rule; other accounts' trusted programs can still unload.
+6. Recorded without a question (one defensible answer): the first
+ state-changing verb on `/v1` is settled by adr-2610031153127219:
+ its route, access rule and refusals.
+
+Typed links: refines itd-2609061441241254 (pins hold), itd-2609061441285238
+(grace holds), itd-2609211335097114 (unload behaviour) and itd-2610031004535845
+(the API unload stays under API rules); builds on itd-2609081259493890 (three
+surfaces); resolves the documentation half of iss-2610031018046897.
+
+## Open Questions
+
+_None open._
+
+## Audit Notes
+
+
+Fidelity review — receipt rcp-92f55f59b36b (verifier intent-auditor claude-opus-5-5).
+
+Provenance: intent-auditor@claude-opus-5-5 · rubric_hash sha256:effa65b3e9e88ff29433b443ec2be159522a8b0b71cf1434526514aa61edb13e · prompt_hash sha256:da5db8a92bc53a92f1b6154442389fd05929e569d5cf05ac4d16b3698e8f9809
+Input attestations: diff:8e4d2cc4806865dbfb371b11911225c40b649caa..66069c7172bf51f44d799c7b22a40e81e34da3e0 (PR #151, feat/api-unload)@sha256:2732c316c97a92cb22e40dbd6cb605d380ce8616ddcd5170109d57da09f12690; diff:f13e5f627fe8707ed8dc562e89159f195eddd706..fd608850e9c214d99203442590330d4365088823 (PR #158, feat/api-unload-setting)@sha256:15bdedc67d777a00c0ee93fd5db5d9a8c9de762cd3535d3b1d5d1cc45fd84b3b; repo:integrate/0.10.0 at dd929d8a7dbe0446792423b43594d8b84583561e@-;
+
+Acceptance rollup: MET 9 · MET_WITH_CONCERNS 2 · NOT_MET 0 · INCONCLUSIVE 0
+
+Per-criterion verdicts:
+- ac-1 — MET: On a keyless install a loopback caller is entitled via fromThisMachine, Release unloads an idle unpinned model, and the 200 answer carries status unloaded; gateway and pool tests both exercise it and pass.
+ evidence: internal/gateway/gateway.go:370 — "return g.admittedKeyed(r) || fromThisMachine(r)"
+ evidence: internal/gateway/gateway.go:1727 — "writeJSON(w, http.StatusOK, map[string]any{"status": "unloaded", "model": id})"
+ evidence: internal/runtime/release_test.go:42 — "if err := p.Release("Org/M", Caller{Kind: "this_mac"}); err != nil {"
+ evidence: internal/gateway/unload_test.go:95 — "func TestAProgramOnThisMacMayUnload(t *testing.T) {"
+- ac-2 — MET: The entitlement check runs first and returns one fixed 403 before the switch or the body is read; the test shows identical bodies for a loaded and an absent model, no release, and the same refusal with the switch off.
+ evidence: internal/gateway/gateway.go:1687 — "if !g.entitled(r) || (isLoopback(r.RemoteAddr) && !sameOriginFetch(r)) {"
+ evidence: internal/gateway/gateway.go:1688 — "writeError(w, http.StatusForbidden, "unloading a model is not available to this client")"
+ evidence: internal/gateway/unload_test.go:116 — "if b1 != b2 {"
+ evidence: internal/gateway/unload_setting_test.go:28 — "if code != http.StatusForbidden || strings.Contains(body, "turned off") {"
+- ac-3 — MET: withAuth refuses a missing key with 401 and tags a verified key; pairedOnly admits a paired client as keyed and tags it; tests cover keyless refusal, key-holder success, and a real paired client through TLSHandler.
+ evidence: internal/gateway/gateway.go:263 — "r = withCaller(r, runtime.Caller{Kind: stats.CallerAPIKey})"
+ evidence: internal/gateway/pairing.go:396 — "r = withCaller(r, runtime.Caller{Kind: stats.CallerPairedClient, Client: client.Name, Fingerprint: shortFingerprint(spki)})"
+ evidence: internal/gateway/unload_test.go:131 — "code != http.StatusUnauthorized"
+ evidence: internal/gateway/unload_setting_test.go:104 — "gp.TLSHandler(srv.registry).ServeHTTP(w, r)"
+- ac-4 — MET: Cross-site and same-site Sec-Fetch-Site, foreign Origin, rebound Host, form posts and text/plain JSON are all refused on keyless and keyed installs, with zero releases asserted; the route also requires application/json.
+ evidence: internal/gateway/unload_test.go:152 — "func TestAWebPageOnThisMacCannotUnload(t *testing.T) {"
+ evidence: internal/gateway/unload_test.go:177 — "if n := len(pool.releasedIDs()); n != 0 {"
+ evidence: internal/gateway/gateway.go:1702 — "writeError(w, http.StatusUnsupportedMediaType, `the body must be JSON, sent as "Content-Type: application/json"`)"
+- ac-5 — MET: Release checks the pin under the pool lock and refuses with ErrPinned, which the route maps to 409; the pool test shows a pinned model refused and still resident.
+ evidence: internal/runtime/pool.go:2515 — "if p.isPinnedLocked(e.repoID) {"
+ evidence: internal/runtime/release_test.go:34 — "if err := p.Release("org/m", Caller{Kind: "this_mac"}); !errors.Is(err, ErrPinned) {"
+ evidence: internal/runtime/release_test.go:38 — "if len(p.Resident()) != 1 {"
+- ac-6 — MET: Release refuses loading, in-flight (preemptible idle holds included, as they increment inFlight) and in-grace models without stopping anything; tests assert each refusal, a sub-500ms answer for loading, and that the model stays resident.
+ evidence: internal/runtime/pool.go:2521 — "return fmt.Errorf("%s is still loading: %w", repoID, ErrLoading)"
+ evidence: internal/runtime/pool.go:2523 — "if e.inFlight > 0 {"
+ evidence: internal/runtime/pool.go:2526 — "if !p.graceElapsedLocked(e, 0) {"
+ evidence: internal/runtime/release_test.go:69 — "if time.Since(start) > 500*time.Millisecond {"
+ evidence: internal/runtime/release_test.go:88 — "!errors.Is(err, ErrInGrace)"
+ evidence: internal/gateway/gateway.go:1721 — "writeError(w, http.StatusConflict, err.Error())"
+- ac-7 — MET: With APIUnloadOff set the route answers 403 to every entitled caller (others get the generic refusal), and a wired control-panel test shows /api/models/unload still unloading with the switch off.
+ evidence: internal/gateway/gateway.go:1697 — "if g.cfg().APIUnloadOff {"
+ evidence: internal/gateway/unload_setting_test.go:18 — "func TestTheSwitchOffRefusesTheRoute(t *testing.T) {"
+ evidence: internal/gateway/unload_setting_test.go:51 — "func TestTheSwitchOffLeavesThePanelsUnloadWorking(t *testing.T) {"
+- ac-8 — MET_WITH_CONCERNS: One key api_unload_off (omitempty, zero means on) is read by Go, shown by config show, and filled/posted by the panel; the settings-surface enumeration covers it. Concern: the panel half is held only by a source-regex test, and 'never refused over it' rests on Validate having no rule for the field, with no save-path test naming this key (uneditedFormBody omits it).
+ evidence: internal/config/config.go:426 — "APIUnloadOff bool `json:"api_unload_off,omitempty"`"
+ evidence: internal/ui/static/app.js:1456 — "$('setAPIUnload').checked = !c.api_unload_off;"
+ evidence: internal/ui/static/app.js:2184 — "api_unload_off: !$('setAPIUnload').checked,"
+ evidence: internal/gateway/unload_setting_test.go:38 — "func TestAnAbsentSwitchMeansOn(t *testing.T) {"
+ evidence: internal/ui/apiunload_test.go:14 — "regexp.MustCompile(`api_unload_off:\s*!\$\('setAPIUnload'\)\.checked`),"
+ evidence: internal/lifecycle/testdata/config_show.json:5 — ""api_unload_off": false,"
+ evidence: internal/archtest/settings_surface_test.go:99 — "func TestEverySettingHasAPanelControlOrAnExemption(t *testing.T) {"
+- ac-9 — MET: No DELETE route exists on /v1 (DELETE requests get 404/405 with nothing released), and an AST archtest holds that the gateway's Pool and Models interfaces carry no removing method and routes() registers no DELETE.
+ evidence: internal/gateway/unload_test.go:219 — "func TestNoClientCanDeleteAModelThroughTheAPI(t *testing.T) {"
+ evidence: internal/archtest/v1_no_delete_test.go:19 — "func TestNothingReachableFromV1CanDeleteAModel(t *testing.T) {"
+ evidence: internal/gateway/gateway.go:205 — "mux.HandleFunc("POST /v1/dessau/unload", g.handleUnload)"
+- ac-10 — MET_WITH_CONCERNS: A release is recorded as a removal with reason released and a validated caller kind (unknown kinds dropped, key never carried), and logged as 'model unloaded' reason=released by=< kind>. Concern: it is visible in the statistics store file and the log, not in the control panel's Statistics tab, which summarises evictions only.
+ evidence: internal/stats/stats.go:273 — "By string `json:"by,omitempty"`"
+ evidence: internal/app/app.go:1106 — "o.rec.Released(repoID, by.Kind)"
+ evidence: internal/app/app.go:1112 — "o.log.Info("model unloaded", "model", repoID, "reason", stats.ReasonReleased, "by", by.Kind)"
+ evidence: internal/stats/released_test.go:17 — "r.Released("org/a", "sk-a-key-in-the-wrong-place")"
+ evidence: internal/gateway/unload_setting_test.go:95 — "if strings.Contains(got[i].Kind+got[i].Client, "bh_secret") {"
+ evidence: internal/ui/static/app.js:2430 — "m.evictions ? `evicted ${m.evictions}×` : null,"
+- ac-11 — MET: docs/unload-reference.md names both routes in one table, says who can use each, lists every 409/403/401/404/400/415 refusal, the switch and the records; an archtest holds the page to both route names.
+ evidence: docs/unload-reference.md:11 — "| `POST /v1/dessau/unload` | A program on this Mac, a program that sends the API key, a paired client — unless the operator turns it off | A model nobody is relying on |"
+ evidence: docs/unload-reference.md:12 — "| `POST /api/models/unload` | A program on this Mac, through the control panel's own routes | Any loaded model that is not answering a request |"
+ evidence: docs/unload-reference.md:44 — "### When it is refused"
+ evidence: internal/archtest/unload_docs_test.go:12 — "func TestTheUnloadPageNamesBothRoutes(t *testing.T) {"
+
+Gap audit:
+- honoured:
+ - A program can free a model's memory without anyone opening the control panel
+ evidence: internal/gateway/gateway.go:1717 — "if err := g.pool.Release(id, callerOf(r)); err != nil {"
+ - A pinned or just-used model stays where it is
+ evidence: internal/runtime/pool.go:2515 — "if p.isPinnedLocked(e.repoID) {"
+ evidence: internal/runtime/pool.go:2526 — "if !p.graceElapsedLocked(e, 0) {"
+ - A device Dessau does not trust is refused
+ evidence: internal/gateway/gateway.go:1687 — "if !g.entitled(r) || (isLoopback(r.RemoteAddr) && !sameOriginFetch(r)) {"
+ - Alice can turn this off in Settings
+ evidence: internal/ui/static/index.html:694 — "< legend>Unloading from a program< /legend>"
+ - The previously undocumented control-panel unload route is documented
+ evidence: docs/unload-reference.md:86 — "## `POST /api/models/unload`"
+- diverged:
+ - Alice reads who released a model in 'the statistics': delivered in the statistics store file and the server log, not in the control panel's Statistics tab
+ evidence: docs/statistics-store-reference.md:106 — "| `by` | On `released` only: the kind of caller that asked the model API to unload the model"
+ evidence: internal/ui/static/app.js:2430 — "m.evictions ? `evicted ${m.evictions}×` : null,"
+- missing: (none)
+
+Scope-condition dispositions:
+- cond-2610031153302285 — survived: The route admits exactly the gateway's existing entitlement (keyed admission, paired client, or fromThisMachine) and refuses a keyless network caller and a browser page.
+ evidence: internal/gateway/gateway.go:370 — "return g.admittedKeyed(r) || fromThisMachine(r)"
+ evidence: internal/gateway/unload_test.go:109 — "func TestAnotherDeviceOnAKeylessNetworkIsRefusedAndLearnsNothing(t *testing.T) {"
+- cond-2610031153304419 — survived: Release refuses not-loaded, pinned, loading, in-flight and in-grace models under the pool lock before stopping anything.
+ evidence: internal/runtime/pool.go:2529 — "p.stopEntryByLocked(e, StopReleased, &by)"
+ evidence: internal/runtime/release_test.go:15 — "func TestReleaseUnloadsOnlyAModelNobodyIsRelyingOn(t *testing.T) {"
+- cond-2610031153300110 — survived: Release decides on the model's state alone; the Caller is passed through only for the record and the pool reads nothing in it, so any trusted caller may release any eligible model.
+ evidence: internal/runtime/observer.go:82 — "// passes it on and reads nothing in it."
+ evidence: internal/gateway/unload_test.go:97 — "code, body := serveUnload(g.Handler(), unloadRequest(thisMac, `{"model":"org/warm"}`, nil))"
+- cond-2610031153308005 — survived: The only added /v1 route is the unload POST, whose body is just a model id; no DELETE or load/change verb exists and the archtest holds it.
+ evidence: internal/gateway/gateway.go:205 — "mux.HandleFunc("POST /v1/dessau/unload", g.handleUnload)"
+ evidence: internal/archtest/v1_no_delete_test.go:19 — "func TestNothingReachableFromV1CanDeleteAModel(t *testing.T) {"
+
+
+## Grounds
+
+- pursued: programs that test several models in turn have to wait for someone to click Unload, as happened on 2026-10-03; shown wrong if test tools never use it. And on one Mac a model left loaded after a test blocks the next, so freeing it promptly lets more models be tried; shown wrong if test rounds are no faster with it.
diff --git a/.abcd/development/specs/open/spc-2610030929021692-dessau-tells-alice-when-a-model-she-downloaded-has-a-newer.md b/.abcd/development/specs/closed/spc-2610030929021692-dessau-tells-alice-when-a-model-she-downloaded-has-a-newer.md
similarity index 100%
rename from .abcd/development/specs/open/spc-2610030929021692-dessau-tells-alice-when-a-model-she-downloaded-has-a-newer.md
rename to .abcd/development/specs/closed/spc-2610030929021692-dessau-tells-alice-when-a-model-she-downloaded-has-a-newer.md
diff --git a/.abcd/development/specs/open/spc-2610031153301961-a-program-that-finished-with-a-model-can-tell-dessau-to.md b/.abcd/development/specs/closed/spc-2610031153301961-a-program-that-finished-with-a-model-can-tell-dessau-to.md
similarity index 100%
rename from .abcd/development/specs/open/spc-2610031153301961-a-program-that-finished-with-a-model-can-tell-dessau-to.md
rename to .abcd/development/specs/closed/spc-2610031153301961-a-program-that-finished-with-a-model-can-tell-dessau-to.md
diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md
index b5c71e31..33ecc988 100644
--- a/.abcd/work/DECISIONS.md
+++ b/.abcd/work/DECISIONS.md
@@ -443,3 +443,12 @@
- 2026-10-04 — **The gateway may refuse a `/v1/completions` prompt that is not a JSON string** (iss-2610040805353721; `impact: internal` for this record; the fix is `breaking`). Reproduced on the Mac first, per the maintainer's REPRODUCE FIRST practice: on v0.9.3 with mlx-lm 0.32.0, `["hi"]` and `[""]` each froze the model server (no answer to it or to the normal prompt after it), while `[72,73]` was refused by mlx-lm with 404 and the server stayed healthy. That meets the condition the maintainer set on 2026-10-03 (refuse if it crashes the server), so the refusal goes ahead: a new ADR partly superseding adr-2610040749545010, reading the prompt's JSON kind and nothing else, and a gateway refusal with a 400 and a test that fails first. Built by the next session; nothing is built here.
- 2026-10-04 — **The frozen-server measurement is done and the record closes** (iss-2610040752568866; `impact: internal`). A frozen mlx-lm 0.32.0 server answers `/health` with 503, and main's health watch (1ff92cc9) stops it within one tick; the failing request gets a 502 in 1.2 s, not a hang. Measured on a scratch build of main on the maintainer's Mac; details on the record.
- 2026-10-04 — **The panel's attribution spike passes, so step 2 of itd-2610031004535845 may start** (spc-2610031016319710; `impact: internal`). On the maintainer's Mac, `peerIsThisAccount` attributed Safari, Brave (for Chrome), Firefox, curl and Go's HTTP client in the browsers' own account over `127.0.0.1` and `::1`, and refused curl run as another account; median lookup 0.34 ms, maximum 2.9 ms (the other-account miss). Brave resolves `localhost` to `::1`, so step 2's loopback rule covers both families. Details in the spec.
+- 2026-10-04 — **A bridged history opens on the user, held in buildRequest** (iss-2610032306154631; branch `fix/bridge-history-opens-on-user`; `impact: fix`). The turn count, the store's byte budget and the window trim all drop one turn at a time from the front, and the turn count is even while a question is appended before it is asked, so past 16 exchanges a channel sent a history opening on an assistant turn whenever the window did not cut further — which Gemma- and Mistral-style templates raise on. The fix is in `buildRequest`, the one place every trimmed history passes through on its way to the gateway: a cut starts only on a user turn, and the fallback that sends one truncated turn sends the newest user turn. The store itself may still hold a leading answer; it is never sent, and trimming the store in pairs would have touched two drop paths and the byte accounting for no difference in what the gateway sees. What would show this wrong: a bridged request whose `messages[0].role` is `assistant`.
+- 2026-10-04 — **The self-test honours pins at its unload through the probe's own check** (iss-2610032241098944; branch `fix/selftest-honours-pins`; `impact: fix`). The self-test's adapter called the pool's Unload, the operator's, which ignores pins, so a model the self-test loaded and a person pinned mid-run was unloaded at the run's end, pinned and not loaded. Both idle jobs' adapters now unload through one App helper, `unloadUnpinned`, which refuses a pinned model with the job's own `ErrPinned` (`contextprobe.ErrPinned`, and a new `selftest.ErrPinned`); the probe's behaviour is unchanged. Not the pool's `Release`, which also refuses a model inside its eviction grace and so could refuse the self-test's just-used model. The check and the stop are two calls, as the probe's are, so a pin saved in the instant between them is still lost; closing that needs a pool-side primitive under the pool's lock, an `internal/runtime` change left for a decision. What would show this wrong: TestTheSelfTestLeavesAModelPinnedDuringItsRunLoaded failing, or a pinned model unloaded at the end of a self-test run.
+- 2026-10-04 — **The gateway refuses a `/v1/completions` prompt that is not a JSON string, reading its kind and nothing else** (iss-2610040805353721; adr-2610042021365934, partly superseding adr-2610040749545010; branch `fix/refuse-non-string-prompt`; `impact: breaking`). Built on the maintainer's decision recorded earlier today, after `["hi"]` and `[""]` froze mlx-lm 0.32.0 on the Mac and `[72,73]` was refused upstream with 404. `emptyConversation` in `internal/gateway/emptyprompt.go` now refuses, with 400 and `"prompt" must be a string`, a prompt whose raw value does not begin with a quote — an array of any kind, a number, an object, a boolean, `null` — before a model is resolved; it never looks inside an array, and decodes only a value that is already a string, for the emptiness test. A missing prompt and the chat and bridge paths are unchanged. An empty array, refused before as empty, is now refused as not a string; `null` was already refused, as empty, because it decodes to an empty Go string. The readers' list entry names the wider reading. What would show this wrong: a later mlx-lm pin that generates from a list prompt, which this would then withhold, or a frozen server after a string prompt this check let through.
+- 2026-10-04 — **A context probe's bounds carry the provenance they were made under, and are dropped when it moves** (iss-2610032241096901; branch `fix/probe-resume-clamps-window`; `impact: fix`). The record offered two remedies: clamp `b.hi` to the served window on resume, or drop bounds made under another provenance. Dropping fits the measurement model in `internal/registry/measurement.go`, where a figure holds only under the runtime, budget, decode concurrency and served window it was taken under and moving any one makes it stale; clamping repairs only `hi`, leaves `lo` verified above the new window and a guard reading taken under the old budget, and still stamps the mixed result current. So the bounds record `Sources.Provenance` at creation; a resume under a different one starts over, a run whose provenance moves with no yield between saves nothing and stays queued or due, and a saved figure is stamped with the bounds' provenance, so a move after the last check is caught by the staleness refresh instead of being stamped current. Reproduced by two tests that saved 32,231 and 64,365 tokens under a served window of 8,192 before the change. What would show this wrong: a saved measurement whose window is above the served window it is stamped with.
+- 2026-10-04 — **A bridged history alternates, and the single-turn fallback is measured encoded, both held in buildRequest** (iss-2610042030092101, iss-2610042030098334; branch `fix/bridge-history-alternates`; `impact: fix`). Found by the independent review of `fix/bridge-history-opens-on-user`. A refused or empty answer leaves its question unanswered, so the next message sent two user turns in a row, which strict-alternation templates (Gemma, Mistral v0.1/v0.2) raise on until `/reset`: `buildRequest` now sends only the newest of a run of user turns, rather than `answer.go` popping the unanswered turn, which would have had to prove the turn was still the one it appended after the store's budget or `/reset` had moved underneath it; the store keeps what it holds. The fallback that sends one truncated turn cut it to `budget/4` runes, so four-byte runes and six-byte JSON escapes went over the window and plain text used a quarter of it: it now keeps the longest tail whose encoded body fits the budget, found by halving. What would show this wrong: a bridged request with two adjacent `user` turns, or a fallback body over `(window - max_tokens) * 4` bytes.
+- 2026-10-04 — **The self-test passes over a model that is pinned and not loaded** (iss-2610032241098944; branch `fix/selftest-skips-unloaded-pins`; `impact: fix`). The refusal above made the self-test cold-load a pinned model that was not in memory, the normal state after a restart, and then refuse to unload it, leaving it resident and unevictable. Of the remedies, the maintainer chose option (b): the loop's `next()` skips a model that is pinned and not resident, read from the tick's own view of the pool, so a pinned model is measured only while it is already in memory and is then left there (the `wasResident` path); the during-run refusal stays. Why: it mirrors the context probe, which drops pinned candidates, through the same pin (`App.isPinned`, now `selftest.Server.Pinned`), and the self-test never becomes a loader of pinned models. The pool's view and the load are still two steps, so a pinned model unloaded by hand in the instant between them is loaded and kept. What would show this wrong: TestTheSelfTestDoesNotLoadAModelPinnedAndNotLoaded failing, or a pinned model resident after a self-test run that found it not loaded.
+- 2026-10-04 — **A context probe's bounds carry the provenance they were made under, and are dropped when it moves** (iss-2610032241096901; branch `fix/probe-resume-clamps-window`; `impact: fix`). The record offered two remedies: clamp `b.hi` to the served window on resume, or drop bounds made under another provenance. Dropping fits the measurement model in `internal/registry/measurement.go`, where a figure holds only under the runtime, budget, decode concurrency and served window it was taken under and moving any one makes it stale; clamping repairs only `hi`, leaves `lo` verified above the new window and a guard reading taken under the old budget, and still stamps the mixed result current. So the bounds record `Sources.Provenance` at creation, and take their ceiling `hi` from that same read's served window rather than from the candidate list, so the bound and the stamp cannot come from two reads either side of a move; a resume under a different one starts over, a run whose provenance moves with no yield between saves nothing and stays queued or due, and a saved figure is stamped with the bounds' provenance and judged against what is in force once it is written (`probeSources.Save` re-judges after `SetMeasurement`), so a settings save landing between the last check and the save marks the figure stale instead of leaving it current; the settings save's own re-judging may already have run by then, so it alone does not catch that move (amended after the independent review). Reproduced by two tests that saved 32,231 and 64,365 tokens under a served window of 8,192 before the change. What would show this wrong: a saved measurement whose window is above the served window it is stamped with.
+- 2026-10-04 — **A settings save re-judges measurements after the pool takes the new budget** (iss-2610042040451551; branch `fix/probe-provenance-followup`; `impact: fix`). `SetConfig` ran `refreshStaleness` before `Pool.SetMemoryBudget`, and the provenance's `BudgetBytes` reads the pool, so a budget save was judged against the old budget: a figure stamped 82,463,372,083 bytes read current under 41,231,686,041 until the next start. Not every figure, though: the provenance's `ServedContext` comes from `ServedWindow`, which derives an automatic window from the configuration's budget (`memoryBudget`, that is `enforcedBudget(Config().MaxResidentBytes)`), already stored by then, so a model whose automatic window follows the budget did read stale (`served_context`). The miss was a model with a set window, no KV charge, or an automatic window clamped at its declared size. Two remedies: move the re-judging after the budget is applied, or have `provenanceFor` read `BudgetBytes` from the configuration as well. The pool's figure was kept: it is what enforces the budget, the probe's memory guard measures against it (`probeSources.Available`), and `TestTheProvenanceIsWhatThePoolAndTheRuntimeSay` pins the provenance to it. So the provenance reads two homes, the pool for the budget and the configuration for the served window. They agree once a save has finished (the app is the pool's only writer and hands it `enforcedBudget` of the stored figure), but not between the configuration's store and `SetMemoryBudget`. A probe reading the provenance in that interval gets a mixed reading: its check before saving rejects a figure whose bounds were made under another reading, and a figure stamped with the mixed reading itself is judged against the finished settings by the re-judging that now runs last. That is why the reordering is still right: the re-judging moved to after `SetMemoryBudget` and `RefreshCharges`, once every setting the save changed is in force, and never sees a mixed reading. It also lifts a recorded load failure at once when a save moves only the budget, which `docs/context-probe.md` already promised (`TestABudgetOnlySaveLiftsALoadFailure`); before, the mark stood until the next start. What would show this wrong: a save that moves the budget leaving a measurement stamped with the old budget reading current.
+- 2026-10-04 — **The 0.10.0 cut, codename Prellerhaus**, by hand on the 0.9.3 precedent, on the maintainer's instruction of 2026-10-04 ("autonomously cut a new release", then "fix all four, then cut"). The version is a minor because the release carries `impact: breaking` entries (the refused sampling values, the empty-prompt and non-string prompt refusals, the mlx-lm 0.32.0 runtime, the removal of shared-cache mode among them) and the project is below 1.0; `build/CODENAME` is unchanged, as at 0.8.0 and 0.9.0. Before the roll, the four fixes the 2026-10-04 handoff named as release essentials were built test-first on their own lanes, each reviewed independently (Opus; the gateway change adversarially on Fable) and merged on `integrate/0.10.0`: the non-string completions prompt refusal (iss-2610040805353721, adr-2610042021365934), a bridged history that opens on the user (iss-2610032306154631), the self-test honouring pins (iss-2610032241098944), and a resumed context probe dropping bounds made under other settings (iss-2610032241096901). The reviews' findings were fixed in the same cut rather than stepped over: a stuck Discord channel after an unanswered message (iss-2610042030092101, major), the bridge fallback's byte budget (iss-2610042030098334), the self-test loading a pinned model it could not then unload (fixed by skipping pinned, unloaded models, mirroring the probe), a budget change judged against the old budget (iss-2610042040451551); smaller ones were captured with remedies. itd-2610030857275099 and itd-2610031024247803 ship (specs closed, fidelity reviews ingested; criterion 9 of the first is recorded not met, pending itd-2610030656210408 step 2, iss-2610042101435880). The two open majors are deferred out loud with `deferred_after: "v0.10.0"`: iss-2609190242198542 (four of six live Discord checks passed on 2026-10-04; two postponed by the maintainer) and iss-2609200815308397 (the client builds and its script checks pass; the appearance switch is unchecked by hand). The roll is manual, as `abcd launch` still cannot cut this repository; the receipts protocol does not apply because `release.yml` arms no semantic gate. Retention after verification deletes the v0.9.3 release and keeps its tag.
diff --git a/.abcd/work/issues/open/iss-2609190242198542-the-six-live-checks-the-discord-bridge-needs-against-real-di.md b/.abcd/work/issues/open/iss-2609190242198542-the-six-live-checks-the-discord-bridge-needs-against-real-di.md
index 78600ea2..ab5ff9fe 100644
--- a/.abcd/work/issues/open/iss-2609190242198542-the-six-live-checks-the-discord-bridge-needs-against-real-di.md
+++ b/.abcd/work/issues/open/iss-2609190242198542-the-six-live-checks-the-discord-bridge-needs-against-real-di.md
@@ -9,8 +9,8 @@ found_during: "fidelity audit of itd-2609180959397172"
origin: researcher-authored
production_mode: hand-written
found_at: ".abcd/work/DECISIONS.md"
-deferred_after: "v0.9.3"
-deferral_reason: "Re-deferred at the 0.9.3 cut: the six live Discord checks need the maintainer's hand and a real Discord server; the maintainer tests by hand against this release; recorded here rather than stepped over."
+deferred_after: "v0.10.0"
+deferral_reason: "Re-deferred at the 0.10.0 cut: four of the six live checks passed against v0.9.3 on 2026-10-04 (a real bot connecting, a mention and a direct message answered, a long answer split across messages, the slash commands); the sleep-and-wake resume and the rate-limit check were postponed by the maintainer because the Mac had to stay awake for another release, and run against 0.10.0."
---
The six live checks the Discord bridge needs against real Discord are owed and none has been done: a real bot with a real token connecting at all; a mention in a real channel answered; a long answer cut and continued as Discord renders it; a resume across the Mac actually sleeping and waking; the slash commands appearing in Discord's own UI; and the edit cadence holding against Discord's real rate limits rather than a fake's headers. The shipping decision line at .abcd/work/DECISIONS.md:301 names all six as OWED and never claims them done, which is honest, but nothing in the issue ledger tracks them, so the intent is shipped with six of its acceptance criteria resting on a fake gateway. Six of the eleven criteria are MET_WITH_CONCERNS in the fidelity verdict rcp-91c0608082bd for exactly this reason. Until they are done, no user-facing prose should say the bridge is verified against Discord.
@@ -26,3 +26,7 @@ maintainer is the six checks done once, each outcome written into the spec's
Verification and a dated decision line — or a decision that the bridge ships
with them owed, which is what the shipping line already says and which this
record then tracks until it changes.
+
+## Deferral 2026-10-04
+
+Re-deferred at the 0.10.0 cut. On 2026-10-04 the maintainer ran four of the six checks against v0.9.3 with a real bot in a test server: the bot connected; a mention and a direct message were answered; a long answer was cut at paragraphs across several messages; `/model` listed and switched models and `/reset` cleared the channel. The sleep-and-wake resume (a 73-second sleep held the connection, so no resume was exercised) and the rate-limit check were postponed by the maintainer because the Mac had to stay awake for another release's cut. Findings from the session are captured separately. The remaining two checks run against 0.10.0.
diff --git a/.abcd/work/issues/open/iss-2609200815308397-switching-appearance-from-system-to-dark-and-back-to-system.md b/.abcd/work/issues/open/iss-2609200815308397-switching-appearance-from-system-to-dark-and-back-to-system.md
index aec2a78d..14bc47ee 100644
--- a/.abcd/work/issues/open/iss-2609200815308397-switching-appearance-from-system-to-dark-and-back-to-system.md
+++ b/.abcd/work/issues/open/iss-2609200815308397-switching-appearance-from-system-to-dark-and-back-to-system.md
@@ -9,8 +9,12 @@ found_during: "maintainer manual test of the chat client, 2026-09-20"
origin: researcher-authored
production_mode: hand-written
found_at: "client/DessauChat/DessauChat.swift"
-deferred_after: "v0.9.3"
-deferral_reason: "Re-deferred at the 0.9.3 cut: a client appearance defect (System to Dark to System leaves Settings dark) found in the maintainer's manual test; the maintainer tests by hand against this release, and the fix is next up in the client lanes; recorded here rather than stepped over."
+deferred_after: "v0.10.0"
+deferral_reason: "Re-deferred at the 0.10.0 cut: the Swift client builds and its 46 script checks pass (2026-10-04), but the appearance switch has not been checked by hand in the built app; it is a client defect outside this server cut's fixes, and the maintainer tests it by hand against this release."
---
Switching Appearance from System to Dark and back to System leaves the Settings window dark and blanks the main window's chat content: the main window turns light but shows no messages until the Settings window is closed, at which point the conversation reappears. Seen on macOS with the Settings window open beside the main window; the Appearance picker reads System while the Settings window is still rendered dark. Every window applies preferredColorScheme from the same AppStorage value, so a nil scheme (System) after an explicit one is not re-evaluated the same way in each scene.
+
+## Deferral 2026-10-04
+
+Re-deferred at the 0.10.0 cut. On 2026-10-04 the Swift client built with `client/build.sh` against the macOS 27 SDK and the four `client/tests` scripts passed (46 checks), but the System to Dark to System switch has not been checked by hand in the built app. It is a client defect, outside this server cut's fixes; the maintainer tests it by hand against this release.
diff --git a/.abcd/work/issues/open/iss-2610041338330838-discord-answers-fill-in-in-jumps-not-word-by-word-the-bridge.md b/.abcd/work/issues/open/iss-2610041338330838-discord-answers-fill-in-in-jumps-not-word-by-word-the-bridge.md
new file mode 100644
index 00000000..839188c1
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610041338330838-discord-answers-fill-in-in-jumps-not-word-by-word-the-bridge.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610041338330838"
+slug: "discord-answers-fill-in-in-jumps-not-word-by-word-the-bridge"
+severity: "minor"
+category: "ux"
+source: "manual-test"
+found_during: "live Discord checks (iss-2609190242198542), 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/bridge/discord/editor.go"
+remedy: "Measure the bridge's edit cadence against Discord's per-channel rate limit and tighten it to the fastest rate that holds, so the answer fills in as smoothly as Discord allows."
+---
+
+Discord answers fill in in jumps, not word by word. The bridge posts a placeholder and edits in the text at most every 2 s (minEditInterval in internal/bridge/discord/editor.go), so a reply appears as a few large chunks. The maintainer wants it to read like a live stream, word by word. Discord has no streaming API and rate-limits message edits per channel, so word-by-word can only be approximated by a tighter edit cadence. Seen during the live Discord checks on 2026-10-04 (v0.9.3).
diff --git a/.abcd/work/issues/open/iss-2610041457075978-a-streamed-v1-chat-completions-request-admitted-inside-the.md b/.abcd/work/issues/open/iss-2610041457075978-a-streamed-v1-chat-completions-request-admitted-inside-the.md
new file mode 100644
index 00000000..a9d2d504
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610041457075978-a-streamed-v1-chat-completions-request-admitted-inside-the.md
@@ -0,0 +1,66 @@
+---
+schema_version: 1
+id: "iss-2610041457075978"
+slug: "a-streamed-v1-chat-completions-request-admitted-inside-the"
+severity: "minor"
+category: "bug"
+source: "agent-finding"
+found_during: "peer session's local brief-to-records run, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/registry/measurement.go"
+remedy: "Reproduce on main; if the stall is memory, refuse at admission a request whose projected peak footprint exceeds the memory budget (the projection the probe's memory guard already makes), naming the largest request that fits, instead of admitting it by the served window."
+---
+
+A streamed /v1/chat/completions request admitted inside the served window, but above the size the memory guard stopped the context probe at, stalled for 64 minutes and held the model. Qwen3-Coder-Next-4bit on v0.9.3 has served_context 58260 and measured_context 21606, with measured_bound memory_guard. A request with about 29.5k estimated prompt tokens and requested_tokens 48212 was admitted, produced a first token after 44 s, then nothing until the client gave up after 3875 s (class cancelled, 0 completion tokens counted). in_flight stayed 1 throughout, so the model could not be unloaded. The memory-guard figure is a verified floor rather than a ceiling (docs/context-probe.md), so this is evidence that admission by the served window can let through a request whose projected footprint the guard already judged too large. It is not proof that everything above the measured figure fails. Seen by a peer session's scripted run on 2026-10-04; not reproduced on main.
+
+## Second case, 2026-10-04: under the measured window, so not the memory guard alone
+
+From the same peer run, verified against `/api/stats` and `/v1/models`:
+
+- **Ternary-Bonsai-27B-mlx-2bit**, streamed, thinking off. About 29.5k
+ estimated prompt tokens, requested_tokens 45065, served_context 55113.
+- **Its probe stopped at measured_context 44560 with measured_bound
+ `served_window`**, not `memory_guard`. So this prompt sat well inside both
+ the served window and the measured one.
+- **Same shape as the first case:** first token after 128 s, then nothing
+ until the client's 15-minute idle timeout ended it at 1361 s (class
+ `cancelled`, 0 completion tokens counted).
+- **No other request ran during either stall.** Each was the only request
+ in flight, so contention is ruled out.
+- **For contrast:** Nemotron-3.5-Lightning completed a 29.5k-token prompt in
+ 198 s in the same run (first token at 28 s, 17,110 tokens out). The same
+ Bonsai served 2–10k-token prompts normally.
+
+So the memory-guard explanation does not cover the Bonsai case. The common
+factors are a ~30k-token streamed prompt, a first token, and then silence.
+The remedy's first step, reproducing on main, now needs to tell apart three
+possibilities: decode stalling at that context in these two models; the
+model server's stream stalling after its first chunk; and the gateway's
+relay holding back what it receives. The record's remedy may change once
+that is known.
+
+## Third case, 2026-10-04: Bonsai wedges on a small prompt and stays wedged
+
+From the peer's chapter-sized re-run (17:06–18:44), verified against `/api/stats`:
+
+- **Qwen3-Coder-Next-4bit:** all 8 chapter requests completed (1.6k–8.6k
+ estimated prompt tokens, first token in 2–18 s). For this model the ~30k
+ prompt in the first case stays the suspect.
+- **Ternary-Bonsai-27B-mlx-2bit stalled on its first chapter:** about 1.8k
+ estimated prompt tokens. The first token arrived after 10 s, then nothing
+ until the client cancelled at 1180 s.
+- **It then answered nothing at all.** Each of the next 7 requests to it
+ (17:34–18:34) ended as class `unreachable` after exactly 600 s, with no
+ first token, until an unload at 18:44 cleared it.
+
+So, at least for Bonsai, prompt size is not the cause. A streamed request
+can leave the model server wedged, and it stays wedged until an operator
+unloads it. That is the frozen-server shape of iss-2610040752568866, seen on
+v0.9.3, which has no health watch.
+
+Whether main's health watch would catch this depends on what `/health`
+answers in this state. The earlier measurement covered only a generation
+thread that had died, not one that hangs. That is the first thing to
+establish when reproducing. Severity is worth reconsidering: a model that
+stops answering until someone notices and unloads it is more than minor.
diff --git a/.abcd/work/issues/open/iss-2610041945020793-discord-bridge-answers-stop-silently-at-a-fixed-1-024-token.md b/.abcd/work/issues/open/iss-2610041945020793-discord-bridge-answers-stop-silently-at-a-fixed-1-024-token.md
new file mode 100644
index 00000000..8ef4608d
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610041945020793-discord-bridge-answers-stop-silently-at-a-fixed-1-024-token.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610041945020793"
+slug: "discord-bridge-answers-stop-silently-at-a-fixed-1-024-token"
+severity: "minor"
+category: "bug"
+source: "manual-test"
+found_during: "live Discord checks (iss-2609190242198542), 2026-10-04, v0.9.3"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/bridge/discord/conversation.go"
+remedy: "Size the bridge's max_tokens from the model's served window, as conversation.go already does for the history, instead of a fixed 1024; when an answer still stops on length, say so in the reply rather than ending silently or reporting that the model answered with nothing."
+---
+
+Discord bridge answers stop silently at a fixed 1,024-token cap. Every bridged request asks for max_tokens = answerTokens (1024, internal/bridge/discord/conversation.go, the same on main), whatever the model's served window. When the model reaches it the reply simply ends: mid-sentence, sometimes with markdown left open (an unclosed **). With a reasoning model the hidden reasoning spends the budget first. Nemotron-3.5-Lightning used 404 tokens to say a one-line hello, and one reply was 'The model answered with nothing.' (1,024 tokens, all reasoning). Seen 2026-10-04: three Nemotron answers and one Qwen3-Coder-Next answer each stopped at exactly 1,024 tokens.
diff --git a/.abcd/work/issues/open/iss-2610041945030524-the-discord-bridge-ignores-edited-messages-and-the-docs-do.md b/.abcd/work/issues/open/iss-2610041945030524-the-discord-bridge-ignores-edited-messages-and-the-docs-do.md
new file mode 100644
index 00000000..a295a315
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610041945030524-the-discord-bridge-ignores-edited-messages-and-the-docs-do.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610041945030524"
+slug: "the-discord-bridge-ignores-edited-messages-and-the-docs-do"
+severity: "nitpick"
+category: "documentation"
+source: "manual-test"
+found_during: "live Discord checks (iss-2609190242198542), 2026-10-04, v0.9.3"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "docs/discord-bridge.md"
+remedy: "State in docs/discord-bridge.md that the bot reads a message only when it is sent, never an edit, so a mention or question added by editing is not answered and must be sent again as a new message."
+---
+
+The Discord bridge ignores edited messages, and the docs do not say so. A message that gains its mention of the bot by an edit, or whose question is changed after sending, is never answered: the bridge reads a message only when it is created. That is a reasonable design, but docs/discord-bridge.md is silent on it, so to a user it looks like the bot has stopped working. Seen 2026-10-04 with an edited '@Dessau tell me more about epoché'.
diff --git a/.abcd/work/issues/open/iss-2610041945030758-the-idle-clock-appears-not-to-count-client-requests-that-end.md b/.abcd/work/issues/open/iss-2610041945030758-the-idle-clock-appears-not-to-count-client-requests-that-end.md
new file mode 100644
index 00000000..1675896b
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610041945030758-the-idle-clock-appears-not-to-count-client-requests-that-end.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610041945030758"
+slug: "the-idle-clock-appears-not-to-count-client-requests-that-end"
+severity: "minor"
+category: "bug"
+source: "agent-observation"
+found_during: "tracing an unexplained model load after a peer's run, 2026-10-04, v0.9.3"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/app/contextprobe.go"
+remedy: "Read the idle check the self-test and context probe share; if it ignores requests that fail before reaching a model, count every client request toward the idle clock, with a test that a request ending unreachable resets it."
+---
+
+The idle clock appears not to count client requests that end unreachable. On 2026-10-04 a client's seven requests to a wedged Bonsai each ended as class unreachable after 600 s, the last at 18:44:05. The idle-time jobs (self-test, context probe) share idle_threshold_sec, which was 3600 here, yet the self-test started at 18:45, about a minute later. The last request that reached a model ended at 17:34, so the clock seems to have counted only those. A client that is retrying against a stuck model is not idle, and an idle job loading models around it adds load at the worst moment. Inferred from /api/stats and /api/state timings; the idle check itself was not read.
diff --git a/.abcd/work/issues/open/iss-2610041945032954-a-discord-message-that-mentions-the-bot-s-role-not-the-bot.md b/.abcd/work/issues/open/iss-2610041945032954-a-discord-message-that-mentions-the-bot-s-role-not-the-bot.md
new file mode 100644
index 00000000..6d0f55ce
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610041945032954-a-discord-message-that-mentions-the-bot-s-role-not-the-bot.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610041945032954"
+slug: "a-discord-message-that-mentions-the-bot-s-role-not-the-bot"
+severity: "minor"
+category: "ux"
+source: "manual-test"
+found_during: "live Discord checks (iss-2609190242198542), 2026-10-04, v0.9.3"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/bridge/discord/answer.go"
+remedy: "Find out whether Discord delivers the content of a message that mentions the bot's managed role to a bot without the message-content intent; if it does, answer it as a mention of the bot; if it does not, say in docs/discord-bridge.md that the bot must be picked from the popup, not the role."
+---
+
+A Discord message that mentions the bot's role, not the bot, is silently ignored. When the bot joins a server Discord creates a managed role with the same name ('Dessau'). Typing @Dessau without picking from the popup can attach the mention to that role. The bridge answers only when msg.Mentions holds the bot user (internal/bridge/discord/answer.go), so the message gets no answer and no hint why. Confirmed 2026-10-04: three such messages went unanswered, and clicking one showed Discord's role pop-up ('Dessau — 1').
diff --git a/.abcd/work/issues/open/iss-2610041945033770-the-context-probe-measures-whether-a-model-accepts-and.md b/.abcd/work/issues/open/iss-2610041945033770-the-context-probe-measures-whether-a-model-accepts-and.md
new file mode 100644
index 00000000..03a5af47
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610041945033770-the-context-probe-measures-whether-a-model-accepts-and.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610041945033770"
+slug: "the-context-probe-measures-whether-a-model-accepts-and"
+severity: "minor"
+category: "future-work-seed"
+source: "user-observation"
+found_during: "maintainer's request after the 2026-10-04 stall investigation; state-of-the-art check: RULER arXiv:2404.06654, HELMET"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/app/contextprobe.go"
+remedy: "Extend the context probe, rather than adding a new tool, with a usability check at its measured size: RULER-style multi-needle retrieval at several depths (arXiv:2404.06654), scored and recorded beside measured_context, so the window Dessau publishes is one the model retrieves from correctly, not only one it accepts."
+---
+
+The context probe measures whether a model accepts and answers a prompt of a given size, not whether it can use that much context. Its measured window says a prompt that long loaded, prefilled and got an answer before a bound stopped the probe. It does not show that the model retrieves or reasons over content near the start of that window. Published long-context evaluations show the two differ widely. RULER (arXiv:2404.06654) found models that score near-perfectly on vanilla needle-in-a-haystack retrieval yet degrade sharply as context grows, with few holding up at 32K despite claiming more. HELMET (2024, updated 2025) reaches the same conclusion with broader tasks. A served window set from the probe can therefore advertise context a model cannot actually use.
diff --git a/.abcd/work/issues/open/iss-2610041945034263-the-idle-time-self-test-runs-chat-benchmarks-on-non-chat.md b/.abcd/work/issues/open/iss-2610041945034263-the-idle-time-self-test-runs-chat-benchmarks-on-non-chat.md
new file mode 100644
index 00000000..69fb1e95
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610041945034263-the-idle-time-self-test-runs-chat-benchmarks-on-non-chat.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610041945034263"
+slug: "the-idle-time-self-test-runs-chat-benchmarks-on-non-chat"
+severity: "minor"
+category: "architectural-insight"
+source: "agent-observation"
+found_during: "tracing an unexplained model load after a peer's run, 2026-10-04, v0.9.3"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/selftest/selftest.go"
+remedy: "The maintainer decides whether non-chat models are self-tested at all: either skip every model that cannot chat, as Load now does (iss-2610031010371709), or give decision models their own short test through /v1/systemone once itd-2610030656210408 lands; record the choice in docs/self-test.md."
+---
+
+The idle-time self-test runs chat benchmarks on non-chat models. At 18:45 on 2026-10-04 it loaded mlx-community/clef-4bit, a decision model that cannot chat, to run pp512, tg128 and tg128xN through chat completions. Its previous self-test record for that model reads 'yielded'. A chat benchmark says nothing useful about a model that answers typed decisions, and it costs a cold load of a large model. Nothing records whether non-chat models (decision models, OCR models) should be self-tested at all.
diff --git a/.abcd/work/issues/open/iss-2610041945038826-a-decision-model-is-charged-about-five-times-its-size.md b/.abcd/work/issues/open/iss-2610041945038826-a-decision-model-is-charged-about-five-times-its-size.md
new file mode 100644
index 00000000..4aea2ee8
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610041945038826-a-decision-model-is-charged-about-five-times-its-size.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610041945038826"
+slug: "a-decision-model-is-charged-about-five-times-its-size"
+severity: "minor"
+category: "bug"
+source: "agent-observation"
+found_during: "tracing an unexplained model load after a peer's run, 2026-10-04, v0.9.3"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/runtime/pool.go"
+remedy: "Check what chargeLocked charges for a decision model; if it reserves a KV cache for a served chat window that the decision server never uses, charge decision models by their own measured peak instead, before itd-2610030656210408 ships."
+---
+
+A decision model is charged about five times its size against the memory budget. While the self-test loaded mlx-community/clef-4bit on 2026-10-04, /api/state showed bytes 16330982843 (about 16 GB on disk) and charge_bytes 82463242771 (about 82 GB). The charge probably includes a KV cache sized for a served chat window, which a decision model answering short typed questions never fills. An over-charge like this makes the pool refuse or delay other models for memory that is never used.
diff --git a/.abcd/work/issues/open/iss-2610041948272032-the-idle-threshold-cannot-be-set-above-one-hour-the-context.md b/.abcd/work/issues/open/iss-2610041948272032-the-idle-threshold-cannot-be-set-above-one-hour-the-context.md
new file mode 100644
index 00000000..71ccf47e
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610041948272032-the-idle-threshold-cannot-be-set-above-one-hour-the-context.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610041948272032"
+slug: "the-idle-threshold-cannot-be-set-above-one-hour-the-context"
+severity: "minor"
+category: "ux"
+source: "user-observation"
+found_during: "maintainer's request after the self-test loaded models straight after a peer's run, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/config/config.go"
+remedy: "Raise MaxIdleThresholdSec to 86400 (24 hours), changing the config validation, the panel's Idle threshold field and docs/context-probe.md together, with the three-surfaces test covering the new upper bound."
+---
+
+The idle threshold cannot be set above one hour. The context probe and the self-test share idle_threshold_sec, which is held to 60–3600 seconds (MaxIdleThresholdSec in internal/config/config.go, and max="3600" on the panel's Idle threshold field). The maintainer wants these jobs to run only after a long quiet spell, up to 24 hours, so they never load models during an ordinary break in use. Their threshold is already at the 3600 cap.
diff --git a/.abcd/work/issues/open/iss-2610041956214381-the-self-test-has-no-idle-threshold-of-its-own-and-the.md b/.abcd/work/issues/open/iss-2610041956214381-the-self-test-has-no-idle-threshold-of-its-own-and-the.md
new file mode 100644
index 00000000..36a8cd3b
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610041956214381-the-self-test-has-no-idle-threshold-of-its-own-and-the.md
@@ -0,0 +1,16 @@
+---
+schema_version: 1
+id: "iss-2610041956214381"
+slug: "the-self-test-has-no-idle-threshold-of-its-own-and-the"
+severity: "minor"
+category: "ux"
+source: "user-observation"
+found_during: "maintainer's request after the self-test loaded models straight after a peer's run, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/config/config.go"
+remedy: "Give the self-test its own idle threshold (a self_test_idle_threshold_sec setting beside idle_threshold_sec, which stays the context probe's), defaulting to 14400 seconds (4 hours), across config, the panel's Self-test box and docs/self-test.md together; it needs the raised cap from iss-2610041948272032."
+blocked_by: [iss-2610041948272032]
+---
+
+The self-test has no idle threshold of its own, and the shared default is too short for it. It waits for the same idle_threshold_sec as the context probe, whose default is 300 seconds (DefaultIdleThresholdSec in internal/config/config.go). So, out of the box, it loads and benchmarks a model five minutes after the last request: an ordinary pause in use. On 2026-10-04 it started loading models straight after a peer's run. The maintainer wants the two jobs separated, with the self-test waiting four hours by default, while the context probe keeps its own threshold.
diff --git a/.abcd/work/issues/open/iss-2610042033419572-the-self-test-s-and-the-context-probe-s-unloadunpinned.md b/.abcd/work/issues/open/iss-2610042033419572-the-self-test-s-and-the-context-probe-s-unloadunpinned.md
new file mode 100644
index 00000000..4ed63f85
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610042033419572-the-self-test-s-and-the-context-probe-s-unloadunpinned.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610042033419572"
+slug: "the-self-test-s-and-the-context-probe-s-unloadunpinned"
+severity: "minor"
+category: "bug"
+source: "impl-review"
+found_during: "independent review of fix/selftest-honours-pins, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/app/contextprobe.go"
+remedy: "Add a pool-level UnloadUnpinned in internal/runtime that checks the pin under the same hold of p.mu as the stop and refuses a pinned model with a sentinel the idle jobs map to their own ErrPinned; route unloadUnpinned through it. internal/runtime is a trust boundary, so the change needs an adversarial security review."
+---
+
+The self-test's and the context probe's unloadUnpinned (internal/app/contextprobe.go) checks the pin with isPinned and then stops the model with Pool.Unload; each takes the pool's lock separately, so a pin saved in the instant between the check and the stop is lost: the idle job unloads a model a person has just pinned, leaving it pinned and not loaded.
diff --git a/.abcd/work/issues/open/iss-2610042036091478-the-empty-prompt-check-refuses-a-prompt-made-only-of-unicode.md b/.abcd/work/issues/open/iss-2610042036091478-the-empty-prompt-check-refuses-a-prompt-made-only-of-unicode.md
new file mode 100644
index 00000000..9026685c
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610042036091478-the-empty-prompt-check-refuses-a-prompt-made-only-of-unicode.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610042036091478"
+slug: "the-empty-prompt-check-refuses-a-prompt-made-only-of-unicode"
+severity: "nitpick"
+category: "bug"
+source: "impl-review"
+found_during: "adversarial security review of fix/refuse-non-string-prompt, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/gateway/emptyprompt.go"
+remedy: "Measure on the Mac whether a prompt of only Unicode whitespace tokenises to nothing on the pinned runtime; if it does not, trim only ASCII whitespace in the emptiness check, with a test either way."
+---
+
+The empty-prompt check refuses a prompt made only of Unicode whitespace that the model server would answer. internal/gateway/emptyprompt.go trims a /v1/completions prompt with strings.TrimSpace, which treats U+00A0 (no-break space), U+3000 (ideographic space) and similar characters as space. So a prompt of only those is refused as empty (probed in the security review), although mlx-lm would tokenise it and answer. The check exists because a prompt that tokenises to nothing froze the model server on the Mac; a no-break-space prompt tokenises to something. It fails safe (a refusal, not a crash) and predates the non-string refusal.
diff --git a/.abcd/work/issues/open/iss-2610042036094209-a-request-body-carrying-invalid-utf-8-inside-a-json-string.md b/.abcd/work/issues/open/iss-2610042036094209-a-request-body-carrying-invalid-utf-8-inside-a-json-string.md
new file mode 100644
index 00000000..4bc1aed8
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610042036094209-a-request-body-carrying-invalid-utf-8-inside-a-json-string.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610042036094209"
+slug: "a-request-body-carrying-invalid-utf-8-inside-a-json-string"
+severity: "minor"
+category: "bug"
+source: "impl-review"
+found_during: "adversarial security review of fix/refuse-non-string-prompt, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/gateway/gateway.go"
+remedy: "Refuse a request body that is not valid UTF-8 with a 400 at the gateway (utf8.Valid on the bytes it already holds), confirming under the prompt-reading ADRs that a validity check reads no content."
+---
+
+A request body carrying invalid UTF-8 inside a JSON string reaches the model server and fails on its handler thread with a 502. Go's json.Unmarshal into RawMessage does not validate UTF-8 inside strings, and json.Marshal forwards those bytes unchanged. mlx-lm 0.32.0's request handler then calls raw_body.decode(), which raises UnicodeDecodeError, and its except clause catches only json.JSONDecodeError. The handler thread errors, the connection closes, and the client gets a 502 rather than a clear 400. The generation thread is not affected, so other clients are unharmed. Found by the security review of the non-string prompt refusal; predates it.
diff --git a/.abcd/work/issues/open/iss-2610042100405045-the-control-panel-does-not-show-a-program-s-unload-when-a.md b/.abcd/work/issues/open/iss-2610042100405045-the-control-panel-does-not-show-a-program-s-unload-when-a.md
new file mode 100644
index 00000000..db748e44
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610042100405045-the-control-panel-does-not-show-a-program-s-unload-when-a.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610042100405045"
+slug: "the-control-panel-does-not-show-a-program-s-unload-when-a"
+severity: "minor"
+category: "ux"
+source: "impl-review"
+found_during: "fidelity review rcp-92f55f59b36b of itd-2610031024247803, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/ui/static/app.js"
+remedy: "Show released unloads in the panel's Statistics tab beside evictions, with the caller kind, held by a UI test against the field Go publishes."
+---
+
+The control panel does not show a program's unload. When a client unloads a model through /api/models/unload, the statistics store records the reason released and the kind of caller (itd-2610031024247803, criterion 10), and the log line says so. But the panel's Statistics tab counts evictions only and never shows a released unload (internal/ui/static/app.js, around line 2430). An operator using the panel cannot see that a program unloaded a model, though Go records it. AGENTS.md treats a Go capability with no panel equivalent as a gap.
diff --git a/.abcd/work/issues/open/iss-2610042100409263-the-rule-that-saving-an-unrelated-setting-is-never-refused.md b/.abcd/work/issues/open/iss-2610042100409263-the-rule-that-saving-an-unrelated-setting-is-never-refused.md
new file mode 100644
index 00000000..1c614beb
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610042100409263-the-rule-that-saving-an-unrelated-setting-is-never-refused.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610042100409263"
+slug: "the-rule-that-saving-an-unrelated-setting-is-never-refused"
+severity: "minor"
+category: "tech-debt"
+source: "impl-review"
+found_during: "fidelity review rcp-92f55f59b36b of itd-2610031024247803, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/ui/apiunload_test.go"
+remedy: "Add api_unload_off to the untouched-save test's form body (uneditedFormBody) and a save test that changes another setting while api_unload_off carries an unusual stored value, so the three-surfaces promise is held by a test."
+---
+
+The rule that saving an unrelated setting is never refused over api_unload_off is not held by a test. The fidelity review of itd-2610031024247803 (criterion 8) found that it holds only because the field has no validation rule. No save test names the field, and the save test's form helper uneditedFormBody leaves it out, so a future validation rule on api_unload_off could refuse saves of other settings without any test failing. AGENTS.md requires each cross-surface promise to be armed by a test. The panel side is checked only by a source pattern-match (internal/ui/apiunload_test.go).
diff --git a/.abcd/work/issues/open/iss-2610042101430192-a-staged-update-removes-the-old-copy-of-a-model-before-the.md b/.abcd/work/issues/open/iss-2610042101430192-a-staged-update-removes-the-old-copy-of-a-model-before-the.md
new file mode 100644
index 00000000..d4714c95
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610042101430192-a-staged-update-removes-the-old-copy-of-a-model-before-the.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610042101430192"
+slug: "a-staged-update-removes-the-old-copy-of-a-model-before-the"
+severity: "minor"
+category: "bug"
+source: "impl-review"
+found_during: "fidelity review rcp-c60839654fc3 of itd-2610030857275099, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/app/app.go"
+remedy: "Keep the old copy aside until the new version has been loaded and answered once (or the operator confirms), and restore it automatically when the first load fails, with a test where the new version passes the directory checks but fails to load."
+---
+
+A staged update removes the old copy of a model before the new version has loaded successfully. The spec for itd-2610030857275099 says the old copy is removed only after the new one 'passes readiness'. DECISIONS.md reads readiness as the directory checks, so internal/app/app.go (around line 1759) deletes the old copy as soon as the new record is written. A new version that passes those checks but fails at its first load leaves nothing to fall back to: the model is unusable until the operator downloads an older version by hand.
diff --git a/.abcd/work/issues/open/iss-2610042101435880-in-the-shipped-binary-no-decision-model-is-ever-marked.md b/.abcd/work/issues/open/iss-2610042101435880-in-the-shipped-binary-no-decision-model-is-ever-marked.md
new file mode 100644
index 00000000..2c94a07a
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610042101435880-in-the-shipped-binary-no-decision-model-is-ever-marked.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610042101435880"
+slug: "in-the-shipped-binary-no-decision-model-is-ever-marked"
+severity: "minor"
+category: "bug"
+source: "impl-review"
+found_during: "fidelity review rcp-c60839654fc3 of itd-2610030857275099, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/app/app.go"
+remedy: "Wire the reviewed-build manifest from itd-2610030656210408 step 2 into app.New in cmd/dessau, so decision models are marked 'awaiting review' with no Update until a release ships their reviewed build; add a test on the binary's own wiring."
+---
+
+In the shipped binary no decision model is ever marked 'awaiting review': criterion 9 of itd-2610030857275099 is not met. The update check has a hook for decision models (internal/app/app.go around line 242: 'Nil means no model is a decision model'), but cmd/dessau/main.go builds the app without a list of reviewed builds, and none ships. So a decision model with a newer version is marked 'available', with Update offered, and no Dessau release can make Update appear by shipping a reviewed version. The waiting behaviour exists only in tests that supply the list by hand. It depends on itd-2610030656210408 step 2 (the embedded reviewed-build manifest). Today decision models are not served as decision models, so updating one changes no served behaviour.
diff --git a/.abcd/work/issues/open/iss-2610042101436222-the-test-that-model-and-file-names-are-shown-safely-in-the.md b/.abcd/work/issues/open/iss-2610042101436222-the-test-that-model-and-file-names-are-shown-safely-in-the.md
new file mode 100644
index 00000000..fe8b9ee1
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610042101436222-the-test-that-model-and-file-names-are-shown-safely-in-the.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610042101436222"
+slug: "the-test-that-model-and-file-names-are-shown-safely-in-the"
+severity: "nitpick"
+category: "tech-debt"
+source: "impl-review"
+found_during: "fidelity review rcp-c60839654fc3 of itd-2610030857275099, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/ui"
+remedy: "Add a UI test that renders an update card for a repository name containing markup and asserts it appears as text; drop the file-name half from the criterion or show file names and test them."
+---
+
+The test that model and file names are shown safely in the update panel does not render a name with markup. internal/ui's test only searches the panel source for escapeHtml calls, while the spec planned a UI test with markup in a repository name and a file name. The card also shows no file names, so the file-name half of criterion 11 of itd-2610030857275099 is never exercised.
diff --git a/.abcd/work/issues/open/iss-2610042101436891-itd-2610030857275099-s-press-release-says-a-decision-model.md b/.abcd/work/issues/open/iss-2610042101436891-itd-2610030857275099-s-press-release-says-a-decision-model.md
new file mode 100644
index 00000000..b8f3b1e4
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610042101436891-itd-2610030857275099-s-press-release-says-a-decision-model.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610042101436891"
+slug: "itd-2610030857275099-s-press-release-says-a-decision-model"
+severity: "nitpick"
+category: "inconsistency"
+source: "impl-review"
+found_during: "fidelity review rcp-c60839654fc3 of itd-2610030857275099, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: ".abcd/development/intents/shipped"
+remedy: "Record which promise stands: if decision models are marked 'awaiting review', say so in a successor record or the intent's audit notes (a shipped intent's text is not rewritten), so the press release is not read as the behaviour."
+---
+
+itd-2610030857275099's press release says a decision model is never marked, but its acceptance criteria give one an 'awaiting review' mark, and the panel code shows it (internal/ui/static/app.js, around line 596). The fidelity review listed this as a promise delivered differently. A shipped intent's press release now contradicts its own criteria.
diff --git a/.abcd/work/issues/open/iss-2610042101439623-the-update-check-cannot-see-a-newer-version-s-code-when-its.md b/.abcd/work/issues/open/iss-2610042101439623-the-update-check-cannot-see-a-newer-version-s-code-when-its.md
new file mode 100644
index 00000000..63fe9d74
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2610042101439623-the-update-check-cannot-see-a-newer-version-s-code-when-its.md
@@ -0,0 +1,15 @@
+---
+schema_version: 1
+id: "iss-2610042101439623"
+slug: "the-update-check-cannot-see-a-newer-version-s-code-when-its"
+severity: "minor"
+category: "bug"
+source: "impl-review"
+found_during: "fidelity review rcp-c60839654fc3 of itd-2610030857275099, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/app/updatecheck.go"
+remedy: "When config.json is an LFS pointer, fetch its real content through the resolve endpoint the download already uses (or mark the version 'cannot check'), so a version that ships its own code is never offered Update."
+---
+
+The update check cannot see a newer version's code when its config.json is stored in Git LFS. internal/app/updatecheck.go (around line 238) reads config.json to decide whether a newer version names code of its own. When that file is an LFS pointer the check cannot read it, so the version is marked 'available' with Update offered. Only the update's own launch checks refuse it later, after a download.
diff --git a/.abcd/work/issues/open/iss-2610032241096901-a-resumed-probe-bisects-to-bounds-from-before-a-settings-change.md b/.abcd/work/issues/resolved/iss-2610032241096901-a-resumed-probe-bisects-to-bounds-from-before-a-settings-change.md
similarity index 72%
rename from .abcd/work/issues/open/iss-2610032241096901-a-resumed-probe-bisects-to-bounds-from-before-a-settings-change.md
rename to .abcd/work/issues/resolved/iss-2610032241096901-a-resumed-probe-bisects-to-bounds-from-before-a-settings-change.md
index 589631db..fffe310e 100644
--- a/.abcd/work/issues/open/iss-2610032241096901-a-resumed-probe-bisects-to-bounds-from-before-a-settings-change.md
+++ b/.abcd/work/issues/resolved/iss-2610032241096901-a-resumed-probe-bisects-to-bounds-from-before-a-settings-change.md
@@ -10,6 +10,10 @@ origin: researcher-authored
production_mode: hand-written
found_at: "internal/contextprobe/probe.go"
remedy: "On resume, clamp b.hi to the candidate's current served window, or drop the bounds when the provenance differs from the one they were made under."
+resolution: "A context probe's bounds now record the provenance they were made under; a resume under a different one starts the run over, a run whose provenance moves mid-run saves nothing and is retried, and a saved figure is stamped with the bounds' provenance."
+impact: fix
+resolved_by:
+ commit: "2ae3853d5a6d5ac11da71be1ce451979863f7d77"
---
A resumed context probe bisects to bounds made under the settings in force when the run began: the bounds are kept in memory with no reset on a settings change, and b.hi was taken from the served window then. A run that yields (or, since iss-2609211754251373, meets a pin) and resumes after the served window was lowered can save a window above the new served window while stamping the new provenance.
diff --git a/.abcd/work/issues/open/iss-2610032241098944-the-self-test-unloads-a-model-pinned-during-its-run.md b/.abcd/work/issues/resolved/iss-2610032241098944-the-self-test-unloads-a-model-pinned-during-its-run.md
similarity index 74%
rename from .abcd/work/issues/open/iss-2610032241098944-the-self-test-unloads-a-model-pinned-during-its-run.md
rename to .abcd/work/issues/resolved/iss-2610032241098944-the-self-test-unloads-a-model-pinned-during-its-run.md
index 7fa2d4b3..8de35f9e 100644
--- a/.abcd/work/issues/open/iss-2610032241098944-the-self-test-unloads-a-model-pinned-during-its-run.md
+++ b/.abcd/work/issues/resolved/iss-2610032241098944-the-self-test-unloads-a-model-pinned-during-its-run.md
@@ -10,6 +10,10 @@ origin: researcher-authored
production_mode: hand-written
found_at: "internal/app/selftest.go"
remedy: "Refuse the self-test's unload of a pinned model the way the probe's adapter now does (ErrPinned), leaving the model resident."
+resolution: "The self-test's adapter unloads through unloadUnpinned, the helper the context probe's adapter now shares, which refuses a pinned model with selftest.ErrPinned and leaves it loaded and pinned."
+impact: fix
+resolved_by:
+ commit: "a66e8c89"
---
The self-test ignores pins as the context probe did: selfTestServer.Unload calls Pool.Unload directly, so a self-test that loaded a model unloads it at the end of its run even when the model was pinned during the run, leaving it pinned and not loaded.
diff --git a/.abcd/work/issues/open/iss-2610032306154631-a-bridged-history-can-start-with-an-assistant-turn.md b/.abcd/work/issues/resolved/iss-2610032306154631-a-bridged-history-can-start-with-an-assistant-turn.md
similarity index 77%
rename from .abcd/work/issues/open/iss-2610032306154631-a-bridged-history-can-start-with-an-assistant-turn.md
rename to .abcd/work/issues/resolved/iss-2610032306154631-a-bridged-history-can-start-with-an-assistant-turn.md
index 009be3ed..cc8a4e8e 100644
--- a/.abcd/work/issues/open/iss-2610032306154631-a-bridged-history-can-start-with-an-assistant-turn.md
+++ b/.abcd/work/issues/resolved/iss-2610032306154631-a-bridged-history-can-start-with-an-assistant-turn.md
@@ -10,6 +10,10 @@ origin: researcher-authored
production_mode: hand-written
found_at: "internal/bridge/discord/conversation.go"
remedy: "Drop turns in user/assistant pairs, or drop any leading assistant turns after every trim, so a history always opens on the user."
+resolution: "buildRequest, the one place every trimmed history passes through, starts a cut only on a user turn and its single-turn fallback sends the newest user turn, so the history sent always opens on the user"
+impact: fix
+resolved_by:
+ commit: "852e98010705f87c5b62b8b2e2f101474276904e"
---
A bridged request's history can start with an assistant turn: the turn count, the store's byte budget and buildRequest's window trim all drop turns one at a time from the front, so after a drop the oldest kept turn may be the model's answer. Chat templates that require strict user/assistant alternation (Gemma- and Mistral-style) raise on that, and the channel's next message fails. Pre-existing through buildRequest; more frequent now that a channel keeps 32 turns rather than 64 and the budget trims across channels.
diff --git a/.abcd/work/issues/open/iss-2610040805353721-completions-prompt-array-reaches-the-generation-thread.md b/.abcd/work/issues/resolved/iss-2610040805353721-completions-prompt-array-reaches-the-generation-thread.md
similarity index 92%
rename from .abcd/work/issues/open/iss-2610040805353721-completions-prompt-array-reaches-the-generation-thread.md
rename to .abcd/work/issues/resolved/iss-2610040805353721-completions-prompt-array-reaches-the-generation-thread.md
index 6869d96d..fe34ebc5 100644
--- a/.abcd/work/issues/open/iss-2610040805353721-completions-prompt-array-reaches-the-generation-thread.md
+++ b/.abcd/work/issues/resolved/iss-2610040805353721-completions-prompt-array-reaches-the-generation-thread.md
@@ -10,6 +10,10 @@ origin: researcher-authored
production_mode: hand-written
found_at: "internal/gateway/emptyprompt.go"
remedy: "Maintainer's decision needed: may the gateway refuse a /v1/completions prompt that is not a JSON string? That reads the field's JSON kind, as badStop already does for stop, and refuses nothing that works on the pinned server, but it widens adr-2610040749545010's grant, so it would be a new ADR. Reproduce on the Mac first if the decision is to follow the earlier REPRODUCE FIRST practice."
+resolution: "The gateway refuses a /v1/completions prompt that is not a JSON string with a 400, reading only the value's JSON kind (adr-2610042021365934)"
+impact: breaking
+resolved_by:
+ commit: "a6d586a4dfaa9b855013e4f314a7aebf3f99d127"
---
A /v1/completions prompt that is an array of strings reaches mlx-lm 0.32.0's generation thread outside its try (medium confidence: transformers 5.14.1 verified locally, the mlx-lm path read from source). encode treats a list of strings as a batch, so ["hi"] tokenizes to [[72,73]] and [""] to [[]]; _tokenize returns the nested list, and fetch_nearest_cache, the cache trie's search, insert_segments and batch_generator.next() then run on the generation thread outside the try, where the nested list raises. The empty-prompt check refuses only an empty array and leaves [""] and ["hi"] alone, because looking inside the array, or at the prompt's JSON kind, reads more than the emptiness adr-2610040749545010 grants. On 0.32.0 a list prompt never yields a valid generation.
diff --git a/.abcd/work/issues/resolved/iss-2610042030092101-a-bridged-request-that-fails-with-nothing-written-or-that.md b/.abcd/work/issues/resolved/iss-2610042030092101-a-bridged-request-that-fails-with-nothing-written-or-that.md
new file mode 100644
index 00000000..5ca47897
--- /dev/null
+++ b/.abcd/work/issues/resolved/iss-2610042030092101-a-bridged-request-that-fails-with-nothing-written-or-that.md
@@ -0,0 +1,20 @@
+---
+schema_version: 1
+id: "iss-2610042030092101"
+slug: "a-bridged-request-that-fails-with-nothing-written-or-that"
+severity: "major"
+category: "bug"
+source: "impl-review"
+found_during: "independent review of fix/bridge-history-opens-on-user, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/bridge/discord/answer.go"
+remedy: "In buildRequest, drop any user turn that is directly followed by another user turn, keeping the newest, so the history sent alternates whatever the store holds."
+refines: [iss-2610032306154631]
+resolution: "buildRequest sends only the newest of a run of user turns, so a history with an unanswered question still alternates"
+impact: fix
+resolved_by:
+ commit: "bb43455b"
+---
+
+A bridged request that fails with nothing written, or that the model answers with nothing, leaves the user turn it appended unanswered, so the channel's next message sends two user turns in a row. Gemma and Mistral v0.1/v0.2 chat templates require strict user/assistant alternation and raise on that, so every later message in the channel fails the same way and the channel is stuck until /reset.
diff --git a/.abcd/work/issues/resolved/iss-2610042030098334-buildrequest-s-fallback-cuts-the-newest-user-turn-to-budget.md b/.abcd/work/issues/resolved/iss-2610042030098334-buildrequest-s-fallback-cuts-the-newest-user-turn-to-budget.md
new file mode 100644
index 00000000..6626a920
--- /dev/null
+++ b/.abcd/work/issues/resolved/iss-2610042030098334-buildrequest-s-fallback-cuts-the-newest-user-turn-to-budget.md
@@ -0,0 +1,19 @@
+---
+schema_version: 1
+id: "iss-2610042030098334"
+slug: "buildrequest-s-fallback-cuts-the-newest-user-turn-to-budget"
+severity: "minor"
+category: "bug"
+source: "impl-review"
+found_during: "independent review of fix/bridge-history-opens-on-user, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/bridge/discord/conversation.go"
+remedy: "Trim the tail of the turn until the encoded body fits the budget, judged the way the gateway judges it (len(body)/4 + max_tokens)."
+resolution: "buildRequest's fallback keeps the longest tail whose encoded body fits the budget, found by halving, instead of budget/4 runes"
+impact: fix
+resolved_by:
+ commit: "3fe92633"
+---
+
+buildRequest's fallback cuts the newest user turn to budget/4 runes, not to the budget in encoded bytes, so a turn of four-byte emoji, or of characters json.Marshal escapes to six bytes (<, >, &, control characters), still sends a body the gateway judges over the window. At a served window of 4096 with a 16 KiB message the body is judged at 5653 tokens with < repeated and 4117 with emoji, and the gateway refuses it.
diff --git a/.abcd/work/issues/resolved/iss-2610042040451551-a-change-to-the-memory-budget-never-marks-a-context.md b/.abcd/work/issues/resolved/iss-2610042040451551-a-change-to-the-memory-budget-never-marks-a-context.md
new file mode 100644
index 00000000..a2d3c444
--- /dev/null
+++ b/.abcd/work/issues/resolved/iss-2610042040451551-a-change-to-the-memory-budget-never-marks-a-context.md
@@ -0,0 +1,19 @@
+---
+schema_version: 1
+id: "iss-2610042040451551"
+slug: "a-change-to-the-memory-budget-never-marks-a-context"
+severity: "minor"
+category: "bug"
+source: "impl-review"
+found_during: "independent review of fix/probe-resume-clamps-window, 2026-10-04"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/app/app.go"
+remedy: "Re-judge the measurements in SetConfig after the pool's budget is applied, so the provenance reads the budget the save put in force; test by changing MaxResidentBytes through SetConfig and asserting the saved figure reads stale budget."
+resolution: "SetConfig now re-judges measurements after Pool.SetMemoryBudget and RefreshCharges, so a budget save is judged against the budget it put in force; TestABudgetSaveMarksAMeasurementStaleForTheBudget fails before and passes after."
+impact: fix
+resolved_by:
+ commit: "e96a9154"
+---
+
+A change to the memory budget never marks a context measurement stale until the next start. SetConfig re-judges every measurement with refreshStaleness before it applies the new budget to the pool with SetMemoryBudget, and the provenance it judges by reads the budget from the pool, so the save is judged against the old budget. Verified in review: a figure stamped with a budget of 82463372083 bytes and a budget of 41231686041 in force reads stale empty, while StaleAgainst on the provenance in force says budget.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16d9cf51..6b2bf653 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ GitHub release notes.
## [Unreleased]
+## [0.10.0] - 2026-10-04
+
### Added
- **The models list says which models are builds of one model.**
@@ -87,8 +89,135 @@ GitHub release notes.
([request fields](docs/request-fields.md#an-empty-answer-budget);
itd-2610030656210408).
+- **Downloading a model you already have no longer takes it out of service,
+ and can no longer leave it half one version and half the next.**
+ `impact: fix`. The newer version is fetched beside the one being served,
+ every file checked against HuggingFace's hash for it, and swapped in only
+ once it has checked out and the requests the model was answering have
+ finished; any failure leaves the old version serving, unchanged. Before,
+ the model was marked as downloading for the whole re-download, and a file
+ of the same size from the old version was kept unchecked while a file the
+ new version dropped was never removed (iss-2610030913179523;
+ [how it works](docs/model-updates.md)).
+- **A download fetches every file at one commit, and Dessau records which.**
+ `impact: additive`. A download first asks HuggingFace for the
+ repository's current commit, then lists and fetches every file at that
+ commit rather than at `main`, so a commit that lands on the Hub while
+ Alice's download is running can no longer leave her with a model that is
+ half one version and half the next. The registry records the commit and
+ each downloaded file's hash as the Hub listed it; a model downloaded
+ before this, or found on disk by a rescan, is "version unknown". This is
+ the groundwork for checking downloaded models for newer versions
+ (itd-2610030857275099).
+
+- **A request carrying `draft_model` or `adapters` is refused.**
+ `impact: breaking`. Security. The model server reads both as an
+ instruction to load files from a path the request gives — a second model
+ for speculative decoding, or adapter weights, with the served model
+ reloaded to apply them — and a model it loads that way can name a Python
+ file of its own that loading runs. Dessau checks the model it loads before
+ the model server starts, and could not check one a request named. A
+ request on `/v1/chat/completions` or `/v1/completions` whose top level
+ carries either field, with any value, `null` included, is now answered
+ with a 400 naming the field — for example `"draft_model" is not accepted:
+ Dessau does not load a second model for a request` — before any model is
+ loaded or evicted, and the model server never receives it. No sampling
+ parameter is affected. See
+ [Fields a completion request may not carry](docs/request-fields.md).
+- **A server loads only a model whose folder and `config.json` belong to the
+ account running it.** `impact: breaking`. Security. Any other model is
+ refused before any process starts, and its card says why: "this model's
+ files belong to another account, which Dessau does not load". A model
+ downloaded through the control panel, by whoever asked, belongs to the
+ serving account. A model folder copied in from another account does not;
+ download it again from the serving account.
+
+### Removed
+
+- **Shared-cache mode is gone: Dessau serves from one macOS account.**
+ `impact: breaking`. `make install-shared` is removed, and Dessau neither reads
+ nor serves anything from the machine-wide folder under `/Users/Shared`: a
+ model is served only from the serving account's own models folder, and at
+ start-up any model the index still records elsewhere is dropped from the
+ list when the serving account's models folder is there and holds no copy of
+ it, and is otherwise re-pointed at its place in that folder — never served
+ from where the index recorded it. Dessau keeps everything —
+ models, settings, logs, statistics and its private runtime — in the serving
+ account's own `~/Library/Application Support/Dessau` (or wherever
+ `DESSAU_ROOT` points, as before). Other accounts on the same Mac use the
+ server the way any client does, at `http://localhost:11535/v1`, with no key
+ and no copy of the models; they do not launch Dessau Server themselves.
+ Nothing is migrated. If you used the shared cache: choose the account that
+ serves, start Dessau there, download your models again from its control
+ panel (settings saved in another account's folder are not read), and remove
+ the old folder under `/Users/Shared` once nobody needs it. `dessau
+ uninstall` acts on this account's own folder only.
+
### Fixed
+- **One unanswered Discord message no longer leaves the channel stuck on
+ Gemma- and Mistral-style models.** `impact: fix`. A message the model
+ refused before writing anything, or answered with nothing, stayed in the
+ conversation unanswered, so the channel's next message sent two of the
+ person's turns in a row. Models whose chat template insists the person and
+ the model take turns raised an error on that, and every later message in
+ the channel failed the same way until `/reset`. The history sent now leaves
+ out a message that was never answered and keeps the newest
+ ([Discord bridge](docs/discord-bridge.md); iss-2610042030092101).
+- **A very long Discord message is cut to fit a small window whatever it is
+ written in.** `impact: fix`. When one message was too long for the model's
+ window on its own, the bridge kept its end by counting characters, not the
+ bytes the request is judged by. A message of emoji, or of `<`, `>`, `&` or
+ control characters, could still be over the window, and the person was told
+ the conversation was too long. The end of the message is now measured as it
+ is sent, so it always fits, and a message of plain text keeps more of what
+ was typed ([Discord bridge](docs/discord-bridge.md); iss-2610042030098334).
+- **A long Discord conversation no longer fails on Gemma- and Mistral-style
+ models.** `impact: fix`. When the bridge shortened a conversation, the
+ oldest turn it kept could be the model's own answer, and models whose chat
+ template insists the history opens on the person raised an error, so the
+ channel's next message failed. Past sixteen exchanges in one channel, it
+ could happen on every message. The history sent now always opens on something
+ somebody said, and still ends on their newest message
+ ([Discord bridge](docs/discord-bridge.md); iss-2610032306154631).
+- **The self-test no longer unloads a model you pinned while it ran.**
+ `impact: fix`. When the self-test loaded a model to measure it, it unloaded
+ the model at the end of the run even if you had pinned it in the meantime,
+ leaving it pinned but not in memory. A model pinned during the run now stays
+ loaded and pinned ([self-test](docs/self-test.md); iss-2610032241098944).
+- **A paused context measurement no longer records a window the model is
+ not served at.** `impact: fix`. A measurement that paused
+ for a request or a pin resumed from the steps it had taken under the
+ settings in force when it began, so after the served window was lowered it
+ could record a window above the one the model is now served at, and mark
+ it current. A paused measurement now starts again when the runtime, the
+ memory budget, the decode concurrency or the served window has changed
+ since it began, and one whose settings change while it runs records
+ nothing and is taken again ([how to](docs/context-probe.md);
+ iss-2610032241096901).
+ loaded and pinned. The self-test also no longer loads a pinned model that is
+ not in memory, as after a restart: it measures a pinned model only while the
+ model is already loaded, and leaves it there
+ ([self-test](docs/self-test.md); iss-2610032241098944).
+ since it began. One whose settings change while it runs records nothing,
+ and the model is measured again unless the figure saved before is current
+ again under the settings in force (after **Measure now**, it is measured
+ again regardless). A figure whose settings change just as it is recorded
+ is marked stale rather than current, and the largest size a measurement
+ tries is taken from the same settings it is recorded under
+ ([how to](docs/context-probe.md); iss-2610032241096901).
+- **Changing the memory budget marks a context measurement stale at once.**
+ `impact: fix`. A save that changed the memory budget judged each
+ measurement against the budget in force before the save. A model whose
+ automatic served window follows the budget still read stale, for its
+ served window; but for a model with a served window set by hand, with no
+ per-token memory charge, or whose automatic window stops at the size it
+ declares, a figure taken under the old budget kept reading current, and
+ could still be adopted, until Dessau restarted. The measurements are now
+ judged once the new budget is in force. The same change lifts a model's
+ **did not load** mark as soon as a save changes the memory budget, as the
+ guide says it does; before, the mark stood until Dessau restarted
+ ([how to](docs/context-probe.md); iss-2610042040451551).
- **A failed update is on the model's card as soon as the update stops.**
`impact: fix`. The reason was written a moment after the update stopped
counting as a download, so the panel could draw the card in between with
@@ -103,6 +232,17 @@ GitHub release notes.
empty array. Dessau reads only whether they are empty, and keeps nothing
([reference](docs/request-fields.md#nothing-to-answer);
adr-2610040749545010; iss-2610031758029994).
+- **A prompt that is a list no longer leaves a model unable to answer
+ anyone.** `impact: breaking`. A `/v1/completions` request whose `prompt`
+ was an array of strings, such as `["hi"]` or `[""]`, froze the model server
+ as an empty prompt did. A `prompt` that is not a string — an array of any
+ kind, a number, an object, a boolean or `null` — is now refused with
+ **400** and `"prompt" must be a string`. None of these produced an answer
+ before: an array of token ids was already refused by the model server.
+ Dessau reads only whether the prompt is a string, never what an array
+ holds, and keeps nothing
+ ([reference](docs/request-fields.md#nothing-to-answer);
+ adr-2610042021365934; iss-2610040805353721).
- **The Discord bridge holds at most 8 MiB of conversation.**
`impact: fix`. Its limits were counted in characters and channels, so
anyone who could reach the bot could make it hold half a gigabyte of text
@@ -279,73 +419,6 @@ GitHub release notes.
model server did not start answering in time and names
`upstream_header_timeout_sec` in `config.json`, where the wait is set
(iss-2610030919536329).
-### Changed
-
-- **Downloading a model you already have no longer takes it out of service,
- and can no longer leave it half one version and half the next.**
- `impact: fix`. The newer version is fetched beside the one being served,
- every file checked against HuggingFace's hash for it, and swapped in only
- once it has checked out and the requests the model was answering have
- finished; any failure leaves the old version serving, unchanged. Before,
- the model was marked as downloading for the whole re-download, and a file
- of the same size from the old version was kept unchecked while a file the
- new version dropped was never removed (iss-2610030913179523;
- [how it works](docs/model-updates.md)).
-- **A download fetches every file at one commit, and Dessau records which.**
- `impact: additive`. A download first asks HuggingFace for the
- repository's current commit, then lists and fetches every file at that
- commit rather than at `main`, so a commit that lands on the Hub while
- Alice's download is running can no longer leave her with a model that is
- half one version and half the next. The registry records the commit and
- each downloaded file's hash as the Hub listed it; a model downloaded
- before this, or found on disk by a rescan, is "version unknown". This is
- the groundwork for checking downloaded models for newer versions
- (itd-2610030857275099).
-
-- **A request carrying `draft_model` or `adapters` is refused.**
- `impact: breaking`. Security. The model server reads both as an
- instruction to load files from a path the request gives — a second model
- for speculative decoding, or adapter weights, with the served model
- reloaded to apply them — and a model it loads that way can name a Python
- file of its own that loading runs. Dessau checks the model it loads before
- the model server starts, and could not check one a request named. A
- request on `/v1/chat/completions` or `/v1/completions` whose top level
- carries either field, with any value, `null` included, is now answered
- with a 400 naming the field — for example `"draft_model" is not accepted:
- Dessau does not load a second model for a request` — before any model is
- loaded or evicted, and the model server never receives it. No sampling
- parameter is affected. See
- [Fields a completion request may not carry](docs/request-fields.md).
-- **A server loads only a model whose folder and `config.json` belong to the
- account running it.** `impact: breaking`. Security. Any other model is
- refused before any process starts, and its card says why: "this model's
- files belong to another account, which Dessau does not load". A model
- downloaded through the control panel, by whoever asked, belongs to the
- serving account. A model folder copied in from another account does not;
- download it again from the serving account.
-
-### Removed
-
-- **Shared-cache mode is gone: Dessau serves from one macOS account.**
- `impact: breaking`. `make install-shared` is removed, and Dessau neither reads
- nor serves anything from the machine-wide folder under `/Users/Shared`: a
- model is served only from the serving account's own models folder, and at
- start-up any model the index still records elsewhere is dropped from the
- list when the serving account's models folder is there and holds no copy of
- it, and is otherwise re-pointed at its place in that folder — never served
- from where the index recorded it. Dessau keeps everything —
- models, settings, logs, statistics and its private runtime — in the serving
- account's own `~/Library/Application Support/Dessau` (or wherever
- `DESSAU_ROOT` points, as before). Other accounts on the same Mac use the
- server the way any client does, at `http://localhost:11535/v1`, with no key
- and no copy of the models; they do not launch Dessau Server themselves.
- Nothing is migrated. If you used the shared cache: choose the account that
- serves, start Dessau there, download your models again from its control
- panel (settings saved in another account's folder are not read), and remove
- the old folder under `/Users/Shared` once nobody needs it. `dessau
- uninstall` acts on this account's own folder only.
-
-### Fixed
- **Dessau no longer loads a model whose `config.json` names code of its
own.** `impact: fix`. Security. A repository can ship a Python file of its
diff --git a/docs/context-probe.md b/docs/context-probe.md
index dde0e021..f56c6a3e 100644
--- a/docs/context-probe.md
+++ b/docs/context-probe.md
@@ -81,7 +81,9 @@ Nor is a [pinned](pinning-models.md) model. The probe stops the model before eve
step, and a pin promises that nothing stops it, so the pin wins: a pinned
model is never picked, **Measure now** on its card says so, and when a model
is pinned while it is being measured, the measurement stops at its next step,
-keeping the sizes it has already verified. Unpin it, and the next run goes on from there.
+keeping the sizes it has already verified. Unpin it, and the next run goes on from there,
+unless the settings a measurement depends on have changed in the meantime
+(see [Read the result](#read-the-result)).
To measure one model without switching the probe on, open the **My Models** tab
and press **Measure now** on its card. The run starts at the next idle
@@ -111,6 +113,15 @@ decode concurrency or the model's served window has changed since it was
taken, and the card says which. A stale figure is not published and cannot
be adopted; measure again.
+The same four settings govern a measurement that is not finished. When the
+runtime, the memory budget, the decode concurrency or the model's served
+window changes while a measurement is paused — for a request, or for a pin —
+or while it is running, the measurement starts over under the new settings
+rather than finishing: the sizes it verified under the old ones are dropped,
+and nothing measured under the old settings is saved. A model whose earlier
+figure is current again under the new settings is not measured again, unless
+you asked for it with **Measure now**.
+
The figure is also published on the models list as `measured_context` and
`measured_bound`, beside `context_length` and `served_context`:
[The models list](models-list.md) says what each means.
diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md
index 3e3069f6..cdea9055 100644
--- a/docs/discord-bridge.md
+++ b/docs/discord-bridge.md
@@ -107,7 +107,11 @@ field.
message. Discord keeps what Dessau would not, and the bridge does not
pretend otherwise.
- A conversation is kept to the most recent turns that fit the model's served
- window; older turns drop off rather than the request being refused.
+ window; older turns drop off rather than the request being refused. A
+ message the model did not answer — refused, or answered with nothing — is
+ left out of the history sent with the next one. A single message too long
+ for the window on its own is sent cut down to its end, so the latest part of
+ it is what the model sees.
- The bridge holds a limited number of conversations and answers a small
number of requests at once. It keeps up to 64 conversations of 32 turns, a
turn up to 16 KiB — every message Discord delivers fits whole — and 8 MiB of
diff --git a/docs/pinning-models.md b/docs/pinning-models.md
index f484aebf..e5a62de5 100644
--- a/docs/pinning-models.md
+++ b/docs/pinning-models.md
@@ -25,6 +25,8 @@ Models**.
straight away, rather than served by unloading it.
- The [context probe](context-probe.md) does not measure it, because the probe
unloads the model before every step.
+- The [self-test](self-test.md) measures it only while it is already in
+ memory, and leaves it there afterwards.
The client that is refused is told only that there is not enough memory. It is
never told which models are protected, or what this Mac is running.
@@ -33,9 +35,10 @@ never told which models are protected, or what this Mac is running.
- **It does not load the model.** Pinning protects a model; it does not put it
in memory. Until something loads it — a request, **My Models → Load**, or
- **Preload** — the card reads `pinned, not loaded`. Name a model in both
- **Preload** and **Pinned models** to have it loaded at start-up and protected
- from then on.
+ **Preload** — the card reads `pinned, not loaded`. Neither of Dessau's idle
+ jobs, the context probe and the self-test, loads a pinned model. Name a model
+ in both **Preload** and **Pinned models** to have it loaded at start-up and
+ protected from then on.
- **It does not survive everything.** Your own **Unload** works on a pinned
model, and so does quitting Dessau or a model server crashing. The pin stays
in Settings either way, so the model is protected again the next time it
diff --git a/docs/request-fields.md b/docs/request-fields.md
index 8b0ba133..939ead34 100644
--- a/docs/request-fields.md
+++ b/docs/request-fields.md
@@ -4,8 +4,8 @@
to the model server, changing only the fields listed under
[What Dessau changes in a request it passes on](#what-dessau-changes-in-a-request-it-passes-on).
Two fields are refused instead, and a request carrying either goes no further;
-so is a request with nothing to answer, one that asks for an empty answer, or
-one whose `stop` is not text.
+so is a request with nothing to answer, a `prompt` that is not a string, one
+that asks for an empty answer, or one whose `stop` is not text.
## The refused fields
@@ -57,15 +57,18 @@ A request with nothing to generate from is refused with **400**:
| Request | Refused when | The error message |
| --- | --- | --- |
-| `POST /v1/completions` | `prompt` is an empty string, a string of only whitespace, or an empty array | `"prompt" is empty: there is nothing to complete` |
+| `POST /v1/completions` | `prompt` is an empty string or a string of only whitespace | `"prompt" is empty: there is nothing to complete` |
+| `POST /v1/completions` | `prompt` is not a string: an array (of strings, of token ids, or empty), a number, an object, `true`, `false` or `null` | `"prompt" must be a string` |
| `POST /v1/chat/completions` | `messages` is an empty array | `"messages" is empty: there is no conversation to answer` |
-On the pinned model server, one empty prompt can leave that model unable to
-answer anyone. Dessau reads whether the prompt or the messages are empty, and
-nothing else of them; it keeps none of what it reads. A chat message whose
-content is empty is passed on, because the chat template still frames it. A
-`prompt` or `messages` of another type, or one that is missing, is passed on
-unchanged.
+On the pinned model server, one empty prompt, or a `prompt` that is an array
+of strings, can leave that model unable to answer anyone, and a `prompt` of
+token ids is refused by the model server itself. Dessau reads whether the
+prompt is a string and whether it or the messages are empty, and nothing else
+of them: it never looks inside an array. It keeps none of what it reads. A
+chat message whose content is empty is passed on, because the chat template
+still frames it. A `messages` of another type, or a `prompt` or `messages`
+that is missing, is passed on unchanged.
## An empty answer budget
diff --git a/docs/self-test-reference.md b/docs/self-test-reference.md
index 9407ae6a..e8fd6545 100644
--- a/docs/self-test-reference.md
+++ b/docs/self-test-reference.md
@@ -31,7 +31,7 @@ waited on, and the run is dropped with a line in the log.
| `at` | integer | When the run started, Unix seconds, UTC. |
| `outcome` | string | `ok`, `yielded`, `stopped` or `failed`; see below. |
| `reason` | string | For a failed run, `load` or `request`; absent otherwise. |
-| `cold_load` | boolean | `true` when the model was not in memory before the run, so `load_ms` is a load from disk and the run unloaded the model afterwards. |
+| `cold_load` | boolean | `true` when the model was not in memory before the run, so `load_ms` is a load from disk and the run unloaded the model afterwards, unless a request took the model over or it was [pinned](pinning-models.md) during the run. |
| `load_ms` | integer | How long acquiring the model took, in milliseconds. For a model that was already in memory this is close to zero. |
| `tests` | array | The tests that completed, in the order they ran; see below. A yielded, stopped or failed run carries those that finished before it ended. |
diff --git a/docs/self-test.md b/docs/self-test.md
index c2e2dc1a..aa4d09ac 100644
--- a/docs/self-test.md
+++ b/docs/self-test.md
@@ -54,8 +54,12 @@ so a figure here can be set beside a published one.
When the set is done Dessau writes one line to the results file and, if the
self-test was what loaded the model, unloads it again; a model that was
-already in memory is left there. Then it goes back to waiting for the next
-idle minute and the next model.
+already in memory is left there. The self-test never unloads a
+[pinned](pinning-models.md) model, so one you pin while the run is going stays
+in memory. Nor does it load one: a pinned model that is not in memory is
+passed over, and is measured on an idle minute when something else has loaded
+it. Then Dessau goes back to waiting for the next idle minute and the next
+model.
## What it costs, and what it never does
diff --git a/internal/app/app.go b/internal/app/app.go
index 9ce216e9..cf451bb4 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -572,11 +572,8 @@ func (a *App) SetConfig(c config.Config) error {
a.Pool.SetPinned(c.PinnedIDs())
// The idle jobs' switches apply live too: on starts the loop, and off
// cancels a run in progress and releases its model, so a person who
- // switched it off because the Mac is needed gets the Mac back now. And a
- // save may have moved a measurement's provenance — the budget, the
- // concurrency, a served window — so every measurement is judged again.
+ // switched it off because the Mac is needed gets the Mac back now.
a.applyIdleJobs(c)
- a.refreshStaleness()
// The bridge applies live too, and after the settings are in force: on
// with a token opens the connection, off closes it, and a changed token
// re-identifies. It is never refused — a token Discord will not accept is
@@ -603,6 +600,14 @@ func (a *App) SetConfig(c config.Config) error {
// than what it was enforcing before the save — the gateway already refuses
// against the new window from the next request.
a.Pool.RefreshCharges()
+ // A save may have moved a measurement's provenance — the budget, the
+ // concurrency, a served window — so every measurement is judged again,
+ // and only now: the provenance reads the budget from the pool, which
+ // enforces it and which the probe's memory guard measured against, so a
+ // re-judging before SetMemoryBudget judged a budget save against the old
+ // budget and marked nothing stale until the next start
+ // (iss-2610042040451551).
+ a.refreshStaleness()
// Applied live, and after every other setting: the line below is written at
// whatever level this save just chose, so an operator who switches to
// detailed sees the save that switched it in the detail they asked for.
diff --git a/internal/app/contextprobe.go b/internal/app/contextprobe.go
index 7499b4d9..85e82c93 100644
--- a/internal/app/contextprobe.go
+++ b/internal/app/contextprobe.go
@@ -39,11 +39,9 @@ func (s probeSources) Candidates() []contextprobe.Candidate {
if !m.CanChat(rule) || m.LoadFailed() {
continue
}
- served, _ := s.a.ServedWindow(m)
out = append(out, contextprobe.Candidate{
RepoID: m.RepoID,
Declared: m.ContextLength,
- Served: served,
Bytes: chargedSize(m),
KVChargePerToken: m.KVChargePerToken,
Measured: m.Measured,
@@ -90,15 +88,22 @@ func (s probeSources) Available() int64 {
return s.a.Pool.MemoryBudget() - used
}
-// Unload stops a model server for the probe, and never a pinned one: the
-// pool's Unload is the operator's and stops whatever it is given, so the pin
-// is the probe's to honour (iss-2609211754251373). A pin saved between this
-// check and the stop is met at the next step's unload.
+// Unload stops a model server for the probe, and never a pinned one
+// (iss-2609211754251373); see unloadUnpinned. A pin saved between the check
+// and the stop is met at the next step's unload.
func (s probeSources) Unload(repoID string) error {
- if s.a.isPinned(repoID) {
- return fmt.Errorf("%s: %w", repoID, contextprobe.ErrPinned)
+ return s.a.unloadUnpinned(repoID, contextprobe.ErrPinned)
+}
+
+// unloadUnpinned is the idle jobs' unload: the pool's Unload, refused with
+// refusal — the job's own ErrPinned — when the model is pinned. The pool's
+// Unload is the operator's and stops whatever it is given, so a pin is each
+// idle job's to honour, and this is the one place they honour it.
+func (a *App) unloadUnpinned(repoID string, refusal error) error {
+ if a.isPinned(repoID) {
+ return fmt.Errorf("%s: %w", repoID, refusal)
}
- return s.a.Pool.Unload(repoID)
+ return a.Pool.Unload(repoID)
}
// isPinned reports whether the pool holds the model pinned, whichever way
@@ -113,8 +118,19 @@ func (a *App) isPinned(repoID string) bool {
return false
}
+// Save records a measurement and judges it at once against what is in force.
+// The probe stamps it with the provenance it found in force before saving,
+// but a settings save can move that provenance between the probe's check and
+// this write, and the settings save's own re-judging may already have run.
+// Judging here, after the write, means the figure is never left reading
+// current under settings it was not taken under once Save returns. It judges
+// whether or not the write reached the disk: the registry puts the figure in
+// force in memory before it writes the file, so a failed write still leaves
+// it live.
func (s probeSources) Save(repoID string, m *registry.Measurement) error {
- return s.a.Registry.SetMeasurement(repoID, m)
+ err := s.a.Registry.SetMeasurement(repoID, m)
+ s.a.refreshStaleness()
+ return err
}
func (s probeSources) MarkIncomplete(repoID string, on bool) error {
diff --git a/internal/app/contextprobe_test.go b/internal/app/contextprobe_test.go
index 12b49814..56e807f1 100644
--- a/internal/app/contextprobe_test.go
+++ b/internal/app/contextprobe_test.go
@@ -2,6 +2,8 @@ package app
import (
"errors"
+ "os"
+ "path/filepath"
"strconv"
"testing"
"time"
@@ -166,6 +168,34 @@ func TestASaveRejudgesMeasurementsAndAnUnrelatedSaveKeepsTheProbeSettings(t *tes
}
}
+// A save that moves the memory budget re-judges against the budget it put in
+// force, not the one the pool enforced before it: the provenance reads the
+// pool's budget, so the re-judging must come after the pool takes the new one
+// (iss-2610042040451551).
+func TestABudgetSaveMarksAMeasurementStaleForTheBudget(t *testing.T) {
+ a := newTestApp(t)
+ readyModel(t, a, "org/m", 131072)
+ prov := probeSources{a}.Provenance("org/m")
+ if err := a.Registry.SetMeasurement("org/m", ®istry.Measurement{
+ Window: 65536, Bound: registry.BoundModel, At: 1,
+ Runtime: prov.Runtime, BudgetBytes: prov.BudgetBytes, DecodeConcurrency: prov.DecodeConcurrency, ServedContext: prov.ServedContext,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ c := a.Config()
+ c.MaxResidentBytes = prov.BudgetBytes / 2
+ if err := a.SetConfig(c); err != nil {
+ t.Fatal(err)
+ }
+ if got := a.Pool.MemoryBudget(); got == prov.BudgetBytes {
+ t.Fatalf("the fixture is wrong: the save left the pool's budget at %d", got)
+ }
+ m, _ := a.Registry.Get("org/m")
+ if m.Measured == nil || m.Measured.Stale != registry.StaleBudget {
+ t.Errorf("after a budget save the measurement reads %+v, want stale for the budget (budget now %d)", m.Measured, a.Pool.MemoryBudget())
+ }
+}
+
// The provenance the app stamps is the one it judges by: the pinned runtime,
// the pool's budget and concurrency, the model's served window.
func TestTheProvenanceIsWhatThePoolAndTheRuntimeSay(t *testing.T) {
@@ -184,6 +214,66 @@ func TestTheProvenanceIsWhatThePoolAndTheRuntimeSay(t *testing.T) {
func itoa(n int) string { return strconv.Itoa(n) }
+// The probe stamps a figure with the provenance its bounds were made under
+// and checks it is still in force before saving, but a settings save can
+// land between that check and the save, and its own re-judging has already
+// run by then. The probe's save re-judges, so the figure reads stale rather
+// than current under settings it was not taken under.
+func TestAProbeSaveIsJudgedAgainstTheSettingsInForce(t *testing.T) {
+ a := newTestApp(t)
+ readyModel(t, a, "org/m", 131072)
+ src := probeSources{a}
+ prov := src.Provenance("org/m")
+ if prov.ServedContext != 131072 {
+ t.Fatalf("the fixture is wrong: served window in force %d", prov.ServedContext)
+ }
+ // Stamped with the served window that was in force at the probe's
+ // check, before a save raised it to the one now in force.
+ if err := src.Save("org/m", ®istry.Measurement{
+ Window: 30000, Bound: registry.BoundServedWindow, At: 1,
+ Runtime: prov.Runtime, BudgetBytes: prov.BudgetBytes, DecodeConcurrency: prov.DecodeConcurrency, ServedContext: 32768,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if m, _ := a.Registry.Get("org/m"); m.Measured == nil || m.Measured.Stale != registry.StaleServedContext {
+ t.Errorf("a figure saved under another served window reads %+v, want stale for the served window", m.Measured)
+ }
+}
+
+// The registry puts a figure in force in memory before it writes the file, so
+// a write that fails still leaves the figure live: the probe's save judges it
+// all the same, rather than leaving it reading current under settings it was
+// not taken under until the next settings save (iss-2610032241096901).
+func TestAProbeSaveThatFailsToPersistIsStillJudged(t *testing.T) {
+ a := newTestApp(t)
+ readyModel(t, a, "org/m", 131072)
+ src := probeSources{a}
+ prov := src.Provenance("org/m")
+ // Break the registry's write deterministically, without relying on
+ // permissions (the test may run as root): a non-empty directory where
+ // registry.json goes makes the final rename fail.
+ if err := os.Remove(a.Paths.State); err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(a.Paths.State, "occupied"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ err := src.Save("org/m", ®istry.Measurement{
+ Window: 30000, Bound: registry.BoundServedWindow, At: 1,
+ Runtime: prov.Runtime, BudgetBytes: prov.BudgetBytes, DecodeConcurrency: prov.DecodeConcurrency, ServedContext: 32768,
+ })
+ if err == nil {
+ t.Fatal("the fixture is wrong: the registry's write did not fail")
+ }
+ m, _ := a.Registry.Get("org/m")
+ if m.Measured == nil {
+ t.Fatal("the fixture is wrong: the figure is not live in memory")
+ }
+ if m.Measured.Stale != registry.StaleServedContext {
+ t.Errorf("a figure whose write failed reads %+v, want stale for the served window", m.Measured)
+ }
+}
+
// The probe measures through chat completions, so only a model the server
// offers to chat is a candidate: the same verdict the models list publishes
// as `chat`, read from its one home. A ready image-to-text model is not one,
diff --git a/internal/app/loadfailure_test.go b/internal/app/loadfailure_test.go
index c3d7f53a..6891784b 100644
--- a/internal/app/loadfailure_test.go
+++ b/internal/app/loadfailure_test.go
@@ -141,3 +141,33 @@ func TestAFailedModelIsSkippedByIdleWorkAndRefusedWithItsReason(t *testing.T) {
t.Error("a moved served window left the failure standing")
}
}
+
+// A save that moves only the memory budget lifts a load failure at once: the
+// load may go differently under another budget, and the re-judging runs after
+// the pool takes the budget the save put in force, so it judges the failure
+// against that budget rather than the one it was recorded under
+// (iss-2610042040451551).
+func TestABudgetOnlySaveLiftsALoadFailure(t *testing.T) {
+ a := newTestApp(t)
+ readyModel(t, a, "org/m", 131072)
+ prov := (probeSources{a}).Provenance("org/m")
+ if err := a.Registry.SetLoadFailure("org/m", ®istry.LoadFailure{
+ Reason: "could not load: ValueError: Model type glm_ocr not supported.", At: 1,
+ Runtime: prov.Runtime, BudgetBytes: prov.BudgetBytes, DecodeConcurrency: prov.DecodeConcurrency, ServedContext: prov.ServedContext,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ c := a.Config()
+ c.MaxResidentBytes = prov.BudgetBytes / 2
+ if err := a.SetConfig(c); err != nil {
+ t.Fatal(err)
+ }
+ after := (probeSources{a}).Provenance("org/m")
+ if after.BudgetBytes == prov.BudgetBytes || after.Runtime != prov.Runtime ||
+ after.DecodeConcurrency != prov.DecodeConcurrency || after.ServedContext != prov.ServedContext {
+ t.Fatalf("the fixture is wrong: the save must move the budget alone, provenance %+v -> %+v", prov, after)
+ }
+ if m, _ := a.Registry.Get("org/m"); m.LoadFailed() {
+ t.Errorf("a budget-only save left the failure standing: %+v (budget now %d)", m.LoadFailure, a.Pool.MemoryBudget())
+ }
+}
diff --git a/internal/app/selftest.go b/internal/app/selftest.go
index 02285408..4e84b01a 100644
--- a/internal/app/selftest.go
+++ b/internal/app/selftest.go
@@ -76,7 +76,16 @@ func (s selfTestServer) Activity() selftest.Activity {
return act
}
-func (s selfTestServer) Unload(repoID string) error { return s.a.Pool.Unload(repoID) }
+// Unload stops a model the self-test loaded, and never a pinned one: a model
+// pinned during the run stays loaded and pinned (iss-2610032241098944); see
+// unloadUnpinned.
+func (s selfTestServer) Unload(repoID string) error {
+ return s.a.unloadUnpinned(repoID, selftest.ErrPinned)
+}
+
+// Pinned is the pool's pin, the one the context probe's candidates and both
+// idle jobs' unloads read (isPinned).
+func (s selfTestServer) Pinned(repoID string) bool { return s.a.isPinned(repoID) }
func (s selfTestServer) Concurrency() int { return s.a.Pool.DecodeConcurrency() }
diff --git a/internal/app/selftest_pins_test.go b/internal/app/selftest_pins_test.go
new file mode 100644
index 00000000..12a9873d
--- /dev/null
+++ b/internal/app/selftest_pins_test.go
@@ -0,0 +1,274 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/intentdriven/Dessau/internal/config"
+ "github.com/intentdriven/Dessau/internal/mlxtest"
+ "github.com/intentdriven/Dessau/internal/registry"
+ "github.com/intentdriven/Dessau/internal/runtime"
+ "github.com/intentdriven/Dessau/internal/selftest"
+)
+
+// chatLauncher stands up a fake model server that answers chat, on the
+// socket the pool allocated, so a real pool loads and holds the model the
+// self-test measures.
+type chatLauncher struct{}
+
+func (chatLauncher) Precheck(runtime.Spec) error { return nil }
+
+func (chatLauncher) Launch(_ context.Context, spec runtime.Spec) (runtime.Process, error) {
+ srv := mlxtest.Start(mlxtest.Options{ModelArg: spec.ModelPath, Socket: spec.Socket})
+ return &toolProcess{srv: srv, done: make(chan struct{})}, nil
+}
+
+// newSelfTestPinApp is an app with one ready chat model behind a real pool.
+// The model carries a current tool-calling verdict so the tool probe queues
+// nothing on its load: a probe request in flight would read as a client's,
+// end the run as yielded, and the run would skip its unload whatever the pin.
+func newSelfTestPinApp(t *testing.T, id string) *App {
+ t.Helper()
+ paths := config.NewPaths(t.TempDir())
+ a, err := New(Options{Paths: paths, Config: config.Default(), Launcher: chatLauncher{}})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ t.Cleanup(func() { a.Close() })
+ dir := paths.ModelDir(id)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "weights.safetensors"), []byte("w"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Registry.Put(registry.Model{
+ RepoID: id, Path: dir, State: registry.StateReady, Bytes: 1 << 20, ChatTemplate: true,
+ ToolCalling: ®istry.ToolCalling{Can: false, At: 1, Runtime: runtime.MLXLMVersion()},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return a
+}
+
+// pin saves the model pinned, through the settings save a person's pin goes
+// through, spelt differently from the registry so the fold is exercised.
+func pin(t *testing.T, a *App, id string) {
+ t.Helper()
+ c := a.Config()
+ c.Models = map[string]config.ModelSettings{id: {Pinned: true}}
+ if err := a.SetConfig(c); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// pinningServer is the app's own self-test adapter with one addition: the
+// first time the self-test's load returns, the model is pinned, as a person
+// pinning it on the panel while the run is in progress would.
+type pinningServer struct {
+ selfTestServer
+ once sync.Once
+ pinIt func()
+}
+
+func (s *pinningServer) Acquire(ctx context.Context, repoID string) (selftest.Upstream, func(), error) {
+ up, release, err := s.selfTestServer.Acquire(ctx, repoID)
+ if err == nil {
+ s.once.Do(s.pinIt)
+ }
+ return up, release, err
+}
+
+// runOnce runs the self-test loop against srv until it has written one run,
+// then stops it. The run's result is written after its unload, so a run on
+// file is a run whose unload has been decided.
+func runOnce(t *testing.T, srv selftest.Server) selftest.Run {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), selftest.FileName)
+ r := selftest.New(selftest.Options{
+ Server: srv, Path: path,
+ Tick: 10 * time.Millisecond, Poll: 5 * time.Millisecond, Quiet: time.Millisecond,
+ })
+ r.SetEnabled(true)
+ var runs []selftest.Run
+ waitFor(t, "the self-test to write its run", func() bool {
+ runs, _ = selftest.ReadResults(path)
+ return len(runs) > 0
+ })
+ r.Close()
+ return runs[0]
+}
+
+func residentIn(a *App, id string) bool {
+ key := config.FoldRepoID(id)
+ for _, m := range a.Pool.Residency().Models {
+ if config.FoldRepoID(m.RepoID) == key {
+ return true
+ }
+ }
+ return false
+}
+
+// A model the self-test loaded and a person pinned while the run was in
+// progress is still loaded, and still pinned, when the run ends: the pin
+// promises the model stays in memory, and the self-test's tidy-up is not the
+// person's unload (iss-2610032241098944).
+func TestTheSelfTestLeavesAModelPinnedDuringItsRunLoaded(t *testing.T) {
+ a := newSelfTestPinApp(t, "org/m")
+ srv := &pinningServer{selfTestServer: selfTestServer{a}, pinIt: func() { pin(t, a, "ORG/M") }}
+ run := runOnce(t, srv)
+ if !run.ColdLoad {
+ t.Fatalf("the run found the model resident; the case needs the self-test's own load")
+ }
+ // A yielded run skips its unload whatever the pin, so only a completed
+ // run puts the refusal to the test.
+ if run.Outcome != selftest.OutcomeOK {
+ t.Fatalf("the run's outcome is %q, want %q; the case needs a run that reaches its unload", run.Outcome, selftest.OutcomeOK)
+ }
+ if !a.isPinned("org/m") {
+ t.Error("the model is no longer pinned after the run")
+ }
+ if !residentIn(a, "org/m") {
+ t.Errorf("the self-test unloaded a model pinned during its run (outcome %q)", run.Outcome)
+ }
+ // The refusal is the self-test's own, so the loop can tell it from a
+ // failed unload, and it changes nothing.
+ if err := (selfTestServer{a}).Unload("org/m"); !errors.Is(err, selftest.ErrPinned) {
+ t.Errorf("the self-test's unload of a pinned model = %v, want ErrPinned", err)
+ }
+ if !residentIn(a, "org/m") {
+ t.Error("a refused unload took the model out")
+ }
+}
+
+// A model pinned before the run and already resident is left resident and
+// pinned too: the self-test leaves residency as it found it.
+func TestTheSelfTestLeavesAModelPinnedBeforeItsRunLoaded(t *testing.T) {
+ a := newSelfTestPinApp(t, "org/m")
+ pin(t, a, "ORG/M")
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _, release, err := a.Pool.Acquire(ctx, "org/m")
+ if err != nil {
+ t.Fatal(err)
+ }
+ release()
+ run := runOnce(t, selfTestServer{a})
+ if run.ColdLoad {
+ t.Fatalf("the run loaded the model itself; the case needs it resident beforehand")
+ }
+ if !a.isPinned("org/m") {
+ t.Error("the model is no longer pinned after the run")
+ }
+ if !residentIn(a, "org/m") {
+ t.Errorf("the self-test unloaded a pinned model that was resident before its run (outcome %q)", run.Outcome)
+ }
+}
+
+// watchingServer is the app's own self-test adapter, counting the loop's
+// calls: Ready opens every tick, so its count says how many ticks have
+// passed, and Acquire is every load the self-test asked for.
+type watchingServer struct {
+ selfTestServer
+ mu sync.Mutex
+ readies int
+ acquired []string
+}
+
+func (s *watchingServer) Ready() []string {
+ s.mu.Lock()
+ s.readies++
+ s.mu.Unlock()
+ return s.selfTestServer.Ready()
+}
+
+func (s *watchingServer) Acquire(ctx context.Context, repoID string) (selftest.Upstream, func(), error) {
+ s.mu.Lock()
+ s.acquired = append(s.acquired, repoID)
+ s.mu.Unlock()
+ return s.selfTestServer.Acquire(ctx, repoID)
+}
+
+func (s *watchingServer) counts() (int, []string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.readies, append([]string(nil), s.acquired...)
+}
+
+// A model pinned and not loaded — the state every pinned model is in after a
+// restart — is not one the self-test loads: the self-test never unloads a
+// pinned model, so it would stay resident after the run, and the self-test
+// would have become a loader of pinned models. A pinned model is measured
+// only while it is already in memory, and an unpinned model beside it is
+// measured as ever (iss-2610032241098944).
+func TestTheSelfTestDoesNotLoadAModelPinnedAndNotLoaded(t *testing.T) {
+ a := newSelfTestPinApp(t, "org/m")
+ dir := filepath.Join(t.TempDir(), "n")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "weights.safetensors"), []byte("w"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Registry.Put(registry.Model{
+ RepoID: "org/n", Path: dir, State: registry.StateReady, Bytes: 1 << 20, ChatTemplate: true,
+ ToolCalling: ®istry.ToolCalling{Can: false, At: 1, Runtime: runtime.MLXLMVersion()},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ pin(t, a, "ORG/M")
+ if residentIn(a, "org/m") {
+ t.Fatal("the pinned model is resident before the run; the case needs it not loaded")
+ }
+
+ srv := &watchingServer{selfTestServer: selfTestServer{a}}
+ path := filepath.Join(t.TempDir(), selftest.FileName)
+ r := selftest.New(selftest.Options{
+ Server: srv, Path: path,
+ Tick: 10 * time.Millisecond, Poll: 5 * time.Millisecond, Quiet: time.Millisecond,
+ })
+ r.SetEnabled(true)
+ waitFor(t, "the self-test to measure the unpinned model", func() bool {
+ runs, _ := selftest.ReadResults(path)
+ for _, run := range runs {
+ if run.Model == "org/n" {
+ return true
+ }
+ }
+ return false
+ })
+ // Ready is read twice a tick (the tick's own read and the next model's),
+ // so six more reads are at least two whole ticks after the unpinned
+ // model's run, either of which would have picked the pinned one.
+ seen, _ := srv.counts()
+ waitFor(t, "more ticks to pass", func() bool {
+ n, _ := srv.counts()
+ return n >= seen+6
+ })
+ r.Close()
+
+ _, acquired := srv.counts()
+ for _, id := range acquired {
+ if config.FoldRepoID(id) == config.FoldRepoID("org/m") {
+ t.Errorf("the self-test loaded a model pinned and not loaded (loads %v)", acquired)
+ break
+ }
+ }
+ if residentIn(a, "org/m") {
+ t.Error("the pinned model is resident after the self-test; it was not loaded before")
+ }
+ if !a.isPinned("org/m") {
+ t.Error("the model is no longer pinned after the self-test")
+ }
+ runs, _ := selftest.ReadResults(path)
+ for _, run := range runs {
+ if config.FoldRepoID(run.Model) == config.FoldRepoID("org/m") {
+ t.Errorf("the self-test measured a model pinned and not loaded: %+v", run)
+ }
+ }
+}
diff --git a/internal/archtest/prompt_content_test.go b/internal/archtest/prompt_content_test.go
index 4f026bc1..58f6ad87 100644
--- a/internal/archtest/prompt_content_test.go
+++ b/internal/archtest/prompt_content_test.go
@@ -26,7 +26,7 @@ import (
// makes the list of exceptions a thing that exists and has to be edited.
var promptContentReaders = map[string]string{
"internal/gateway/systemmerge.go": "the merge itself — the one reader adr-2609061610102325 grants",
- "internal/gateway/emptyprompt.go": "the emptiness check adr-2610040749545010 grants as a narrow exception: it reads whether a /v1/completions prompt is empty or only whitespace, or whether a chat request's messages are an empty array, and nothing else — never what a message says, never how many there are — and keeps nothing (iss-2610031758029994)",
+ "internal/gateway/emptyprompt.go": "the emptiness check adr-2610040749545010 grants as a narrow exception, widened by adr-2610042021365934 to the prompt's JSON kind: it reads whether a /v1/completions prompt is a JSON string (from the first byte, never inside an array or object) and whether that string is empty or only whitespace, or whether a chat request's messages are an empty array, and nothing else — never what a message says, never how many there are — and keeps nothing (iss-2610031758029994, iss-2610040805353721)",
"internal/gateway/assemble.go": "names a message's role and content on the ANSWER side only: it joins the streamed deltas of an answer into the one message an unstreamed request is answered with (iss-2610030919536329); reads nothing of a request's messages",
"internal/runtime/pool.go": "builds the readiness probe's own one-line conversation; reads nothing from a client",
"internal/mlxtest/fake.go": "the fake mlx server tests relay to, which answers requests rather than making them",
diff --git a/internal/bridge/discord/conversation.go b/internal/bridge/discord/conversation.go
index f9f20197..e4dc309d 100644
--- a/internal/bridge/discord/conversation.go
+++ b/internal/bridge/discord/conversation.go
@@ -336,7 +336,23 @@ const bytesPerToken = 4
// conversation is shortened here and never refused there. A single turn too
// large for the window on its own is truncated, because refusing it would
// leave the person unable to say anything at all in that channel.
+//
+// THE HISTORY SENT OPENS ON THE USER (iss-2610032306154631). The turn count,
+// the store's byte budget and the window below all drop turns one at a time
+// from the front, so the oldest turn kept can be the model's answer, and
+// Gemma- and Mistral-style chat templates raise on a history that opens on
+// one. Every trim's result passes through here on its way to the gateway, so
+// this is where it is held: a cut lands only on a user turn, and the turn the
+// fallback sends alone is the newest user turn.
+//
+// AND IT ALTERNATES (iss-2610042030092101). A request refused before a token
+// was written, or answered with nothing, leaves its question in the history
+// unanswered, so the next message would send two user turns in a row — which
+// the same templates raise on, failing every later message in the channel
+// until `/reset`. Only the newest of a run of user turns is sent; the store
+// keeps what it holds.
func buildRequest(model string, turns []turn, served int64) ([]byte, error) {
+ turns = alternating(turns)
window := served
if window <= 0 {
window = defaultWindow
@@ -348,6 +364,9 @@ func buildRequest(model string, turns []turn, served int64) ([]byte, error) {
budget := (window - int64(answer)) * bytesPerToken
for start := 0; start < len(turns); start++ {
+ if turns[start].Role != roleUser {
+ continue
+ }
body, err := json.Marshal(request{
Model: model, Messages: turns[start:], MaxTokens: answer, Stream: true,
})
@@ -358,29 +377,55 @@ func buildRequest(model string, turns []turn, served int64) ([]byte, error) {
return body, nil
}
}
- // Even the newest turn alone is over the window. Keep its tail — the end
- // of what somebody typed is the part they are asking about.
+ // Even the newest user turn alone is over the window. Keep its tail — the
+ // end of what somebody typed is the part they are asking about.
last := turn{Role: roleUser}
- if len(turns) > 0 {
- last = turns[len(turns)-1]
+ for i := len(turns) - 1; i >= 0; i-- {
+ if turns[i].Role == roleUser {
+ last = turns[i]
+ break
+ }
+ }
+ //
+ // The tail is measured ENCODED, as the loop above measures and the gateway
+ // judges (iss-2610042030098334): a cut in runes would let a rune of four
+ // bytes, or a character JSON escapes to six (`<`, `>`, `&`, a control
+ // character), carry the body past the window. The encoded size only grows
+ // as the tail does, so the longest tail that fits is found by halving.
+ runes := []rune(last.Content)
+ encode := func(n int) ([]byte, error) {
+ t := turn{Role: last.Role, Content: string(runes[len(runes)-n:])}
+ return json.Marshal(request{
+ Model: model, Messages: []turn{t}, MaxTokens: answer, Stream: true,
+ })
+ }
+ lo, hi := 0, len(runes) // a tail of lo runes fits, or lo is 0; hi+1 does not
+ for lo < hi {
+ mid := lo + (hi-lo+1)/2
+ body, err := encode(mid)
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(body)) <= budget {
+ lo = mid
+ } else {
+ hi = mid - 1
+ }
}
- last.Content = tailRunes(last.Content, int(budget/bytesPerToken))
- return json.Marshal(request{
- Model: model, Messages: []turn{last}, MaxTokens: answer, Stream: true,
- })
+ return encode(lo)
}
-// tailRunes keeps the last n runes of s, cutting on a rune boundary so the
-// result is still text.
-func tailRunes(s string, n int) string {
- if n <= 0 {
- return ""
- }
- r := []rune(s)
- if len(r) <= n {
- return s
+// alternating is turns without any user turn that is directly followed by
+// another user turn, so a run of unanswered questions is sent as its newest.
+func alternating(turns []turn) []turn {
+ kept := make([]turn, 0, len(turns))
+ for i, t := range turns {
+ if t.Role == roleUser && i+1 < len(turns) && turns[i+1].Role == roleUser {
+ continue
+ }
+ kept = append(kept, t)
}
- return string(r[len(r)-n:])
+ return kept
}
// headBytes keeps as much of the start of s as fits in n bytes, cutting on a
diff --git a/internal/bridge/discord/conversation_order_test.go b/internal/bridge/discord/conversation_order_test.go
new file mode 100644
index 00000000..311343ea
--- /dev/null
+++ b/internal/bridge/discord/conversation_order_test.go
@@ -0,0 +1,322 @@
+package discord
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/intentdriven/Dessau/internal/gateway"
+)
+
+// opensOnTheUser fails unless a built request's history starts with a user
+// turn and ends with the newest thing the person said. Gemma- and
+// Mistral-style chat templates raise on a history that opens on the model's
+// answer, and the channel's next message then fails (iss-2610032306154631).
+func opensOnTheUser(t *testing.T, body []byte, newest func(string) bool) {
+ t.Helper()
+ var req request
+ if err := json.Unmarshal(body, &req); err != nil {
+ t.Fatal(err)
+ }
+ if len(req.Messages) == 0 {
+ t.Fatal("the request carries no turns at all")
+ }
+ if first := req.Messages[0]; first.Role != roleUser {
+ t.Errorf("the history sent opens on a %q turn (%q); it must open on the user", first.Role, headRunes(first.Content, 40))
+ }
+ last := req.Messages[len(req.Messages)-1]
+ if last.Role != roleUser || !newest(last.Content) {
+ t.Errorf("the history sent ends on a %q turn (%q), want the newest user message", last.Role, headRunes(last.Content, 40))
+ }
+}
+
+func is(want string) func(string) bool { return func(got string) bool { return got == want } }
+
+// The turn count is even and a question is appended before it is asked, so
+// once a channel has had maxTurns/2 exchanges every trim drops exactly one
+// turn: the oldest question, leaving its answer at the front. Driven end to
+// end against the fake, so it is the history the gateway is actually handed.
+func TestAHistoryTrimmedByTheTurnCountOpensOnTheUser(t *testing.T) {
+ f := newFakeDiscord(t)
+ b, asked := recording(t, f, "an answer")
+ connected(t, f, b)
+
+ exchanges := maxTurns/2 + 1
+ for i := range exchanges {
+ f.message("question "+strconv.Itoa(i), false, false)
+ answered(t, f, b)
+ }
+ got := asked()
+ if len(got) != exchanges {
+ t.Fatalf("the gateway was asked %d times, want %d", len(got), exchanges)
+ }
+ body, err := json.Marshal(got[len(got)-1])
+ if err != nil {
+ t.Fatal(err)
+ }
+ opensOnTheUser(t, body, is("question "+strconv.Itoa(exchanges-1)))
+ // Exactly one turn lost to the order rule, never a whole exchange: the
+ // count held maxTurns turns, the oldest question went to it and its
+ // answer goes here, leaving questions 1 to 16 and answers 1 to 15.
+ sent := got[len(got)-1].Messages
+ if want := 2*(exchanges-1) - 1; len(sent) != want {
+ t.Errorf("the history sent holds %d turns, want %d", len(sent), want)
+ }
+ if len(sent) > 0 && sent[0].Content != "question 1" {
+ t.Errorf("the history sent opens on %q, want %q", sent[0].Content, "question 1")
+ }
+}
+
+// keeps fails unless a built request's history is exactly want, so a fix
+// that silently drops more than it must is caught.
+func keeps(t *testing.T, body []byte, want []turn) {
+ t.Helper()
+ var req request
+ if err := json.Unmarshal(body, &req); err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(req.Messages, want) {
+ t.Errorf("the history sent is %v, want %v", req.Messages, want)
+ }
+}
+
+// The store's byte budget drops the oldest turn across every channel, one at
+// a time, so it can take a channel's question and leave its answer.
+func TestAHistoryTrimmedByTheByteBudgetOpensOnTheUser(t *testing.T) {
+ c := newConversations()
+ conv := c.get("channel-0")
+ conv.append(turn{Role: roleUser, Content: "the first question"})
+ conv.append(turn{Role: roleAssistant, Content: "the first answer"})
+ conv.append(turn{Role: roleUser, Content: "the second question"})
+
+ // Fill the store to exactly its budget from other channels, then go one
+ // byte over: the budget drops one turn, the oldest, which is channel-0's
+ // first question.
+ filler := 0
+ add := func(content string) {
+ c.get("channel-" + strconv.Itoa(1+filler%(maxChannels-1))).append(turn{Role: roleUser, Content: content})
+ filler++
+ }
+ for room := maxStoreBytes - c.bytes(); room > 0; room = maxStoreBytes - c.bytes() {
+ add(strings.Repeat("x", min(room, maxTurnBytes)))
+ }
+ add("x")
+ if h := conv.history(); len(h) != 2 {
+ t.Fatalf("channel-0 holds %d turns after the budget trimmed; the set-up meant to drop exactly one", len(h))
+ }
+
+ body, err := buildRequest("a-model", conv.history(), 1<<20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ opensOnTheUser(t, body, is("the second question"))
+ keeps(t, body, []turn{{Role: roleUser, Content: "the second question"}})
+}
+
+// buildRequest drops turns from the front until the body fits the window, so
+// the window can cut between a question and its answer.
+func TestAHistoryTrimmedToTheWindowOpensOnTheUser(t *testing.T) {
+ const served = 1024 // half for the answer: a budget of 2,048 bytes
+ turns := []turn{
+ {Role: roleUser, Content: strings.Repeat("q", 3000)},
+ {Role: roleAssistant, Content: "a short answer"},
+ {Role: roleUser, Content: "a short question"},
+ {Role: roleAssistant, Content: "another short answer"},
+ {Role: roleUser, Content: "the newest question"},
+ }
+ body, err := buildRequest("a-model", turns, served)
+ if err != nil {
+ t.Fatal(err)
+ }
+ opensOnTheUser(t, body, is("the newest question"))
+ // The window cuts the long question, the order rule its answer, and
+ // nothing more: the exchange after it fits and is kept.
+ keeps(t, body, turns[2:])
+}
+
+// When nothing fits, only one turn is sent, truncated. That turn is the
+// newest user message, never a trailing answer standing on its own.
+func TestTheFallbackSendsTheNewestUserTurn(t *testing.T) {
+ const served = 1024
+ question := "the newest question " + strings.Repeat("q", 200_000)
+ turns := []turn{
+ {Role: roleUser, Content: question},
+ {Role: roleAssistant, Content: strings.Repeat("a", 200_000)},
+ }
+ body, err := buildRequest("a-model", turns, served)
+ if err != nil {
+ t.Fatal(err)
+ }
+ opensOnTheUser(t, body, func(got string) bool { return got != "" && strings.HasSuffix(question, got) })
+ var req request
+ if err := json.Unmarshal(body, &req); err != nil {
+ t.Fatal(err)
+ }
+ if len(req.Messages) != 1 {
+ t.Errorf("the fallback sent %d turns, want the one", len(req.Messages))
+ }
+ if judged := int64(len(body)/bytesPerToken) + int64(req.MaxTokens); judged > served {
+ t.Errorf("the fallback would be judged at %d tokens against a window of %d", judged, served)
+ }
+}
+
+// The fallback is held to the window in ENCODED bytes, the way the gateway
+// judges it. A cut in runes lets a turn of four-byte emoji, or of characters
+// JSON escapes to six bytes, through at several times the budget
+// (iss-2610042030098334).
+func TestTheFallbackFitsTheWindowInEncodedBytes(t *testing.T) {
+ const served = 4096
+ for name, unit := range map[string]string{
+ "angle": "<",
+ "entity": "<",
+ "emoji": "\U0001F600",
+ "control": "\x01",
+ "plain": "y",
+ } {
+ t.Run(name, func(t *testing.T) {
+ question := headBytes(strings.Repeat(unit, maxTurnBytes), maxTurnBytes)
+ body, err := buildRequest("a-model", []turn{{Role: roleUser, Content: question}}, served)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var req request
+ if err := json.Unmarshal(body, &req); err != nil {
+ t.Fatal(err)
+ }
+ if judged := int64(len(body)/bytesPerToken) + int64(req.MaxTokens); judged > served {
+ t.Errorf("the fallback would be judged at %d tokens against a window of %d", judged, served)
+ }
+ if len(req.Messages) != 1 {
+ t.Fatalf("the fallback sent %d turns, want the one", len(req.Messages))
+ }
+ kept := req.Messages[0].Content
+ if kept == "" || !strings.HasSuffix(question, kept) || len(kept) == len(question) {
+ t.Fatalf("the fallback kept %d of %d bytes, want a non-empty tail of the question", len(kept), len(question))
+ }
+ // As much as fits: one rune more would be over.
+ r := []rune(question)
+ more := string(r[len(r)-len([]rune(kept))-1:])
+ bigger, err := json.Marshal(request{Model: "a-model", Messages: []turn{{Role: roleUser, Content: more}}, MaxTokens: req.MaxTokens, Stream: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(bigger) <= (served-req.MaxTokens)*bytesPerToken {
+ t.Errorf("the fallback kept %d runes, and one more would still have fitted", len([]rune(kept)))
+ }
+ })
+ }
+}
+
+// alternates fails unless a built request's turns take strict turns: user,
+// assistant, user, and so on, ending on the user. Gemma- and Mistral-style
+// chat templates raise on two user turns in a row (iss-2610042030092101).
+func alternates(t *testing.T, req request) {
+ t.Helper()
+ for i, m := range req.Messages {
+ want := roleUser
+ if i%2 == 1 {
+ want = roleAssistant
+ }
+ if m.Role != want {
+ t.Errorf("turn %d of %d is a %q turn (%q), want %q: the history sent does not alternate",
+ i, len(req.Messages), m.Role, headRunes(m.Content, 40), want)
+ }
+ }
+ if n := len(req.Messages); n == 0 || req.Messages[n-1].Role != roleUser {
+ t.Error("the history sent does not end on the user")
+ }
+}
+
+// A request refused with nothing written, and an answer that comes back
+// empty, each leave the question they appended unanswered. The channel's
+// next message must still send a history that alternates, or a
+// strict-alternation template refuses it and every message after it, and the
+// channel is stuck until /reset (iss-2610042030092101). Driven end to end
+// against the fake, so it is the history the gateway is actually handed.
+func TestAnUnansweredQuestionDoesNotBreakTheAlternation(t *testing.T) {
+ f := newFakeDiscord(t)
+ var mu sync.Mutex
+ var asked []request
+ opts := f.options(time.Now)
+ opts.Ask = func(ctx context.Context, req gateway.AskRequest) error {
+ var body request
+ if err := json.Unmarshal(req.Body, &body); err != nil {
+ return err
+ }
+ mu.Lock()
+ asked = append(asked, body)
+ n := len(asked)
+ mu.Unlock()
+ switch n {
+ case 2:
+ return errors.New("refused before a token was written")
+ case 3:
+ return nil // an answer of nothing at all
+ }
+ payload, _ := json.Marshal(map[string]any{
+ "choices": []any{map[string]any{"delta": map[string]any{"content": "answer " + strconv.Itoa(n-1)}}},
+ })
+ req.OnEvent(payload)
+ return nil
+ }
+ b := New(opts)
+ t.Cleanup(func() { _ = b.Close() })
+ connected(t, f, b)
+
+ for i := range 4 {
+ f.message("question "+strconv.Itoa(i), false, false)
+ answered(t, f, b)
+ }
+ mu.Lock()
+ got := append([]request(nil), asked...)
+ mu.Unlock()
+ if len(got) != 4 {
+ t.Fatalf("the gateway was asked %d times, want 4", len(got))
+ }
+ last := got[3]
+ alternates(t, last)
+ want := []turn{
+ {Role: roleUser, Content: "question 0"},
+ {Role: roleAssistant, Content: "answer 0"},
+ {Role: roleUser, Content: "question 3"},
+ }
+ if !slices.Equal(last.Messages, want) {
+ t.Errorf("the history sent is %v, want %v: the unanswered questions go, the newest stays", last.Messages, want)
+ }
+}
+
+// buildRequest holds the alternation whatever the store hands it: every run
+// of user turns is cut to its newest.
+func TestOnlyTheNewestOfConsecutiveUserTurnsIsSent(t *testing.T) {
+ turns := []turn{
+ {Role: roleUser, Content: "unanswered 1"},
+ {Role: roleUser, Content: "question 1"},
+ {Role: roleAssistant, Content: "answer 1"},
+ {Role: roleUser, Content: "unanswered 2"},
+ {Role: roleUser, Content: "unanswered 3"},
+ {Role: roleUser, Content: "question 4"},
+ }
+ body, err := buildRequest("a-model", turns, 1<<20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var req request
+ if err := json.Unmarshal(body, &req); err != nil {
+ t.Fatal(err)
+ }
+ alternates(t, req)
+ want := []turn{
+ {Role: roleUser, Content: "question 1"},
+ {Role: roleAssistant, Content: "answer 1"},
+ {Role: roleUser, Content: "question 4"},
+ }
+ if !slices.Equal(req.Messages, want) {
+ t.Errorf("the history sent is %v, want %v", req.Messages, want)
+ }
+}
diff --git a/internal/bridge/discord/conversation_test.go b/internal/bridge/discord/conversation_test.go
index 20c28557..797ec067 100644
--- a/internal/bridge/discord/conversation_test.go
+++ b/internal/bridge/discord/conversation_test.go
@@ -13,9 +13,16 @@ import (
// stays.
func TestAConversationIsTrimmedToTheServedWindow(t *testing.T) {
const served = 2048
+ // Alternating, as a channel's history does, so it is the window that
+ // trims it and not the rule that sends only the newest of a run of user
+ // turns.
var turns []turn
- for i := range 200 {
- turns = append(turns, turn{Role: roleUser, Content: "turn " + strconv.Itoa(i) + " " + strings.Repeat("x", 200)})
+ for i := range 201 {
+ role := roleUser
+ if i%2 == 1 {
+ role = roleAssistant
+ }
+ turns = append(turns, turn{Role: role, Content: "turn " + strconv.Itoa(i) + " " + strings.Repeat("x", 200)})
}
turns[len(turns)-1].Content = "the newest thing said"
@@ -43,6 +50,9 @@ func TestAConversationIsTrimmedToTheServedWindow(t *testing.T) {
if len(req.Messages) >= len(turns) {
t.Errorf("all %d turns were sent; the window is %d tokens", len(req.Messages), served)
}
+ if len(req.Messages) <= 1 {
+ t.Errorf("only %d turn was sent; the window holds more than the newest turn", len(req.Messages))
+ }
}
// A single message too large for the window on its own is truncated rather
diff --git a/internal/contextprobe/probe.go b/internal/contextprobe/probe.go
index 8f136416..f2e9f1f4 100644
--- a/internal/contextprobe/probe.go
+++ b/internal/contextprobe/probe.go
@@ -45,11 +45,11 @@ const Name = "context-probe"
type Candidate struct {
RepoID string
// Declared is the window the model's own configuration declares; zero
- // means none, and there is nothing to bisect between.
+ // means none, and there is nothing to bisect between. The window Dessau
+ // serves the model at is not here: the probe's largest step is bounded
+ // by the served window in Sources.Provenance, the read its figure is
+ // stamped from, so the ceiling and the stamp cannot disagree.
Declared int64
- // Served is the window Dessau serves the model at (the operator's
- // setting, or Declared), which bounds the largest step.
- Served int64
// Bytes and KVChargePerToken are what the memory guard projects from.
Bytes, KVChargePerToken int64
// Measured is the model's current measurement, or nil.
@@ -79,7 +79,9 @@ type Sources interface {
// flight on it, runtime.ErrNotLoaded when it is not resident, ErrPinned
// when it is pinned.
Unload(repoID string) error
- // Save records a completed measurement on the model.
+ // Save records a completed measurement on the model, and judges it
+ // against what is in force once it is written, so a figure whose
+ // provenance moved after the probe's last check reads stale.
Save(repoID string, m *registry.Measurement) error
// MarkIncomplete records that a probe of the model was interrupted.
MarkIncomplete(repoID string, on bool) error
@@ -168,6 +170,11 @@ type bounds struct {
// run bisects rather than starting the sweep over — or, when it did
// not, resumes the sweep from lo.
swept bool
+ // prov is the provenance the bounds were made under. Every step they
+ // hold was taken under it — hi from its served window, the guard from
+ // its budget — so they are kept only while it is still in force, and a
+ // figure drawn from them is stamped with it (iss-2610032241096901).
+ prov registry.Provenance
}
// New builds a Probe.
@@ -350,16 +357,31 @@ func (p *Probe) Run(s *selftest.Session, model string) {
return
}
key := config.FoldRepoID(model)
+ // Read before taking the probe's lock: the app answers it from the
+ // registry, the pool and the configuration.
+ inForce := p.opts.Sources.Provenance(model)
p.mu.Lock()
b, ok := p.bounds[key]
+ if ok && b.prov != inForce {
+ // Bounds made under another provenance measure that one: resuming
+ // from them would bisect to a window the settings in force may not
+ // serve, and save it stamped as current. The run starts over.
+ delete(p.bounds, key)
+ ok = false
+ p.opts.Log.Info("context probe: the settings changed since the run began; starting it over", "model", model)
+ }
if !ok {
// The gateway's served-window check is in force at the served
// window whether or not it is below the declared one, so a sweep
// that reaches the cap without a refusal was stopped by it; the
// model's own limit is recorded only from a step it actually refused.
- b = &bounds{hi: min(cand.Declared, MaxProbeWindow), bound: registry.BoundServedWindow}
- if cand.Served > 0 && cand.Served < b.hi {
- b.hi = cand.Served
+ // The served window is the one in the provenance the bounds are
+ // stamped with, so the ceiling and the stamp come from one read and
+ // a window moved after it cannot give a ceiling from one setting and
+ // a stamp from another.
+ b = &bounds{hi: min(cand.Declared, MaxProbeWindow), bound: registry.BoundServedWindow, prov: inForce}
+ if inForce.ServedContext > 0 && inForce.ServedContext < b.hi {
+ b.hi = inForce.ServedContext
}
p.bounds[key] = b
}
@@ -461,13 +483,30 @@ func (p *Probe) Run(s *selftest.Session, model string) {
}
}
- prov := p.opts.Sources.Provenance(model)
+ p.unloadWaiting(s.Ctx, model)
+ if p.opts.Sources.Provenance(model) != b.prov {
+ // The settings moved during the run, with no yield between to catch
+ // it: the figure holds under the provenance the bounds were made
+ // under, not the one in force, so it is not saved. The bounds are
+ // dropped and the model left queued or due, so a fresh run measures
+ // it under what is in force now.
+ p.mu.Lock()
+ delete(p.bounds, key)
+ p.mu.Unlock()
+ p.opts.Log.Info("context probe: the settings changed during the run; its figure is not saved, and it starts over", "model", model)
+ return
+ }
+ // Stamped with the provenance the bounds were made under, which is the
+ // one just found in force. A settings save can still land between that
+ // check and the save below, and its own re-judging may already have run;
+ // the stamp stays truthful, and Save judges the figure against what is in
+ // force once it is written, so such a move marks it stale rather than
+ // leaving it current.
m := ®istry.Measurement{
Window: b.loTokens, Bound: b.bound, At: p.opts.Now().Unix(),
- Runtime: prov.Runtime, BudgetBytes: prov.BudgetBytes, DecodeConcurrency: prov.DecodeConcurrency,
- ServedContext: prov.ServedContext, GuardBytes: b.guardBytes,
+ Runtime: b.prov.Runtime, BudgetBytes: b.prov.BudgetBytes, DecodeConcurrency: b.prov.DecodeConcurrency,
+ ServedContext: b.prov.ServedContext, GuardBytes: b.guardBytes,
}
- p.unloadWaiting(s.Ctx, model)
if err := p.opts.Sources.Save(model, m); err != nil {
// Forty minutes of GPU are in the bounds; keep them for a retry and
// say what happened, rather than throwing the run away.
diff --git a/internal/contextprobe/probe_test.go b/internal/contextprobe/probe_test.go
index 258dd821..a5db332e 100644
--- a/internal/contextprobe/probe_test.go
+++ b/internal/contextprobe/probe_test.go
@@ -34,6 +34,8 @@ type fakeGateway struct {
prompts []string
requests int
auth string
+ // inside counts the requests a handler is still serving.
+ inside int
}
func newFakeGateway(t *testing.T, accept int64, above int) *fakeGateway {
@@ -45,6 +47,14 @@ func newFakeGateway(t *testing.T, accept int64, above int) *fakeGateway {
}
func (g *fakeGateway) handle(w http.ResponseWriter, r *http.Request) {
+ g.mu.Lock()
+ g.inside++
+ g.mu.Unlock()
+ defer func() {
+ g.mu.Lock()
+ g.inside--
+ g.mu.Unlock()
+ }()
var body struct {
Messages []struct {
Content string `json:"content"`
@@ -78,6 +88,13 @@ func (g *fakeGateway) handle(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"usage": map[string]any{"prompt_tokens": tokens}})
}
+// active is how many requests a handler is still serving.
+func (g *fakeGateway) active() int {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return g.inside
+}
+
func (g *fakeGateway) seen() []string {
g.mu.Lock()
defer g.mu.Unlock()
@@ -94,6 +111,25 @@ type fakeSources struct {
saved map[string]*registry.Measurement
incomplete map[string]bool
url, key string
+ // prov is the provenance in force; zero means the default below.
+ prov registry.Provenance
+ // next, when set, answers the next reads of the provenance in turn,
+ // one each, before prov or the default answers again: a setting that
+ // moves between two reads.
+ next []registry.Provenance
+}
+
+// withServed puts a served window into force for every model: the
+// provenance carries it, as the app's does.
+func (f *fakeSources) withServed(window int64) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.prov = provServed(window)
+}
+
+// provServed is the default provenance with the given served window.
+func provServed(window int64) registry.Provenance {
+ return registry.Provenance{Runtime: "0.31.3", BudgetBytes: 1, DecodeConcurrency: 4, ServedContext: window}
}
func newFakeSources(url string, cands ...Candidate) *fakeSources {
@@ -111,8 +147,29 @@ func (f *fakeSources) Candidates() []Candidate {
}
return out
}
-func (f *fakeSources) Provenance(string) registry.Provenance {
- return registry.Provenance{Runtime: "0.31.3", BudgetBytes: 1, DecodeConcurrency: 4, ServedContext: 131072}
+
+// Provenance is the next queued reading when a test queues some, then prov
+// when a test sets one, and otherwise the default with the candidate's
+// declared window as the served one, which is what the app serves when
+// nothing sets another.
+func (f *fakeSources) Provenance(id string) registry.Provenance {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if len(f.next) > 0 {
+ p := f.next[0]
+ f.next = f.next[1:]
+ return p
+ }
+ if f.prov != (registry.Provenance{}) {
+ return f.prov
+ }
+ served := int64(131072)
+ for _, c := range f.cands {
+ if c.RepoID == id {
+ served = c.Declared
+ }
+ }
+ return provServed(served)
}
func (f *fakeSources) Available() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.available }
func (f *fakeSources) Unload(id string) error {
@@ -181,6 +238,7 @@ func (p *fakePool) Activity() selftest.Activity {
func (p *fakePool) Unload(string) error { return nil }
func (p *fakePool) Concurrency() int { return 1 }
func (p *fakePool) Fits(string) bool { p.mu.Lock(); defer p.mu.Unlock(); return p.fits }
+func (p *fakePool) Pinned(string) bool { return false }
func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
@@ -229,7 +287,7 @@ func waitFor(t *testing.T, what string, cond func() bool) {
t.Fatalf("gave up waiting for %s", what)
}
-var model = Candidate{RepoID: "org/m", Declared: 131072, Served: 131072, Bytes: 1 << 30, KVChargePerToken: 1024}
+var model = Candidate{RepoID: "org/m", Declared: 131072, Bytes: 1 << 30, KVChargePerToken: 1024}
// The switch off, nothing queued: nothing is due, whatever finished
// downloading. "Measure now" makes a model due whatever the switch says.
@@ -327,9 +385,8 @@ func TestAStepStoppedByTheDeadlineIsAFloor(t *testing.T) {
// accepts everything up to it is bounded by the served window.
func TestAServedWindowCapsTheSweep(t *testing.T) {
gw := newFakeGateway(t, 1<<40, http.StatusInternalServerError)
- c := model
- c.Served = 16_384
- src := newFakeSources(gw.srv.URL, c)
+ src := newFakeSources(gw.srv.URL, model)
+ src.withServed(16_384)
p := probeOf(src, true)
r := runner(t, newFakePool("org/m"), p)
r.SetEnabled(true)
@@ -617,7 +674,7 @@ func TestARefusedKeyAbandonsTheRun(t *testing.T) {
func TestAPlantedDeclaredWindowIsCappedAndTheCalibrationClamped(t *testing.T) {
gw := newFakeGateway(t, 1<<40, http.StatusInternalServerError)
c := model
- c.Declared, c.Served = 1<<23, 1<<23
+ c.Declared = 1 << 23
src := newFakeSources(gw.srv.URL, c)
p := probeOf(src, true)
r := runner(t, newFakePool("org/m"), p)
diff --git a/internal/contextprobe/provenance_test.go b/internal/contextprobe/provenance_test.go
new file mode 100644
index 00000000..e36abd4d
--- /dev/null
+++ b/internal/contextprobe/provenance_test.go
@@ -0,0 +1,130 @@
+package contextprobe
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/intentdriven/Dessau/internal/registry"
+)
+
+// lowerServed puts a lower served window into force for the one candidate:
+// the app's provenance reads it, and the gateway's served-window check
+// refuses above it.
+func lowerServed(src *fakeSources, gw *fakeGateway, window int64) {
+ src.withServed(window)
+ gw.mu.Lock()
+ gw.accept = window
+ gw.mu.Unlock()
+}
+
+// sweptPast reports whether the gateway has seen a step of at least size
+// tokens.
+func sweptPast(gw *fakeGateway, size int) func() bool {
+ return sweptPastSince(gw, 0, size)
+}
+
+// sweptPastSince is sweptPast counting only the steps from the from'th on.
+func sweptPastSince(gw *fakeGateway, from, size int) func() bool {
+ return func() bool {
+ seen := gw.seen()
+ for _, s := range seen[min(from, len(seen)):] {
+ if len(s) > size*4 {
+ return true
+ }
+ }
+ return false
+ }
+}
+
+// assertHeldUnder fails unless the measurement fits the served window in
+// force and is stamped with the provenance it was made under.
+func assertHeldUnder(t *testing.T, m *registry.Measurement, served int64) {
+ t.Helper()
+ if m.Window > served {
+ t.Errorf("saved window %d is above the served window %d in force", m.Window, served)
+ }
+ if m.ServedContext != served {
+ t.Errorf("measurement stamped with served window %d, want %d", m.ServedContext, served)
+ }
+}
+
+// A run that yields keeps its bounds for a resume, but they were made under
+// the provenance in force when it began. When the served window is lowered
+// meanwhile, the resumed run must not bisect to them and save a window above
+// the new served window stamped as current (iss-2610032241096901).
+func TestAResumedProbeDropsBoundsMadeUnderAnotherProvenance(t *testing.T) {
+ gw := newFakeGateway(t, 131072, http.StatusBadRequest)
+ gw.delay = 30 * time.Millisecond
+ src := newFakeSources(gw.srv.URL, model)
+ p := probeOf(src, true)
+ pool := newFakePool("org/m")
+ r := runner(t, pool, p)
+ r.SetEnabled(true)
+ waitFor(t, "the sweep to pass 16,384 tokens", sweptPast(gw, 32768))
+ pool.mu.Lock()
+ pool.inFlight["org/other"] = 1
+ pool.mu.Unlock()
+ waitFor(t, "the probe to yield", func() bool { return r.Status().Job == "" })
+ if src.result("org/m") != nil {
+ t.Fatal("a yielded probe wrote a figure")
+ }
+ // The yielded step's handler may still be reading its prompt; the steps
+ // counted from here on are the resumed run's alone.
+ waitFor(t, "the gateway to finish the yielded step", func() bool { return gw.active() == 0 })
+ lowerServed(src, gw, 8192)
+ from := len(gw.seen())
+ pool.mu.Lock()
+ delete(pool.inFlight, "org/other")
+ pool.mu.Unlock()
+ waitFor(t, "a measurement after resuming", func() bool { return src.result("org/m") != nil })
+ assertHeldUnder(t, src.result("org/m"), 8192)
+ // The resume itself starts over: no step after the move is sized from
+ // the old bounds. Without the check on resume, the check before saving
+ // would still throw the figure away and a fresh run save a right one, so
+ // only the steps show that the resume did not bisect between bounds the
+ // settings in force do not serve.
+ if sweptPastSince(gw, from, 8192)() {
+ t.Error("the resumed run sent a step above the served window in force: it resumed from bounds made under the old one")
+ }
+}
+
+// The bounds' ceiling and their provenance come from one read. Here the
+// first read of the provenance still has the served window from before a
+// raise, and every later one the raised window, which is what a raise landing
+// just after the run begins looks like: a ceiling from the first read and a
+// stamp from a later one would stop the sweep at the old window and save that
+// figure, bound by the served window, stamped with the raised one.
+func TestAProbesCeilingComesFromTheProvenanceItIsStampedWith(t *testing.T) {
+ gw := newFakeGateway(t, 131072, http.StatusBadRequest)
+ src := newFakeSources(gw.srv.URL, model)
+ src.next = []registry.Provenance{provServed(8192)}
+ src.withServed(131072)
+ p := probeOf(src, true)
+ r := runner(t, newFakePool("org/m"), p)
+ r.SetEnabled(true)
+ waitFor(t, "a measurement", func() bool { return src.result("org/m") != nil })
+ m := src.result("org/m")
+ if m.ServedContext != 131072 {
+ t.Fatalf("measurement stamped with served window %d, want 131072", m.ServedContext)
+ }
+ if m.Bound == registry.BoundServedWindow && m.Window <= m.ServedContext/2 {
+ t.Errorf("saved window %d bound by the served window, stamped with a served window of %d: the ceiling came from another read", m.Window, m.ServedContext)
+ }
+}
+
+// The same move inside one run, with no yield between: the bounds were made
+// under the provenance in force when the run began, so the figure they give
+// is not saved as current under another.
+func TestAProbeDoesNotSaveAFigureAcrossAProvenanceChange(t *testing.T) {
+ gw := newFakeGateway(t, 131072, http.StatusBadRequest)
+ gw.delay = 30 * time.Millisecond
+ src := newFakeSources(gw.srv.URL, model)
+ p := probeOf(src, true)
+ r := runner(t, newFakePool("org/m"), p)
+ r.SetEnabled(true)
+ waitFor(t, "the sweep to pass 16,384 tokens", sweptPast(gw, 32768))
+ lowerServed(src, gw, 8192)
+ waitFor(t, "a measurement", func() bool { return src.result("org/m") != nil })
+ assertHeldUnder(t, src.result("org/m"), 8192)
+}
diff --git a/internal/gateway/emptyprompt.go b/internal/gateway/emptyprompt.go
index a0082a73..1ec2ab19 100644
--- a/internal/gateway/emptyprompt.go
+++ b/internal/gateway/emptyprompt.go
@@ -10,8 +10,9 @@ import (
const promptField = "prompt"
// emptyConversation is the refusal for a request with nothing to generate
-// from, or "" for none: a /v1/completions prompt that is empty or only
-// whitespace, or a chat request whose messages are an empty array.
+// from, or "" for none: a /v1/completions prompt that is not a JSON string, or
+// is a string that is empty or only whitespace, or a chat request whose
+// messages are an empty array.
//
// One empty prompt freezes the pinned mlx-lm's model server: the process
// stays up, Dessau still reports the model loaded, and every later request to
@@ -19,10 +20,16 @@ const promptField = "prompt"
// Mac with mlx-lm 0.32.0). adr-2610040749545010 grants this check as a narrow
// exception to the conversation boundary: it reads whether the prompt or the
// messages are empty and nothing else — never what a message says, never how
-// many there are — and keeps nothing. A prompt that is not a string, a
-// messages value that is not an array, and a missing field are passed on as
-// they were: telling them apart reads more than emptiness, which the grant
-// does not cover.
+// many there are — and keeps nothing.
+//
+// A prompt that is an array of strings freezes the same server in the same
+// way (["hi"] and [""], iss-2610040805353721, reproduced on the Mac), and a
+// token array is refused by it, so no prompt but a string yields a generation
+// there. adr-2610042021365934 widens the grant for the prompt alone to its
+// JSON kind, read from the first byte of the raw value: nothing inside an
+// array or an object is looked at, and only a value that is already a string
+// is decoded. A messages value that is not an array, and a missing field, are
+// passed on as they were.
func emptyConversation(payload map[string]json.RawMessage, chat bool) string {
if chat {
if isEmptyArray(payload[messagesField]) {
@@ -34,11 +41,12 @@ func emptyConversation(payload map[string]json.RawMessage, chat bool) string {
if !ok {
return ""
}
- if isEmptyArray(raw) {
- return `"prompt" is empty: there is nothing to complete`
+ t := bytes.TrimSpace(raw)
+ if len(t) == 0 || t[0] != '"' {
+ return `"prompt" must be a string`
}
var s string
- if json.Unmarshal(raw, &s) == nil && strings.TrimSpace(s) == "" {
+ if json.Unmarshal(t, &s) == nil && strings.TrimSpace(s) == "" {
return `"prompt" is empty: there is nothing to complete`
}
return ""
diff --git a/internal/gateway/emptyprompt_test.go b/internal/gateway/emptyprompt_test.go
index 77d177ae..7319d8dd 100644
--- a/internal/gateway/emptyprompt_test.go
+++ b/internal/gateway/emptyprompt_test.go
@@ -15,9 +15,10 @@ import (
// A request with nothing to generate from is refused before anything is
// resolved or loaded: one empty prompt froze the pinned mlx-lm's model server
// on the Mac, and every later request to that model hung until it was
-// unloaded (iss-2610031758029994). Only emptiness is refused; a request with
-// anything in it is relayed as it always was, and a field of the wrong kind
-// or a missing one stays the model server's to refuse.
+// unloaded (iss-2610031758029994). Only emptiness is refused here; a request
+// with anything in it is relayed as it always was, and a missing field stays
+// the model server's to refuse. A prompt that is not a string is the next
+// test's.
func TestARequestWithNothingToGenerateFromIsRefused(t *testing.T) {
const model = `"model":"mlx-community/Qwen3-8B-4bit"`
const text = "/v1/completions"
@@ -36,8 +37,6 @@ func TestARequestWithNothingToGenerateFromIsRefused(t *testing.T) {
{"empty messages with space inside", chatCompletionsPath, `{` + model + `,"messages":[ ` + "\n" + ` ]}`, true},
{"an ordinary prompt", text, `{` + model + `,"prompt":"The capital of France is"}`, false},
{"a prompt with a leading space", text, `{` + model + `,"prompt":" One plus one equals"}`, false},
- {"a prompt array with an entry", text, `{` + model + `,"prompt":["hi"]}`, false},
- {"a prompt that is not a string, for the server to refuse", text, `{` + model + `,"prompt":5}`, false},
{"no prompt at all, for the server to refuse", text, `{` + model + `}`, false},
{"messages on the completions path, which reads the prompt", text, `{` + model + `,"prompt":"hi","messages":[]}`, false},
{"ordinary messages", chatCompletionsPath, `{` + model + `,"messages":[{"role":"user","content":"hi"}]}`, false},
@@ -75,6 +74,66 @@ func TestARequestWithNothingToGenerateFromIsRefused(t *testing.T) {
}
}
+// A /v1/completions prompt that is not a JSON string is refused before
+// anything is resolved or loaded: on the Mac, ["hi"] and [""] each froze the
+// pinned mlx-lm's model server as the empty prompt did, and a token array is
+// refused by it with a 404, so no prompt of another kind yields a generation
+// there (iss-2610040805353721, adr-2610042021365934). Only the value's kind is
+// read; a string, whatever it says, is relayed.
+func TestACompletionsPromptThatIsNotAStringIsRefused(t *testing.T) {
+ const model = `"model":"mlx-community/Qwen3-8B-4bit"`
+ for _, c := range []struct {
+ name, prompt string
+ refused bool
+ }{
+ {"an array of one string", `["hi"]`, true},
+ {"an array of one empty string", `[""]`, true},
+ {"an array of token ids", `[72,73]`, true},
+ {"a nested array", `[["hi"]]`, true},
+ {"a number", `123`, true},
+ {"an object", `{}`, true},
+ {"null", `null`, true},
+ {"a boolean", `true`, true},
+ {"null, after a string given first", `"hi","prompt":null`, true},
+ {"a string", `"The capital of France is"`, false},
+ {"a string that looks like an array", `"[\"hi\"]"`, false},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ srv, pool, fake := newTestGateway(t, config.Default())
+ body := `{` + model + `,"prompt":` + c.prompt + `}`
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/completions", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := srv.Client().Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ pool.mu.Lock()
+ acquired := len(pool.acquired)
+ pool.mu.Unlock()
+ if !c.refused {
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status %d, %s; want the string prompt relayed", resp.StatusCode, got)
+ }
+ if fake.LastBody() == nil {
+ t.Error("a string prompt did not reach the model server")
+ }
+ return
+ }
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("status %d, %s; want a 400", resp.StatusCode, got)
+ }
+ if !strings.Contains(string(got), `\"prompt\" must be a string`) {
+ t.Errorf("the refusal does not say the prompt must be a string: %s", got)
+ }
+ if acquired != 0 || fake.LastBody() != nil {
+ t.Error("a refused request reached the pool or the model server")
+ }
+ })
+ }
+}
+
// The bridge's in-process path holds the same rule as the network's.
func TestTheBridgeRefusesEmptyMessages(t *testing.T) {
g, _, fake := askGateway(t, "ok")
diff --git a/internal/gateway/generation_refusal.go b/internal/gateway/generation_refusal.go
index 6f938b54..3d790714 100644
--- a/internal/gateway/generation_refusal.go
+++ b/internal/gateway/generation_refusal.go
@@ -58,7 +58,8 @@ var templateOwnArgs = map[string]bool{
// generationRefusal is the refusal for a request carrying a value the model
// server's generation thread would die on, or "" for none. The conversation
// itself is not read here: emptyConversation holds the one narrow exception,
-// whether there is any prompt at all (adr-2610040749545010).
+// whether there is any prompt at all and whether a completions prompt is a
+// string (adr-2610040749545010, adr-2610042021365934).
func generationRefusal(payload map[string]json.RawMessage) string {
for _, b := range numericBounds {
raw, ok := payload[b.key]
@@ -122,6 +123,11 @@ func badLogitBias(payload map[string]json.RawMessage) string {
// Go decodes such an escape to U+FFFD, so badStop passes it, but the model
// server's Python keeps the lone surrogate, and encoding it raises on the
// generation thread. The raw bytes are what is read.
+//
+// A string prompt is not held to the same rule. The pinned mlx-lm (0.32.0)
+// encodes the prompt inside the generation loop's try, so an encode error
+// answers that one request and the thread survives; the stop strings are
+// encoded after that try, which is why only they can kill it.
func badStopText(payload map[string]json.RawMessage) string {
raw, ok := payload["stop"]
if !ok {
diff --git a/internal/selftest/selftest.go b/internal/selftest/selftest.go
index cd7aba88..970f3f9b 100644
--- a/internal/selftest/selftest.go
+++ b/internal/selftest/selftest.go
@@ -43,6 +43,11 @@ import (
// model's day: the model is measured once it can be served.
var ErrNotNow = errors.New("the model cannot be served just now")
+// ErrPinned is Server.Unload's refusal for a pinned model. The self-test
+// unloads only what it loaded itself, and a model pinned meanwhile is one a
+// person wants kept in memory, so it stays (iss-2610032241098944).
+var ErrPinned = errors.New("the model is pinned, and the self-test never unloads a pinned model")
+
// Server is what the self-test needs from the app: the models it may test,
// the pool's ordinary way of loading one, the pool's view of what is going on,
// and a way to unload what the self-test itself loaded.
@@ -63,8 +68,13 @@ type Server interface {
Acquire(ctx context.Context, repoID string) (Upstream, func(), error)
// Activity is what the pool is doing right now.
Activity() Activity
- // Unload stops a model server. The pool refuses one that is serving.
+ // Unload stops a model server. The pool refuses one that is serving,
+ // and the implementation refuses a pinned one with ErrPinned.
Unload(repoID string) error
+ // Pinned reports whether the model is pinned. The self-test measures a
+ // pinned model only while it is already resident: it never unloads one,
+ // so a pinned model it loaded would stay in memory after the run.
+ Pinned(repoID string) bool
// Concurrency is the decode concurrency each model server runs with,
// which is how many requests the concurrent test sends at once.
Concurrency() int
@@ -487,7 +497,7 @@ func (r *Runner) tick(ctx context.Context) {
}
}
if job == nil && (r.opts.SelfTest == nil || r.opts.SelfTest()) {
- model = r.next(now)
+ model = r.next(now, act)
}
for _, m := range noRoom {
r.sayNoRoom(m)
@@ -653,8 +663,9 @@ func resident(act Activity, repoID string) bool {
// next is the ready model measured longest ago, or "" when every ready model
// has a result younger than the re-test window. A model with no result at
-// all comes first.
-func (r *Runner) next(now time.Time) string {
+// all comes first. act is the tick's view of the pool, the same one the run's
+// wasResident is read from.
+func (r *Runner) next(now time.Time, act Activity) string {
ready := r.opts.Server.Ready()
r.mu.Lock()
defer r.mu.Unlock()
@@ -663,6 +674,15 @@ func (r *Runner) next(now time.Time) string {
if at, ok := r.tested[config.FoldRepoID(id)]; ok && now.Sub(at) < r.opts.Retest {
continue
}
+ // A pinned model is measured only while it is already resident,
+ // and then left there: the self-test never unloads a pinned model,
+ // so loading one would leave it in memory after the run. Pinned
+ // and not loaded — every pinned model after a restart — it is
+ // passed over, as the context probe passes over a pinned model; it
+ // stays due, and is not charged a day (iss-2610032241098944).
+ if !resident(act, id) && r.opts.Server.Pinned(id) {
+ continue
+ }
// A model that would need something evicted is left for a tick when
// there is room; it stays due, and is not charged a day.
if !r.opts.Server.Fits(id) {
@@ -761,7 +781,11 @@ func (r *Runner) run(ctx context.Context, model string, wasResident bool) {
if wasResident || res.Outcome == OutcomeYielded {
return
}
- if err := r.opts.Server.Unload(model); err != nil {
+ // A model pinned during the run is refused with ErrPinned and stays.
+ switch err := r.opts.Server.Unload(model); {
+ case errors.Is(err, ErrPinned):
+ r.opts.Log.Debug("self-test: left the model it loaded in memory, because it was pinned during the run", "model", model)
+ case err != nil:
r.opts.Log.Debug("self-test: could not unload the model it loaded", "model", model, "err", err)
}
}()
diff --git a/internal/selftest/selftest_test.go b/internal/selftest/selftest_test.go
index 8560a069..1a55aa53 100644
--- a/internal/selftest/selftest_test.go
+++ b/internal/selftest/selftest_test.go
@@ -188,6 +188,10 @@ func (s *fakeServer) Unload(id string) error {
func (s *fakeServer) Concurrency() int { return s.concurrency }
+// Pinned is false: the fake pins nothing, and the pin rule is tested against
+// the app's own adapter and pool (internal/app/selftest_pins_test.go).
+func (s *fakeServer) Pinned(string) bool { return false }
+
func (s *fakeServer) Fits(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()