Skip to content

fix: settled issues and the maintainer's interview decisions (redirects, UTF-8, caps, admission, prompt cache, panel) - #203

Merged
REPPL merged 34 commits into
mainfrom
integrate/fixes-2026-10-07b
Oct 7, 2026
Merged

REPPL merged 34 commits into
mainfrom
integrate/fixes-2026-10-07b

Conversation

@REPPL

@REPPL REPPL commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Every open issue that could be settled without a design decision, and every issue the maintainer settled in an interview on 2026-10-07. Each lane was built test-first on its own branch. Every lane that touches a trust boundary had an adversarial security review, and every review finding is applied or filed.

Fixes and changes

Issue(s) Change Review
iss-2610071200187548, iss-2610071200187920, iss-2610071200183905 One redirect rule for both content fetches (update check and download): the token is stripped for the rest of a chain once it leaves the Hub, an https→http hop is refused, and a CDN refusal names the CDN instead of giving token advice security: approve; low finding applied
iss-2610042036094209 A request body that is not valid UTF-8 is refused with 400 at the gateway. The system-message merge declines a lone surrogate, and Ask makes the same check security: approve; low finding and notes applied
iss-2610042033419572 An idle job's pin check and stop run under one pool lock (Pool.UnloadUnpinned) security: approve; remaining window filed
iss-2610032231045098 A tool-call probe queued again during its run survives the dequeue reviewed
iss-2610041945020793 Discord bridge answers are sized from half the served window, and an answer cut off at the length limit says so reviewed
iss-2610042100409263 Test: a save never refuses an untouched api_unload_off mutation-checked
iss-2610041945030524, iss-2610032216257762 Docs: the bridge ignores edits; the recording field moves to the models-list reference —
iss-2610041948272032 The idle threshold reaches 24 hours (impact: additive) reviewed
iss-2610041956214381 The self-test has its own idle threshold, default 4 hours (impact: breaking: the self-test's default wait moves from 5 minutes) reviewed
iss-2610042101436891 Decision models are never marked "awaiting review" (maintainer decision) reviewed
iss-2610042101436222 The update card lists the files a newer version changes, rendered as text and tested with markup reviewed
iss-2610042100405045 The Statistics tab shows unloads released by a program beside evictions reviewed
iss-2609190254516275 Connection caps: 32 per client address, 512 per listener, and a separate 512 for loopback security: approve; medium finding (panel lockout on a :: bind) fixed
iss-2610071035138788 Each model admits only the requests its KV charge covers (impact: breaking: a second request to one model waits at the default). The tool-call probe now gives way to a client security: approve; medium finding fixed
iss-2610071035130302 The prompt cache is bounded to one served window of real KV, stored in the registry as kv_bytes_per_token and passed to serve.py security: approve
iss-2610041945034263 Docs: the self-test passes over models that cannot chat —

Test-timing races in two staged-update tests that came with #202 are fixed (each passes 40 runs in a row).

Records

  • Maintainer decisions from the interview: one dated line each in .abcd/work/DECISIONS.md. One issue is moot (wontfix). The context probe's usability check is promoted to draft itd-2610071208598233, and a decision-model self-test is seeded.
  • Follow-ups filed from reviews: the pin-save window, request-body memory on a keyless install, a bridge test flake, and the latent-cache under-charge.

Still owed

  • A real-Mac footprint check that the bounded prompt cache stays inside the charge (launcher footprint sampling).
  • Bridge answers have no upper limit beyond half the window. A ceiling is a one-line change if wanted.

Gates (on the head pushed)

  • gofmt -l . empty; go build ./...; go vet ./... (darwin and linux)
  • make test: exit 0, all packages under -race
  • abcd lint: 168 findings, the same as main

REPPL added 30 commits October 7, 2026 13:05
The self-test's and the context probe's unload read the pin through
Pool.Pinned and then stopped the model through Pool.Unload, each taking
the pool's lock on its own. A pin saved between the two was overridden:
the idle job unloaded a model a person had just pinned, leaving it pinned
and not loaded.

Pool.UnloadUnpinned reads the pin and stops the model under one hold of
p.mu, so a pin saved through SetPinned lands either before the check and
is honoured, or after the stop. Both idle jobs route through it and map
runtime.ErrPinned to their own ErrPinned; the operator's Unload keeps its
rule and still stops a pinned model. The pin state stays the pool's one
set; nothing copies it.

Resolves: iss-2610042033419572
Assisted-by: Claude
The update check's content fetch dropped the access token only on the
hop that left the Hub's origin. net/http rebuilds each hop's headers
from the first request, so a chain that left and came back sent the
token to the Hub again, letting a host off the origin choose one
authenticated Hub request. It also followed an https-to-http hop. The
model download set no redirect policy at all and relied on net/http's
stripping, which keeps the token on a hop to the Hub's host at another
port or to a subdomain.

Both now go through doContent and its one policy, followContent: keep
the 10-hop limit, refuse a non-https hop when the base URL is https,
and drop Authorization from every hop once any hop of the chain is off
the Hub's origin. refuseOffOrigin, the API client's policy, is
unchanged.

SmallFileAt's non-200 error now names the content CDN and gives no
token advice when the refusal came from off the Hub's origin, since the
CDN is never sent the token.

Resolves: iss-2610071200187548
Resolves: iss-2610071200187920
Resolves: iss-2610071200183905
Assisted-by: Claude
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 handler then fails raw_body.decode() on its handler thread,
drops the connection, and the client gets a 502 it cannot act on.

The completion routes (/v1/chat/completions and /v1/completions share
one handler) now run utf8.Valid on the body bytes they already hold,
right after the size-limited read and before the JSON parse, and refuse
with a 400 in the usual error shape: "request body is not valid UTF-8".
Nothing is resolved, acquired or forwarded for such a request.

The prompt-reading ADRs govern any further reading of prompt content:

  "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." (adr-2610042021365934)

This check is not such a reading. It is a check of the whole body's
wire encoding, the same class as the JSON syntax check the gateway
already makes over every byte: RFC 8259 section 8.1 requires JSON
exchanged between systems to be UTF-8. It names no field, learns
nothing about what any prompt says, keeps nothing, and the refusal
carries none of the body; the archtest readers' scan is untouched.

TestMergingRefusesInvalidUTF8InSystemContent asserted the old relay of
invalid bytes; on the network path the body is now refused before
merging, so it asserts the 400 and that nothing reached the model
server. The merge's own UTF-8 guard stays, since the bridge's Ask path
merges a body it did not read from the network, and is now held by a
case in TestMergeSystemMessagesRefusesShapesItCannotRebuild.

Resolves: iss-2610042036094209
Assisted-by: Claude
The idle threshold stopped at one hour, so an operator could not keep
the idle jobs to a long quiet spell such as the night: their threshold
was already at the cap. The maintainer confirmed 24 hours on 2026-10-07.

MaxIdleThresholdSec is now 86400. The config validation, the panel's
Idle threshold field and docs/context-probe.md move together, and a
test holds the field to exactly the server's bounds beside the
three-surfaces check that it is never narrower. The minimum and the
default are unchanged, so this is additive.

Resolves: iss-2610041948272032
Assisted-by: Claude
A resident-only Acquire could return ErrGone while another caller's load
was finishing; when that load's modelLoaded report reached Enqueue before
drain reached dequeue, Enqueue skipped the model as already queued and
dequeue then removed it, so the model went unprobed until its next load.

The queue now keeps one flag for its head: Enqueue sets it when the model
it is asked for is the head, serve clears it before each look at the model,
and dequeue moves a flagged head to the back instead of dropping it. A load
reported before the probe's last look is one that look sees, so it asks for
nothing more; only one reported after it survives. A single flag is enough
because only the head is ever being served, which makes it simpler than a
generation counter.

Resolves: iss-2610032231045098
Assisted-by: Claude
The second update removes its own aside copy just after the download
mark is cleared, outside the lock, so the test sometimes counted two
copies straight after the update settled. That failed it in about 1
run in 10 under load. The test now waits for the one held copy. The
behaviour it guards is unchanged. It passes 40 runs in a row under
-race.

Refs: iss-2610042101430192
Assisted-by: Claude
An interview settled each open issue whose remedy needed a choice. The
remedies now name the chosen fix. A moot issue moves to wontfix. The
probe's usability seed is promoted to a draft intent, and a decision-model
self-test is seeded for later. One decision line lists every answer. The
self-test docs now say models that cannot chat are passed over, which has
been the behaviour since a026423 and resolves the first issue.

Resolves: iss-2610041945034263
Assisted-by: Claude
…ps on length

Every bridged request asked for max_tokens = 1024 whatever window the
model was served at, so a long answer stopped mid-sentence without a
word, and a reasoning model could spend all of it thinking and be
reported as having answered with nothing.

buildRequest already fell back to half the window for the answer when
the window was too small for the fixed figure; that half becomes the
rule, so the answer grows with the window while the history keeps the
other half and the request still fits the window the gateway judges.

The notice comes from the gateway's finish_reason, not a guess: a
stream ending on "length" is followed by a message of its own saying
the answer reached its length limit (kept out of the history), and one
that wrote nothing says the model reached its length limit before it
wrote an answer.

Resolves: iss-2610041945020793
Assisted-by: Claude
Saving an unrelated setting was never refused over api_unload_off only
because the field has no validation rule. No test named it, so a later
rule could have refused every other save without a failure. The form
helper now posts it, the unedited-save table has a row with the route
turned off, and a new test changes another setting while the switch is
off and checks it stays off. Both fail when Validate is made to refuse
the switch.

Resolves: iss-2610042100409263
Assisted-by: Claude
…'s encoding

The security review of the UTF-8 refusal found that the system-message
merge decoded a lone surrogate escape (\ud800) to U+FFFD, rewriting
content into a character the client never sent. The merge now declines
such content, as it declines invalid bytes. Ask, the bridge's in-process
route, now makes the same encoding check as the network route, as its
contract says. The docs say an escape is checked as the bytes it is, and
a decision line records why the check fits the prompt-reading ADR.

Refs: iss-2610042036094209
Assisted-by: Claude
The security review of the shared redirect rule found that only the
update check's read named the content CDN on a refusal. A download the
CDN refused, such as one with an expired signed URL, still read as the
Hub needing an access token, though the CDN is never sent one. Both
content paths now build their refusal through one helper.

Refs: iss-2610071200183905
Assisted-by: Claude
The security review of the idle-unload pin race fix found a smaller window left: a settings save writes the pin under cfgMu and tells the pool after releasing it. Closing it needs a lock-order check first, so it is filed with that remedy.

Assisted-by: Claude
…he reference

The bridge reads a message only when it is created, so a mention or a
question added by editing goes unanswered, and the page did not say so.
It now says so under Limits. docs/transcript.md mixed a how-to with
reference material. The recording field and the icons drawn from it now
live in the models-list reference under Recording, and the how-to keeps
the marking steps, the bridge and debug-logging rules, and a link.

Resolves: iss-2610041945030524
Resolves: iss-2610032216257762
Assisted-by: Claude
The update check's press release for itd-2610030857275099 says a decision
model is never marked, but criterion 9 and the panel gave an unreviewed
newer version the 'newer version awaiting review' mark. The maintainer
decided on 2026-10-07 that the press release stands.

An unreviewed newer version of a decision model is now recorded as the
check records a model with nothing newer: no mark, no Update. A reviewed
version is still the only one a decision model is offered or fetched at,
so removing the mark does not open an update the review gate would have
refused. The awaiting_review status is gone from the check, the registry
(a stored record carrying it is dropped on load, pre-1.0) and the panel;
the intent's audit notes record the withdrawn mark.

Resolves: iss-2610042101436891
Assisted-by: Claude
The update card's test that HuggingFace names are shown safely searched
the panel source for escapeHtml calls, and the card showed no file names
at all, so the file-name half of itd-2610030857275099 criterion 11 was
never exercised. The maintainer decided on 2026-10-07 to show and test
file names rather than drop that half.

The check now records the files a newer version changes, adds or
removes (documentation never), bounded to the first ten by name plus a
count of all, so a repository that changes a thousand shards costs the
index a dozen names. The registry holds a planted list to plain names,
the bound and a count no smaller than the list. The card lists them on a
line of its own through escapeHtml, and a UI test renders the panel's own
renderModels with markup in the repository and a file name, built from
the registry's own JSON, and asserts both appear as text.

Resolves: iss-2610042101436222
Assisted-by: Claude
When a program unloads a model through /api/models/unload the recorder
writes a removal with reason released and the kind of caller, but the
per-model counters the Statistics tab draws counted evictions only, so the
panel never showed it. AGENTS.md treats a Go capability with no panel
equivalent as a gap.

ModelCounters now carries released, counts by caller kind (an unknown
kind under ""), kept apart from evictions and copied out of Summary. The
panel's model card says "unloaded by a program N× (...)" beside
"evicted N×", in its own words for each kind and never echoing a kind it
does not know. A UI test draws the card from stats.ModelCounters as Go
marshals it and walks stats.CallerKinds, so a renamed field or a new kind
without words fails.

Resolves: iss-2610042100405045
Assisted-by: Claude
The read bounds turned one connection held forever into one held for the
bound, then reconnect; nothing capped how many a peer could hold at once,
so a machine reconnecting in a loop on /pair or the API could hold a
socket and a goroutine per attempt. The maintainer decided on a cap per
client address plus a global cap per listener.

One wrapper, capConns, now sits under every listener: the plain ones in
runServer before they serve, and the TLS ones beneath the TLS layer so a
refused connection is closed before any handshake byte is read and
net/http still sees the *tls.Conn. A connection over 32 from one address
or 512 on the listener is closed on accept, counted, and logged at debug
at most once a second without the peer's address. The slot is released
once, however many times the connection is closed. Loopback is held to
the 512 only: every account on this Mac is one address, and a
per-address cap there would let one local client lock the panel out.
IPv6 peers are keyed by the full address; the figures and what would
show them wrong are recorded in .abcd/work/DECISIONS.md.

Resolves: iss-2609190254516275
Assisted-by: Claude
The self-test shared idle_threshold_sec with the context probe, whose
default is five minutes, so out of the box it loaded and benchmarked 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, the self-test waiting four
hours by default while the context probe keeps its own threshold.

self_test_idle_threshold_sec sits beside idle_threshold_sec, defaults to
14400 seconds and takes the same 60-86400 bounds, on all three surfaces:
config validation and load-time repair, a field in the panel's Self-test
box with the panel's self-test prose reading it, and the self-test pages.
The idle loop keeps one idea of what counts as use, including every
admitted client request, and takes the quiet period per run.

The self-test's default wait moves from 300 s to 4 hours, which the
repository records as breaking on the decode_concurrency precedent.

Resolves: iss-2610041956214381
Assisted-by: Claude
…ng folder

The restore removes the version that failed to load just after it writes the record, so the test sometimes found it still in the staging folder when the record had already changed. Lanes 3 and 11 saw it fail under full-suite load. The test now waits for the folder to empty. It passes 40 runs in a row under -race.

Refs: iss-2610042101430192

Assisted-by: Claude
…6-10-07b

# Conflicts:
#	internal/gateway/control_untouched_test.go
The security review of the connection caps found that with a bind to ::
the panel's localhost reaches the dual-stack wildcard listener as ::1.
There it shared the 512 with every remote peer, so a machine on the LAN
that filled the listener locked the operator out of the panel, with far
fewer connections than descriptor exhaustion needed before. Loopback now
counts against a 512 of its own, and remote peers against theirs. The
docs say so, and that the per-address cap guards against accidents, not
against a peer with many addresses.

Refs: iss-2609190254516275
Assisted-by: Claude
…lake

The connection caps' security review assessed how much memory whole request bodies can hold on a keyless install. The cap now bounds it, but the exposure predates it and is filed with a remedy. A Discord bridge test that failed once under full-suite load, on a base older than today's bridge changes, is filed to be reproduced before it is called a flake.

Assisted-by: Claude
chargeLocked charges each model one served window of KV per decode
slot (DecodeConcurrency sequences), but startLocked sized the model's
semaphore at 2 x DecodeConcurrency. At the default of 1 the server is
launched without --decode-concurrency, so mlx-lm's own batch limit let
both admitted requests decode together, and two long requests on one
model could build about twice the cache the budget reserved.

The maintainer decided on 2026-10-07 to admit what is charged: the
semaphore is now sized to DecodeConcurrency. At the default a second
request to the same model waits for the first to finish. The
overload ceiling (cap(sem) + MaxQueueDepth) falls by the same amount,
so MaxQueueDepth keeps meaning "requests waiting beyond the batch".

TestTheChargeCoversEveryRequestThePoolAdmits holds the charge to every
request the pool runs at once, at 1, 2 and 3 batched requests, and
checks the next request waits until a running one is released. Three
tests that assumed the 2x capacity are updated; the resident-only
acquire test now releases the loading request first, as the tool-call
probe's always is. impact: breaking (a behaviour change, recorded in
CHANGELOG and docs).

