The sentinels a Recovery and a Publisher must say are sayable from outside - #9
Merged
Conversation
…tside Promoting qcow out of internal/ moved the types and left the vocabulary behind. `qcow.Recovery` is the guard the blank-disk defect closed: its RestoreFrom and Current must wrap "this volume has never published" to mean an empty chain is correct, and every other error means refuse. That sentence had exactly one spelling, `internal/commit.ErrNoHead`, which another module cannot import — so an implementation written in spinbox could say every error meaning "refuse" and none meaning "permit", and `qcow.Open` refused every volume it was handed. The interface the promotion existed to expose could not be implemented by the consumer it was exposed for. `ErrNoHistory` is that condition under this package's name, and it *is* `commit.ErrNoHead` rather than a second value that means the same thing. Two sentinels would have to be kept mutually wrapping by hand, and the day one was returned bare, errors.Is would answer differently on the two spellings of one condition; one value under two names cannot drift, and everything in this tree that already returns ErrNoHead keeps working untouched. The audit that followed found the same defect once more, on the path where it costs more. The Manager branches on two of Publisher's errors — `ErrHeadMoved`, which stops the volume, and `ErrRootSuperseded`, which abandons a collapse — and both lived in internal/commit as well. A publisher in another module could not say either, and the one it could not say that matters is the first: a host whose compare-and-set on HEAD lost, unable to report it, goes on serving a guest whose writes can never be published, which is the fencing failure §15 is about. They are now `qcow.ErrNotWriter` and `qcow.ErrRootSuperseded`, the same values, and the Publisher interface documents that an implementation has to be able to produce them. Paths.List gained the one line of contract a caller outside this repository cannot guess and a sweep depends on: a missing directory is fs.ErrNotExist and never an empty list. The test is in `package qcow_test`, deliberately: an in-package test can reach internal/commit and so can prove nothing about what a consumer can reach. It implements Recovery, Paths and Runner out of nothing but the exported surface and drives Open through the volume that is born empty, the reopened chain the store has not moved past, and three refusals. Planted against the sentinel being a second value, it goes red on all three. What the audit did *not* fix, and could not: `Deps` still asks for `clock.Clock`, `disk.Disk` and `qmp.Dialer`, whose methods return internal types, so no implementation of them exists outside this module and `internal/simio/real` — the implementations a real host wants — is unreachable too. `Manager.Volumes` returns `internal/agent`'s status type, which a consumer can read and cannot name. So `qcow.Manager` is not yet the host-side facade ADR-0021 §4 promises spin's runner; `Open` and the chain functions are, and Deps now says so at the line that makes it. Closing it means deciding where the simio boundary sits relative to the public surface, which is not a doc comment's decision and not a re-export either: what a consumer needs is the real implementations, and those are the ones INV-01 confines. Verified: task ci. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019GHRRFvbnZi5q3KSTvyW5a
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.
The defect
qcow.Recoveryis the guard that closed the blank-disk defect:RestoreFromandCurrentmust wrap "this volume has never published" to mean an empty chain is correct, and every
other error means refuse. That sentence had exactly one spelling —
internal/commit.ErrNoHead— which no other module can import. An implementation written in spinbox could therefore say
every error meaning refuse and none meaning permit, so
qcow.Openrefused every volume itwas handed. The interface the promotion out of
internal/existed to expose could not beimplemented by the consumer it was exposed for.
The fix
qcow.ErrNoHistoryis that condition under this package's name, and it iscommit.ErrNoHeadrather than a second value meaning the same thing. Two sentinels wouldhave to be kept mutually wrapping by hand, and the day one was returned bare
errors.Iswould answer differently on the two spellings of one condition. One value under two names
cannot drift, and everything in the tree that already returns
ErrNoHeadkeeps workinguntouched.
What the audit of the rest of the surface found
Publisher, where it costs more. The Manager branches on two ofthe injected publisher's errors —
commit.ErrHeadMoved, which stops the volume, andcommit.ErrRootSuperseded, which abandons a collapse — and both were internal too. Apublisher in another module could not say either; the consequence of not being able to say
the first is a host that lost the compare-and-set on HEAD going on to serve a guest whose
writes can never be published. Now
qcow.ErrNotWriterandqcow.ErrRootSuperseded, thesame values, with the requirement stated on the
Publisherinterface.Paths.List's missing contract. A directory that is not there must befs.ErrNotExistand never an empty list —
Manager.volumeIDsbranches on it, and sweeping a failed listingas if it were empty deletes every chain on the machine. One line of doc; an outside
implementer had no way to know.
Depsis not implementable from outside, and this PR does not fix it.Clock,Diskand
Dialerareinternal/simio/internal/qmpinterfaces whose own methods returninternal types (
clock.Instant,clock.Timer,disk.File,disk.Usage), andinternal/simio/real— the implementations a real host actually wants — is unreachable forthe same reason.
Manager.Volumesreturnsinternal/agent's status type, which a consumercan read but cannot name. So
qcow.Manageris not yet the host-side facade ADR-0021 §4promises spin's runner;
Openand the chain functions are. Closing it means decidingwhere the simio boundary sits relative to the public surface — not a doc comment's decision,
and not a re-export either, because what a consumer needs is the real implementations and
those are what INV-01 confines.
Depsnow says all of this at the line that makes it.Nothing was added to STATUS.md: it is at its 120-line cap, and this is a decision that lives
in the code.
Runner,Witness,Publisher,Recoveryand
Pathstake and return only public types;Chain,Config,CompactionPolicy,Stateand its members,Lineage,Ancestor,Restored,SealedLayer,LiveImage,OpenRequesthave no internal-typed fields; the path helpers andSweep/HeldCommits/state marshalling are strings and bytes.
ErrBadStatewrapsframed.ErrCorrupt/framed.ErrFormatTooNew, which an outside caller cannot name — deliberate and alreadydocumented:
ErrBadStateis the one thing to branch on and the cause rides along for anoperator.
Proof
qcow/api_external_test.go, inpackage qcow_testdeliberately — an in-package test canreach
internal/commitand so proves nothing about what a consumer can reach. It implementsRecovery,PathsandRunnerout of nothing but the exported surface and drivesOpenthrough: the volume born empty, a reopened chain the store has not moved past (
Current'shalf of the sentence), and three refusals — the catalog naming a commit the store has no HEAD
for, a store that would not answer, and a published history that moved past this disk.
Proven able to fail: with
ErrNoHistoryplanted as a separateerrors.Newand the package'serrors.Iscalls left oncommit.ErrNoHead, all three tests go red —Delete
qcow.ErrNoHistoryand the file stops compiling, which is the other half of the guard.Verification
task ci— green, exit 0 (fmt:check, build, lint incl. the simulable analyzer andbuf breaking, deadcode, dev:entries, comment:rot, refs, status:cap,go test -race ./..., dst,workflows:verify). No Postgres needed; no VM lane runs inside it.
task ci:fulland thedemo:stage*targets were deliberately not run — this machine's QEMU/Docker/Postgres are inuse elsewhere.
🤖 Generated with Claude Code
https://claude.ai/code/session_019GHRRFvbnZi5q3KSTvyW5a