1.0.0 - 2026-08-22
Release Notes
Added
-
groove doctorsays what an old.kb-mcpignoreleft in the index.
ADR-0007 renamed the
project with no aliases and no automatic migration, so an ignore file under the
old name is not read and stops excluding anything at all. What comes back into
the index is whatever the two gates that still apply admit — the current
exclusion rules, and whether[parsers].enabledopens that extension. Nothing
said so.Two findings, both warnings.
indexed-despite-legacy-ignorenames the indexed
documents the old file matches that the current rules do not exclude — real
paths, up to five of them, not a count.legacy-ignore-not-examinedis what
comes out when the check could not be completed — the file is there and cannot
be read, or the filesystem will not say whether it is there at all: a check
that could not run is not a check that found nothing, and reporting the two
the same way is how a clean bill of health stops meaning anything. Its wording
claims only that, with what was actually observed carried alongside, because
one of those two cases never established that the file exists.The remedy has three branches, because the destination has three states. With
the name.grooveignorefree, the fix is a rename. With a working ignore
file there, it is copying over the lines you still want — never an overwrite.
And when the name is not free but nothing is being applied from it — a
directory, a refused link, a file over the cap, or a name the filesystem would
not answer for — the remedy says that and sends you to the destination first,
because neither renaming nor copying into it produces an ignore file and the
documents just reported would stay indexed either way. No branch asks for a
deletion: a knowledge base whose new file is broken looks identical from here,
and the old file may be the only copy of the patterns."Free" throughout means the filesystem said so, not that it failed to say
otherwise. Whether a name is free, taken, or unanswerable is one three-valued
question with one implementation, asked by both the check and the remedy.The old file goes through the same
ExclusionRulesthe index walk asks, so
this is not a second implementation of the exclusion rule; and it is asked
about documents that are in the database, which is what lets the finding
carry paths instead of the observation that a filename exists. The check is
for the migration and is due to be removed in 1.1.0, in one file. -
All four front pages open with a banner —
README.md,README.ja.md,
and the twodocs/indexpages. It draws what separates this from a plain
vector store: a semantic path and a lexical path converging on one node, and
ranked results leaving it for an MCP client — the RRF fusion of the
sqlite-vec and FTS5 legs.It carries no words. A wordmark would repeat the
# GrooveSeekheading
directly beneath it, a caption strip at this width is illegible on a phone,
and text drawn into an image is text no screen reader and no translation can
reach. Thealtattribute carries the meaning instead, and it is written per
language.Light and dark, chosen by
prefers-color-scheme, like the logo and the
screenshot before it. WebP rather than PNG — 33 KB against roughly 1 MB, for
an image every visitor loads. The PNGs are committed as a fallback, and
assets/README.mdsays what to swap.That file also stops repeating a claim it could never check. It recorded
a report thatraw.githubusercontent.comserves.svgastext/plain,
which is why nothing here references an SVG; the report could not be measured
when it was written because the host answered 429 all session. Measured now
against this repository's own files, the host answersimage/svg+xml— and
image/webpfor the banner. Whether GitHub's Markdown renderer would then
display an SVG is a second question, about itscamoproxy, and is still
untested; the PNGs stay for that reason rather than the old one.The 56-pixel logo those four pages used to open with is gone, since the
banner carries the mark at its centre. No page embedsassets/logo-*any
more —/uinever did, drawing the diamond as a character and its favicon
as an inlinedata:URI. The files stay: the SVGs are where the mark is
defined, which is what the banner's colours were measured against.
Changed
-
groove status,groove service statusandgroove service listprint
their results on stdout. All three used to write everything to stderr, so
groove status | grep Documentsreceived nothing — the pipe looked like it
would work and silently did not.
ADR-0008 declared the
stdout/stderr split frozen but left these three explicitly unsettled;
ADR-0010
settles them, because after 1.0.0 moving them would be a major release.A caller redirecting with
2>&1is unaffected. A caller capturing stderr
alone now reads nothing where it used to read the counts.What stays on stderr is everything that is not an answer:
index's progress,
the confirmations fromservice install/uninstall/tray-install/
tray-uninstall, andstatus's "No index found" — which reports an
inability to answer and leaves stdout empty. The wording of these lines is
still not stable; only the channel is.groove doctor --format jsonremains
the machine-readable route todocumentsandchunks. -
One implementation now answers
HostandOriginwherever they are
asked,/mcpincluded. The two questions had four implementations —
rmcp's for/mcp, and GrooveSeek's own for/healthzand for the admin
routes — fed one list and expected to agree. Measured, they did not: with
the identical allow-list,
Host: user:pw@127.0.0.1:PORT,@127.0.0.1:PORT,127.0.0.1@localhost,
127.0.0.1:65536andlocalhost:abcwere accepted on/mcpand refused
next door, and the admin refusal bodies were missing theForbidden:
prefix the others carried./mcptherefore refuses those five spellings now, where it used to
answer 200. All are malformed Hosts that no browser or MCP client
constructs, and the change can only refuse more, never less: no spelling was
found that rmcp refused and GrooveSeek accepted. Its refusal wording is
unchanged. See ADR-0009.What each route is compared against has not changed:
allowed_origins
still reaches/mcpand the admin routes only, the admin routes still match
Hostagainst a loopback-only list of their own, and/healthzstill
validatesHostalone and only whenhealthz_public = false.Two consequences worth having on their own: a refused request no longer
reserves a session seat before being turned away (measured with
max_sessions = 1, a foreignHostused to get429and now gets403),
and refusal logging on/mcpis bounded — rmcp wrote one line per refusal
with no limit, and the gate carries the same one-line-a-minute budget the
session limit has used since v0.27.0. -
An admin refusal no longer repeats the header it refused. The 403 body
readHost 'kb.example.lan' not in admin allow-list, echoing caller-supplied
bytes back;/healthznext door and rmcp on/mcpboth say only that the
header was not allowed. This surface now says the same, and the rejected
value goes to the log instead, where the operator who can act on it will see
it. -
rebuild_indexrefuses a second call while one is running. A rebuild
re-embeds the whole corpus while holding the embedder and the database, so a
second call never ran beside the first — it queued behind it, withsearch,
get_documentand/uiunavailable for the sum of the two. Nothing bounded
how many could queue: the session gate lets every non-initializerequest
past without taking a seat, somax_sessionsdid not apply, and
spawn_blockingcannot be aborted, so closing the connection did not stop one
either. A few dozen bytes of request bought a full re-vectorisation, as many
times over as the caller liked.The second caller now gets an error naming how long the running rebuild has
been going, instead of a wait with no upper bound. The bound is on the MCP
tool:groove indexruns in its own process and still overlaps a served
rebuild.
Security
-
/uiand/api/admin/statusvalidateOrigin. The admin routes are
served by GrooveSeek rather than by rmcp, so the check that guards/mcp
never reached them: any page open in the operator's browser could call them
cross-origin. Nothing leaked — they areGETs, and a foreign page cannot read
a response that carries no CORS headers — but nothing kept that true either,
and the first admin route with a side effect would have inherited the gap.
They now compare against the same effective[transport.http].allowed_origins
rmcp gets, with a test that asks both surfaces about one origin and requires
the same answer. As on/mcp, a request carrying noOriginstill passes,
so the tray,curland the page's own status poll are unaffected. -
Admin refusals no longer write a log line each. Bound to a non-loopback
address, the peer check refuses before anything else looks at the request, so
a stream of cheap requests wrote an unbounded stream of lines to the daemon's
log file. The session gate on/mcpalready thinned its refusals to one line
a minute, carrying the count of what it stood for; the admin gates now share
one such budget between them. -
/uiis served with a Content Security Policy andnosniff. The policy
isdefault-src 'none'plus exactly what the page uses — its inline
<script>and<style>, itsdata:favicon, same-originfetch,
frame-ancestors 'none'— so an external script or stylesheet added to the
page fails there rather than loading. Both headers are attached outside the
two gates, so refusals carry them too. -
h2was updated 0.4.13 → 0.4.18 to clear RUSTSEC-2026-0258. It is a
transitive dependency, reached throughhyper— and so throughaxum, which
the HTTP transport is built on — and throughreqwest, which the model
downloader uses. No GrooveSeek code calls it directly.
Removed
-
groove validate --strictis gone. It was accepted and discarded — the
documentation said so, which made it a promise the binary did not keep: a CI
job that passed--strictbelieved it had asked for stricter checking and
had not. Giving it meaning after 1.0.0 would change what an accepted flag
does, which is a major release; removing it now costs nothing and adding it
back when[options].allow_unknown_fieldsexists is a minor one. Scripts
passing it will now fail to parse, which is the visible version of what was
already happening silently. See
ADR-0010. -
kb.pathis gone from/api/admin/status, and from/ui's status band.
It held the knowledge base's absolute path, which on Windows reads
C:\Users\<name>\...— the operator's account name, in a JSON body and in
every screenshot of the page most likely to end up in a bug report. Nothing
consumed it: the Windows tray readsdaemon.pidandindexing.active, and
the page's own comment claiming the tray needed the field was wrong. What
identifies a knowledge base to the person looking at it —kb.documents,
kb.chunks,kb.model— is unchanged. ADR-0008
puts this surface outside the 1.0 freeze, which is why the removal happens
now rather than during 1.x.
Fixed
-
groove service installon macOS reports an error instead of panicking.
Twounwrap()calls in the LaunchAgent backend. Neither is reachable on a
normal system —plist_pathalways returns a path under
~/Library/LaunchAgents, and a home directory is normally UTF-8 — but
install()returns aResultand its caller already handles one, so a panic
was the wrong way to say either had gone wrong. -
Every word GrooveSeek writes to stderr is ASCII, and a test now says so.
A Japanese Windows console is CP932, where an em dash or a kana arrives as
mojibake, so AGENTS.md has required diagnostics to be ASCII since
v0.25.0. Nothing checked it, and review kept finding the same defect one
instance at a time.43 messages contributed characters that console cannot render: the whole
ofgroove service install/uninstall/statuson all three platforms,
which spoke Japanese and now speaks English; em dashes ingroove tune's two
notes and two warnings, in threegroove indexPDF refusals, in the two
[parsers].enabledrefusals, in a watcher diagnostic, in the poisoned-mutex
warning and in both PowerShell decoding errors; and thegroove evalnote
this began with. Only the wording changed — an error that named a file still
names the same file.The rule is about the words a message chooses, not the data it names, so a
note called日本語のノート.mdstill comes out ofgroove indexas itself.groove index --progressdraws its bar with=>-instead of eighth-block
characters. The bar is drawn to stderr by indicatif, which made it the one
place the rule was broken by a library's rendering rather than by a message —
and the one that would have looked worst on the console the rule is about.tests/diagnostics_stay_ascii.rswalks the workspace source at run time
instead of naming files, so a new file is covered by existing rather than by
being remembered. -
The golden query file and the eval history are read with a bound. Both
default to living inside the knowledge base and both were read whole with no
cap — a stray binary on one of those names was parsed as-is. They now go
through the same route.grooveignoretakes, so a hard link, a FIFO, or
something that is not a regular file is a refusal rather than an unbounded
read. Symlinks are refused on Unix only, which is that route's existing
and deliberate scope: making one on Windows needs a privilege this threat
model's attacker does not have, and refusing reparse points there would
refuse every OneDrive and Dropbox placeholder.The size caps differ, because the two files differ. The golden is written
by a person, and 1 MiB is far past what it is for. The history is written by
groove evaland carries every golden query with its hits, once per retained
run: measured, this repository's own 25-query golden produces 0.598 MiB
after ten runs, and the same golden atlimit = 20produces 1.049 MiB. A
megabyte is inside its ordinary range, so the history's cap is 64 MiB.A history that cannot be read now stops
groove evalinstead of reading as
empty. An empty history is not inert — the new run is pushed onto it and
saved back over the same path — so answering "empty" for a file that is
intact and merely unread would replace every baseline with one run, and
--fail-on-regressionwould then pass without having compared anything.
Content that was read and does not parse still starts fresh, unchanged: those
bytes held no baseline to lose.--no-historyskips the file entirely.A dangling symlink counts as a file that is there, for the same reason.
Path::existsfollows the link and answers about the target, so a history
kept on a volume that is not mounted read as absent — and absent is the
answer that leads to the new file being renamed over the link.And what
evalwrites is now bounded by what it will read.history_size
bounds the number of runs kept and bounded nothing about the size, so a large
golden or a high--limitcould write a history the next run refused —
groove evalproducing a file only it could no longer read. Saving now drops
the oldest runs until the result fits, warning when it does; the run the
next diff compares against is the last to go. If a single run does not fit,
the save reports that instead of writing it, and says to reduce the golden or
the limit, or to pass--no-history.groove tunereached the golden through a different function thangroove evaldid, so the two are now one:evalno longer keeps its own copy of the
read-and-parse, and a bound added to either is a bound on both. -
A refusal printed to stderr carried an em dash.
AGENTS.mdkeeps stderr
ASCII so a CP932 console does not render it as mojibake, and
Refused::log_line's "not a regular file" message had two — printed from
groove index, from the.grooveignorereader, and fromget_document,
since each was written. A fourth caller inevalis what got it noticed.The path in that line is still interpolated as-is: a note named in Japanese
makes the message non-ASCII whatever the wording is, and escaping it would
hand the reader\u{65e5}\u{672c}where they expected a filename. That
trade-off is left where it is, and the test that pins the wording says so. -
The documentation told you to type a prompt command no shipped recipe
produces.docs/mcp-tools.md, its Japanese counterpart andprompts.rs
all rendered the prompt path as/mcp__groove__<name>, but a client builds
that path from the key you wrote in your.mcp.json, and all four
bundled recipes call the serverai-knowledge. Anyone who copied a recipe
was given the wrong command; anyone who chose their own name was given a
different wrong one. The path is now written/mcp__<server>__<name>with
the note that<server>is yours, and a test rejects any concrete name
spelled into that position. -
docs/index.mdhad no Japanese counterpart, though the language policy
andCorpus's own documentation both said every page underdocs/has one.
The landing page's navigation text — the row describing each page — was
English-only. docs/index.ja.md now exists and the two
link to each other, and a test walksdocs/in both directions so a page
published in one language cannot go missing from the other again. -
An
allowed_originsentry without a scheme refused every browser, in
silence.[transport.http].allowed_hoststakes a barehost:port— its
parser falls back to reading the whole string as a host — and the key beside
it looks identical but requires a scheme.allowed_origins = ["127.0.0.1:3100"]was therefore dropped by rmcp before any comparison,
which left Origin validation switched on with nothing to match: every
request carrying anOriginheader got 403, including/ui's own search.
Nothing warned. The "this list names no loopback origin" check strips the
scheme optionally, so it read the host as127.0.0.1and concluded the list
was fine.Such a config is now refused at startup, with a message quoting the entry and
naming the key that does accept that spelling. A config that used to start
will now stop — with an error you can act on, rather than a server that
answers 403 to everything. An empty list is still accepted: that one is the
documented off switch.The check runs where the list is consumed — resolving an HTTP transport —
and nowhere earlier, so it cannot refuse a value that was never going to be
read. Two earlier placements could. Checking during config loading meant a
groove.tomlin a cloned repository could stop every command, because a
discovered config'sallowed_originsis discarded as untrusted before it is
ever used. Checking after that, but still during loading, meant a typo in an
HTTP-only setting stoppedindex,search,validateand a stdio server —
none of which read the key.The same list carries a second cost, which cannot be removed. rmcp matches an
entry with no port against every port on that host — wider than RFC 6454,
where an omitted port means the scheme's default. Writing the port in does
not fix it: the browser omits the port too, sohttp://127.0.0.1:80would be
compared against a request that carries none and would refuse the very page
it exists for. At port 80 the derived default therefore has to include the
port-less spelling, and a page served from any other local port can reach
/mcp; the server now says so at startup. An entry you write yourself is
left alone, becausehttps://kb.example.comis the shipped proxy recipe and
means 443. -
--path-globsplit its value on commas, which no glob survives. A glob's
own syntax uses commas —docs/{a,b}/**is one pattern — and the flag cut it
in half, leavingdocs/{ato be rejected as an unclosed alternate group. The
MCPpath_globsparameter takes an array and never had the problem, so the
same value worked over one surface and failed over the other: exactly what
aligning the two was meant to prevent.docs/usage.mdhad always described this flag as (repeatable) and never
as comma-separated, so the code was the side that disagreed.--tag-any,
--tag-alland--exclude-pathskeep their commas, which is their documented
contract — none of those values can contain one meaningfully.If you were passing several patterns in one
--path-globseparated by commas
— undocumented, and it would have broken on any pattern containing braces —
pass the flag once per pattern instead. -
groove-schema.toml.exampleoffered two field types the schema refuses.
The comment listed"integer"and"date"among itstypevalues;
schema.rsrejects both at compile time, because frontmatter is held as
strings throughout and neither is implemented. Anyone following the template's
own documentation met a schema that would not load. The body was always
right — it already expresses a date as a string with a pattern — so it was the
comment above it that was wrong. The file is also now in English, matching
groove.toml.example; it was the last shipped example still in Japanese.
Internal
-
server.rsbecame four files. The search half isserver/search.rs,
document reading isserver/documents.rs, and the corpus side of thekb://
resource surface isserver/kb_uri.rs. What stayed behind is the tool surface
itself — the#[tool_router]/#[tool_handler]impls, the parameter and
response types, andmod tests.No behaviour change: the bodies moved byte-identical and in the order they
were already in. The only thing that changed was visibility, and only where
the parent still calls or names something — eachpub(super)was named by
cargo checkafter a move that widened nothing, rather than chosen in
advance. -
The eval golden has five questions with two right answers, and the quality
gate stopped averaging them together with the other twenty-five. No
behaviour change; the last of the 2026-08-18 audit's test-coverage rows.Every golden query named exactly one document, so nothing measured whether a
search returns several relevant documents when several are relevant. The five
new ones are built on pairs the fixture corpus already had — the two release
documents, the two database ones, the two authentication ones, the pair that
both say when to act before you know why, and the pair an incident write-up is
assembled from. No documents were added: the golden is never copied into the
corpus, so a new query cannot change the candidate set the other queries are
scored against, and the twenty-five re-measure identically.The two groups are averaged separately, because a query with two right
answers caps recall@1 at 0.5 and blending them moves the headline number by an
amount that depends on how many such queries the golden holds rather than on
whether retrieval got worse — measured, adding five drags the blend from 0.92
to 0.85 on BGE-small with nothing changed. The four existing floors keep their
values and are now compared against the group they were measured over.On the new group recall@1 is 0.50 on both models — its ceiling — and MRR is
1.000 on both, because every one of the five puts one of its two documents at
rank 1. That is exactly the blindness the queries were added for. recall@5 is
the one that separates the models, 0.80 against 1.00, where on the
single-answer group the same metric separates them 0.96 against 1.00 and was
rejected as a gate for that reason. -
A hybrid search is now held to a fixed number of SQL statements, and the
two bounds a graph walk is built on are property-tested. No behaviour
change; two more of the gaps the 2026-08-18 audit named.The performance guard this project had compared wall-clock as a ratio. That
is right for what it guards and wrong as the only one: timing on a shared
runner is noise, so it is#[ignore]d, runs once a night, and its threshold
has to be loose enough to survive that runner. Counting statements instead
costs milliseconds, gives the same answer on every machine, and runs on every
pull request — and it catches the regression a stopwatch notices last, a
query issued per candidate, per result or per document.search_hybridissues two statements: one for the vector leg, one for
the full-text leg, with the fusion done in Rust over what they returned. Two
at 50 chunks and at 500, asking for one result and for ten. Counting every
statement SQLite traces instead gives 175 and 769 — FTS5 reading
fts_chunks_docsizeonce per row it scores for bm25, which this project
neither wrote nor wants to change, and a gate over that number would have
been red the day it landed.The graph walk's node budget and seed cap take whatever an MCP client sends,
including0andu32::MAX. Both are now generated rather than sampled, and
the asymmetry at zero is stated as the rule rather than as two examples:
max_nodes = 0is a coherent request and is honoured, while
max_seed_chunks = 0would make an answer indistinguishable from "no such
document" and becomes 1..grooveignore's!is generated too, across nine spellings, every
hardcoded name, and three depths. Breaking the rule three ways showed what
that adds: two of the three breakages are caught by the example tests as
well, and the third — giving!gitignore's own precedence, so it wins
where an earlier line ignored something — passes every example and fails
both properties, because each example spells its negation!namein a file
with no ignore line at all. The shrunk counterexample is*followed by
!.git. -
The two Origin startup warnings are now checked for what they say and when
they fire. They are the only thing an operator gets in two configurations
that otherwise look like they are working — one where Origin validation is
off, and one where/uiis served but its search is refused with nothing on
screen to say why. Both are inline conditions rather than predicates, so
nothing could reach them: neither the wording nor the trigger was tested.A third test asserts they stay quiet on an ordinary configuration. A
warning that fires always would satisfy the other two and teach an operator
to ignore the line.The shared stderr drain in
tests/commonkeeps its lines now rather than
discarding them. It has to be that one: a second reader on the same pipe
would take lines away from the first, and the first is where the bound
address comes from. -
Nightly gained a macOS leg, and the launchd backend gained end-to-end
tests.install,statusanduninstallare reached only from#[ignore]
territory, and nightly ran Linux and Windows — so on macOS none of them had
ever been executed by anything. That is the same gap AU-09 closed for
Windows, left open for the third platform.Measured on a GitHub-hosted runner before writing any of it:
launchctl managernameanswersAqua, so thegui/<uid>domain the backend bootstraps
into exists there — which is not a given, sincelaunchctl(1)says a GUI
domain is created at GUI login and other CI fleets report
Bootstrap failed: 125for exactly this.bootstrapexits 0,RunAtLoad
really starts the program, andbootoutcleans up.The skip list for the big-model tests moved out of the matrix and into the
step that uses it, now that two legs share it: a long string written once per
leg is a string that gets updated once. -
Four gaps the 2026-08-18 audit named now have tests. No behaviour change.
groove service uninstallandstatustake--service-name; the instance
name used to be positional on both, and nothing checked that the old spelling
was refused rather than quietly ignored — which would have left
groove service uninstall workremoving the instance calledgroove.groove searchreadsrerank_by_defaultfromgroove.toml. The decision
was a pure function with its own tests, none of which could see whether the
command line handed it the key at all. The new test tells the two apart by
the shape ofscore: an RRF sum is bounded by2 / (rrf_k + 1), and a
cross-encoder logit is not on that scale.Three boundary inputs: an empty knowledge base, a query far past the 1 KiB
the MCP surface refuses, and a query made only of characters outside the BMP.
The long-query test also records where the real ceiling is on that surface —
Windows caps a whole command line at 32,767 characters, so a 64 KiB query
fails before the process starts. -
docs/stability.mdwrites out what a search answers with. It had
promised since v0.27.0 that "every field documented today keeps its name,
type, and meaning" while documenting none of them — a promise with no subject,
in the document that says what 1.0 freezes. All 28 fields of thesearch
response are now listed with their type and presence rule, for the MCP tool
and forgroove search --format json, taken from the types rather than the
prose. Three tests hold the table to the response in both directions and hold
the Japanese table to the English one.The tests found the hole on their first run: the table stopped at
match_spansandexpanded_fromwithout describing what is inside them, so
five fields sat outside the freeze while looking covered.low_confidenceis frozen as a field, not as a judgement. The key is
present and boolean; the formula, the default threshold, and which queries
trip it are explicitly outside the freeze. Measured, it tracks how much the
fused scores are distributed rather than whether the answer is right — on a
corpus where all 25 golden queries were answered correctly at rank 1 it still
fired on 14 of them, and reranking can switch it off outright — cross-encoder
logits often make the mean negative, and the sign check then answersfalse
whatever the spread was (measured:falsefor all 25 withbge-v2-m3). A
falsetherefore tells a caller nothing when a reranker ran.
docs/filters.mdrecords both limits. No behaviour changed: no corpus-independent threshold exists, so
moving the default would have swapped one arbitrary number for another. -
Two
chunks_exact(2)calls in the PDF parser becameas_chunks::<2>().
Rust 1.98.0 stabilisedclippy::chunks_exact_to_as_chunks, and CI installs
stableunpinned, so the lint arrived on its own and turned-D warnings
into a failure on code nobody had touched.as_chunks::<2>().0splits
identically — the trailing odd element goes to.1the waychunks_exact
left it in.remainder()— and states the pair width in the type, which is
what both call sites meant. -
docs/usage.mdnow documentsRUST_LOG. Raising the log level is the
first step in diagnosing a wronggroove.toml, aget_best_practicethat
reports "not found", or a query that matches less than expected — and the
variable appeared nowhere a user would look. The new section says which
target to raise, what each level adds, and what is not behind it: the
chosen config file is logged atinfoalready, andindex's progress does
not go through the logger at all. -
The flag-coverage check no longer reads
docs/decisions/. An ADR
explaining why a flag was removed has to name it, which failed the reverse
direction of the check — and ADRs are immutable once merged, so the failure
could not have been repaired, only worked around.CHANGELOG.mdwas already
excluded for the same reason. This tightens the forward direction rather than
loosening it: a flag named only in a decision record no longer counts as
documented, andevery_long_flag_the_binary_accepts_is_documentedstill
passes, so none was relying on one. -
Three holes in the tests that guard the frozen surface. The flag-coverage
check pooled the English and Japanese documentation into one buffer, so a flag
described in only one language satisfied it — whiledocs/stability.md, the
page that gives "documented" its meaning, is the English one. Each language is
now checked separately; both pass, which is the cheapest moment to make sure
they keep doing so.The pairing tables covered two of the six MCP tools.
rebuild_indexnow pairs
withgroove index, and the tools that have no command behind them —
get_document,get_best_practice— declare their parameters and the reason
they have no second surface, so a parameter cannot appear on any of the six
unnoticed.list_topicsis recorded as the one that takes none at all.And the pairing rule now checks values, not only names: a flag documented as
repeatable must not carry a delimiter, and one documented as a comma list
must. -
Origin validation is now tested through a running server. The check
shipped in 0.27.0 with twenty-five tests, every one of them against the
function that assembles the allow-list rather than against the server that
applies it — deleting the call that hands that list to rmcp left the whole
suite passing. Four tests now bind a server, send real requests, and assert
what comes back: a foreign origin is refused, a request with noOriginis
not, the server accepts its own bound address, and an empty list really does
turn the check off.They bind with
--bind 127.0.0.1:0and read the port back, because the
allow-list is derived from the address the listener received; a test that
supplies the port cannot tell that apart from one that echoes it. And they run
in ordinarycargo test, without--ignoreand without a feature flag, so a
regression fails the pull request that causes it rather than the next nightly. -
/uiand the request it sends are now exercised through a running
server. The page started searching through/mcpin v0.27.0 and nothing
asserted since that the request it sends is one the server accepts — the
existing web UI tests are feature-gated and ignored, which is right for the
ones that build an index and wrong for a check PR CI therefore never runs.
Five tests, none ignored or gated: the page is served, the handshake-free
tools/callit sends is accepted, dropping the protocol header is refused
(which is what gives the previous one meaning),/api/searchis absent from a
shipped server, and/uirefuses a foreignHost.The request is read out of the page, not transcribed: the
fetchtarget,
the method, the headers, and the stringified body with its envelope and
nesting all come fromcallTool. Anything the reader cannot model stops the
test rather than being skipped — a computed target, an unrecognised value, an
option the replay does not implement, a body that is no longer
JSON-stringified — because a shape it cannot read must never be reported as a
shape that matches. A transcribed request passes happily while the page it
claims to describe has changed.A separate assertion pins the page to rmcp's
STANDARD_HEADERS. Both are
needed: measured, a page pinned toLATESTstill gets a result, because rmcp
accepts a handshake-free call on known older versions — so the live test
alone would not have caught the mistake most likely to be made. -
The tests no longer choose the port they tell the server to bind. They
bound127.0.0.1:0, read the number, dropped the listener, and passed that
number on the command line — leaving a window in which anything else starting
a server could take it, with a dozen of them running in parallel inside one
test binary. The helper's own comment called the window theoretical.
tests/mcp_protocol_surface.rsflaked twice in three days.They also captured the server's stderr and never read it, and a pipe nobody
empties eventually blocks the process writing to it — which fits the symptom
seen: a server that answered/healthzand then returned an empty body. The
watcher spawner already drained, with a comment saying why, but only after
/healthzanswered, so the startup window went unread. Reading the assigned
address requires draining from the moment the child starts, so one change
closes both.Three spawners did it, not one: the shared helper, its watcher variant, and
tests/http_transport.rs, which carried its own copy.--portand
pick_free_portnow appear nowhere undertests/, and the reader that finds
the address is shared rather than copied — one parser, so a change to the
server's wording cannot be fixed in one spawner and left in another. The
flake is intermittent, so this is not shown to have fixed it; what is shown
is that two known ways for these tests to interfere with each other are gone. -
No test mutates the process environment any more. One was left: it set
GROOVE_CONFIG_HOME, asserted, and put it back, with a note saying nothing
else mutated the environment beside it. True, and beside the point — the
hazard is not another writer, it is every concurrent reader.
TrustRoots::from_envreads that same variable to decide which directories
are trusted, so a test callingConfig::discover()while this one held it
would have seen/tmp/groove-test-overrideas a trust root, and failed
somewhere else for a reason invisible from where it failed.The judgement now takes its input as an argument —
resolve_config_home_in,
matchingConfig::discover_in, which already had this shape. The same move
applies toenv_dir's rule that an empty value counts as unset: that rule
had no test at all, because reaching it meant setting a variable. It is
dir_from_env_valuenow, and deleting the filter fails a test instead of
none.
grooveseek 1.0.0
Download grooveseek 1.0.0
| File | Platform | Checksum |
|---|---|---|
| grooveseek-aarch64-apple-darwin.tar.xz | Apple Silicon macOS | checksum |
| grooveseek-x86_64-pc-windows-msvc.zip | x64 Windows | checksum |
| grooveseek-aarch64-unknown-linux-gnu.tar.xz | ARM64 Linux | checksum |
| grooveseek-x86_64-unknown-linux-gnu.tar.xz | x64 Linux | checksum |
groove-tray 1.0.0
Download groove-tray 1.0.0
| File | Platform | Checksum |
|---|---|---|
| groove-tray-x86_64-pc-windows-msvc.zip | x64 Windows | checksum |
groove-svc 1.0.0
Download groove-svc 1.0.0
| File | Platform | Checksum |
|---|---|---|
| groove-svc-x86_64-pc-windows-msvc.zip | x64 Windows | checksum |