Skip to content

OM-M1.1: the model takes the front door, protocol moves to pyquadcortex.protocol - #17

Merged
jonathanstokes merged 5 commits into
mainfrom
feat/om-m1.1-namespace-flip
Aug 12, 2026
Merged

OM-M1.1: the model takes the front door, protocol moves to pyquadcortex.protocol#17
jonathanstokes merged 5 commits into
mainfrom
feat/om-m1.1-namespace-flip

Conversation

@jonathanstokes

@jonathanstokes jonathanstokes commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Closes #9

What changed

import pyquadcortex now gives you the Quad Cortex itself. The message-level API
that 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.

# before
import pyquadcortex
from pyquadcortex import Scene, Setlist

with pyquadcortex.connect() as qc:
    qc.switch_scene(Scene.B)

# after
from pyquadcortex import protocol
from pyquadcortex.protocol import Scene, Setlist

with protocol.connect() as qc:
    qc.switch_scene(Scene.B)

That is the whole migration. qcctl behaves exactly as before. If you installed
the 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.

import pyquadcortex

with pyquadcortex.connect() as device:
    print(device.firmware, device.serial)

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 does
not take ownership, so closing the Device leaves 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.py reads a committed copy of the old
pyquadcortex/__init__.py, taken verbatim from git at 0.40.0, and asserts that every single name it used to export is reachable
under pyquadcortex.protocol. The list of names is generated from that file, not
typed 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 from pyquadcortex/model/.

tests/test_import_cleanliness.py walks every module in the package and imports
each one in a fresh process, checking that none of them pulls in hid. That rule
already existed, but it was enforced by people remembering it. Now it covers
anything anyone adds, in either namespace.

tests/test_packaging.py reads the qcctl target out of pyproject.toml and
imports 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 an
import 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, and capture.md, which had been using the
old 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:

511 passed, 1 skipped in 6.51s

Hardware suite, on the connected unit:

5 passed, 1 skipped in 12.07s

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

  • The generated protobuf bindings moved too, to pyquadcortex/protocol/proto/.
    Leaving them at pyquadcortex.proto, right next to pyquadcortex.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/ paths
    stay as written; the steering doc's change log records where the directory went
    and why.
  • qcctl moved to pyquadcortex.protocol.cli and pyproject.toml follows it.
    The command is unchanged. This is the one part of the move a user could notice,
    and only until they reinstall.
  • Device.client is a public way back down to the protocol layer. The story
    did not ask for it. Without it, anyone who calls pyquadcortex.connect() is
    stuck in the model, and they cannot open a second connection because the device
    allows only one.
  • The version string moved to pyquadcortex/_version.py. Both namespaces
    publish __version__, and this is the way to do that without one importing the
    other.
  • A closed Device now refuses reads. This bullet used to say the behaviour
    was undefined and consistent with a closed QuadCortex. Review showed that was
    wrong: a Device that had already read its identity kept answering firmware
    and serial from cache, with no unit behind it, while repr() said closed.
    firmware, serial and client now raise. That defines the explicit
    close() only - a connection that goes away on its own is still the reconnect
    story, 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 pedal
    block 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.client lifetime and _owns_client visibility - partly closed
    (client raises after close, repr() says owns or borrows). The rest is an
    API decision that belongs with the reconnect code.
  • ADR-0007 has no exception type or test yet - the reviewer agrees it is not
    a defect; whether to pin the exception and a supported predicate now is an
    owner call.
  • tests/test_docs.py - the stale-row blind spot is fixed; running
    documentation 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-tools 1.83.0 emits
gencode 5.29.3, while the committed bindings are 7.35.1 and pyproject.toml
pins protobuf>=7.35.1,<8. A regeneration today walks the gencode backwards
with no symptom, because the runtime only validates runtime >= gencode.
scripts/compile_protos.sh now prints what changed so it is visible, and
CLAUDE.md says to read the gencode version in the diff. The grpcio-tools>=1.68
floor in the dev extra probably wants raising - not touched here.

🤖 Generated with Claude Code

….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.
@jonathanstokes
jonathanstokes marked this pull request as ready for review August 11, 2026 20:46
CLAUDE.md never defines 'the flip', so the parenthetical on the proto
bindings rule left a cold reader guessing. Name the move instead.

@jonathanstokes jonathanstokes left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 main with every changed line an import path or a path inside a docstring. framing.py and hid_ids.py are byte-identical. Export parity is exact at 70 of 70 names.
  • Packaging works end to end. A built wheel carries the _pb2 files and entry_points.txt reads qcctl = pyquadcortex.protocol.cli:main.
  • The .gitignore edit is comment-only, and all three proto files are still tracked, so ADR-0001 holds.
  • import hid appears exactly once, lazily, at pyquadcortex/protocol/session.py:48.
  • The hid guard genuinely goes red when broken: 18 of 19 cases fail on an injected module-scope import, including one wrapped in try/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.

Comment thread pyquadcortex/model/device.py
Comment thread pyquadcortex/model/device.py Outdated
Comment thread tests/test_namespace.py
Comment thread tests/test_namespace.py Outdated
Comment thread tests/test_namespace.py
Comment thread README.md Outdated
Comment thread docs/domain-model.md
Comment thread tests/test_docs.py
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):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/ADR.md
- **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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/releasing.md
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`.
@jonathanstokes
jonathanstokes merged commit 2a9081e into main Aug 12, 2026
4 checks passed
@jonathanstokes
jonathanstokes deleted the feat/om-m1.1-namespace-flip branch August 12, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OM-M1.1: The model becomes the front door, protocol moves one import deeper

2 participants