Resolves: iss-2610071035138788
Assisted-by: Claude
REPPL added 4 commits October 7, 2026 13:43
With each model admitting only the requests its charge covers, one by
default, a client arriving while the tool-call probe held the model
waited for the probe's whole answer, up to two minutes. The security
review of that change found it. The probe now watches the model while
its request runs, as the self-test does. When a request is in flight
beside its own hold, it abandons the request, lets the model go, and is
asked again once the model falls quiet.

Refs: iss-2610071035138788
Assisted-by: Claude
…al KV

serve.py built server.LRUPromptCache(cli.prompt_cache_size) with the
entry count alone, so mlx-lm 0.32.0 kept up to ten prompt caches with
max_bytes at its default of 2^63. Each entry can be a whole served
window of KV, so a model serving several long conversations held
several windows of cache that its charge does not count.

The maintainer decided on 2026-10-07 to bound the cache within the
existing charge: max_bytes is one served window of real (uninflated)
KV for one sequence, and the charge is not raised. The real figure is
stored, not derived: kv_bytes_per_token sits beside kv_charge_per_token
in the registry, computed from the same shape by
KVShape.BytesPerToken, bounded at the scan and again when read back
(the charge's bound, and never above the charge). It travels through
ModelFacts, the rescan (which fills it for existing models at the next
start), the download and update records, app's ResolvedModel,
runtime.ResolvedModel and the launcher's Spec.

The launcher passes Dessau's own --dessau-prompt-cache-bytes N (N =
real bytes per token x served window) straight after the socket, and
--prompt-cache-size 10 explicitly. serve.py takes the flag by position
before mlx-lm's parser sees the rest, refuses the launch on a missing,
negative, non-integer or out-of-range value, and passes it as
max_bytes= (the 0.32.0 signature, confirmed against the wheel:
__init__(self, max_size: int = 10, max_bytes: int = 1 << 63)).

A model whose real figure or window is unknown follows the precedent
for a missing KVChargePerToken: it is charged the flat figure
(capability.LoadCostOf), a documented fallback rather than a refusal.
That charge reserves no cache, so its bound is 0 and the server keeps
no prompt cache between requests; checked against the real 0.32.0
LRUPromptCache, max_bytes=0 holds no entries.

Upstream's own --prompt-cache-bytes is not unused, as the issue first
said: in 0.32.0 it drives a trim on the batched path (server.py around
line 761) but never reaches the constructor. It is not passed.

Still owed: a real-Mac footprint check (launcher footprint sampling)
that a model holding several long conversations stays inside its
charge. impact: fix.

Resolves: iss-2610071035130302
Assisted-by: Claude
…named

The security review of the prompt-cache bound found that a config claiming a compressed-latent cache lowers its own memory charge. It predates the bound and is filed with a remedy.

Assisted-by: Claude
@REPPL
REPPL enabled auto-merge October 7, 2026 12:58
@REPPL
REPPL added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 76bbe23 Oct 7, 2026
4 checks passed
@REPPL
REPPL deleted the integrate/fixes-2026-10-07b branch October 8, 2026 06:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant