Skip to content

Add MCP client tools with authority, taint, and published-server lifecycle - #178

Merged
yellowman merged 155 commits into
mainfrom
claude/new-session-hafb2u-7jzpi5
Aug 24, 2026
Merged

Add MCP client tools with authority, taint, and published-server lifecycle#178
yellowman merged 155 commits into
mainfrom
claude/new-session-hafb2u-7jzpi5

Conversation

@yellowman

@yellowman yellowman commented Aug 23, 2026

Copy link
Copy Markdown
Owner

A turn can use tools that live on a remote MCP server. The headline work is
six commits; the branch carries 127 more from earlier qualification
tranches — see "Also in this branch" below, which is the part to read before
deciding what merging this means.

What MCP does now

  • External servers become ordinary dynamically discovered tools. No new
    tool category, no separate loop: a discovered tool joins the same offered
    list as file_search and web_fetch, in the same dialect, and is dispatched
    by the same round runner.
  • The official SDK owns protocol compatibility. mcp>=2,<3 is a runtime
    dependency and the wire arbiter. Nothing here names a protocol version or a
    transport frame; Client(url) negotiated 2026-07-28 against the SDK's own
    server with no version logic in this codebase. Streamable HTTP only — stdio
    is out of scope because "connect to a server" would become "spawn the
    executable this row names".
  • Authority is parent-side and persisted. A server is a globally visible,
    admin-owned mcp artifact of kind mcp.server; ownership is read from the
    artifact row, never from a field inside schema. The map from model-visible
    name to remote tool lives on InvocationContext and never crosses the pipe
    to the worker — a worker sends a name, and the parent decides what that name
    means. An entry carries a URL and a taint class, and a worker that could send
    either could name a host of its own and call it local_read.
  • The network, taint and untrusted-data boundaries all apply. Discovery and
    dispatch run inside the same tool_network_guard as every other tool,
    redirects included. A result is bounded, scanned and wrapped in the same
    envelope fetched web content gets, with the rule stated in the system block
    whenever such a tool is offered. A tainted turn loses every egress server
    alongside web_fetch; a local_read server survives for the reason
    file_search does.
  • Operators can actually publish servers. POST /v1/artifacts accepts a
    visibility, defaulting to private, with shared/global gated on the
    admin role read from the authenticated token. The admin console has a form
    for it and points at the config-ops flow for retirement.
  • An MCP-only chat reaches them. The workflow selector asks whether the
    turn needs tools — attachments, web, or a configured server — from
    persisted state, with no network probe in the decision.
  • Hostile discovery metadata is rejected. A tool's description and
    inputSchema reach the model in the tool contract, before any call and so
    before any result has been scanned. They are bounded in size, depth and
    count and scanned for injection patterns and envelope markers; a tool whose
    metadata fails is dropped rather than rewritten, because neutralizing a
    schema changes enum values and property names and offers the model a
    contract the server does not implement. Rejection logs and does not taint.
  • Published capabilities survive personnel changes but lose authority.
    Deleting an account removes its private artifacts and detaches the published
    ones. A detached MCP server keeps its row, versions and patch history, and
    goes inert — the admin attestation was what made it a capability — until an
    admin publishes it again.
  • The foreign key refuses lifecycle bypasses instead of guessing.
    artifact.owner_user_id is ON DELETE RESTRICT. A key cannot see
    visibility, and both answers it could give on its own destroy something:
    CASCADE removes published configuration, SET NULL leaves a private
    artifact and its payload behind an erased account. delete_user deletes,
    detaches, then removes the account, so nothing references it by then and the
    restriction never blocks the supported path.

Review findings

Five of the six MCP commits fixed something a layer above where the previous
commit's tests stood
. The witnesses were not wrong; they were at the wrong
altitude, and each one looked complete from where it sat.

  • Store-level artifact tests could not see that the route's kind must start with the type prefix check made the chosen type/kind pair impossible to
    create.
  • Tests calling _build_agent_context directly could not see that no ordinary
    chat ever selected the tool-agent path.
  • Client tests that passed arguments to call by hand could not see that the
    SDK exposes input_schema, not the wire's inputSchema — so every remote
    tool had been offered to the model with an empty parameter list.

Two instrument corrections belong to the same lesson: a heartbeat tick count
over a whole turn measured nothing, because the count reached any threshold
from the parts that were never blocked, and a length assertion that read the
module's own constant moved with the mutation. Both passed against the defect
before being rewritten.

Also in this branch

127 commits of earlier qualification work, grouped:

  • tool-worker isolation and sandbox boundaries; attachment handling and archive
    extraction limits
  • artifact and workflow authority resolved from the persisted row, failing
    closed
  • object and account lifetimes: conversation, context, private artifact,
    filesystem namespace, and account erasure as one boundary
  • test-harness isolation — per-worker Postgres, Redis database and filesystem
    root — and the xdist lanes built on it
  • SPEC canonicalization, a deletion campaign removing duplicated concepts, and
    test-subsumption passes arbitrated by mutation
  • the Responses wire validated against the OpenAI SDK's generated types, and
    browser auth with the first Playwright lane

If the intent is to merge only the MCP work, this branch is the wrong unit and
should be split; the title describes the last six commits, not the diff.

Evidence

Local: 2,816 non-browser tests passed, 26 skipped (make test-xdist), and
11 browser tests passed (make test-browser).

No GitHub checks have run on this branch to date, which is expected rather than
a fault: the workflow triggers on push to main/develop and on
pull_request targeting them, and this branch had neither event. Opening this
PR should start the 3.10/3.11/3.12 matrix plus the browser job. If no checks
appear, that is a real CI plumbing problem rather than a branch-configuration
one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA


Generated by Claude Code


Note

Overview
Replaces ASD-STE100 with a repository writing-style skill (Google developer docs–aligned) and points CLAUDE.md at it, plus rules for which test lane to run and how to verify claims.

Local and CI test harness. Makefile adds test-fast / test-xdist / test-browser, excludes the Playwright lane from other targets, and makes qa use the parallel lane. Ruff now follows pyproject.toml instead of replacing ignore lists. GitHub Actions applies schema via scripts/migrate.sh, requires real user-namespace confinement (LIMINALLM_REQUIRE_CONFINEMENT, sysctl on the runner), and adds a dedicated Chromium/Playwright job.

Operator and product docs. SHARED_FS_ROOT is documented as an environment-only path (needed before DB settings exist). README/INSTALL clarify embedding-width immutability, adapter promotion vs disk files, the OpenAI-compatible Responses API, MCP search tools, API keys, and the local JAX decoder/KV-cache path.

Reviewed by Cursor Bugbot for commit df82b56. Bugbot is set up for automated code reviews on this repo. Configure here.

claude added 30 commits August 10, 2026 07:12
The pgvector path ranked a user's files by `ORDER BY embedding <-> query`
and nothing else. That is the single-vector paradigm at its most exposed,
and it contradicted this project's own rule: SPEC §2.5 said the hybrid
ranking applied "to rag, notes, and conversation recall alike", and notes
and compaction obeyed it while the primary RAG path did not.

Worse, RAGService was never passed `is_semantic`. With the default hash
encoder the production path therefore ranked documents by hash-vector
distance — the SPEC's own definition of noise — with no keyword channel to
fall back to.

Weller et al. (ICLR 2026) is the argument for why one channel is not
enough: the number of top-k document sets a d-dimensional embedding can
return is capped by d, so some relevant combinations are unreachable by any
query, whatever the encoder was trained on. Their LIMIT probe makes it
concrete — 46 documents, one-clause queries, best embedder at 54.3
recall@2 against BM25's 97.8. On a synonym rewrite of the same corpus BM25
collapses to 10.6 and the embedders hold. Neither channel is safe alone,
and they fail on disjoint inputs.

So retrieval now runs both and fuses them:

- a lexical channel over Postgres FTS, OR'd terms, backed by a GIN
  expression index on to_tsvector('simple', content). Measured on 50k
  chunks: 28.7 ms/query with the index, 239.7 ms without.
- weighted reciprocal rank fusion, semantic 0.55 / lexical 0.45. Rank,
  not score: cosine is bounded and BM25 is not, so any weighted sum needs
  a normalizer and every normalizer moves with the pool. Fusion by
  position also lets a chunk both channels rank well beat one that only a
  single channel loves.
- a channel ranks only what it matched. Zero is silence, not a weak
  opinion, since an arbitrary order over non-matches would otherwise carry
  the channel's full weight.
- without a real encoder the dense channel does not speak at all, and a
  query with no lexical match returns empty rather than the nearest hash
  vectors. Arbitrary chunks read to the model as evidence.

An optional rerank stage lands behind `rag_rerank`, off by default. The
serving model reads query and shortlist in one pass; it is the only stage
bound by neither ceiling and the only one that can say "none of these". It
is bounded, it treats the chunks as untrusted input to a decision inside an
explicit envelope, and it fails open — losing the model must never mean
losing the user's grounding.

SPEC §2.5 is amended rather than quietly broken: "bm25 is the tie-breaker,
never the peer" is not expressible as a rank fusion, and the evidence above
is against it. Lexical is now a weighted peer that can win.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
A closer read of Weller et al. changed three things.

Fusion is now the only ranking mechanism, not one of three. Notes search
weighted lexical 0.6 against semantic 0.4 — the opposite of the precedence
rule it claimed to follow — and conversation recall had to score a turn it
never had the budget to embed as a literal zero, which its weighted sum then
held against that turn. Rank fusion has no such problem: a channel that did
not rank something simply did not rank it. Both now call service/ranking.py.
recall keeps semantic_weight; it is the semantic channel's fusion weight now
rather than a blend coefficient.

Reranking becomes auto | on | off, defaulting to auto. The paper's reranker
result is a frontier long-context model solving all 1000 queries where the
best embedder stayed under 60 — evidence that does not transfer to a 7B
running locally, which is this project's premise. So auto asks whether there
is positive evidence for the serving model: a curated family list plus the
parameter count an open-weight name declares. Unknown is a no, because this
stage can drop a user's grounding. A mixture-of-experts name reads as its
per-expert size and lands off, which is the safe direction. The resolution
is logged so an operator can see the guess instead of inferring it from
latency, and on/off overrule it in either direction.

Retrievers now return a shortlist and the stages above cut it, so both
retrieval paths get the rerank stage and it sees more than the answer it is
meant to improve. The candidate pool widens to the reranker's declared
appetite: a reranker handed exactly the chunks that were going to be
returned anyway can reorder them but never reach the one that placed just
outside the cut.

A bare NONE from the reranker is now honoured. It is the one thing this
stage can say that no ranking can, and grounding an answer in chunks just
judged irrelevant is how a model ends up citing text that does not support
it. Anything with more to say than the word itself stays a hedge, and every
other unreadable reply still fails open.

SPEC §2.5 is rewritten rather than patched: the theory with its actual
bounds, the LIMIT numbers for both splits, the synonym result that stops
anyone concluding "just use BM25", the qrel-density metric that says which
parts of this system sit at the hard end of the scale, and the finding that
LIMIT does not correlate with BEIR — so a good benchmark position is not a
mitigation. Multi-vector retrieval is recorded as deliberate future work
with the reason it is not here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
This is the one entry in the paper that attacks the dimension bound rather
than working around it. The bound describes a score that is one inner product
between one query vector and one document vector; MaxSim is not that, and the
authors say so in their limitations. It is also the only architecture they
measure that beats single-vector on both LIMIT splits — 83.5 against 54.3
recall@2 on the 46-document set, 23.1 against 3.0 on the 50k one.

A chunk is now optionally stored as several vectors in knowledge_chunk_vector,
one per sentence-sized segment, and scored by MaxSim: for each part of the
query, its best-matching part of the chunk, summed. Retrieval is the usual two
stages — each query part gathers candidates by nearest segment, so a chunk
qualifies on its best part rather than its average, and candidates are then
scored exactly against all of their segments. Approximate search decides who
is considered; it never decides the order.

The failure this fixes is asserted, not assumed. A pooled embedding has to
answer for a whole chunk at once, so a chunk covering two subjects lands
between them and loses to one that is merely nearby. The tests build exactly
that corpus and check both halves: pooled-only similarity returns the near
miss first, and the same corpus with segments kept separate returns the right
chunk. Neither test is true by construction of the other.

It is not ColBERT and the code says so in as many words. Segments are
sentence-sized and embedded by the same encoder as everything else, because
that encoder is an OpenAI-compatible /embeddings endpoint and such an endpoint
returns one vector per input — per-token vectors are not reachable through it.
What carries over is the mechanism, not the granularity, at roughly an order
of magnitude less storage. The seam is deliberate: a real late-interaction
model replaces segment_text and the embed call without touching the storage,
the candidate generation, or the scoring.

Fusion gains the channel at 0.55 and the pooled vector steps back to 0.25
rather than out — the same signal read less precisely should not vote twice at
full strength, but a whole-chunk vector still says something no single best
part does.

Off by default, and honestly so: it costs an embedding call per segment at
ingestion and a row per segment in the index, it needs a real encoder, and
coverage is not retroactive. A chunk without segments is unranked by the
channel rather than penalised by it, which keeps a partly-migrated corpus
working; that it tilts toward the covered part is the cost of enabling it
before a backfill exists, and SPEC says so rather than leaving it to be found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
A review of the three retrieval commits on this branch found eight real
defects in them. Fixed here.

Two were silent no-ops. The four new retrieval settings are read once, when
RAGService is built, but were absent from both lists of model-affecting keys
— so an admin could switch reranking on, read the new value back from the
console, and have nothing happen until the process restarted. And
late_segments defaulted to 0, clamped to max(1, ...), which made
segment_text return a single part that _index_segments then skipped: late
interaction reported itself enabled, did the extra query work, and kept an
empty index. The floor is now 2, because one segment is the pooled vector
under another name.

One lost data. Widening the local_hybrid pool without removing the caller's
truncation meant retrieve() cut an unsorted concatenation, so the first
context filled every slot and the second was unreachable however well it
matched. Those lists are ranked within a context and carry no score to rank
across them, so they are interleaved now.

One could fail an ingest that had already succeeded: _index_segments guarded
the embed call but not the write, so a missing table or a dimension mismatch
escaped to the caller as a 500 after the chunk rows were committed, and the
retry duplicated them.

The rest are smaller. Late candidates are now capped across all query parts
rather than per part, with a set for the dedup — nine parts times a hundred
each, all scored by pure-Python MaxSim, is not something to put on a
synchronous path. A trailing fragment no longer earns its own vector: the
merge only ever looked backwards at a segment that was still short, so a
short last sentence survived alone, and MaxSim takes the best segment. Notes
search scales its fused score to the fusion ceiling, because the search route
publishes it as "score" and the witness report as "similarity", and a raw
value tops out near 0.016. And conversation recall stops normalizing scores
that rank fusion discards, and takes its weight from the shared constant
instead of its own copy.

Both regressions have tests that fail without the fix.

Three findings are left alone as out of scope, all pre-existing: JSON Patch
remove creates the parents it is removing from, taint withdraws run_python
but not web_fetch, and "testserver" sits in the production tenant trust list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Three findings from the review of this branch, all pre-existing.

Tenancy had a hole where its own docstring promised strictness. localhost,
127.0.0.1, ::1 and the test client's testserver resolved to the default
tenant even with tenant_domains configured, on the theory that a probe
arrives by address rather than by site name. But Host is chosen by whoever
can reach the port, so anyone reaching the service directly named the
default tenant — and with allow_signup on, registered an account there. No
probe ever needed it: probes do not authenticate and never resolve a tenant.
The exemption is gone; an operator who wants a bare hostname served lists it
like any other.

The two halves are now one named rule rather than an inline comparison
repeated three times. The site comes from the host the server was addressed
as; the account comes from the authenticated session, never the request.
Neither is sufficient alone, which is the point: the host is attacker-chosen
on the unproxied path, and a session is a bearer credential that stays valid
against whatever site it is replayed at. A blank on either side is now a
mismatch rather than a skipped check — the caller with nothing to compare is
the one that resolved no site, which is the case least safe to wave through.

taint.py withdrew run_python and nothing else, which withdrew the wrong
thing. run_python's own schema promises no network, so it was never how a
secret leaves; web_fetch takes a model-supplied URL, which is exactly what an
injected page asks for. web_search too — the provider is fixed but the query
is not, and a query is as good a channel as a path for anything short. Local
search stays available, because a tainted turn must still be able to tell the
user what the page attempted. The refusal message says which of the two it
is.

JSON Patch resolved remove through the creating walker, so removing a missing
path built the containers it was removing from: remove /a/b on {} returned
{"a": {}}. Model-authored config ops and artifact PATCH bodies drop optional
keys routinely, so this was quietly writing empty objects into runtime config
and artifact schemas. remove now walks without creating and does nothing when
the parent is not there.

Left alone deliberately: apply_op still skips an op missing its verb or path
rather than rejecting it. The review read that as the same silent-success
problem, but test_config_ops.py asserts the skip by name, so it is a decision
this codebase already made rather than an oversight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
The last commit tightened the tenant rule in three places, deleted the host
exemption, and wrote SPEC prose asserting the invariant as though that were
all of it. A second review found the four places it was not.

complete_oauth never compared tenants at all. app_user.email is globally
unique, so resolving an account by provider id or email finds it whatever
site the flow began at — signing in with Google at globex minted acme's
tokens, while the password path had always refused exactly that. login kept
the old truthy comparison, so a blank site tenant short-circuited it to False
and login would admit any user in any tenant while refresh and authenticate
rejected the same request.

Both now go through AuthService._site_matches, along with the two that were
already converted. One method rather than four copies of a six-line rule: the
copy that gets missed on the next edit is an authorization hole, and this
commit exists because two were missed.

Making a blank hint fail also made a blank default_tenant_id an unrecoverable
lockout — every user's tenant is blank too, so every request 401s, including
the admin call that would put the value back. The field now refuses to be
empty.

And deleting the infra-host exemption exposed a normalizer mismatch: settings
split hosts at the first colon while the request path kept IPv6 brackets, so
"::1" stored nothing, "[::1]" stored the key "[", and a bracketed Host matched
neither. IPv6 loopback was unmappable and, without the exemption,
unreachable. There is one normalizer now, and a bare literal canonicalizes to
the bracketed spelling the wire uses.

Retrieval had two of its own. The candidate cap added last commit was
first-come across all query vectors, and the first vector is the whole query
— so it took the entire pool and no query part was ever consulted, collapsing
late interaction back to single-vector recall. Each part gets a share now.
The local_hybrid interleave fixed one context starving the other but
guaranteed an irrelevant context half the answer; the union is scored again
so relevance decides, with the interleave surviving only as the tie-break.

Smaller: fusion_ceiling counted channels that ranked nothing, so a note
placing first in the only channel with an opinion published as half a bar.
The model-affecting settings list is one tuple instead of two that had to be
edited together — the drift that caused the bug it was added to fix. Dead
_normalized_scores removed, compaction's redundant deferred import hoisted,
and web.py's comment no longer claims a false positive costs only a redaction
now that it also withdraws the turn's web access.

ruff caught an undefined name in the semantic local_hybrid branch that no
test reached. It has a test now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Fourteen findings, all real, none in tenancy — that area traced clean this
pass. Most of these are mine from the last two commits.

The worst was a NONE verdict returning the unread tail. The reranker reads
the top rag_rerank_candidates and, told none of them help, was handing back
everything below them — chunks that rank *worse* than the ones just judged
unhelpful. "Nothing here answers this" became "here are the weaker ones", in
a stage whose stated purpose is to stop a model citing text that does not
support it. It returns nothing now, and the test that asserted the old
behaviour asserted a bug.

auto was judging the wrong model. Everywhere else in the kernel the serving
model is `adapter_server_model or base_model`; reading base_model alone meant
an operator pointing at a self-hosted 7B while leaving model_path at its
default got reranking enabled on the strength of "gpt-4o-mini". Which was
itself wrong twice over: a prefix match cannot tell gpt-4o from gpt-4o-mini,
and gpt-4o-mini is the shipped default — so out of the box, auto turned the
stage on for the smallest model in the family. Small variants are now matched
as whole name parts, and a size the name declares beats family membership.
The first attempt at that used a substring check and rejected every Gemini
model there is, because "mini" lives inside "gemini".

parse_order harvested digits from anywhere in the reply, including the
visible reasoning blocks that o1, o3 and deepseek-r emit — so "passage 3
mentions 2024 revenue" parsed as a ranking, counted as success, and silently
reordered the user's context. And a chunk could forge a candidate: the
numbered list was newline-joined without collapsing the snippet, so a passage
containing its own "[1] ..." line added an entry the model could pick.

Late interaction had the mirror of the guard it already had: the write path
degraded on a missing table, the read path did not, so enabling the setting
on a database that had not had sql/schema.sql re-applied broke every
retrieval instead of losing one channel. Indexing now also stops after the
first failure rather than buying 4000 embeddings to throw away.

The notes score was worse for being normalized. Rank fusion packs its scores
together by construction, so scaling to the ceiling moved everything into
0.90-1.00 — and it went out under the same "similarity" name that vault_sweep
uses for a real cosine, from the same module. Search publishes a rank now,
and the witness publishes an actual cosine.

And config.py held a second MODEL_AFFECTING_SETTINGS that the last commit did
not update, so the console never labelled the four retrieval settings as
rebuilding the model stack — while its own test compared the schema against
that same stale list and agreed with itself. This is verbatim the drift the
last commit claimed to have eliminated. There is one list now.

Smaller: segment inserts batch instead of one round trip each, the local
path stops embedding a query the store will ignore, a redundant index on
(chunk_id) is dropped in favour of the composite that already covers it, and
LegacyOnlyStore returns ids like the real store so a future late-interaction
test cannot pass by skipping the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Thirteen findings. The pattern the reviewer named is the useful part: the
three worst all sit in rerank.py, and each is a place where the comment or
docstring states an invariant the code does not implement — and in two of
them the test passed because it exercised a stub more capable than the real
object, or a reply shape a real model would not emit.

The adapter-server fix from the last pass did not work. LLMService keeps
adapter_server_model on its backend, not on itself, so reading it off the
service found nothing and fell back to the configured base — the exact bug
the comment claimed to have fixed. It is a serving_model property now, on
LLMService, because the tokenizer and the context window resolve the same
pair and it had already been copied twice. The test builds a real
LLMService; the SimpleNamespace that hid this is gone.

The NONE branch refused to return the unread tail because it ranks below
what the model rejected. The partial-rejection path — far more common —
appended it anyway, so fusion ranks 21+ took grounding slots from head
chunks the model had just read and dismissed. Only what the reranker kept
comes back now.

The reasoning-block strip only matched a closed <think>...</think>, so a
reply truncated mid-thought was scanned for digits and the narration parsed
as a ranking. And "last line with a digit" read an ordered list backwards:
"2. Passage 1" parsed as the answer 2. The answer is picked by shape now.

Two more that mattered. The lexical channel was filtered by
embedding_model_id, so flipping that managed setting made every stored chunk
invisible to keyword search as well as to vector search — retrieval returning
nothing at all, for an exact filename as much as for a paraphrase, with no
backfill job to recover. The filter belongs to the vector channels; keyword
search compares no vectors. And late interaction's query side never ran on a
real question: segment_text's 12-word floor is tuned for chunks, so every
query folded back to one segment and the channel became the single-vector
recall it exists to avoid. The query side has its own floor.

The rest: min_length=1 accepted "   ", so the lockout that constraint was
added to prevent still happened; the ingest stop was scoped to one file, so a
directory walk still paid segments x chunks embeddings per file; content_tsv
is a stored generated column so ts_rank stops re-tokenizing every matching
row; chunks.index() compared full embedding vectors to find a position
enumerate gives for free; rerank borrows web.py's envelope markers instead of
defining a second vocabulary; and add_chunk_vectors' docstring no longer
claims a re-ingestion idempotence the ingest path does not provide.

Not fixed, deliberately: rag_rerank and rag_rerank_candidates still rebuild
the whole model service stack, because they are read at construction. The
review is right that a prompt-sizing integer should not cost a teardown of
the LLM, embeddings, training and workflow services, but the fix is to make
RAGService read them per call, and that is a change worth making on its own
rather than at the end of a review pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Both of its settings only ever shape one prompt: whether to run, and how many
snippets go into it. Capturing them when RAGService was built made them
structural anyway — they had to sit in MODEL_AFFECTING_SETTINGS, so nudging a
candidate budget from 20 to 25 tore down and rebuilt the LLM backend, the
embeddings service, RAG, training, the clusterer and the workflow engine,
took the reload lock, and interrupted in-flight work. For a number that
bounds a prompt.

The closure-with-attributes is now an object that reads a settings provider
per call. The runtime hands it `lambda: self.settings`, which refresh_settings
already keeps current for every managed setting, so `auto`/`on`/`off` and the
candidate budget take effect on the next turn with nothing rebuilt.

RAGService needs no new knowledge for this. It already asked the reranker for
its budget to size the candidate pool, and a disabled reranker now answers
zero — so the pool does not widen for work that will not happen, and the
stage hands back what it was given without a model call. The two late
interaction settings stay in the rebuild list, because they change what
ingestion writes rather than how one prompt is shaped.

One thing that had to change with it: `auto` is re-decided every retrieval, so
logging the resolution each time would be noise and logging none would leave
an operator inferring the guess from latency. It logs on change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Eleven findings. The worst was an upgrade hazard I created last pass:
content_tsv is a new column on an old table, and the startup verifier walks a
table list, so an install that pulled this branch without re-running
migrations booted clean and then answered every grounded chat turn with a
500. Startup now names the column and the fix, the lexical channel is guarded
like the late one so a channel failing costs a channel rather than the turn,
and knowledge_chunk_vector joins the required tables.

Three defects in the reply parser, all in code whose comments claim the
opposite. A single numbered line was not treated as a list, so "1. Passage 3"
harvested both digits and promoted a chunk the model had not chosen. The NONE
verdict was matched against the raw reply while numbers came from the
reasoning-stripped one, so a bare NONE from any reasoning family — which is
every family the allowlist targets — never registered. And the module
docstring still said reranking was off by default after the default became
auto.

Two settings bugs of the same shape as ones already fixed. The encoder gate
the pgvector path shed was still live on the memory path, so flipping
embedding_model_id there still answered nothing at all until a full
re-ingest. And _require_non_blank rejected "   " but returned " acme "
unstripped, which matches no account — the same lockout by a quieter route,
and tenant_domains was already stripping its side.

The witness report ranked and published hash-fallback cosine, because
_pair_similarity never checked is_semantic. That is the one thing SPEC §2.5
says must never happen, in a module where search_notes had just been changed
to obey it.

Performance, both measured. MaxSim normalizes once per vector and compares by
dot product instead of calling a general cosine per pair: 0.44 s to 0.069 s
for a 20-chunk pool, same arithmetic. Segment ingestion embeds one batch per
chunk rather than one call per segment, so a 500-chunk file is 500 round
trips instead of 4000.

Also: the reranker's two properties collapse into one, because `enabled` both
mutated state and logged while being read twice per retrieval; and a dead
branch in rank_turns whose comment described a decision the code did not
make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
The previous commit shipped the code without these: the script that edits
SPEC failed an assertion partway through, so it wrote nothing and the commit
went out with the prose describing the old behaviour.

Records what changed: the startup check for content_tsv and the guard behind
it, the two parser rules (a single numbered line is still a list; NONE is
tested against the same stripped text the numbers come from), and the two
measured performance changes in late interaction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Five review passes over this branch's retrieval work found roughly sixty
defects, and they fell into a small number of shapes that repeat. The three
rules that would have caught most of them go in CLAUDE.md so the next session
starts with them.

Execute before claiming: "mini" is a substring of "gemini", and no amount of
re-reading the line said so — running it did, immediately.

Build test doubles from the real object: a SimpleNamespace carrying an
attribute the real service does not have made a broken fix pass its own test
twice.

Grep the class when you fix the instance: the encoder gate was removed from
one retrieval path and left on the other; the settings list was deduplicated
in one file and not the second; the closed form of a tag was handled and the
unclosed form was not.

And the discipline underneath them: a comment is not evidence. Four times a
comment or a SPEC line stated an invariant the code beside it did not
implement, because the prose and the code came from the same intent and
neither checked the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Standing authorization from the owner for multi-agent orchestration in code
review specifically: fan out finders across distinct angles, then put every
finding through two independent verifiers before reporting it — one that has
to reproduce the failure by running code, one that argues the finding is
wrong and rejects when uncertain. Both must agree for it to survive.

Recorded here because it is an authorization, and the next session starts
without it otherwise. It covers review; orchestrating other work still needs
asking.

The justification is measured. Five single-pass reviews of this branch each
missed defects the next pass found, and several of those were bugs introduced
by the previous pass's own fix. The finding rate never fell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
The first review pass to run adversarial verification rather than one reader.
Six finders across distinct angles, then every finding through a reproducer
and a skeptic independently; a finding survived only if both agreed.

The gate earned its cost in both directions. Three findings were rejected
with substance — one reporter had measured segment_text on a string
ingest_text never stores, one described a candidate-starvation bound that
does not occur on any corpus the real ingester produces. Reading alone would
have "fixed" all three.

The worst confirmed defect loses the answer entirely under the kernel's own
default configuration. Postgres indexes "user_id" as 'user' + 'id'; the BM25
tokenizer's \w+ keeps it whole. So the store's full-text query matched a
chunk on "user id" and the re-score in _fuse gave it 0.0, ranked_positive
deleted it as silence, and with the hash encoder — where lexical is the only
live channel — retrieval returned nothing at all for a question the corpus
answers. On a folder of ordinary source files, 6 of 9 queries came back with
no grounding while the store had found the chunk.

The class is wider than that tokenizer pair: any pre-filtered pool re-scored
by a different scorer can be emptied. So the fix is not to match the two
tokenizers but to stop discarding — BM25 orders the pgvector lexical pool and
may no longer empty it, because membership in that pool IS the match signal.
The local path keeps the silence rule, since its pool is a top-N by another
score and a zero there really is a non-match. That distinction is a
parameter, not a comment.

The rest:

The pooled vector was demoted for every chunk whenever ANY chunk in the pool
had segments, so a chunk late interaction had nothing to say about was buried
by its neighbours. It now steps back only for chunks the late channel ranked.

segment_text packed into ceil(len/max) buckets, which yields fewer than the
cap — nine pieces at a cap of eight produced five, missing the operator's
budget by up to half.

Two more in the reply parser. A newline-separated ranking collapsed to the
model's LAST pick and discarded the rest, logging a successful rerank. And
prose was read as a ranking: "NONE of the 5 passages help" grounded the
answer in passage 5. Nothing ranking-shaped now means no opinion, which fails
open.

And ':' was in neither model-name separator class, so an Ollama tag
(deepseek-r1:1.5b) or an OpenRouter suffix (openai/gpt-4o-mini:online) hid
the part that decides and auto turned reranking on for a 1.5B.

25 of 34 findings went unverified — the run verified 9 by its own cap and
said so rather than reading as full coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
…is total

Reliability and injection hardening for the rerank stage, closing the three
unverified findings from the adversarial pass and the transport problem
underneath all of them.

The structural change: when the serving backend speaks tool calls, the
verdict arrives as one (submit_ranking, ranking array, empty list = none
help) and is read from the tool_calls wire field — a channel beside content
that document text physically cannot write to. A passage, or an entire
reply, that spells out a perfect ranking call is still just characters in
the content channel. The parser problem and the forgery problem die
together, which is why the transport is the fix rather than another round
of parser patching. One model call either way: a tool-capable model that
answers in text falls through to the prose parser on the same response.
Both transports land in one validator — booleans rejected before int admits
them, since true would read as passage 1; picks deduped, range-checked,
bounded.

The prose parser remains for backends without tools, and it is now total.
Reply bounded before any regex touches it. Digit runs longer than any valid
index are prose, skipped before int() — CPython refuses str->int past ~4300
digits, and that ValueError sat outside the fail-open guard, so an
unreadable reply crashed the very turn fail-open exists to save. Ascending
spans ("1-3") expand so the middle passage is ranked instead of deleted; a
descending pair is ambiguous and fails open rather than guessing. A bare
</think> with no opener — what an R1-style template leaves when the opener
lives in the prompt — marks everything before it as reasoning, and the NONE
check runs on the same stripped text. An explicit trailing answer
("Final: 3, 1") outranks numbered narration, whose prose digits otherwise
pollute the order.

The query seam is closed. It sits outside the untrusted envelope because
the model must read it as the question — and on the agent path it is
model-authored, which after a tainted fetch means attacker-influenced. It
gets the passage treatment anyway: collapsed to one line so it cannot mint
a numbered entry or an instruction block, markers neutralized so it cannot
touch the envelope, bounded so it cannot bury the instructions after it.

SPEC states out-of-band verdicts as a rule, not a reranker feature: any
verdict that gates data prefers a structured channel; surviving prose
parsers must be total and fail open; the witness and digest parsers are
named as the next candidates.

The end-to-end test the adversarial pass flagged as missing exists now — a
reranker inside a real RAGService against a real store, off/on/NONE/garbage
— plus the tool transport verified against the exact dict shape
model_backend returns, and every parser case above as a regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
The tool transport made prose the degraded mode, and a degraded mode nobody
is told about is a wrong answer waiting to be diagnosed from the outside.
The component now says it, in both places an operator looks.

The reranker owns a transport property — None, "tool", or "prose" — and an
active reranker resolving to prose logs rag_rerank_prose_transport once per
transition, not per retrieval: a warning on every turn teaches the operator
to ignore the log, which is worse than no warning. The admin console gets
the same fact as a runtime warning the schema route attaches to the
rag_rerank entry, rendered beside the control, present while the condition
holds and gone when it clears.

Which backends this names, verified from the code rather than asserted:
local_lora / local_gpu_lora have no tool method at all — the project's own
self-hosted premise is exactly the deployment that cannot signal
out-of-band today — and an API backend without a configured client is the
other. Wire support is not model behaviour, though: a small model behind a
tool-capable endpoint can still answer in text every time, which the
latched warning cannot see. So every prose-path verdict also logs
transport="text", tool-capable wire or not; the warning sees the wire, the
per-event field shows the habit.

Building the end-to-end test exposed a gap the per-call settings change
left behind. Removing rag_rerank from MODEL_AFFECTING_SETTINGS also removed
the only synchronous refresh the settings PUT gave it — every other setting
leans on the polling watcher, which exists for the OTHER workers, so "the
change is live" was true only after an interval nobody promised the admin.
The PUT now refreshes its own worker's settings before responding;
reads-per-retrieval of a stale object were still stale.

The console flow is tested through the real endpoint with a real admin:
warning absent while off, present after PUT rag_rerank=on against the
tool-less test backend, gone again after PUT auto. The latch is tested for
once-per-transition including re-warning after off-and-on, and a
tool-capable double never draws it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
The premise deployment was the one stuck on the degraded transport:
local_lora / local_gpu_lora had no generate_with_tools at all, so the
self-hosted JAX path — the deployment this project exists for — could not
signal out-of-band, and an active reranker on it drew the admin warning
with no way to clear it.

A raw checkpoint has no second wire, so the channel is what vllm and
llama.cpp do serverside: a contract the backend enforces. Tools are
advertised in a system block (JSON Schema plus the emission format), the
model emits <tool_call>{json}</tool_call> — the de-facto local standard,
the tag Qwen and Hermes chat templates already emit — and the backend
parses that block out of MODEL OUTPUT ONLY, returning the same dict shape
as the API backend so nothing downstream can tell the transports apart.

The property that makes a provider's tool channel unforgeable by documents
survives in one line: input text is never parsed. A chunk, a fetched page,
a pasted document can spell the tag — it lands in input, and only the
model writes to the output stream. The test states that as an executable
fact: a perfect call block in the input never becomes a call.

The extractor refuses to guess. A malformed block stays visible text
rather than becoming an inferred call — digit-harvesting wearing a new tag
— and shapes that are not calls (empty name, non-dict arguments, a JSON
array) are rejected. Call count and block size are bounded before
json.loads sees anything.

Two honest limits, stated where they bind. Whether a given checkpoint
emits the contract is model behaviour, not a capability the flag can
promise — it shows per event as transport="text", the same wrinkle
tool-capable API wires have. And a parrot-prone small model is one echo
away from carrying a document's block into its own output, so
neutralize_markers now defangs the tag in untrusted input alongside the
envelope markers — every consumer of that function gains the defense at
once, the rerank prompt included.

supports_tools is a side-effect-free property (reading a capability flag
must not load a tokenizer or touch JAX), and its going true also opens the
agent tool loop for local deployments — same channel, same contract,
deliberate. The admin prose-transport warning now names only the backend
that earns it: an API backend without a configured client.

Verified by execution end to end before the tests were written: a real
LLMService over the real local backend, only the forward pass canned,
reranks over the tool channel with transport == "tool", and an empty
ranking is the NONE verdict on the same wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
POST /v1/responses speaks OpenAI's Responses dialect over the kernel's own
chat turn, so any agent framework gets personas, adapters, hybrid RAG,
notes and memory behind a base-model-shaped endpoint. Success bodies are
the bare Responses object and errors are OpenAI's {"error": {...}} — SDKs
parse by shape, so the Envelope never leaks here. previous_response_id
resolves through the assistant message to its conversation; context_id (a
liminallm extension) grounds the thread on the first turn. v1 rejects
stream, caller tools, instructions and store=false by name.

Auth for headless agents: sk-liminal- API keys, SHA-256 at rest, plaintext
shown once. Only the /v1/responses dependency reads keys — a leaked key
can chat and nothing else; it cannot list conversations or mint or revoke
keys. Mint/list/revoke at /v1/auth/api-keys (session auth, 20 active max,
tombstone revocation) or from the new API Keys section on the Settings
tab. Agent-created conversations carry meta.source="responses" and the
sidebar tags them "api".

The kernel's internal tool loop keeps its transport on this surface —
including local_lora/local_gpu_lora via the advertised tool channel — so
agents get the same grounded answers on every backend.

SPEC: §2.1 user_api_key, §13 envelope exception, §13.1 served surface,
§13.2 key endpoints, §17 UI expectations. README: agents section +
endpoints. Tests: 19 wire-shape/continuity + 7 key-lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
…input cap

A review probe reproduced three leaks on POST /v1/responses. A JSON-array
or malformed body drew FastAPI's 422 {"detail": ...} — the exact shape the
route's docstring claimed to prevent; the body is now read raw and
validated by hand, so both cases 400 in OpenAI's vocabulary. A ServiceError
mid-turn (provider failure) escaped to the app-wide handler and answered in
the kernel Envelope; the route now reshapes service errors (status kept),
storage conflicts (409), and crashes (generic 500 — internals never reach
the wire). And input had no length bound while ChatMessage.content caps at
100k chars to prevent DoS: the same cap now applies, enforced as item text
accumulates rather than measured after the join.

Six regression tests pin the shapes; SPEC's wire-shape and scope bullets
describe the hardened behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Three gaps between this kernel and the frontier, closed in one pass:

- The agent loop runs a round of read-only tool calls (file_search,
  history_search, note_search) concurrently, results and snippets always
  in call order. Rounds containing a taint-class tool stay strictly
  sequential — a web_fetch that records an injection finding must be able
  to withdraw run_python later in the same round, and that ordering only
  exists one call at a time. The egress guard is thread-local, so the
  round runner re-applies it inside every worker; without that, the socket
  allowlist would silently permit.

- POST /v1/responses speaks stream:true as SSE response.* events
  (created -> deltas -> completed, response.failed on error), with the
  reply's id minted before the first event and the assistant message
  persisted under it, so created and completed carry the same id.
  Everything refusable refuses before the stream starts; admission slots
  release however the stream ends.

- POST /v1/mcp is a minimal MCP server (Streamable HTTP, 2025-06-18):
  initialize, ping, tools/list, tools/call; notifications 202; batching
  rejected by name; GET 405. Two read-only tools backed by the kernel's
  own retrieval: note_search and knowledge_search. Read-only is the
  security posture — no egress for an injected document to abuse. Same
  API keys as /v1/responses; the SPEC carries the MCP roadmap (resources,
  prompts, OAuth, and an MCP client under the taint discipline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
The route walker caught GET /v1/mcp enforcing no rate limit. It is a
constant 405 — this server offers no server-initiated stream — which is
the version_info class of endpoint: the limit would cost more than the
handler. The POST half, where all the work happens, authenticates and
limits. Recorded in EXEMPT with the reason, the way the sweep asks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
The compat layer carried reasoning_tokens and cached_tokens through the
agent loop so a consumer could see them, and the served usage mapper then
threw them away in the last ten feet. Now usage serves the details
objects (always present, zeros when unknown — typed SDKs require the
fields) and the total falls back to input+output for backends that report
parts without a sum. The local JAX path also stamps total_tokens at the
source, so its own tokenizer's counts arrive whole on every surface.

Server-side tool runs stop being invisible: file_search and web_search
appear in output as the dialect's own items (file_search_call with
queries, web_search_call), streamed as output_item added/done pairs that
close before the message item opens. Only dialect-native types are used —
typed SDK parsers never meet an unknown discriminator — so note_search,
history_search, run_python and web_fetch stay out of output and ride the
full trace instead.

Grounding snippets, that full trace, and the active adapters ride under
one namespaced top-level key (liminallm), which the OpenAI SDKs preserve
and strict readers never see. Citations are deliberately NOT faked into
annotations: an annotation needs a character anchor and a file identity
this surface cannot honestly provide.

At ingestion, refusal parts now count as text — a reply that was entirely
a refusal used to flatten to "" and the turn then fabricated "No response
generated." over the model's actual words.

Skipped on purpose: an "incomplete" status for round-capped agent turns
(the loop withholds tools on the last round to force a real answer —
that is completion by design, not truncation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
The Responses ingestion path and the Gemini shim both carried
cached_tokens and reasoning_tokens through; the chat.completions sites
read only the three totals — silencing exactly the servers the
self-hosted lane runs, since vLLM-style prefix caching reports its cache
hits as prompt_tokens_details.cached_tokens on that transport. One
_chat_usage helper now serves all three sites (blocking, tool-calling,
streaming), with the same flat-int convention the other compat layers
use, so the loop aggregates the details and the served usage fills in
with no consumer changes.

On local_lora/local_gpu_lora cached stays 0 truthfully: the local
forward pass keeps no KV state to reuse, so there is nothing cached to
report — the SPEC now says so rather than leaving the zero to be read
as a gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
…euse

Serving used to run LoRA over a synthetic embedding table — sin(arange(...))
with no attention and no layers. That made the local lane shape-correct and
semantically empty: it could not answer anything, and it held no KV state,
which is why local cached_tokens had nothing to report.

service/transformer.py is the base model that stand-in was standing in for:
plain JAX (no flax, no torch), RMSNorm + RoPE + grouped-query attention with
a KV cache + SwiGLU, loading config.json and *.safetensors straight from the
model directory. A missing tensor raises rather than defaulting; a
half-loaded model answers confidently and wrongly.

Four invariants are pinned by tests that run a real checkpoint, because each
is the kind that reads correct and is not: incremental decode reproduces a
full recompute, attention is causal, a LoRA adapter at B=0 changes not one
logit, and a warm prefix cache produces byte-identical output to a cold one.

The prefix cache exploits the one property chat guarantees — turn N's prompt
is a strict prefix of turn N+1's. Entries are content-addressed and
adapter-keyed, matched on strict token prefix only, bounded by a token
budget, and cleared outright whenever adapter weights actually reload. The
reused length is reported as cached_tokens, so it surfaces as
input_tokens_details.cached_tokens on the served Responses API with no
consumer change.

Two defects the execution found, both from this project's recurring classes:

- Clamping out-of-vocab ids collapsed distinct prompts into identical model
  input. The fix is upstream — a loaded checkpoint's vocabulary is
  authoritative, including for the tokenizer fallback.
- test_adapter_checksum built its backend with __new__ plus hand-set
  attributes, so it broke the moment the class gained state. It now
  constructs the real object, which cannot drift from the real interface.

Training still fits LoRA against the old synthetic table, so adapters it
produces do not match the serving matrices. They are refused whole and
logged rather than half-applied, and SPEC §5.1 carries that gap as a named
next step instead of implying the ladder works locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
SPEC §5.4.4 defines the loss over model_apply(params_base, lora_params,
inputs). The loop instead minimised a loss over a synthetic sine
embedding table with no attention and no positions: a context-free
bigram task over a geometry where token ids are neighbours because their
integers are. The optimizer worked, the gate ran, versions were written,
and the weights fitted no model that exists.

Training now loads the same frozen checkpoint serving loads, applies the
LoRA matrices inside its attention projections, and differentiates only
those matrices — the base parameters are closed over, so "only on
adapters, never on the base model" is structural rather than a promise,
and a test asserts they come out bit-identical. The L2 term SPEC asks
for is in the objective; the holdout number deliberately is not, because
B starts at zero and can only grow, so charging the regularizer to the
eval would count learning as a penalty against promotion.

Matrices are now sized from the checkpoint per §5.2 — A is [r, d_in], B
is [d_out, r], and grouped-query attention makes k/v narrower than q,
which one guessed hidden width could never express. B initializes to
zero so a fresh adapter is exactly the identity, which is what lets §5.5
hold an adapter on the prompt rung without perturbing the model before
data has earned any weights.

What cannot be trained is now skipped rather than faked: no base
checkpoint, no LoRA matrices, or matrices that match no projection. A
skipped run never promotes (§5.4.6), so the adapter stays on the prompt
rung. A dataset too small to hold anything out no longer promotes
either — "promoted only when holdout loss improves" refuses what it
cannot measure, and unevaluated weights now change the model.

Verified end to end on a real checkpoint: after training toward a
target, that target's rank in the serving model improves from 8 to 2,
and serving's blended weights are bit-identical to the trained file.

Two test doubles built with __new__ and hand-set attributes are now real
objects; they encode what the test believed the class held, and broke as
soon as it held anything else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Review of 595ae38 found the real-forward trainer had made three
pre-existing paths load-bearing, and each was wrong.

Composition did not implement the §5.2 equation its own docstring
quoted. It gate-weighted A and B separately, summed across adapters and
divided by the total weight: for one adapter that computes (gA)/g = A,
so the router's gate cancelled itself and 0.2 behaved exactly like 1.0;
for two it forms the product of averages, whose expansion contains
B1A2 and B2A1 — one adapter's up-projection against another's
down-projection, terms the SPEC sum does not have. Composition is now by
rank concatenation, A* = [A1;A2] and B* = [g1a1B1, g2a2B2], so
B*A* = sum_j g_j a_j B_j A_j exactly. Ranks may differ; a closed gate
contributes nothing rather than being normalized back into existence.
The declared `.scale` key is now recognized rather than reported as an
unmatched name, so the serialization contract round-trips.

Tokenizer fidelity was the larger hole. Training and serving each had a
hash fallback, and they are different hashes — poly-31 against FNV-1a —
so a real checkpoint whose tokenizer failed to load could train an
adapter on ids serving never emits, pass a holdout tokenized the same
wrong way, and promote. "Train against the model that will serve it"
includes its tokenizer: serving now refuses such a checkpoint, training
skips, and an id outside the checkpoint's vocabulary is refused rather
than clipped into a token nobody wrote.

SFT sequences encoded prompt and target independently, splicing a second
BOS into the middle for any tokenizer that adds one, and truncated by
slicing the head of prompt+target — which can drop the entire supervised
span and leave an all-zero loss mask, an example that reports zero loss
and reads as perfectly learned. The target is now reserved first and the
oldest prompt context trimmed, and an example with no supervised token
is dropped rather than emitted.

The tests could not have caught any of this: every training test began
from hand-written token ids, and the fixture had no tokenizer at all.
The fixture now ships a real one beside the weights, and the new
end-to-end test runs text through the production tokenizer into training
and out through serving with no hand-written ids anywhere. Two tests
asserting the averaging behaviour now assert the composition equation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Review of a39d742 found the composition equation was exactly right about
a number it never received.

RouterEngine computes a gate per adapter, but _select_adapters rebuilt
the activated list from the candidate lookup and returned the gates
separately for tracing, so the weight never reached the backend, which
defaulted to 1.0. A policy asking for 0.18 was served at 1.0. The gate
now travels on the adapter dict it gates; the backend still reads one
place.

That made a second defect reachable: the KV prefix cache keyed on
adapter id and version, so the same adapter at 0.2 and 0.8 shared a
cache identity even though every cached tensor was computed under one of
them. The signature now includes the gate. SPEC said both things at once
- gates are per-request in 5.3, the cache key excluded them - and is
corrected.

Four more, each a contract stated and not implemented:

alpha was documented as a constant but entered the optimizer tree, where
the L2 term alone gives it a gradient; evaluation used the original
value while the file written afterwards carried the moved one, so a gate
could pass under one model and serve another. The trainable tree is now
the LoRA matrices only and constants reattach at serialization.

Prompt truncation asked the tokenizer to truncate first. Tokenizers
truncate from the right, keeping the OLDEST tokens, so the deliberate
"keep the newest context" slice ran on tokens that had already lost it.
Truncation is now the caller's decision, and the sentinel test uses a
tokenizer that refuses to truncate so the code must do it.

An empty target became [0] rather than being dropped: supervision
teaching the model to emit token 0, carrying positive mask weight so no
zero-mask check could catch it.

Serving still clipped an out-of-vocabulary id after training learned to
refuse one - the same fix stopping at the first instance. It refuses
now, and checks negative ids too.

Composition itself partially applied: an A without its B, or adapters
disagreeing on a projection's dimensions, logged and continued with
parts[0], serving a stack the router never chose. Both refuse the whole
stack.

The adapter RAM cache is keyed (adapter_id, version) as 5.3 declares.
A test places a decoy v0002 that must lose to the promoted v0001.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Review of 5f123ca found the mechanics correct and two states still
conflated.

_ensure_model treated _model_error as terminal, so a refused request was
followed by one served from the synthetic stand-in: generate() only asked
whether _model_state existed, and "no real model" meant both "dev box, no
checkpoint" and "checkpoint present but unusable". The missing-tokenizer
case never raised at all. There are now three states. absent allows the
stand-in and says so; valid serves the real model; broken raises on every
request, because a production configuration failure is not a development
lane.

Un-promoted weights were servable through a second door. current_version
of 0 pinned nothing and fell through to a directory scan, and a training
job writes vNNNN/params.json before its eval gate runs - so a prompt-rung
adapter could serve weights the gate never saw, permanently if a crash
landed between writing and quarantine. Version 0 now resolves to no
weights, and prompt mode contributes none regardless of what is on disk.
Two locks, because one is a race.

Training and serving also did not serialize the same conversation. The
role labels are tokens to a raw decoder, so USER: and user: are different
inputs; serving still let the tokenizer truncate, which keeps the oldest
tokens, while training had been fixed to keep the newest. Both now use
one serializer for labels, the context marker, and truncation.

SFT prompts included messages from after the feedback target: the target
row was skipped but later turns were not, so an event trained after the
conversation continued taught its answer conditioned on the future. The
bound is now the target's sequence number.

Three smaller contracts: batch shape reported the source slice rather
than the rows emitted after dropping targetless examples; an orphan B was
invisible because the composition loop is A-driven, leaving one silent
case in "malformed weights fail visibly"; and the gate cache key rounded
to six decimals, where two distinct gates could share KV state.

The end-to-end test is new and was written wrong first: an if/else on
promotion meant every run took the refusal branch, because the fixture
improves its holdout by less than the 1% bar - it would have passed with
promotion entirely broken. It now relaxes only the threshold and asserts
both that the improvement was real and that promotion happened, then
follows one value from preference events through training, the gate,
promotion, a routed 0.4 gate and LLMService into local generation, with a
decoy version on disk that must lose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
…ing them

Review of 822f817 found five rules stated in SPEC and true only in the
paths already under test.

Partial application survived one level above the fix. The pair and
dimension checks live in composition, but assembly then skipped names it
did not recognize and applied the rest, so an adapter carrying a valid Q
pair beside a foreign one still changed the model while the foreign half
went to a log line. One validate_lora_weights checks name, target, layer
index, pairing, rank agreement and the projection's real dimensions, and
raises on the first violation. Serving calls it before assembly, training
before indexing - training skips, and a skipped run cannot promote.

Hybrid adapters were served twice. Prompt injection keyed off the
adapter's own backend field and never asked what the active backend does,
so a graduated skill on local JAX received its trained weights and the
instructions they were distilled from: an input the eval gate never
scored. The local backend now advertises that it applies weights, and a
promoted hybrid adapter skips injection there while keeping it on API
backends. An unpromoted hybrid keeps its prompt everywhere, since no
weights will load for it.

The SFT prompt boundary needed the store, not a window. list_messages
returns the newest 200, so a target older than that was simply absent,
target_seq was None, and the guard did nothing - every later turn became
training context for exactly the events most likely to have them. The
target is now fetched by id and history queried as seq < target_seq; an
unresolvable target drops the example. Fetching the target directly also
keeps its content available as the label, which bounding alone would have
silently removed.

Context reached the local model in a format training never wrote: an
appended "Context: a | b" inside the user turn, rather than the context:
turn the adapter was fitted against. The local path now uses the shared
serializer; API backends keep the provider representation.

And a path pointing straight at params.json answered before the version
check, so a direct file served at current_version 0.

The end-to-end test is now what its docstring claimed: the skill is born
on the prompt rung through SemanticClusterer, trained, graduated to
hybrid with its prompt retained, routed at 0.4 by a real policy through
RouterEngine and WorkflowEngine, and served with a context snippet -
asserting both that the context uses the shared format and that no
fallback prompt accompanies the weights.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
…lacement

Review of cfca075 found four rules enforced one step too late or read
from the wrong field.

Serving validated the composed pair rather than each adapter. Composition
carries only A/B pairs forward, so a foreign key never reached a
validator running afterwards - and concatenation adds ranks up, so two
adapters that each disagree with themselves (A rank 2 with B rank 1, and
A rank 1 with B rank 2) compose into a pair whose totals agree at 3 and 3
while every row pairs with the wrong column. Validation now runs on each
adapter's raw matrices as they load, with the composed pair checked
afterwards as defense; without a loaded model, self-consistency is still
enforced, which is the check that catches that case.

A selected adapter could also leave the stack silently: a promoted
local/hybrid adapter whose weights would not load was skipped and the
rest served. Weightless is legitimate only where the ladder says so -
prompt rung, nothing promoted, closed gate - and otherwise refuses.

Version authority had two escape hatches. A `latest` pointer was trusted
without checking where it resolved, so version 1 could serve v0002
whenever v0001 was missing; and a path pointing straight at params.json
was accepted for any positive version, serving a file on the artifact's
say-so. A bare file cannot demonstrate which version it is, so it can
only satisfy an artifact that was never versioned. The earlier test
asserted the opposite and is inverted.

The background worker defaulted a missing gate decision to promoted,
while the run summary it read had dropped eval_gate entirely - so
gate-rejected runs were marked succeeded and credited the adapter's
router state for a rollout that never happened. The summary carries the
decision; absent means unknown, and unknown is not approval.

Prompt injection branched on the legacy `backend` field while SPEC calls
`mode` authoritative, so mode/backend disagreement gave an adapter both
its weights and the prompt they were distilled from, or neither.

And training and serving agreed on the context marker while disagreeing
on where it goes: appended after every message on one side, inserted
before the final user turn on the other. For a raw decoder that is a
different input. Placement is now one function, and the test compares the
exact serialized string from both sides.

SPEC 6.1 described hybrid as a controller/executor plan, contradicting
5.0.1; 5.0.1 controls and 6.1 is corrected to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1aXnifvwPBfqtnaCYt14n
Comment thread Makefile
claude added 4 commits August 23, 2026 23:36
…test-pg

CI's lint job failed on the first push, and every other job is `needs: lint`,
so the 3.10/3.11/3.12 matrix and the browser job never ran. Seven errors,
none of them on main — all introduced somewhere in this branch and invisible
to every local run.

The reason they were invisible is the interesting half. `make lint` passed
`--ignore E402`, and ruff's `--ignore` does not add to the configured ignore
list, it replaces it. `[tool.ruff.lint]` in pyproject.toml already says
`select = [E, F, W, I]` and `ignore = [E501]`, so the flag suppressed E402
locally and re-enabled E501, while CI — whose explicit `--select`/`--ignore`
only restate the same config — had it the other way round. Five E402s and two
unsorted import blocks sat on the branch through every `ruff check` I ran.

So the flags come off: `ruff check liminallm/ --fix` uses pyproject, which is
what CI uses. The tests line keeps its relaxation with `--extend-ignore`,
which adds rather than replaces.

The seven errors are fixed at the cause rather than suppressed. The E402s were
not deliberate late imports — `_password_hasher` had been inserted above
`auth.py`'s import block, so the block is moved back above it. The two I001s
are ruff's own sort.

Separately, from Bugbot on the PR: `test-pg` was the one lane still running
`pytest tests/` with no marker filter, missed when the browser marker was
introduced. Measured rather than assumed — with Playwright installed by the
dev extra and no Chromium binary, a browser test *errors* rather than
skipping, so `make test-pg` failed after an ordinary `make install`.

Not fixed here, and pre-existing on main: `make lint` also fails on `tests/`
(22 errors there, 25 on this branch) — unsorted imports, `l` as a variable
name, and six repeated dict keys whose values are identical, so nothing is
dropped. Unrelated to this PR, and CI does not lint `tests/`.

Full parallel lane 2816 passed, 26 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
`main`'s security job has failed on every push since 2025-11-30 — about
thirty consecutive runs, last green at 911e7df. The step is byte-identical
between main and this branch, so nothing here caused it; the gate has
simply not been read in nine months.

Fifteen bandit findings at `-ll --skip B101`, twelve of them already on
main. `git blame` against origin/main identified the three this branch
added: all B608, all in postgres.py, all the same shape as seven that were
already there. All fifteen were examined rather than suppressed on sight.

Ten B608 are dynamic SQL where every interpolated fragment is a source
literal — "title = %s", "visibility = 'private'" — selected by an
`is not None` check. No caller value reaches the f-string and every value
is bound. Suppressed per line with that reason.

One B613, the only HIGH, is fixed rather than suppressed. `web.py` held
raw bidi and zero-width characters inside `_INVISIBLE_RE`, the class it
uses to strip exactly those characters from fetched pages. It is data, not
a Trojan Source attack — but a character class nobody can read in an
editor or a diff is not reviewable, and a file carrying raw bidi controls
has the attack's shape whatever the intent. Now written as `\u` escapes
with a comment per range, and proven equivalent by comparing old against
new across all 1,114,112 codepoints: zero differences, 155 characters
matched by both.

B314 (ElementTree in the extractor, no external entities, size-guarded,
and confined to the extraction child) and B102 (exec in the code
interpreter, which is its purpose) are suppressed with their reasons. Two
B615 are the only findings that are not false positives — revision pinning
for `from_pretrained` is real supply-chain hardening — and are suppressed
with a comment saying the pinning question is a product decision rather
than something to settle in a lint pass.

One defect made and caught while doing this: the first pass appended
`# nosec` by line number, and one of those lines opened a triple-quoted
f-string, so the comment became part of the SQL. Bandit was satisfied and
the statement still parsed. Found by asserting the real property rather
than the proxy — walking every module's AST for a string literal
containing "nosec", of which there may be none. That statement is now
concatenated so the suppression has a line to sit on.

`make security` and CI still differ (`-q` against `--skip B101`), left
alone because CI is the more permissive of the two: the local command
cannot pass while CI fails, which is the safe direction for a mismatch.

Full parallel lane 2816 passed, 26 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
With lint passing, the matrix ran for the first time and every job that
loads the suite died before a single test:

    tests/conftest.py:20: from tests.harness import run_id, worker_id
    E   ModuleNotFoundError: No module named 'tests'

Not a 3.12 problem, though that is the job that reported it — reproduced on
3.11 locally in one command. `python -m pytest` puts the working directory
on sys.path; bare `pytest` does not, and CI runs bare `pytest`. Every local
run on this branch used the first form. So a conftest importing
`tests.harness`, which this branch introduced along with the worker
isolation, was never exercised the way CI would exercise it. The browser
job failed identically, same line, same cause.

`pythonpath = ["."]` in [tool.pytest.ini_options] makes the two the same
invocation, which is the property that was missing rather than the path
itself. Verified by running both lanes with bare pytest, as CI does: 2816
passed and 26 skipped on the non-browser lane, 11 passed on the browser
lane.

Side effect worth knowing: CI installs the project non-editably, so
`import liminallm` resolved to site-packages. With the repository root on
the path it resolves to the checked-out tree — the copy the run is meant
to be testing.

That is the third gate on this branch whose local command differed from its
blocking command, after the ruff flags and the unread bandit findings. In
all three the local form was the more permissive one, so local green meant
nothing and nothing showed that it meant nothing. Recorded in
docs/ISSUES.md as one lesson rather than three incidents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
CI got as far as importing the application and died there on every Python
version:

    liminallm/service/auth.py:17: import httpx
    E   ModuleNotFoundError: No module named 'httpx'

`httpx` is imported at module scope by five files — auth, web, sandbox,
voice, gemini_backend — and appears in no dependency list. It has only ever
arrived because `openai` depended on it.

Not a resolution accident: resolving the base set as CI does gives
`openai==3.3.1`, and openai 3.x moved from `httpx` to `httpx2`. Locally the
dev extra pins `openai>=2.8.1` and the lockfile holds 2.8.1, which still
uses `httpx` — so every local environment had it and no CI environment did.
Measured with `uv pip compile` on the exact base set, before and after:
absent, then `httpx==0.28.1` alongside `httpx2==2.12.0`.

A direct import satisfied by somebody else's requirement holds only until
their requirement changes, and when this one broke the application did not
degrade — it failed to import, so every test job died in the conftest.

A sweep of every third-party import in `liminallm/` found two more
undeclared, neither a defect: `numpy` is function-local beside
`safetensors.numpy` in the checkpoint loader, now named in the train extra
because the code imports it directly; `tiktoken` sits in a `try:` with a
heuristic fallback, which is what optional should look like.

`tests/test_declared_dependencies.py` enforces the rule going forward, on
position rather than identity: a module-scope third-party import must be a
declared base dependency, a function-local one need not be. It carries its
own can-see-something check so a broken walk cannot report a clean list, and
pins numpy and tiktoken as deliberately lazy so moving either becomes a
decision. Removing the httpx line fails it.

Still unqualified and recorded rather than fixed: CI installs unpinned and
therefore tests against an openai the suite has never been run against.
3.3.1 does still export every Responses type those tests import, so they
will collect; whether the shapes validate is what the run will say.

Full parallel lane 2820 passed, 26 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Comment thread tests/test_declared_dependencies.py Outdated
Declaring httpx got CI past the conftest for the first time. The 3.10 job
reached 2701 collected items, where every earlier run had died before
collecting one. It then reported two more instances of the shape the guard
was written to catch, both introduced by the guard itself.

tomllib entered the standard library in 3.11. This project's floor is 3.10,
where it is the tomli backport under another name. So the test that catches
undeclared dependencies was itself an undeclared dependency, on the one
interpreter that had to be checked and was not.

tomli is declared in the dev extra rather than taken from pytest, which does
supply it on 3.10 -- measured, pytest 9.1.1 requires it there. That is the
same arrangement httpx had: a direct import satisfied by somebody else's
requirement, which holds until their requirement changes.
packaging.requirements came out for the same reason, in favour of a regex
over the distribution name. Two entries in the name map could never match,
because the regex strips the extra before the lookup and both fell through
to a default that happened to agree; a test now rejects that shape.

numpy is in the train extra, which no CI lane installs. The test job gets it
because its install line names jax, so it looks available everywhere. The
browser job installs base plus dev only. Two test modules new on this branch
imported numpy at module scope beside their own importorskip guards for jax
and safetensors, and took that lane down at collection -- not a failing test
but an aborted run, deselecting 2694 tests that never executed.

The guard now walks tests/ as well, against the narrowest lane rather than
against [project] dependencies: a test module may import at module scope only
what every lane installs, and reaches anything else through importorskip.

Verified rather than reasoned about. The tomllib fix runs on a real 3.10
interpreter, and removing it reproduces the collection error. The numpy fix
was checked by blocking numpy on sys.meta_path to recreate the browser lane's
install set: the mutant gives "Interrupted: 1 error during collection", the
fix collects cleanly. Full parallel lane: 2823 passed, 26 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Comment thread tests/test_declared_dependencies.py Outdated
claude added 4 commits August 24, 2026 00:32
The tests/ guard added in the previous commit asks whether a module-scope
import is in base plus dev, which is what the browser lane installs. It read
requirement strings with a regex that takes the distribution name and stops,
so an environment marker was invisible to it.

tomli>=2.0; python_version < '3.11' is declared in the dev extra, so the set
named installed_everywhere contained tomli -- a package installed on 3.10 and
on nothing else. The browser lane runs 3.11. A module-scope import tomli in
tests/ would have passed the guard and aborted that lane's collection anyway,
which is the one failure the guard exists to prevent. Reported by Cursor
Bugbot against 1030758.

Measured before fixing: 'tomli' in the trusted set was True, and
find_spec("tomli") on the 3.11 interpreter this suite runs on returned None.
The set was named for a property it did not have.

Any marker now disqualifies a name, including one that would hold everywhere.
The parse cannot evaluate markers and should not pretend to, and the two ways
of being wrong are not symmetric: too strict costs one unnecessary
importorskip, too lax costs an aborted lane.

Witnessed behaviourally rather than by inspecting the set. A module-scope
import tomli dropped into tests/ is flagged with the fix in place; with the
marker exclusion reverted the same file passes. That is the reported hole,
reproduced and closed. 8 passed on 3.10 and on 3.11; fast parallel lane
2713 passed, 25 skipped.

Three findings in this file now, all the same sentence with a different
subject: what is declared, what is imported and what is installed are three
different sets, and every defect here came from treating two of them as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
CI's 3.10 job failed after fourteen minutes, with 3.11 and 3.12 cancelled by
matrix fail-fast rather than failed -- so CI could not say whether the defect
was version-specific, and the local 3.11 lane had passed 2823 tests half an
hour earlier. Reproduced by building a 3.10 environment with CI's own two
install lines, which resolves openai 3.3.1 where every local environment held
2.8.1.

openai 3.x made input_tokens_details.cache_write_tokens a required field. In
2.8.1 that object required only cached_tokens, and the served usage block
emitted only that -- so as of 3.x this server had stopped conforming to the
dialect it claims to speak. _responses_usage's own docstring already states
the rule it broke: the details objects are always present, zeros when unknown,
because typed SDKs require the fields. The principle was written down and the
field was not added when the SDK added it.

Read from the turn's usage like its sibling rather than hard-coded to zero, so
a backend that starts reporting cache writes needs no change here.

Checked for siblings rather than assumed: required-field sets diffed across
every model under openai.types.responses in both SDKs. The first diff walked
only top-level exports and could not see InputTokensDetails, which lives in a
submodule -- walking submodules raised it from 218 models to 390 and found six
models that gained a required field. This server emits one of them. The four
*Item variants belong to a stored-items endpoint it does not serve, and the
computer and shell tool outputs are capabilities it does not implement.

The same run failed three more tests on a missing Pillow. It is in the ocr
extra, which no CI lane installs, and the three are not OCR tests -- they gate
on nothing because they exercise the refusal paths, an unreadable image naming
the remedy and a decompression bomb refused before it allocates. So the three
that most deserve to run in CI were the three that could not. Pillow is
declared in the dev extra now so they run rather than skip; importorskip would
have made the lane green by never testing a bomb refusal on any machine but a
developer's.

openai stays uncapped, and the comment says why. The unpinned range is what
surfaced a wire this server had genuinely stopped conforming to; a cap would
have preserved a green suite over a payload no current SDK accepts. The older
note claiming a lockfile qualified the snapshot was never true of CI.

Mutation: removing cache_write_tokens reproduces exactly those five failures,
restoring it gives 42 passed. Verified on both SDKs -- 42 on 3.3.1 under 3.10
and 42 on 2.8.1 under 3.11 -- so following the newer type did not break the
older one. Full suite on the CI-matching 3.10 environment: 2671 passed, 35
skipped.

ISSUES.md records one finding this commit deliberately does not fix: five more
tests import a package no lane declares, starlette among them by way of
fastapi, which is the same shape as httpx. None fails today. That is a tranche,
not a carry-over, and mixing it into a commit about the red would obscure both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
`.coverage` was ignored and `coverage.xml` was not. CI's test step passes
`--cov-report=xml`, so reproducing a CI failure locally runs the same command
and leaves a generated file sitting in the working tree looking like work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
CI's 3.10 and 3.11 jobs both fail, and the cause is not the interpreter or the
dependency set: a CI-matching environment passes 2671 tests here in parallel,
serially, and serially with coverage against a schema built by migrate.sh.
Reading the job log finally gave the answer -- 51 failures, 31 of them one
line:

    PermissionError: [Errno 13] Permission denied: '/proc/self/setgroups'

That is the sandbox working. There is no unconfined fallback by design, so a
kernel that refuses the namespace means model-written code does not run. What
the message does not say is which policy refused, and the coverage data from
the runner narrows it usefully: lines 115-117 of confine.py are unexecuted
while 118 is not, so _linux_available() returned True there -- both sysctls
present and permitting -- and unshare itself succeeded. The refusal is one
line later, inside a namespace the kernel had just granted.

So two things.

The three /proc writes that establish the identity mapping now raise with the
operation and errno, the way unshare, mount, pivot_root and umount2 already
did. They were the only calls in the sequence that surfaced as a bare
PermissionError naming a file and not an operation, and the difference between
"this host has user namespaces switched off" and "this host allowed the
namespace and then refused the mapping inside it" is the difference between
two different fixes. This is an operator-facing improvement independent of CI.

And a temporary diagnostic job reports the runner's kernel, distribution,
userns sysctls, apparmor status, whether unshare(1) works, each call of the
confinement sequence with its own errno, and what _linux_available() and
backend_name() conclude. Its own job rather than a step in `test`, because
`test` prints 2700 verbose lines and then the entire Postgres service-container
log, which makes anything before the tests unreadable in practice -- that is
why this took a full log download and a grep to diagnose at all.
continue-on-error, because a report that fails the build is a report nobody
reads. Delete it once it has answered.

Deciding from the answer rather than now: if this is a runner knob, enable it
explicitly and keep the 31 tests exercising real confinement. If GitHub-hosted
runners prohibit the primitive outright, the boundary moves to a required
confinement-capable lane rather than becoming 31 permanent skips. The sandbox
does not get weakened so that a hostile host policy stops complaining.

Not addressed here, and separate from confinement: ripgrep is absent on the
runner so two settings tests error, two more fail starting a second Postgres,
and seven workflow retry tests report zero retries. Three of those four files
predate this branch, so CI has never run them -- it has never reached them
before this week.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Comment thread .github/workflows/tests.yml Outdated
claude added 5 commits August 24, 2026 01:49
The diagnostic answered. GitHub's hosted runner is Ubuntu 24.04 with
kernel.apparmor_restrict_unprivileged_userns=1, which restricts unprivileged
user namespaces: the namespace is still created, the process holds no
capabilities inside it, and the identity mapping is refused. That is exactly
what the sandbox saw -- unshare succeeding and the next line returning EACCES
-- and it fails 31 tests here because there is no unconfined fallback by
design.

    kernel.unprivileged_userns_clone              = 1        the probe read this
    user.max_user_namespaces                      = 63838    and this
    kernel.apparmor_restrict_unprivileged_userns  = 1        it did not read this

Two defects, and only the second is about CI.

_linux_available() was wrong on a mainstream distribution. Every knob it
consulted said yes while the one that decides said no, so on a stock Noble host
backend_name() advertised an interpreter that fails on every call. It reads the
AppArmor knob now. Pessimistic on purpose: an AppArmor profile carrying
`userns create` lifts the restriction for the programs it covers, and a wrong
False withholds a working interpreter while a wrong True offers a broken one.
confine() stays the authoritative answer either way.

The runner enables the primitive explicitly. Not `|| true`: if that stops
working the lane fails at that step, next to the comment explaining it, rather
than quietly downgrading to skipping the confinement suite.

Which is the part worth stating plainly. Fixing the probe alone would have made
CI green and meant nothing -- requires_backend skips this file when
backend_name() is None, so a correct probe on a restricted runner turns 31
failing confinement tests into 31 passing skips and reports success with the
security boundary completely untested. So the lane declares
LIMINALLM_REQUIRE_CONFINEMENT and a test fails loudly when no backend exists.
It runs code inside the sandbox rather than reading a sysctl, because what
needs proving is that the boundary engages, not that a knob looks encouraging.

Mutation against the runner's real setting: with the knob reading 1,
_linux_available() returns False and backend_name() returns None; the armed
probe then fails naming kernel.apparmor_restrict_unprivileged_userns, and
unarmed the same suite skips 18 quietly, which is correct on a laptop.

The temporary diagnostic job is deleted, having answered. Bugbot was right
about it: `unshare ...; echo rc=$?` under bash -e aborted the step before the
echo, so the two steps that mattered most never ran -- skipped by the very
failure mode the job existed to distinguish. The answer arrived from unshare's
stderr instead, which is luck rather than design. A probe whose failure is the
datum must not be written so that failing suppresses the report. That is also
why the replacement is a test and not a shell step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Forty-six of the fifty-one failures are the confinement cause fixed in the
previous commit. That is measured, not assumed: pointing the availability
probe at a knob reading 1 -- the runner's setting -- reproduces the CI run
file by file, 7 lease failures against 7, 7 retry against 7, and single
failures in child_wire, injection_taint, path_races, tool_authority, web and
workflow_rag_scope, each matching. Of the five that remain, three are fixed
here and one is deliberately left alone.

There was never a retry bug. The seven test_workflow_retry_timeout failures
read `assert 0 == 3`, but the log shows three attempts, backoff of 10ms then
40ms, and workflow_node_retries_exhausted -- SPEC 18.3 behaving exactly as
written. The counter lives in a closure in the parent and _run_builtin_body
never ran there, because every attempt returned 'error': 'worker_unconfined'
from the same /proc/self/setgroups refusal. The assertion reported a true fact
about a cause three layers below its subject. Breaking confinement locally
reproduces all four assertions in CI's order. Nothing in the retry path
changed; fixing it would have meant editing correct code to satisfy a symptom.

ripgrep is a binary no lane installs, so two settings sweeps raised
FileNotFoundError on the runner and passed on every machine that happened to
have it -- the httpx shape again, this time not a Python package at all, which
is why no dependency guard could catch it. Replaced with a pathlib walk and
re, not with grep, which would only move the problem: its regex dialect is not
the one these patterns are written in and it is still an external process. The
walk's output is byte-identical to ripgrep's on both patterns. One of them
legitimately matches nothing, which is the shape that goes vacuous unnoticed,
so both are mutation-tested against a planted violation.

A scratch Postgres reached outside its scratch directory. Debian and Ubuntu
compile unix_socket_directories as /var/run/postgresql, owned by postgres --
writable when the harness runs as root and su's to that user, and not writable
by an ordinary CI user, which is why the cluster would not start there. The
socket now goes in the data directory. The second half matters more: _run sent
both streams to DEVNULL and pg_ctl only prints "could not start server.
Examine the log output", so the reason existed the whole time, in a file, and
the harness discarded it. It now raises with the command, exit status, both
streams and the tail of the server log. Third instrumentation gap of this
shape in two days. Measured as an unprivileged user: with the socket fix
reverted the cluster still fails, and the new message states the permission
error outright.

One failure is left and is not reproduced.
test_generation_lifecycle::test_a_source_rooted_above_the_file_still_serializes
is a real two-thread race, and it does not fail here normally, with
confinement broken, or pinned to one or two CPUs. Its time.sleep(1.0) is the
obvious thing to harden and is not the proximate cause -- CI failed the later
assertion, so that gate held. Editing a race test's synchronisation without
being able to reproduce its failure is how a test starts passing vacuously.
The next run has 46 fewer failures and workers that start, which changes its
timing; a second failure would be worth acting on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Fixing confinement took CI from 51 failures to 6 with no mention of setgroups
or worker_unconfined anywhere in the log. Four of the six were the previous
commit. The fifth changed its story, and the sixth is still not reproduced.

test_injection_findings_reach_the_workflow_trace used to fail as
worker_unconfined and now failed with the model answering without reading the
page, because "Egress address '127.0.0.1' is not allowlisted for tools".

Two guards stand between a tool and a local address. web_fetch_allow_private
is the SSRF check on the URL, and the test opts out of it explicitly. The tool
network allowlist is a separate socket-level guard, built once from settings in
the engine's constructor, so patching settings afterwards never reaches it. The
test never opted out of that one.

It passed anyway, everywhere, for a reason worth recording:
connection_allowlist() returns the proxy's host when a proxy is configured, and
this development environment sets HTTPS_PROXY to a loopback address. So the
allowlist was literally ['127.0.0.1'] and the loopback server the test stands
up was permitted by coincidence of the developer's proxy settings. CI has no
proxy, so the real target list applied. Reproduced by unsetting HTTPS_PROXY:
the test fails locally with CI's exact message and passes with it set. The rig
opts out of both guards now, and dropping the allowlist entry makes it fail
again, so the opt-out is not covering a test that would pass regardless.

Fourth environment-coincidence defect in two days and the least comfortable:
httpx, numpy and ripgrep were things present here and absent there, but this
was a security control satisfied by an unrelated environment variable. A guard
whose test passes only because of the tester's proxy configuration was not
being tested.

test_a_source_rooted_above_the_file_still_serializes has failed twice on CI and
reproduces on no local configuration tried -- ordinary, with confinement
broken, without a proxy, pinned to one CPU, to two, and to one under three
competing hogs at twice the wall clock. So it is not fixed. Its assertions
could only report that the answer was wrong when the question is which commit
landed last, so the gate records each commit and the failure message carries
the sequence.

Two details of that, because the first version was useless and the second
nearly was. Labelling by thread name gave asyncio_0 for both actors, since the
test client runs each request on an executor thread rather than the one that
started it -- evidence that distinguishes nothing. The label reads the
committed chunks instead and says "neither" rather than guessing. Verified by
forcing the assertion: a passing run reads [('neither (1 chunks)', 1.1999),
('upload', 1.4146)], upload last, which is the correct outcome. Forcing it also
caught the check landing on the wrong function, since this file holds two tests
with an identical block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
The browser lane failed once with psycopg.errors.DeadlockDetected at fixture
setup, failing a test that had not started. Not the commit it appeared on:
that one touched ScratchPostgres, which this lane never constructs because it
sets TEST_DATABASE_URL, and a settings test the lane deselects. First sighting
on a lane CI has only just become able to run.

_truncate_all's docstring already named the assumption it breaks -- "this
statement assumes nothing else is looking at it" -- which holds in every lane
but this one. The browser lane runs a real uvicorn server in a thread against
the same database with a pool of its own, so a request still in flight holds
ACCESS SHARE on some tables while the wipe wants ACCESS EXCLUSIVE on all of
them. Two sessions taking locks across many tables in different orders
deadlock, and Postgres kills one of them.

Reproduced rather than reasoned about: a reader holding one table and reaching
for a second, against a TRUNCATE holding the second and reaching for the
first, deadlocks every time. The probe's reader lost where CI's fixture lost,
so either side can be chosen and the fixture has to survive being it.

The first fix did not work, and the measurement caught it before it was
committed. A plain retry against six continuously looping readers changed
nothing: 51 of 60 truncates failed with and without it, identical numbers.
Identical numbers are what prompted checking whether the except branch was
reached at all -- it was, and DeadlockDetected was the right class. Retries
land in the same steady state and stop being independent, so under saturated
contention a retry is not a fix.

Against the contention this lane actually produces -- one request finishing,
overlapping the wipe -- it is decisive: 40 of 40 failed without the retry and
0 of 40 with it. Both numbers are in the docstring, because the boundary is
the useful half. A lane that keeps a database busy while wiping it needs the
server quiesced rather than a higher attempt count, and exhausting these
attempts is how it will say so.

Table order is deterministic now as well. That narrows the window and cannot
close it, since the other session picks its own order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Fixing the socket directory moved test_worker_isolation from failing at pg_ctl
to failing at psql: exit status 3, which is ON_ERROR_STOP firing. Which
statement failed is in stderr, and apply_schema sent stdout to DEVNULL and
never captured stderr, so the answer was discarded one line before it was
needed.

That is the fifth instrumentation gap of this shape in two days, after
confine.py's /proc writes, pg_ctl's own log, the sandbox's worker_unconfined,
and the deadlock retry's counters. Consistent enough to state as a rule:
anything that runs a subprocess and checks its status has to keep what the
subprocess said, because the status is a number and the reason is text.
apply_schema now raises with the database name, the exit code and the tail of
psql's output. Measured against a database that does not exist:

    applying sql/schema.sql to 'does_not_exist_db' failed (psql exit 2):
      psql: error: ... FATAL: database "does_not_exist_db" does not exist

The likely cause of the exit 3 is schema.sql line 236, CREATE EXTENSION
vector. CI reaches pgvector through a service container, and a scratch cluster
is built from the host's binaries, which are stock PostgreSQL. This
development box happens to have postgresql-16-pgvector installed, so the
control file is there and the schema applies -- the fifth environment
coincidence in the same ledger.

So ScratchPostgres.available now asks whether the installation can supply the
extensions the schema creates, reading the control files beside the binaries
rather than starting a cluster to find out. A host that cannot gets a skip
naming the missing extension and saying that a pgvector service container does
not help, because it is a different server. The three call sites report that
reason rather than "needs initdb", which was true of none of them.

This is a skip and the earlier argument against skips still stands, so the
difference is worth stating. The confinement tests would have skipped a
security boundary on the lane meant to prove it. These cover the harness's own
worker isolation on a scratch cluster, the property is exercised anyway by
every xdist run that provisions per-worker databases, and a host that cannot
host the schema cannot run them at all. Saying so beats an opaque exit code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Comment thread tests/test_worker_isolation.py
claude added 3 commits August 24, 2026 02:57
Two things, both about making a failure say what happened.

Bugbot found a fourth availability call site. Three were updated to report
`unavailable_reason`; `_external_or_skip` still said "needs initdb and
redis-server", so a host with initdb and without pgvector -- the case the
check had just been extended to catch -- was told the one explanation that
could not apply to it. It sat behind `_External.available`, which composes two
availability checks and had a skip message of its own, and one indirection was
enough to hide it from a grep for `.available`. This repository's own rule
covers it: grep the class when you fix the instance. The class is swept now,
and the four remaining "needs redis-server" skips were checked and left --
each is gated on ScratchRedis alone, so the message is accurate.

The generation-lifecycle instrumentation now records the source path and chunk
count as well as the generation, because "neither" alone was ambiguous on its
first CI failure. That one line was already worth the change:

    CI       [('walk', 1.2324), ('neither', 1.4468)]
    local    [('neither', 1.1999), ('upload', 1.4146)]

The upload's marked commit was missing entirely from the failing run, which is
a different fact from "the walk committed last" and was not visible before.
With the path recorded, a passing run reads

    [('neither', '.checksums.json', 1, 0.19), ('upload', 'report.md', 1, 1.65)]

and the unnamed commit turns out to be an unrelated file the directory walk
also covers. That explains the ordering difference: the gate arms on the walk's
*first* commit, whichever file the filesystem hands it first. Here that is
.checksums.json, so the gate holds an uncontested file and the race never
happens. On the CI runner it is report.md, so it does.

Reproduced by arming the gate on report.md instead, which is CI's observed
order: three runs, three failures, reading

    [('neither', '.checksums.json', 1, 0.19), ('walk', 'report.md', 1, 1.38)]

The walk's stale generation lands last and the upload never commits at all,
while the upload returns 200 and the new bytes are on disk -- the assertion
above this one checks that and passes. So the file is updated and the index
keeps the previous generation.

That is a product finding rather than a CI one, and it is left for a decision
rather than fixed here: the subsystem is untouched by this branch, and the
test's gate needs to become deterministic in the same change, or it will go on
passing locally for the wrong reason. docs/ISSUES.md has the full evidence.

Full parallel lane: 2824 passed, 27 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
The previous commit message said docs/ISSUES.md held the full evidence for the
race, and it did not yet — the fourth-call-site note had landed and the product
finding had not. It does now: the gate arming on whichever file the filesystem
hands the walk first, `.checksums.json` here and `report.md` on the runner, and
the three-run reproduction showing the walk's stale generation landing last
while the upload's commit never happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
…pted

The previous entry said the index keeps the previous generation and would
answer out of bytes that are gone. Measured, it does not: the rows show one
chunk for .checksums.json and none for report.md, so the walk's stale commit
landed and the upload's invalidation removed it.

The error came from reading the test's assertion message instead of the index.
That assertion is only "UPLOAD WROTE in indexed", which fails both on stale
text and on no text, and its message names the first case; the assertion that
would have told them apart never runs.

The defect stated correctly: replacing a file invalidates every covering
context's chunks for that path, nothing re-indexes the new generation because
an ordinary upload names no context, and the manifest's context association is
reset -- so a context silently stops covering a file, with no error and no
record. Less severe than stale answers, still a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
yellowman and others added 4 commits August 24, 2026 12:37
A context covers a path; the bytes at that path are replaced through the
ordinary upload endpoint, which names no context because that is what
replacing a file looks like. Afterwards the old text must not be retrievable,
the new text must be, and the file must still belong to the context.

Three tests, one invariant from three sides: coverage acquired by a directory
source, coverage acquired by naming the context on the first upload, and an
upload that names one context while another already covered the path. All
three fail here.

Sequential and API-only in its actions -- no threads, no gate, no sleep, no
reach into the engine. Concurrency was never needed to expose this. An earlier
attempt at the same invariant on another branch used two threads and a gated
commit, and it passed or failed according to the order a directory listing came
back in: it held an uncontested file on one machine and the contested one on
another, so it passed locally for a reason unrelated to its subject. This
version fails on every machine where the invariant is broken, which is the only
property that makes it worth having.

Reads go to the store because the served surface has no chunk listing. That is
observation; the actions are the API's, so the same file runs unchanged against
histories whose implementations differ.

Measured against both, and they fail differently:

    main   chunks frozen at the first generation, so the context answers out
           of bytes the file no longer holds
    #178   chunks correctly invalidated and never replaced, so the file leaves
           the context entirely

Same invariant, two manifestations. The branch fixed the invalidation half and
left the re-index half unwritten, turning stale into missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
This branch already emptied every context covering a replaced path, and
was right to: a chunk claiming to be the file's contents is false the
moment new bytes exist. What it could not do under the publication lock
was re-read and re-embed for a set of contexts the request never chose,
so it left those contexts covering a file they no longer described. The
finding in ISSUES named that; this closes it.

`context_source` is the single authority for what a context covers.
Never `knowledge_chunk`, which is the materialisation of it — a stray row
would promote itself into a relationship nobody created, and coverage
would evaporate whenever a cleanup removed the index. Never the upload
manifest, which records only the contexts an upload named, which is
exactly how a directory source stayed invisible.

The upload keeps the bounded half and records the rest: an `ingest_job`
per covering context, drained after the response and on every worker
poll. Between empty and refill the path is absent from those contexts,
which is recoverable and, unlike a stale answer, honest.

The queue takes this branch's own `service.fs.path_lock`, on the same key
an upload takes, and re-reads the generation inside it — waiting for a
lock is when a replacement is most likely to have happened. A worker that
cannot get it stands aside without spending an attempt. Jobs are
generation-checked, collapse onto one pending slot holding the newest,
retry on a schedule rather than immediately, and carry a lease so a
process killed mid-job returns its work instead of stranding it.

Conversations' implicit indexes stay outside all of it, on both sides:
§19.5 scopes an attachment to the chat that received it, so the coverage
query excludes them exactly as the invalidation already did.

Two tests here asserted the old end state — the path absent from the
covering context — which described the code accurately and the intent
inaccurately. They now assert the path is still described, and that what
it says is the current generation.

Fast lane: 2734 passed, 26 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
`context_id` stayed in `run_job` after the body moved into
`_reindex_under_lock`, which the CI linter rejects (F841). CI runs
`ruff check liminallm/ --select=E,F,W,I --ignore=E501`, which is a
different selection from `make lint` — notably it also checks import
order, so it is the command to run before claiming lint is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Replacing a file's bytes changes its generation, not its coverage

Closes the silent coverage loss this branch documented: emptying every
covering context on replacement is correct but only half a correction,
because a context that keeps its context_source row while its chunks are
gone has lost the file rather than been told about it.

context_source is the single authority for coverage. The upload keeps the
bounded half under the publication lock and records an ingest_job per
covering context for the rest; the queue takes that same lock, on the
same key, and re-reads the generation inside it.

Fixes test_a_source_rooted_above_the_file_still_serializes, this branch's
only remaining CI failure.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c8d085b. Configure here.

Comment thread liminallm/api/routes.py
The named context is left out of the enqueue loop because it is about to
be ingested in the request. When that ingest fails, what is left behind
is a `context_source` row saying the context covers the path and no
chunks describing it — a context covering a file it says nothing about,
which is the coverage loss this queue exists to prevent, arriving
through the one branch that does not use it.

Reintroduced by this tranche, not pre-existing: before the source row
was written there was no durable claim to strand, so the manifest
omission was the whole story.

The failure path now records the re-read alongside emptying the stale
chunks. The worker's poll is what refills it, since the request aborts
and nothing it scheduled will run. Attachments are excluded exactly as
they are where the source row is written — none is written for one, so
nothing claims coverage.

Witnessed, and the witness was checked against the unfixed code: without
the enqueue it fails on the pending-job count, which is the assertion
that names the defect.

Fast lane: 2735 passed, 26 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
@yellowman
yellowman merged commit a5a1d19 into main Aug 24, 2026
7 checks passed
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.

2 participants