Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions .github/workflows/posture-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,78 @@ jobs:
shell: bash
# `Formula/doiget.rb` is generated from a release's published `.sha256`
# assets. #247 was closed as completed while four fifths of it had not
# shipped, so the formula is asserted rather than trusted: this fails on
# a hand-edit, a bad checksum, or a tag/version confusion.
# shipped, so the formula is asserted rather than trusted.
#
# What this fails on: a hand-edit, a MALFORMED checksum, a `v`-prefixed
# version. What it CANNOT fail on, because it re-reads the version and
# the checksums from the very file under test: a well-formed checksum
# carrying the wrong value, or a version that is simply stale. It said
# it caught "a bad checksum" and it does not -- nothing here reaches the
# published `.sha256` assets, offline being the point. The stale-version
# half is covered by the release-sync check below; a wrong-value
# checksum is caught by `brew install` and by nothing before it.
run: bash scripts/update-homebrew-formula.test.sh

- name: release-tracking files name one version (#501, #511)
shell: bash
# Four files record "the last published stable", and a stable release
# bumps all four in one maintainer step. Nothing used to hold them
# together, which is how `.claude-plugin/*` sat at 0.8.11 while 0.8.12
# was the shipped release.
#
# What this CANNOT catch, stated so the check does not repeat the
# overclaim it exists to fix: forgetting the step ENTIRELY leaves all
# four at the previous release, mutually consistent, and green.
# Nothing in the repository knows which version is currently published.
# What it does catch is bumping some and not others, and pinning a
# version that was never released -- CHANGELOG.md is the only
# independent source of truth available offline.
run: |
set -euo pipefail
# `|| true` on each is load-bearing. Under `set -euo pipefail` a bare
# assignment takes the exit status of its command substitution, so a
# `grep` that matches nothing -- somebody deletes the `@version` pin from
# .mcp.json, or reformats a manifest -- kills the step HERE, before the
# `::error::` written to explain it. It failed closed, which is right, and
# said nothing, which is the defect this whole job exists to catch. Found
# by review of this change; the same shape was fixed one file over in
# npm/doiget-cli/test/stage-npm.test.sh.
formula=$(grep -oE '^ version "[^"]+"' Formula/doiget.rb | grep -oE '[0-9][^"]*' || true)
plugin=$(grep -oE '"version": *"[^"]+"' .claude-plugin/plugin.json | grep -oE '[0-9][^"]*' || true)
market=$(grep -oE '"version": *"[^"]+"' .claude-plugin/marketplace.json | grep -oE '[0-9][^"]*' || true)
pinned=$(grep -oE '"doiget-cli@[^"]+"' .mcp.json | grep -oE '[0-9][^"]*' || true)
for pair in "Formula/doiget.rb:$formula" ".claude-plugin/plugin.json:$plugin" ".claude-plugin/marketplace.json:$market" ".mcp.json:$pinned"; do
if [ -z "${pair#*:}" ]; then
echo "::error::posture-lint release-sync: no version found in ${pair%%:*} -- the file was reformatted, or .mcp.json lost its doiget-cli@<version> pin"
exit 1
fi
done
echo "formula=$formula plugin=$plugin marketplace=$market .mcp.json=$pinned"
for v in "$plugin" "$market" "$pinned"; do
if [ "$v" != "$formula" ]; then
echo "::error::posture-lint release-sync: release-tracking files disagree. Bump Formula/doiget.rb, .claude-plugin/plugin.json, .claude-plugin/marketplace.json and .mcp.json together (CONTRIBUTING.md, 'one step after a stable release')"
exit 1
fi
done
# An unpublished pin would send every plugin user to a 404. Any `-`
# suffix is a prerelease per SemVer; the first version of this listed
# spellings (`*-beta.*|*-rc.*`) and let `0.9.0-alpha.2` straight
# through, which is the enumerate-the-known-cases mistake this
# release is otherwise about.
case "$pinned" in
*-*)
echo "::error::posture-lint release-sync: .mcp.json pins a prerelease ($pinned); the plugin must point at a published stable"
exit 1
;;
esac
# The one independent check available without a network: a version
# these files claim to track must have a released CHANGELOG section.
if ! grep -qE "^## \[$formula\]" CHANGELOG.md; then
echo "::error::posture-lint release-sync: the tracking files name $formula, which has no '## [$formula]' section in CHANGELOG.md -- they point at a version that was never released"
exit 1
fi
echo "release-tracking files agree on $formula, and CHANGELOG.md has its section"

- name: npm packaging tests (#511)
shell: bash
# The name-list greps above cannot catch a wrong binary name, a
Expand Down
2 changes: 1 addition & 1 deletion .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"command": "npx",
"args": [
"-y",
"doiget-cli",
"doiget-cli@0.8.12",
"serve"
]
}
Expand Down
189 changes: 188 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,197 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`doiget-core` is the only crate with strict semver guarantees during the 0.x line; CLI
flag changes and `doiget-mcp` tool spec changes will be called out explicitly here.

## [Unreleased]
## [0.8.13] - 2026-09-01

### Fixed

