doctor is blind to a product's own declarations and mechanism contracts #274

Open
opened 2026-08-16 02:29:34 +00:00 by PerishFire · 3 comments
Owner

Cutting sidecar v0.8.0-beta.1 on 2026-08-15 surfaced four separate defects in
one afternoon. Each one is a declaration or a call contract that Plumb owns,
each was already broken before the release started, and in every case
plumb doctor . reported true to the skeleton. The cost landed at the most
expensive moment: after landing, in CI, beside an irreversible action.

Filing them together because they are one class, not four bugs.

1. A release declaration that cannot be parsed at all

[release.npm] was added directly above targets, so TOML reparented all four
release targets onto the npm attachment, which rejects unknown fields:

[release]
product = "sidecar"
binaries = ["sidecar"]
[release.npm]
registry = "..."
package = "@perish/sidecar"

targets = [ "x86_64-unknown-linux-gnu", ... ]
$ PLUMB_RELEASE_VERSION=v0.8.0-beta.1 plumb release channel
plumb release: cannot parse ./plumb.toml: TOML parse error at line 9, column 1
  |
9 | targets = [

Three guard runs, two pull requests and a landing on main passed with
plumb doctor . true throughout. Only a verb that parses the release
declaration says anything, and nothing runs one before release day.

The ordering hazard is worth naming on its own: any [release.<attachment>]
table silently captures every later top-level key of [release].

2. A skill seat two minor versions behind stable

~/.claude/skills/plumb sat at v0.18.27 while stable was v0.20.0. That brief
documents plumb release dispatch, a verb v0.20.0 does not have. Reading it as
authoritative produced a wrong procedure: an exact release was assumed to bind a
release/<version> branch rather than refs/tags/<version>, with channel and
version named beside it rather than read from the version.

plumb skill status reports this, but nothing points at it, and the brief is
what a reader trusts first. The current brief states the rule correctly; the
stale one is what was on disk.

3. A wrapper refusing every dispatch the current binary can make

$ plumb ship binary dispatch --version v0.8.0-beta.1
plumb ship: input required for 'Exact non-stable version'

Current Plumb reads channel and version from the ref and sends inputs={} for
an exact release. The product's release-exact.yml still declared both as
required: true. The shared workflow has carried "Ignored; the version is read
from the ref. Stop passing it." for two minor versions, so the wrapper had
drifted into rejecting every dispatch, and nothing reported the drift.

The same reasoning covers guard_contexts and the secret names those wrappers
carry: they are contracts against a shared mechanism, and they fail the same
silent way.

4. A declared attachment that no lane delivers

[release.npm] is declared and plumb doctor accepts it. The exact release then
ran green end to end -- resolve, four builds, coordinate, four smokes, release --
and published nothing to the npm registry, because release-binary.yml does not
carry the package lane; that lives behind the package-exact action. The
release log says only sidecar has no Cargo attachment.

A declaration that no lane can deliver is indistinguishable, from doctor, from
one that ships. The product learns the difference by looking for the artifact.

What these share

Plumb owns the release declaration, the attachment tables, the dispatch
contract, and the skill brief. In every case above, the product held something
that had stopped being true about a Plumb mechanism, and no Plumb gate said so.
doctor reads the skeleton; nothing reads whether the declarations still parse,
still resolve to a lane, or still match the contract they call.

Please do not fix these one cell at a time. Adding a key-order check for
[release.npm] leaves [release.oci], [release.chart], [release.cargo] and
[[document]] in the same state. The load-bearing question is which gate is
answerable for a declaration Plumb owns.

Suggested shape, not a decision

  • A declaration is parseable and internally consistent: static, no inputs, no
    network. This looks like it belongs to doctor as a mechanized rule.
  • A declared attachment resolves to a lane that can deliver it.
  • A dispatch verb proves the wrapper contract before the POST, where the
    evidence is freshest and a refusal costs nothing.
  • Whether a stale skill seat is a doctor finding is genuinely unclear; it is a
    property of the host, not the repository.

Reproducing

Cheapest repro for 1 is two minimal plumb.toml files that differ only in
whether the attachment table precedes targets, plus one plumb doctor --json
each. It needs no release, no network and no credentials.

Tracked on our side as perish.code/plumb-declaration-blindness. Fixes for the
product-side symptoms landed in sidecar as 921ac05 and 5528372; those
repaired the instances, not the blindness.

Cutting `sidecar v0.8.0-beta.1` on 2026-08-15 surfaced four separate defects in one afternoon. Each one is a declaration or a call contract that Plumb owns, each was already broken before the release started, and in every case `plumb doctor .` reported `true to the skeleton`. The cost landed at the most expensive moment: after landing, in CI, beside an irreversible action. Filing them together because they are one class, not four bugs. ## 1. A release declaration that cannot be parsed at all `[release.npm]` was added directly above `targets`, so TOML reparented all four release targets onto the npm attachment, which rejects unknown fields: ```toml [release] product = "sidecar" binaries = ["sidecar"] [release.npm] registry = "..." package = "@perish/sidecar" targets = [ "x86_64-unknown-linux-gnu", ... ] ``` ``` $ PLUMB_RELEASE_VERSION=v0.8.0-beta.1 plumb release channel plumb release: cannot parse ./plumb.toml: TOML parse error at line 9, column 1 | 9 | targets = [ ``` Three guard runs, two pull requests and a landing on `main` passed with `plumb doctor .` true throughout. Only a verb that parses the release declaration says anything, and nothing runs one before release day. The ordering hazard is worth naming on its own: any `[release.<attachment>]` table silently captures every later top-level key of `[release]`. ## 2. A skill seat two minor versions behind stable `~/.claude/skills/plumb` sat at v0.18.27 while stable was v0.20.0. That brief documents `plumb release dispatch`, a verb v0.20.0 does not have. Reading it as authoritative produced a wrong procedure: an exact release was assumed to bind a `release/<version>` branch rather than `refs/tags/<version>`, with `channel` and `version` named beside it rather than read from the version. `plumb skill status` reports this, but nothing points at it, and the brief is what a reader trusts first. The current brief states the rule correctly; the stale one is what was on disk. ## 3. A wrapper refusing every dispatch the current binary can make ``` $ plumb ship binary dispatch --version v0.8.0-beta.1 plumb ship: input required for 'Exact non-stable version' ``` Current Plumb reads channel and version from the ref and sends `inputs={}` for an exact release. The product's `release-exact.yml` still declared both as `required: true`. The shared workflow has carried "Ignored; the version is read from the ref. Stop passing it." for two minor versions, so the wrapper had drifted into rejecting every dispatch, and nothing reported the drift. The same reasoning covers `guard_contexts` and the secret names those wrappers carry: they are contracts against a shared mechanism, and they fail the same silent way. ## 4. A declared attachment that no lane delivers `[release.npm]` is declared and `plumb doctor` accepts it. The exact release then ran green end to end -- resolve, four builds, coordinate, four smokes, release -- and published nothing to the npm registry, because `release-binary.yml` does not carry the package lane; that lives behind the `package-exact` action. The release log says only `sidecar has no Cargo attachment`. A declaration that no lane can deliver is indistinguishable, from `doctor`, from one that ships. The product learns the difference by looking for the artifact. ## What these share Plumb owns the release declaration, the attachment tables, the dispatch contract, and the skill brief. In every case above, the product held something that had stopped being true about a Plumb mechanism, and no Plumb gate said so. `doctor` reads the skeleton; nothing reads whether the declarations still parse, still resolve to a lane, or still match the contract they call. Please do not fix these one cell at a time. Adding a key-order check for `[release.npm]` leaves `[release.oci]`, `[release.chart]`, `[release.cargo]` and `[[document]]` in the same state. The load-bearing question is which gate is answerable for a declaration Plumb owns. ## Suggested shape, not a decision - A declaration is parseable and internally consistent: static, no inputs, no network. This looks like it belongs to `doctor` as a mechanized rule. - A declared attachment resolves to a lane that can deliver it. - A dispatch verb proves the wrapper contract before the POST, where the evidence is freshest and a refusal costs nothing. - Whether a stale skill seat is a `doctor` finding is genuinely unclear; it is a property of the host, not the repository. ## Reproducing Cheapest repro for 1 is two minimal `plumb.toml` files that differ only in whether the attachment table precedes `targets`, plus one `plumb doctor --json` each. It needs no release, no network and no credentials. Tracked on our side as `perish.code/plumb-declaration-blindness`. Fixes for the product-side symptoms landed in sidecar as `921ac05` and `5528372`; those repaired the instances, not the blindness.
Author
Owner

Checked the four against today's mechanism, one at a time. Two are closed by code, one is closed in form but stands on evidence that cannot yet be trusted, and one is untouched. Leaving the issue open for the remaining two.

1. An unparseable release declaration passing doctor — fixed. shape::pair::release parses the declaration through the same strict Spec::read every release verb uses, and a refusal becomes out of true under release.spec-declared: plumb.toml declares a release the current Plumb refuses: <reason>. The ordering hazard you named is caught by the same read, because the reparented keys are exactly what makes the parse fail. Covered by tests/doctor/release.rs::refused.

3. A wrapper refusing every dispatch — fixed by rendering. Lanes are derived from the declaration, not hand-written; drift is byte comparison against a fresh render, and plumb ship binary dispatch refuses outright to start a release on a lane this Plumb did not render. A wrapper cannot silently disagree with the mechanism any more because it is no longer authored.

4. A declared attachment no lane delivers — the rule exists, its evidence does not. release.attachment-deliverable refuses a declared attachment that resolves to no delivering lane. But it resolves through carriers(), a hardcoded attachment→lane table: it says which lane is supposed to carry a medium, not whether that lane actually calls the verb or whether its job image can run it. That is a static table standing in for a fact, and it closes properly only when every repository has migrated to rendered lanes.

2. A stale skill seat — untouched. plumb skill status still reports it and nothing points at it. This one is about the operator's machine rather than the repository, so doctor may be the wrong surface; it needs its own answer.

The class you named — "a declaration Plumb owns, broken before the release, invisible to doctor" — is the right frame, and it is what release.datum-recorded, document.source-tracked, and release.attachment-permitted were all cut to serve this round.

Checked the four against today's mechanism, one at a time. Two are closed by code, one is closed in form but stands on evidence that cannot yet be trusted, and one is untouched. Leaving the issue open for the remaining two. **1. An unparseable release declaration passing doctor — fixed.** `shape::pair::release` parses the declaration through the same strict `Spec::read` every release verb uses, and a refusal becomes `out of true` under `release.spec-declared`: `plumb.toml declares a release the current Plumb refuses: <reason>`. The ordering hazard you named is caught by the same read, because the reparented keys are exactly what makes the parse fail. Covered by `tests/doctor/release.rs::refused`. **3. A wrapper refusing every dispatch — fixed by rendering.** Lanes are derived from the declaration, not hand-written; drift is byte comparison against a fresh render, and `plumb ship binary dispatch` refuses outright to start a release on a lane this Plumb did not render. A wrapper cannot silently disagree with the mechanism any more because it is no longer authored. **4. A declared attachment no lane delivers — the rule exists, its evidence does not.** `release.attachment-deliverable` refuses a declared attachment that resolves to no delivering lane. But it resolves through `carriers()`, a hardcoded attachment→lane table: it says which lane is *supposed* to carry a medium, not whether that lane actually calls the verb or whether its job image can run it. That is a static table standing in for a fact, and it closes properly only when every repository has migrated to rendered lanes. **2. A stale skill seat — untouched.** `plumb skill status` still reports it and nothing points at it. This one is about the operator's machine rather than the repository, so `doctor` may be the wrong surface; it needs its own answer. The class you named — "a declaration Plumb owns, broken before the release, invisible to doctor" — is the right frame, and it is what `release.datum-recorded`, `document.source-tracked`, and `release.attachment-permitted` were all cut to serve this round.
Author
Owner

Item 2 is closed on main f9986bf, and the shape of the answer is the reason it sat untouched.

doctor now names every installed brief whose version is not the running binary's, in every repository, because doctor is what gets run. Three were on this machine while writing it — v0.23.0 beside a running v0.26.0.

The first attempt made it a noted finding, and every fixture in the suite went from clean to unclean for a reason that had nothing to do with the repository being judged. That is the trap: the seats belong to the operator, the verdict belongs to the repository, and mixing them makes doctor lie about the repository to tell the truth about the machine. So it is an observation printed beside the shape, not a finding — a repository that is true stays true beside a stale brief.

The comparison is against the running binary rather than canonical stable. It needs no network, and it asks the question that actually bit you: does this brief describe the plumb I am holding.

That leaves item 4 as the only one still open — release.attachment-deliverable refuses a declared attachment no lane can deliver, but resolves it through carriers(), a hardcoded attachment-to-lane table. It closes properly when every repository has migrated to rendered lanes.

Item 2 is closed on main `f9986bf`, and the shape of the answer is the reason it sat untouched. `doctor` now names every installed brief whose version is not the running binary's, in every repository, because `doctor` is what gets run. Three were on this machine while writing it — v0.23.0 beside a running v0.26.0. The first attempt made it a **noted finding**, and every fixture in the suite went from clean to unclean for a reason that had nothing to do with the repository being judged. That is the trap: the seats belong to the operator, the verdict belongs to the repository, and mixing them makes doctor lie about the repository to tell the truth about the machine. So it is an observation printed beside the shape, not a finding — a repository that is true stays true beside a stale brief. The comparison is against the running binary rather than canonical stable. It needs no network, and it asks the question that actually bit you: does this brief describe the plumb I am holding. That leaves item 4 as the only one still open — `release.attachment-deliverable` refuses a declared attachment no lane can deliver, but resolves it through `carriers()`, a hardcoded attachment-to-lane table. It closes properly when every repository has migrated to rendered lanes.
Author
Owner

Migrated sidecar to rendered lanes at v0.26.0, which is what item 4 was waiting
on from this side. Reporting what the migration measured.

Item 1 fired on the repository that reported it. The first thing v0.26.0
doctor said about sidecar was the declaration refusal, from the package ->
packages rename it had not taken yet:

out of true: plumb.toml declares a release the current Plumb refuses:
unknown field `package`, expected `registry` or `packages`

Item 4 closes structurally here. With the declaration parsing, doctor reports
publishes binary npm, and the rendered ship.yml projects a matrix derived
from the declaration — plumb ship ${{ matrix.medium }} publish. The npm
attachment is delivered because it is declared. The failure this issue opened
with was an exact release running ten green jobs and publishing nothing to the
registry; that shape is no longer reachable, because nobody wires a lane by hand
for a medium.

Two notes from the migration itself, neither a defect:

The rendered guard proving Linux alone read as a regression to me at first,
and I was wrong.
Sidecar had built a three-platform guard, so adopting the
rendered lane looked like dropping the Windows and macOS proof it had just paid
for. It is a trade, not an oversight: guard cost is charged on every change,
while platform evidence is worth taking when a change touches spawn, discovery
or a bridge facet. A repository-owned dispatch-only platform.yml sits beside
the four rendered lanes, plumb lane still reports all four true, and doctor
names the extra one unknown shape: workflow platform has no shadow in the skeleton without touching its exit code. That is exactly the right verdict, and
it is what makes the trade adoptable rather than a wall.

"Track before you seal" caught the trap that bit us two cuts ago. The seal
source walks tracked files, and sealing before git add seals a tree without
the new modules in it — green locally, red on a fresh checkout. v0.26.0 refuses
with source . holds untracked leaves instead of sealing the wrong tree.

Nothing outstanding from us. Thanks for the four-part reply; the reasoning in
item 2 about seats belonging to the operator and verdicts to the repository is
the part I would not have arrived at.

Migrated sidecar to rendered lanes at v0.26.0, which is what item 4 was waiting on from this side. Reporting what the migration measured. **Item 1 fired on the repository that reported it.** The first thing v0.26.0 doctor said about sidecar was the declaration refusal, from the `package` -> `packages` rename it had not taken yet: ``` out of true: plumb.toml declares a release the current Plumb refuses: unknown field `package`, expected `registry` or `packages` ``` **Item 4 closes structurally here.** With the declaration parsing, doctor reports `publishes binary npm`, and the rendered `ship.yml` projects a matrix derived from the declaration — `plumb ship ${{ matrix.medium }} publish`. The npm attachment is delivered because it is declared. The failure this issue opened with was an exact release running ten green jobs and publishing nothing to the registry; that shape is no longer reachable, because nobody wires a lane by hand for a medium. Two notes from the migration itself, neither a defect: **The rendered guard proving Linux alone read as a regression to me at first, and I was wrong.** Sidecar had built a three-platform guard, so adopting the rendered lane looked like dropping the Windows and macOS proof it had just paid for. It is a trade, not an oversight: guard cost is charged on every change, while platform evidence is worth taking when a change touches spawn, discovery or a bridge facet. A repository-owned dispatch-only `platform.yml` sits beside the four rendered lanes, `plumb lane` still reports all four `true`, and doctor names the extra one `unknown shape: workflow platform has no shadow in the skeleton` without touching its exit code. That is exactly the right verdict, and it is what makes the trade adoptable rather than a wall. **"Track before you seal" caught the trap that bit us two cuts ago.** The seal source walks tracked files, and sealing before `git add` seals a tree without the new modules in it — green locally, red on a fresh checkout. v0.26.0 refuses with `source . holds untracked leaves` instead of sealing the wrong tree. Nothing outstanding from us. Thanks for the four-part reply; the reasoning in item 2 about seats belonging to the operator and verdicts to the repository is the part I would not have arrived at.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
PerishLab/plumb#274
No description provided.