fix: third review round — the release pipeline, the rate limiter, the audit log, and a fetch that ate your tags - #589
Merged
Conversation
added 3 commits
September 1, 2026 15:36
…ting user tags Two findings from the third review round, both in code no earlier round had reached. release-plz.yml: the npm publish loop reads its package list from stage-npm.sh through a pipeline ending in sort -u, which exits 0 on empty input. set -euo pipefail therefore protects nothing: a grep that matches nothing (a reformat of the MAP block, a delimiter change) gives an empty word list, the loop runs zero times, and the step falls through to publish the wrapper alone -- green, with every platform binary missing. npm install doiget-cli would succeed while its optionalDependencies fail to resolve. The doiget-* glob this replaced failed LOUDLY on no-match. Trading that for silence in the step that performs the irreversible publish is the wrong direction, and posture-lint guards the identical grep for the identical reason one file over. Now refuses an empty list and a count that is not 4; probed all three cases. fs_store.rs: merge_metadata protected oa_status and license under ADR-0056 and left tags, collections and annotation to the incoming side, which for a fetch is always empty -- all three orchestrator construction sites hard-code Vec::new() / None. So doiget tag X --add priority followed by any doiget fetch X discarded the tag silently. Same loss the adjacent fix closed, on the fields where it is the user's own data. The policy is an explicit UserFields parameter rather than preserve-when-empty, because tag --remove and annotate --clear legitimately mean the empty value; collapsing the two would make removal a silent no-op. Store gains write_user_authored for those four call sites; write keeps the fetch semantics. Also: the two bare grep -c assignments in posture-lint could die before their own ::error::, the same guard applied twice already in that file. Signed-off-by: Sota Shimozono <souta.shimozono@gmail.com>
…l call The MCP server built both fresh inside every tool handler. Consequences, all in code no earlier review round had reached. RateLimiter's pacing state -- the rolling global window and the per-source next-allowed instants -- lives in its own Arc<Mutex<..>> fields, so it paces only the calls that share the instance. Thirteen handlers each got an empty per_source_next and no memory of the last call, so arXiv's 3 s spacing, which docs/LEGAL.md treats as an obligation rather than politeness, was not enforced between MCP calls at all. The type calls itself "process-wide"; nothing made it so. ProvenanceLog::open documents the same contract in its own words: the session_id "MUST be a 26-char ULID generated once per process", and a long-lived handle reuses it. Opening per call broke that thirteen times over, and worse: open seeds (next_seq, last_hash) by reading the file, so two overlapping calls both read the same state and both append rows claiming the same ts_seq with a prev_hash that does not match the row actually before them -- which is what audit-log --verify reports as a broken chain. Server now owns an Arc<RateLimiter> and a OnceLock<Arc<ProvenanceLog>>, opened lazily because Server::new is infallible. RateLimiter::new, Ulid::generate and ProvenanceLog::open each appear exactly once. Asserted by pointer identity, because that is the property; injecting a per-call limiter makes the test fail. Also in this batch: FetchError::Http(_) => NetworkError was a wildcard over all eight HttpError variants, and NetworkError's disposition is retry_after. Six of them cannot change on a retry: an allowlist refusal, an http:// downgrade, a size cap, a wrong content type, an unregistered source key and a malformed header. The mapping every surface routes through was giving the advice ADR-0055 exists to stop giving, and the DenialContext impl 100 lines below already matched all eight. Now exhaustive. One existing assertion changed with it: UnknownSource had been pinned to NetworkError, which recorded the wildcard rather than a decision. It is a wiring fault -- the caller asked HttpClient to fetch for a source it was never given -- and it is the error the TDM reproduction in #462 actually hit, which is part of why that read as a transport problem. resolver_cache's ten functions and openalex::describe_locations were pub with no caller outside doiget-core and no entry in docs/PUBLIC_API.md, so they were accidental semver commitments -- including the on-disk cache layout they encode. This cycle had doubled that surface by adding the _with_options half. Narrowing to pub(crate) is what revealed production calls none of the five plain wrappers; read and write have no caller anywhere, not even a test, and are deleted. pub had been keeping the dead-code lint quiet. 927 tests on oa-only, 1044 with the tdm features. Signed-off-by: Sota Shimozono <souta.shimozono@gmail.com>
Two advisories from the same review round. resolver_cache used a plain std::fs::write. A reader racing it sees a half-written file, toml::from_str fails, and the entry degrades to a miss -- safe, per the module's best-effort contract, but it is a re-fetch nobody asked for and a debug! line that reads like corruption. atomic_write (tmp, fsync, rename) already existed one module over; it is now pub(crate) and both write the same way. safekey_from_metadata_filename mints a Safekey straight from a directory listing. The safety argument is that the filesystem only holds names which already passed guard_safekey at write time -- true today, and a claim about the world rather than something the type enforces, which is the shape this release keeps finding. A debug_assert catches a future write path that skips the guard in the test suite instead of in whatever reads the store next. Signed-off-by: Sota Shimozono <souta.shimozono@gmail.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
github-actions Bot
added a commit
that referenced
this pull request
Sep 1, 2026
fix: third review round — the release pipeline, the rate limiter, the audit log, and a fetch that ate your tags 9a5c8c6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Third review round on the 0.8.13 release. Lands on
next, so it becomes partof #580.
The first round's Rust reviewer and comment auditor both died on a session
limit before reporting, and async/concurrency was never covered by hand
either. So the bulk of this release — the 79 commits accumulated on
nextsince 0.8.12 — had never had a completed Rust review. This round pointed six
agents at exactly that. Every finding below is from code no earlier round had
reached.
The release could have published a wrapper with no binaries
.github/workflows/release-plz.yml— the npm publish loop reads its packagelist through a pipeline ending in
sort -u, which exits 0 on empty input.set -euo pipefailtherefore protects nothing: agrepthat matches nothing(a reformat of
stage-npm.sh's MAP block, a delimiter change) yields an emptyword list, the loop runs zero times, and the step falls through to publish the
wrapper alone — green.
npm install doiget-cliwould then succeed whileevery platform
optionalDependencyfails to resolve.The
doiget-*glob this replaced failed loudly on no-match. Trading that forsilence in the step that performs the irreversible publish is the wrong
direction — and
posture-lint.ymlguards the identicalgrepfor theidentical reason one file over, citing this exact failure mode. The guard had
been applied to the check and not to the publish.
Now refuses an empty list and a count that is not 4. Probed all three cases.
A fetch silently ate the user's tags
merge_metadataprotectedoa_statusandlicenseunder ADR-0056 and lefttags,collectionsandannotationto the incoming side — which for a fetchis always empty, because all three orchestrator construction sites hard-code
Vec::new()/None.Same loss the adjacent fix closed, on the fields where it is the user's own
data rather than a re-derivable reading.
The policy is an explicit
UserFieldsparameter rather thanpreserve-when-empty, because
tag --removeandannotate --clearlegitimatelymean the empty value — collapsing the two would trade one silent data problem
for another.
Storegainswrite_user_authoredfor those four call sites;writekeeps the fetch semantics. Both directions are tested.The MCP server was not pacing arXiv, and could break its own audit log
build_fetch_context()constructed a freshRateLimiterand a freshProvenanceLoginside every one of thirteen tool handlers.RateLimiter's pacing state is instance-local, so each call got an emptyper_source_nextand no memory of the last: arXiv's 3 s spacing, whichdocs/LEGAL.mdtreats as an obligation rather than politeness, was notenforced between MCP calls at all. The type calls itself "process-wide";
nothing made it so.
ProvenanceLog::opendocuments the same contract in its own words — thesession_id"MUST be a 26-char ULID generated once per process". Opening percall broke that thirteen times over, and worse:
openseeds(next_seq, last_hash)by reading the file, so two overlapping calls both readthe same state and both append rows claiming the same
ts_seqwith aprev_hashthat does not match the row before them — which is whataudit-log --verifyreports as a broken chain.Servernow owns one of each.RateLimiter::new,Ulid::generateandProvenanceLog::openeach appear exactly once. Asserted by pointer identity,because that is the property; injecting a per-call limiter makes the test fail.
Six deterministic failures were advertised as retriable
FetchError::Http(_) => NetworkErrorwas a wildcard over all eightHttpErrorvariants, and
NetworkError's disposition isretry_after. An allowlistrefusal, an
http://downgrade, a size cap, a wrong content type, anunregistered source key and a malformed header cannot change on a retry. The
mapping every surface routes through was giving the advice ADR-0055 exists
to stop giving; the
DenialContextimpl 100 lines below already matched alleight variants.
One existing assertion changed with it:
UnknownSourcehad been pinned toNetworkError, which recorded the wildcard rather than a decision. It is awiring fault, and it is the error the TDM reproduction in #462 actually hit —
part of why that read as a transport problem.
Accidental semver surface, and the dead code it was hiding
resolver_cache's ten functions andopenalex::describe_locationswerepubwith no caller outside
doiget-coreand no entry indocs/PUBLIC_API.md—accidental commitments under the 0.x guarantee, including the on-disk cache
layout they encode. This cycle had doubled that surface by adding the
_with_optionshalf.Narrowing to
pub(crate)is what revealed production calls none of the fiveplain wrappers.
readandwritehave no caller anywhere, not even a test,and are deleted; the rest are
#[cfg(test)], which is what they are.pubhadbeen keeping the dead-code lint quiet.
Advisories
resolver_cachewrote with a plainstd::fs::write; a racing reader saw ahalf-written file and degraded to a miss.
atomic_writealready existed onemodule over.
safekey_from_metadata_filenamemints aSafekeyfrom a directory listingon the argument that the filesystem only holds guarded names — true, and a
claim about the world rather than something the type enforces.
debug_assertcatches a future write path that skips the guard.
Verification
Local,
x86_64-pc-windows-gnu:--features oa-only— 927 passed, 0 failed, 1 ignored--features oa-only,metadata,tdm-aps,tdm-elsevier,tdm-springer,tdm-ieee— 1044 passed, 0 failedcargo clippy -D warningsacross all three feature sets — cleancargo fmt --all— cleanEach guard was proven by breaking it: a per-call rate limiter fails the sharing
test, and the publish guard was probed against an empty list, a reformatted MAP
and a dropped platform.
Still open
Not fixed here, and worth a decision before the tag:
Store::read/writedosynchronous filesystem I/O — including a lock poll with
std::thread::sleepupto a 5 s budget — directly inside
async fns, with nospawn_blocking. Underthe CLI's batch
JoinSetor a long-running MCP server that blocks a tokioworker rather than yielding. Pre-existing, newly exercised by call sites this
release added.