OM-M1.1: the model takes the front door, protocol moves to pyquadcortex.protocol - #17
Conversation
….protocol ADR-0006's flip. Today's protocol layer moves to `pyquadcortex.protocol`, unchanged except for the import path, and `pyquadcortex.connect()` now returns the model's `Device`. The move is verbatim. Every changed line in the moved modules is an import path or a docstring path; no method changed behaviour, and no logic moved with it. `pyquadcortex/protocol/__init__.py` carries the pre-flip `__all__` as it stood. The `Device` is a skeleton on purpose: identity (firmware, serial), ownership of the connection, and `from_client(qc)` for scripts that want both layers. The Directory, the cache and the grid are stories #11 and #12. Also here: - `tests/test_namespace.py` reads the pre-flip `__init__.py` verbatim from git (94e5053) and asserts every name it exported resolves under `pyquadcortex.protocol`. The list is generated, not maintained by hand. - `tests/test_import_cleanliness.py` walks every module in the package and imports each in a subprocess, so ADR-0002's "no module-scope `import hid`" now covers the whole layout rather than the modules someone remembered. - The version moved to `pyquadcortex/_version.py` so both namespaces can publish it without one importing the other. - `qcctl` is declared as `pyquadcortex.protocol.cli:main`; the command itself is unchanged. - ADR-0007: the model may represent a control whose wire path is still open, provided the operation it cannot perform refuses rather than guesses. TEMPO MODE is reopened in `docs/domain-model.md` accordingly - "the unit never broadcasts it" had been over-read as "not on the wire at all". No release is cut. Per ADR-0006 the version waits for the M1 anchor, so no release ever has `connect()` meaning two different things. Offline suite: 459 passed, 1 skipped. The hardware suite was run before and after the move and gives identical results (3 passed, 1 skipped, 2 failed); both failures reproduce on a clean checkout of 94e5053 and are unrelated to this change. Closes #9
The one that mattered: ADR-0007 retracted "TEMPO MODE is not on the wire at all" while four living docs still asserted it, so the PR contradicted itself. The correction now lands where the claim actually lived - `protocol.md`'s per-preset tempo section, `manual-coverage.md` in two places, and `capture.md`, whose listener chapter used it as the exemplar of a trustworthy negative. That chapter now carries the second half of the lesson too: a listener proves only that the device does not ANNOUNCE something. The rest: - `tests/test_packaging.py` resolves the console-script target out of `pyproject.toml` and imports it. That string was the only user-visible break in the move and nothing checked it; verified by breaking it and watching the test fail. - `tests/test_namespace.py` enforces the layering rule this PR wrote down - no module under `pyquadcortex/protocol/` may import from `pyquadcortex/model/`. Checked on the source, so a lazy import inside a function cannot hide from it. Verified by planting a violation. - `tests/test_import_cleanliness.py` passes `onerror` to `walk_packages`, which swallows import failures by default. A subpackage whose `__init__` raised would have dropped its modules from the sweep and reported a clean run. Verified by planting a broken subpackage. - `Device.__repr__` said `connected=True` after `close()`, because `close()` never cleared anything. It now reports the object's own state, open or closed, which is true in every case. `close()`'s docstring says what a closed `Device` does. - The readme said the move landed in 0.41.0, a version that does not exist yet. It says "the next release"; the changelog heading stays the one source. - Continuation lines in five import statements left at their pre-rename indentation. Not changed: post-close property access is still undefined. It behaves exactly as the protocol layer does after `QuadCortex.close()`, which is consistent, and defining it properly belongs with the reconnect story (#15). Offline suite: 479 passed, 1 skipped. Hardware suite re-run after these changes: 3 passed, 1 skipped, 2 failed, identical to the pre-flip baseline, no restore failures.
CLAUDE.md never defines 'the flip', so the parenthetical on the proto bindings rule left a cold reader guessing. Name the move instead.
jonathanstokes
left a comment
There was a problem hiding this comment.
Reviewed the head of this branch (c31a24a), so this covers the fix batch in 818b891 as well as the original change.
Verdict: comments only, nothing here blocks a merge in my view. GitHub will not let an author approve their own pull request, so this is a COMMENT review rather than an APPROVE.
Two things are worth doing before the release cut, both about three lines each, and both in pyquadcortex/model/device.py: firmware and serial can hand back an empty string without saying so, and a closed Device keeps answering from cache while its own docstring says it cannot. Details are inline. They stand out because they are the exact failure this library is built to avoid, and because ADR-0007 in this same change writes down the rule they break.
What I checked rather than took on trust
- The offline suite really is
479 passed, 1 skipped. - The move really is behaviour-preserving. All ten moved modules diff clean against
mainwith every changed line an import path or a path inside a docstring.framing.pyandhid_ids.pyare byte-identical. Export parity is exact at 70 of 70 names. - Packaging works end to end. A built wheel carries the
_pb2files andentry_points.txtreadsqcctl = pyquadcortex.protocol.cli:main. - The
.gitignoreedit is comment-only, and all three proto files are still tracked, so ADR-0001 holds. import hidappears exactly once, lazily, atpyquadcortex/protocol/session.py:48.- The
hidguard genuinely goes red when broken: 18 of 19 cases fail on an injected module-scope import, including one wrapped intry/except.
Three notes with no single line to attach them to
No py.typed marker anywhere in the package. Python's rule is that a package without that marker is treated as untyped, so every type annotation on Device, on connect(), and across the whole protocol surface is invisible to anyone running mypy or pyright against this library. The fix is one empty file at pyquadcortex/py.typed. packages = ["pyquadcortex"] in pyproject.toml would ship it as-is. Worth doing in this change specifically, because this is the pull request that restructured the package and added tests/test_packaging.py to pin exactly this kind of promise.
The transport's logger name changed. It was pyquadcortex.transport and it is now pyquadcortex.protocol.transport, because the logger takes its name from the module. Nothing breaks, and no documentation names a logger, but anyone who wrote logging.getLogger("pyquadcortex.transport") in their own setup will find that line silently stops doing anything. The changelog currently says nothing changed except where things are imported from, which technically covers it, but a reader configuring logging would not connect the two.
QuadCortex.close() swallows every exception with no log at all (pyquadcortex/protocol/client.py:228). This is older than this change and is outside the deliberate "the RX thread never dies" exemption, so I am not asking for it here. Flagging it because this change puts the library's documented front door directly on top of it: if releasing the USB handle fails, the unit stays held, and the user finds out on the next run or when Cortex Control refuses to attach, with nothing in this run's output. One log.warning in that handler would make it visible.
Worth saying plainly
The move is clean, and I went looking for drift rather than assuming. Every new parametrised guard carries its own check against passing vacuously, three for three, which is rarer than it should be. _reraise in tests/test_import_cleanliness.py is a genuinely subtle catch: noticing that walk_packages swallows import errors, so a broken subpackage would have quietly shrunk the list into a clean sweep of a package nobody ever opened.
The documentation correction is the best part of this change. Walking "MODE is not on the wire" back to "MODE is never broadcast" across three documents, and adding the second half of the lesson to the chapter that had been using the old claim as its worked example, fixes the overreach where it actually lived instead of only in the new design document. The four comments I left on that section are about finishing that job, not about the direction of it.
| if line.startswith("|")) # table rows only, not prose | ||
| problems = [] | ||
| for m in re.finditer(r"`(pyquadcortex\.)?([a-z_][a-z0-9_]*)\(", rows): | ||
| for m in re.finditer(r"`(protocol\.)?([a-z_][a-z0-9_]*)\(", rows): |
There was a problem hiding this comment.
This pattern skips a stale row instead of flagging it, and stale rows are exactly what this change could leave behind.
The regex is anchored on a backtick, so a leftover row reading `pyquadcortex.blocks(` matches nothing at all: the optional protocol. group fails, then [a-z_][a-z0-9_]* swallows pyquadcortex and runs into a . where it needs a (. Before this change that row was the checked case. Now it is silently passed over.
I checked docs/api.md and it is fully converted, 0 stale rows against 17 converted ones and 106 names checked, so there is no live miss. It is a blind spot pointing in the direction this change moves.
Two related gaps while this is open: nothing here executes a documentation example, it only pattern-matches the table, so every snippet in README.md, architecture.md and capture.md is unchecked by CI. And the model's surface, pyquadcortex.connect, device.firmware, Device.from_client, is not covered at all, even though those are the new front-door names.
There was a problem hiding this comment.
Confirmed the regex behaviour exactly: a row reading `pyquadcortex.blocks(`
produces no match at all, for the reason you give. Fixed.
A new test names stale rows instead of skipping them. Any `pyquadcortex.something(` in that table that is not protocol. or model.
fails, listing what it found. docs/api.md is clean today, so it starts green
and stays a guard pointing the direction this change moves.
Leaving this open, because I have not closed the two related gaps and I do not
want to resolve them on your behalf.
Executing a documentation example needs a fake stack the docs can be run
against, or a hardware run. Covering the model's surface means api.md growing
a model section, which is worth doing when the model has more than identity on
it rather than now. Both are bigger than this change. Say if you want either
filed as its own issue.
| - **Rationale:** The model becomes the documented front door the moment it exists, with no release where `connect()` is ambiguous. The protocol layer loses nothing: same API, same support, one import deeper. | ||
| - **Consequences:** Refines ADR-0004's "additive namespace" consequence: the model is still additive code-wise and the protocol API is still public and unchanged, but import paths flip at M1 - existing 0.x scripts update one import line. The flip and its changelog/readme messaging land in the M1 Epic. The Intent Brief's "Additive, not breaking" requirement is amended to match (owner decision, 2026-08-05). | ||
|
|
||
| ## ADR-0007: The model may represent a control whose wire path is still open |
There was a problem hiding this comment.
This rule has no code, no exception type and no test yet, which is consistent but leaves the first user of it with nothing to inherit.
I am not calling this a defect. The ADR says the exception shape stays open until M3, the pull request says no tempo surface ships here, and pyquadcortex/model/ contains no raise at all, so the three statements agree.
The thing worth noticing is that the four-test safety case in this change does not touch ADR-0007 at all, so nothing mechanical will stop the first M3 surface from guessing. Two decisions are much cheaper to pin now, while the reasoning is fresh, than to retrofit later:
The refusal wants its own exported exception. The pattern already exists here in BlockRefused at pyquadcortex/protocol/client.py:136, but reusing that one would be wrong: BlockRefused means the device said no, and this means we have not found the message, and merging them makes both impossible to catch separately. NotImplementedError is also a poor fit, since callers cannot tell it from a genuine unfinished-method bug and tooling reads it that way too.
There also needs to be a way to ask without touching. With a property that raises, "read it if you can, skip it if you cannot" forces a try/except around every attribute, and any generic state dump or inspector blows up on attribute access. The comment on Device.__repr__ shows this concern is already understood one level up; a raising property reintroduces it one level down. Pairing the exception with a supported predicate avoids that.
When the first refusing property does land, the test worth writing is that the refusal happens with a client whose send and request fail the test if called at all, so refusing-before-writing is pinned rather than assumed.
There was a problem hiding this comment.
Agreed it is not a defect, and I have not turned it into code. Not resolving.
ADR-0007's Open Questions already parks the exception shape until the first such
control ships, M3 at the earliest, so the three statements you checked still
agree. Settling it now means designing an M3 API with no consumer in front of
us. The record is also Decided, so a new constraint would have to arrive as a
resolution of that open question or as a new record, not as an edit.
Your two constraints are the useful part and I think both are right. Reusing
BlockRefused would merge "the device said no" with "we have not found the
message", and then neither can be caught on its own. NotImplementedError reads
as an unfinished method to people and to tooling. The supported predicate is
the point I had not considered: a raising property does break a generic state
dump, which is the same concern the __repr__ comment is protecting one level
up.
I did correct one fact in the record while I was in it. It said all three tests
watched the switch "changed and committed", and the third test's action script
has only the toggle - no OK step before the BANK UP that ends the section. Two
of the three committed. ADR-0007 has not shipped, since this same pull request
introduces it, so fixing a fact in it is not rewriting a shipped decision.
Whether to pin the exception and the predicate now is your call.
PR #18 fixed the two scene echo predicates and added an offline test that imports the hardware module. Three of its four files auto-merged. docs/STEERING.md conflicted: both sides rewrote the same stale sentence about the hardware suite. Kept main's, which is the same correction plus the import-safety requirement the new test depends on. tests/test_scene_echo_predicates.py is new on main and imported pyquadcortex.client and pyquadcortex.proto, both of which this branch moves. Git merged it cleanly because this branch never touched the file; its two imports now point at pyquadcortex.protocol.
Code the story added: - Device checks field PRESENCE before reporting firmware or serial. Both sit in synthetic oneofs, so an absent one decodes as "" and would have shipped as the unit's answer. An incomplete reply raises and is not cached, so a retry can still recover. - A closed Device refuses firmware, serial and client. _closed had been read by nothing but __repr__, so a Device that had cached its identity kept answering after close with no unit behind it. This defines the explicit close() only; a connection lost on its own stays with story #15. - __repr__ says whether the Device owns or borrows its connection. - connect() documents the TimeoutError from the openable-but-silent window. - The read-once cache says its evidence is an inference, not a measurement. Test guards that read stronger than they were: - The pre-flip export snapshot is pinned by content hash and exact count. It cannot be a live `git show`: CI checks the repo out one commit deep. - The parity check asserts each name still resolves to something defined in the protocol layer, not merely that the name exists. - The layering check reads every import spelling. `from pyquadcortex import model` is the house style and was invisible to it; relative imports are resolved, and the prefix match takes a dot boundary. - The import-cleanliness sentinel takes the trailing dot, so it needs a real module inside model/ rather than the package entry. - pyquadcortex.__all__ is pinned as an equality. - qcctl's entry point is executed, not just resolved. - docs/api.md rows still spelled the pre-flip way are named, not skipped. - A new connect() spy pins every argument the model passes down, including handshake_patience, which no fake-stack test could notice going missing. - scripts/check_artifacts.py, run by CI's build job: twine check reads metadata, so nothing looked inside the wheel for the bindings ADR-0001 exists to ship. - compile_protos.sh refuses an output directory that is not the bindings directory, instead of creating one and reporting success. Docs, one account of the TEMPO MODE retraction in every file: - Three tests, not two. The strong instrument is the second, not the third. The third is the 2026-08-06 device-wide sweep, whose script toggles MODE with no written OK step. The claim stood for eight releases, not two. - protocol.md stops filing GlobalTempo as a dead end: one READ returned a clock, and the same document records that it alternates two shapes with the other carrying the 25 params. - changelog.md carries the withdrawal, which it was missing, and states which digit a breaking change moves while the major number is 0. - README and changelog say submodule paths moved too; the README banner and the section below it agree on tense; "it is complete" is scoped. - domain-model.md says landed, not shipped, and its legend defines `open`.
Closes #9
What changed
import pyquadcortexnow gives you the Quad Cortex itself. The message-level APIthat used to live there moved down one level, to
pyquadcortex.protocol.Nothing about the protocol API changed except where it lives. Same classes, same
methods, same arguments, same results. It is a move, not a rewrite.
What you have to do
Change one import line.
That is the whole migration.
qcctlbehaves exactly as before. If you installedthe package in editable mode, reinstall it so the console script finds its new
home.
Why break it now
The library exists to give you an object model of the unit, and that model is now
being built. The name at the front door should be the thing people want, and that
is the model, not the wire. This library is at version 0.x with roughly no users,
so the break costs almost nothing today and would cost a lot later. The decision
was made and recorded ahead of time as ADR-0006.
The version is deliberately NOT cut in this pull request. It gets cut once the
model can actually read a preset, so that no published release ever has
connect()meaning two different things.What the model gives you today
Not much yet, and it says so rather than pretending.
Presets, scenes and the grid are the next stories in the same Epic. Use the
protocol layer for anything the model does not cover. To use both layers in one
script, wrap a connection you already have with
Device.from_client(qc)- it doesnot take ownership, so closing the
Deviceleaves your connection open.How you can check the move was faithful
Four tests do the checking, so nobody has to take my word for it.
tests/test_namespace.pyreads a committed copy of the oldpyquadcortex/__init__.py, taken verbatim from git at 0.40.0, and asserts that every single name it used to export is reachableunder
pyquadcortex.protocol. The list of names is generated from that file, nottyped out, so a name dropped in the move cannot slip past by being forgotten from
a checklist. The same file also enforces the new layering rule: nothing under
pyquadcortex/protocol/may import frompyquadcortex/model/.tests/test_import_cleanliness.pywalks every module in the package and importseach one in a fresh process, checking that none of them pulls in
hid. That rulealready existed, but it was enforced by people remembering it. Now it covers
anything anyone adds, in either namespace.
tests/test_packaging.pyreads theqcctltarget out ofpyproject.tomlandimports it, the way the installed console script does. That string is the one
part of this change a user could notice, and nothing was checking it.
There is also a mechanical check on the diff itself: every changed line in the
moved modules contains the word
pyquadcortex, which means every change is animport path or a path inside a docstring. No logic was touched.
The one decision recorded here that is not about namespaces
ADR-0007: the model may represent a control whose wire path is still open, as long
as the operation it cannot perform refuses rather than guesses.
The case that raised it is TEMPO MODE. The unit's Tempo menu has a GLOBAL /
PRESET switch, and we know exactly what it does. What we have not found is the
message that drives it. Three tests watched for an announcement when the switch is
changed and saw nothing, and the docs had written that down as "not on the wire at
all". That was too strong: the unit not announcing something is not the same as
the unit refusing to tell you. Every one of those tests listened, and none of them
asked.
So the design doc now models the switch and refuses it, instead of omitting it or
letting a write through and guessing which scope it landed in. The last of those
is the failure this whole project is trying to avoid, because a wrong write on
this device looks exactly like a right one.
The correction lands in the protocol record too, not just the design doc:
protocol.md,manual-coverage.md, andcapture.md, which had been using theold conclusion as its worked example of a trustworthy negative result. It now
carries the other half of that lesson: a listener can only prove what a listener
measures.
Nothing about tempo ships here. It is an M3 surface.
Test results
Offline suite:
Hardware suite, on the connected unit:
The two scene echo failures reported here earlier are gone. They were the
no-presence trap fixed in #18, which has since been merged into this branch. No
restore failures, so the unit was left as it was found.
Things worth a second opinion
pyquadcortex/protocol/proto/.Leaving them at
pyquadcortex.proto, right next topyquadcortex.protocol,would have been a permanent trap for anyone reading an import. Nothing was
regenerated; the files are byte-identical and git recorded them as renames.
ADR-0001 is already decided and append-only, so its
pyquadcortex/proto/pathsstay as written; the steering doc's change log records where the directory went
and why.
qcctlmoved topyquadcortex.protocol.cliandpyproject.tomlfollows it.The command is unchanged. This is the one part of the move a user could notice,
and only until they reinstall.
Device.clientis a public way back down to the protocol layer. The storydid not ask for it. Without it, anyone who calls
pyquadcortex.connect()isstuck in the model, and they cannot open a second connection because the device
allows only one.
pyquadcortex/_version.py. Both namespacespublish
__version__, and this is the way to do that without one importing theother.
Devicenow refuses reads. This bullet used to say the behaviourwas undefined and consistent with a closed
QuadCortex. Review showed that waswrong: a
Devicethat had already read its identity kept answeringfirmwareand
serialfrom cache, with no unit behind it, whilerepr()said closed.firmware,serialandclientnow raise. That defines the explicitclose()only - a connection that goes away on its own is still the reconnectstory, OM-M1.7: Scripts survive the unit disconnecting, sleeping, and coming back #15.
Review round
The 30 review threads were worked in a follow-up commit. 25 are fixed and
resolved, 5 are left open for the owner:
pyquadcortex/model/__init__.py- "model" already names an amp or pedalblock in this codebase, so the package directory collides. Four options and
their costs are on the thread. No rename made; this one is yours.
Device.clientlifetime and_owns_clientvisibility - partly closed(
clientraises after close,repr()says owns or borrows). The rest is anAPI decision that belongs with the reconnect code.
a defect; whether to pin the exception and a
supportedpredicate now is anowner call.
tests/test_docs.py- the stale-row blind spot is fixed; runningdocumentation examples and covering the model's surface are not, and are
bigger than this change.
One thing found while working them: the venv's
grpcio-tools1.83.0 emitsgencode 5.29.3, while the committed bindings are 7.35.1 and
pyproject.tomlpins
protobuf>=7.35.1,<8. A regeneration today walks the gencode backwardswith no symptom, because the runtime only validates
runtime >= gencode.scripts/compile_protos.shnow prints what changed so it is visible, andCLAUDE.md says to read the gencode version in the diff. The
grpcio-tools>=1.68floor in the dev extra probably wants raising - not touched here.
🤖 Generated with Claude Code