- **[mcp] (breaking)** `doiget_batch_fetch` and `doiget_batch_from_bibliography`
reported a **refused paper as `INTERNAL_ERROR` and a 404 as `NETWORK_ERROR`**
(#538, review of #580).

`fetch_paper_fetch_error_envelope` shed a hand-rolled copy of
`From<&FetchError> for ErrorCode` this cycle -- the one ending in
`_ => InternalError`. Two verbatim copies of that match stayed behind in
`build_bibliography_envelope` and `batch_fetch_success_envelope`, one screen
below the fix. So the headline of this release, "an access refusal is not an
internal error", held on `doiget_fetch_paper` and on no other fetch surface:
`NotFound`, `Ambiguous` and the brand-new `NotRetrievable` all fell through
the wildcard, and every `Http` status collapsed to `NETWORK_ERROR`.

The last one is the damaging one. `disposition` is derived from the code, so
a 404 on a batch entry arrived as `retry_after` -- an agent told to keep
retrying a DOI that will never resolve, which is the exact failure
[ADR-0055](docs/DECISIONS/0055-error-disposition.md) exists to prevent. Both
sites call `ErrorCode::from(err)` now, and a test drives the real envelope
builders rather than a re-implementation of them.

- **[cli] (breaking)** `doiget batch` reported a PMID-only bibliography entry as
`INVALID_REF` (#500).

The reporting half of #500 landed on `doiget verify` and the MCP bibliography
tool and not here. The verdict the parser reached was flattened into a
placeholder string (`<unsupported-PMID:...>`) and handed back to `Ref::parse`,
which has exactly one thing to say. So a PubMed-exported `.bib` record with a
valid `pmid` field was still reported as malformed -- the one claim #500
exists to stop doiget making -- and the message described the placeholder
rather than the entry. The verdict travels as a value now
(`BatchEntry::Rejected`), so it cannot be flattened, and the comment claiming
the two arms made "different claims" is no longer false.

- **[cli/mcp] (breaking)** `GraphError::Source` mapped to `NETWORK_ERROR` on the
MCP surface and to the wrapped `FetchError` code on the CLI.

An OpenAlex 404 during citation-graph expansion was `NOT_FOUND` (terminal) on
`doiget graph` and `NETWORK_ERROR` (`retry_after`) on
`doiget_expand_citation_graph`. The CLI doc comment said the two "cannot say
different things about the same error". `From<&GraphError> for ErrorCode` now
lives beside the enum in `doiget-core`, and all three call sites -- the third
was an inline copy that did not even call the helper directly above it -- go
through it.

- **[core]** A 404/410/451 carrying a `Retry-After` header was classified
`NETWORK_ERROR` (follow-up to #506).

Adding `retry_after_ms` to `HttpError::HttpStatus` also added
`retry_after_ms: None` to the arm that maps authoritative absences, narrowing
it without comment or test. The header says how long to wait *if* you retry;
it does not make a 404 provisional.

- **[cli/mcp] (breaking)** The arXiv-only text tools answered a DOI with
`NO_OA_AVAILABLE`, whose disposition is `needs_config`.

There is no config knob: they are arXiv-only and DOI-to-arXiv linking is not
built. `NOT_IMPLEMENTED` is terminal and is what `verify` and
`batch_from_bibliography` already give the same situation for PMIDs -- valid
input, absent support.

All five surfaces, not two: `doiget_paper_text` and `doiget_paper_tex_source`
over MCP, and `doiget text` / `doiget source` / `doiget tex-source` on the
CLI. The first pass changed the MCP pair only, which would have left one
binary answering the same question two ways -- and left the tools' own
advertised `description` strings, their input-schema field docs and
`docs/MCP_TOOLS.md` naming a code they no longer return.

- **[mcp] (breaking)** `doiget_resolve_citation`, `doiget_batch_resolve_citations`,
`doiget_tag` and `doiget_annotate` put a **bare string** in `error`, so a
caller had no `code` to branch on and no `disposition` to decide a retry from.
All thirteen sites build `error_object` now.

Section 3 of `docs/ERRORS.md` named `every_bare_string_error_site_is_a_known_one`
as the guard that kept that set from growing. **The guard did not exist.** The
document was accurate about the four tools and wrong about the mechanism
keeping the list honest -- a claim resting on code nobody had written, which
is the defect class that document defines. It exists now, the set it pins is
empty, and a second test fails if the document and the list ever disagree.

- **[core]** `doiget config doctor --network` still adjudicated hosts with
`matches` rather than `permits` (#533).

The `permits` doc comment said "the four adjudication sites ... all route
through here so they cannot disagree". This was a fifth, and the one whose job
is explaining allowlist refusals: the moment a resolver host entered its probe
list, the command that diagnoses #533 would have reproduced it.

- **[core] (breaking)** CSL-JSON entries identified only by a PMID or PMCID
reported "entry has no DOI / arXiv id" (#500), and came out as `INVALID_REF`.
The BibTeX parser learned the distinction; this format did not, and it is the
one a Zotero user is most likely to export. Marked breaking for the same
reason as the `doiget batch` bullet above: the wire code changes to
`NOT_IMPLEMENTED` on `verify`, `batch` and the MCP bibliography tool for this
input shape.

- **[cli]** `doiget search --mode json` emitted `{"ok": true, "total_results": 0}`
with no hint for an over-long query (#534). The fix landed on the MCP tool
only, so this surface went on emitting the exact envelope the issue was filed
about. `zero_result_hint` moved to `doiget-core`; both surfaces call it, and
human mode gets a `= note:` on stderr.

- **[mcp]** `doiget_fetch_paper`, `doiget_batch_fetch` and
`doiget_batch_from_bibliography` logged `SessionEnd` as `result: "ok"` for a
blocked PDF leg (#507) -- on the single-ref tool, beside a non-null
`error_code`, so the row contradicted itself.

The clean-success rule of the CLI was ported to the MCP surface in the
`error_code` half only, and then only to the single-ref tool: the two batch
tools went on calling `.all(|r| r.outcome.is_ok())` a success for a batch
where every entry was refused. That is the outcome the repeat suppression of
#507 has to be able to see. `FetchPaperOutcome::is_clean_success` is the
single boundary for all four call sites now, including the identity-line
check in the CLI that had hand-rolled it a fourth time.

### Changed

- **[test]** The Tier-3 TDM-fetched route is asserted end to end and no longer
`#[ignore]`d (#462).

It was shipped as a failing reproduction, diagnosed as
"`tier_3_allowlists()` is `#[cfg]`-gated and the MCP server does extend its
allowlists with it, so the two disagree somewhere between construction and
use" -- the shape of #454, reachable again -- and raised as something to
decide before cutting this release.

That diagnosis was wrong, in the subject matter of this very release:
accurate about the code, false about the world. Both client builders have two
branches. The production branch does extend with `tier_3_allowlists()` and was
correct throughout. The test-override branch -- taken whenever any
`DOIGET_*_BASE` is set, which every wiremock test does -- built its allowlist
from a fixed table of Tier-1/2 keys with no Tier-3 entry, so no e2e on either
surface could reach the route. The defect was in the harness. Registering the
Tier-3 keys there is what the test now proves by passing, and
`route_coverage_e2e` has no remaining `Gap`.

What survives from that diagnosis, and is still true: `fetch_content` is
implemented by APS alone, so three of the four Tier-3 sources cannot reach the
route the tier exists for. Read it as APS coverage, not Tier-3 coverage.

- **[docs]** `Formula/doiget.rb` and `scripts/update-homebrew-formula.sh` said
the release workflow regenerates the formula "so this file cannot describe a
release that does not exist". No workflow calls the generator, and
`CONTRIBUTING.md` -- added in the same release -- says outright that it is not
automated. The headers describe the real process now.

- **[ci]** The Homebrew posture check said it "fails on a hand-edit, a bad
checksum, or a tag/version confusion". It re-reads the version and the
checksums from the file under test, so a well-formed checksum carrying the
wrong value, and a version that is simply stale, both pass. The step now says
what it checks and what it does not.

- **[ci]** New `release-tracking files name one version` posture check.
`Formula/doiget.rb`, `.claude-plugin/plugin.json`,
`.claude-plugin/marketplace.json` and `.mcp.json` all record the last
published stable, nothing held them together, and that is how `.claude-plugin`
sat at 0.8.11 while 0.8.12 was shipping. Forgetting one of the four is red;
pinning a prerelease is red.

- **[plugin]** `.mcp.json` pins `doiget-cli@0.8.12` instead of resolving the npm
`latest` tag on every server start. The unpinned form also meant that opening
this repository in Claude Code ran the *published release* rather than the
working tree -- editing `crates/doiget-mcp` and testing the build from last
month. `just mcp-dev` registers this checkout as a local-scoped server
instead, and `CONTRIBUTING.md` says so rather than leaving `.mcp.json` to be
edited.

- **[cli]** `verify` and `batch` wrote wire codes as string literals
(`"INVALID_REF"`, `"NOT_IMPLEMENTED"`) next to a doc comment rejecting exactly
that. They come from `ErrorCode::as_wire` now.

- **[test]** The `#[ignore]` detector in `route_coverage_e2e` matched any text
containing `#[ignore` in the block above a test, so a doc comment *discussing*
an ignored test made it report that test as skipped. It reads attribute lines.

- **[docs]** In `crates/doiget-mcp/src/lib.rs`, the doc comment for
`store_root_env_is_usable` had drifted onto `zero_result_hint`.

- **[scripts]** `scripts/*.sh` are executable, and `CONTRIBUTING.md` no longer
documents a command that fails on a fresh Linux or macOS clone.

- **[test]** In `npm/doiget-cli/test/stage-npm.test.sh`, a `grep` matching
nothing killed the script under `set -e` *before* the `check no` written to
report that case, so the diagnostic was unreachable in the one situation it
exists for.

### Fixed (accumulated on `next` since 0.8.12)

- **[store]** A default `metadata_only` re-write **downgraded a known
`oa_status` and `license` to their not-determined markers**, and said nothing
(#583).
Expand Down
Loading
Loading