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
18 changes: 14 additions & 4 deletions .claude/docs-catalog.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": 1,
"last_updated": "2026-08-23T05:59:17+00:00",
"last_updated": "2026-08-24T12:16:05+00:00",
"files": {
".claude/plans/ticklish-inventing-stearns.md": {
"purpose": "markdown document",
Expand All @@ -12,7 +12,8 @@
"P1",
"P5",
"P2",
"P3B"
"P3B",
"UPDPATH"
]
},
"CONTRIBUTING.md": {
Expand All @@ -27,7 +28,8 @@
"touched_by_phases": [
"P5",
"P2",
"P3B"
"P3B",
"UPDPATH"
]
},
"SECURITY.md": {
Expand Down Expand Up @@ -134,6 +136,12 @@
"purpose": "per-phase lane plan",
"touched_by_phases": []
},
"plans/phase-plan-v12-UPDPATH.md": {
"purpose": "per-phase lane plan",
"touched_by_phases": [
"UPDPATH"
]
},
"plans/phase-plan-v2-lifecycle.md": {
"purpose": "per-phase lane plan",
"touched_by_phases": []
Expand Down Expand Up @@ -324,7 +332,9 @@
},
"specs/phase-plans-v12.md": {
"purpose": "multi-phase roadmap spec",
"touched_by_phases": []
"touched_by_phases": [
"UPDPATH"
]
},
"specs/phase-plans-v2.md": {
"purpose": "multi-phase roadmap spec",
Expand Down
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **A cached description is now checked against the package that is actually
configured, not just against its version.** All three refresh sites —
`refresh_server`'s up-to-date short-circuit, `refresh_all`, and
`check_staleness` — paired a cached entry with a server config by **name** and
then decided freshness by comparing **versions** alone. Nothing asked whether
the cache still described the same *package*, so swapping the configured
package at an equal version served the wrong package's tool descriptions
indefinitely: a cache for `old-pkg@1.0.0` against a config for `new-pkg@1.0.0`
looked current forever. The docker case was already covered — a version
against a digest is `incomparable`, which is not `not_newer` — but a
same-ecosystem swap and an npm ↔ pypi ↔ cargo swap were not.

Identity is resolved before the comparison at all three sites now. **An
unknown side means "cannot confirm identity", and that resolves to
refresh — never to "cannot compare, so skip the check."** The second phrasing
is the natural one to reach for and is the same fail-open collapse as
`not is_version_newer(...)`, which shipped three times
(Consiliency/pmcp#155, #156, #163) before it was made unrepresentable.

The cached entry gained a `package_type`, because `package` is a bare name
carrying no ecosystem and npm, pypi and cargo all produce orderable *release*
versions — so npm `foo@1.0.0` and pypi `foo@1.0.0` were indistinguishable to
a name comparison. A cache written before this release has no type, reads as
unknown, and refreshes once. Nothing has to be migrated by hand and the cache
format needs no version bump.

One cosmetic consequence: a stale report is a `(cached_version,
latest_version)` pair, so an entry that is stale by *identity* at an equal
version prints `srv: 1.0.0 -> 1.0.0`. Confusing to read, but not wrong — that
entry genuinely does need regenerating.
- **`pmcp refresh --check-versions` now honours `--cache-dir`.** `run_refresh`
computed the cache path from `--cache-dir` and then called `check_staleness()`
with no arguments, dropping it, so the check silently inspected the default
cache instead of the one that was asked for. Anyone pointing
`--check-versions` at a non-default cache was reading a different file than
they named, with nothing in the output to say so.

### Changed
- **`pmcp refresh --check-versions` now reports a server whose package it cannot
look up separately, as unconfirmed rather than as stale.** A server launched
as `node /opt/srv.js` or `python -m thing` has no classifiable package, so its
configured identity is unknown, and "cannot confirm identity" resolves to
refresh. That is the right rule — the only alternative is to read "cannot
classify" as "assume it matches", which is the fail-open reading that let a
swapped package look current in the first place — but it would have made such
a server appear under "servers with newer versions" on every run, permanently,
beneath a `Run 'pmcp refresh --force' to update.` footer that could not settle
it, since the next check still cannot classify the package. So the report is
now split. Servers with a genuinely newer version keep the existing output and
that footer; servers whose current version could not be looked up are listed
under their own heading which says so, notes that this is not the same as
being out of date, and points out that their descriptions are regenerated by
the next plain `pmcp refresh`. Previously these servers were skipped in
silence. A manifest of only registry-installed (npm/pypi/cargo/docker) servers
is unaffected.
- **`refresh_all` now drops an unclassifiable server's cached descriptions when
regeneration fails, where it previously kept them.** A server that fails the
identity gate has its cached entry discarded up front and is regenerated; if
that regeneration then fails — the server does not start, a version lookup
times out — neither the failure fallback nor the final merge puts the old
entry back, and the server is left with no cached descriptions until a later
refresh succeeds. For a `node`/`python` server this costs something real:
there was never *evidence* of a package mismatch, only an inability to confirm
one, so a transient startup failure now costs descriptions that were probably
still accurate. Writing back descriptions that may describe a different
package is the outcome this release exists to prevent, so the trade is
deliberate — but it is a trade, not a free win.

## [2.3.0] - 2026-08-24

### Fixed
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,32 @@ URL-mode gateway calls.
- `pmcp update <server>` and `pmcp update --all` call the same gateway update workflow.
- Update information is reported on request by `gateway.update_server`; the gateway does not volunteer unprompted "update available" notices. It cannot observe which package version a running server is actually executing, so a volunteered notice could be wrong in either direction (Consiliency/pmcp#150).

**The environment across the update's probe window.** `gateway.update_server`
probes for a new package version and then re-resolves the server config before
restarting anything. Two different kinds of environment change behave
differently across that window, and they are not one rule:

- A **config-driven** change is **refused**. That means anything resolved into
the server's `env` — which includes every manifest credential, since a
manifest credential is resolved into the server config at load time. If it
changes while the probe is running, `update_server` returns `ok=False` and
does **not** restart: the package is fetched but not activated, and no version
is recorded. The guarantee is that *the config restarted onto is the config
that was probed* (Consiliency/pmcp#151). Your change is not lost — rotate a
credential mid-update and it applies on the **next** update, rather than to a
process that was probed with the old value.
- A **genuinely ambient** variable — one set in the gateway's own environment
rather than in a server's config — is **live at spawn**. It can never cause a
refusal (both sides of the check derive from the same base environment, so an
ambient change affects them equally and cancels out), and the restarted server
receives the value in force when it is spawned, not the one in force when the
update began. This is the same behaviour as `gateway.connect_server`,
`gateway.refresh` and auto-reconnect: every spawn path reads the ambient
environment at spawn time.

Freezing the ambient environment across an update is deliberately not done
(Consiliency/pmcp#162).

### Feedback Telemetry

- PMCP can emit failure feedback hints and generate GitHub issue payload previews for agents.
Expand Down Expand Up @@ -1412,6 +1438,9 @@ pmcp logs --tail 100 # Last 100 lines
pmcp refresh
pmcp refresh --server github # Refresh specific server
pmcp refresh --force # Force reconnect all
pmcp refresh --check-versions # Report stale cached descriptions without refreshing
# (honours --cache-dir; a local server whose package
# cannot be classified reports stale on every run)

# Initialize config (interactive)
pmcp init
Expand Down
67 changes: 66 additions & 1 deletion plans/phase-plan-v12-UPDPATH.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
phase_loop_plan_version: 1
phase: UPDPATH
roadmap: specs/phase-plans-v12.md
roadmap_sha256: 3b6439a9dddb9ef5b5b709ed6fa32f889a082ed0b0118554e6ae9c2265bf4847
roadmap_sha256: 2015eca64695daef2a342a772e538a93f3e6f89dcb08840275bd541d9f06a84a
---

# PHASE-2-UPDPATH: Update-path identity and environment contracts
Expand Down Expand Up @@ -367,3 +367,68 @@ automation:
deliberately rotated; refusing would fail an update the operator wanted. A
lane that finds the decision uncomfortable must stop and report rather than
re-deciding it mid-execution.

## Post-execution amendments (2026-08-24)

Recorded by SL-3 after SL-1 and SL-2 merged. The full list of what execution
discovered — including two operator-visible consequences this plan did not
anticipate — is in `specs/phase-plans-v12.md` under
"Post-execution amendments — UPDPATH (2026-08-24)". Only the points where **this
document** was wrong or imprecise are repeated here.

- **"Resolve identity before the short-circuit" was implemented as "resolve
before, compare inside."** The `detect_package_type` / `pkg_name` resolution
moved above the early return as this plan required, and exactly one call
remains in `refresh_server`. The `_same_package` **call**, however, is the
second conjunct of the existing `if version and … and compare_versions(…) ==
"not_newer"`, so `get_package_version` still runs before identity is
consulted. SL-1 reported that placing the guard ahead of the fetch broke
`TestUpToDateShortCircuit`'s call-count assertion, since a mismatch would then
skip the version lookup entirely. This plan read as though the guard would sit
ahead of the fetch; it does not, and nothing in EC-UPDPATH-1..3 requires it to.
- **The `TestUpToDateShortCircuit` fixture warning was misplaced.** That class
needed no changes at all: both of its tests assert the short-circuit does
*not* fire, so an extra always-`False` conjunct cannot move their outcome —
which also means neither of them discriminates a degenerate gate. The
prediction was right for `TestCheckStaleness` (three fixtures gained
`package_type="npm"`), and the test this plan correctly identified as the one
that catches an always-`False` gate,
`test_short_circuit_is_a_single_compare_versions_call`, lives in
`TestShortCircuitUsesCompareVersions`, not in `TestUpToDateShortCircuit`; its
fixture needed `package_type="npm"` too. All four were pure insertions, so
`git diff --numstat -- tests/` reports zero deletions and the fixture work is
invisible unless you read the hunks.
- **EC-3 needed state, not just a gated pair.** This plan named both of
`refresh_all`'s failure paths correctly but not what closing them costs:
setting `existing = None` stops the callee from short-circuiting and does
nothing about the final merge loop, which re-adds cached entries straight from
`existing_servers`. `refresh_all` keeps a `mismatched: set[str]` and excludes
those names from the merge.
- **EC-7 needed no code path of its own** — the empty-`package` case falls out of
`_same_package`'s any-unknown arm for free.
- **`roadmap_sha256` was refreshed at closeout**, and this is a deliberate change
of convention. This phase's own docs lane amended the roadmap, so the pin
written at planning time no longer matched and `validate_plan_doc` reported
`(FM) roadmap_sha256 does not match the referenced roadmap` as an **error**.

The pre-merge board split on it. The red-team seat called it blocking: the
validator is authoritative and prior stale pins do not override the contract.
The adversarial and correctness seats ruled the opposite — the field is
*provenance*, recording what the plan was planned against, and re-pinning
falsifies it, since this plan was demonstrably **not** planned against a
roadmap whose amendment block contains this plan's own closeout. The
correctness seat further noted that the phase-loop runtime deliberately
refuses to gate live behaviour on this hash, treating drift as
"not-corroborated" rather than an error.

Both readings are defensible; the maintainer chose to re-pin, so a shipped
artifact validates clean rather than normalising a validator error across
three plans. The planning-time binding is not lost — it is recoverable from
this file's own history. The v12 plans that ran before this one (TRISTATE,
FANOUT) still carry stale pins; UPDPATH is the first to refresh at closeout,
and that is the convention going forward.

Ordering matters and is easy to get wrong: the re-pin must be the **last**
step before merge. Computing it any earlier goes stale the moment a docs lane
or a board fix touches the roadmap again — which happened twice during this
phase's closeout.
Loading