Skip to content

v0.12 item 1: break the module cycle ARCHITECTURE §6 has recorded since v0.7 - #208

Merged
rohanrkamath merged 1 commit into
mainfrom
v0.12/1-import-cycle
Sep 14, 2026
Merged

rohanrkamath merged 1 commit into
mainfrom
v0.12/1-import-cycle

Conversation

@arpanghoshal

Copy link
Copy Markdown
Member

§6 says dependencies point downward only, and from v0.7 to v0.11 that was false: state -> receipt -> policy -> authority -> state. Nothing broke at run time, because the two edges out of policy.py are function-level, so import ctrlrun resolved in one order and the suite passed. That is why it survived five milestones, and why a reviewer had to find it by reading instead of by a red run.

The edge broken

receipt -> policy. Decision and POLICY_UNAPPROVED move to decision.py, which imports nothing from the package. That was the whole of what a receipt needed from the decider, and it is vocabulary rather than behaviour: an evidence type reaching up into the decider is the edge that most contradicts §6's table, which lists receipt.py as used by everything else.

No public name moves. policy.py re-exports both, so from ctrlrun.policy import Decision is the same object and SPEC-v0.1 §8's frozen __init__ block is untouched. The roadmap called each candidate fix "a public-surface question of its own"; with a re-export this one is not.

The other candidate was moving policy.py's two deferred imports, and it is not available: _from_section constructs an Authority and canonical_grants consumes one, so neither moves below policy.py, and moving the two callers up to control.py would change what policy_hash is taken over, which is evidence in every receipt.

The guard was worth more than the fix

tests/test_module_graph.py walks every module's AST and separates two questions §6 kept conflating:

  • the import-order graph, module-level imports only, which is what runs at import ctrlrun
  • the layering graph, which counts deferred imports and is what §6's table actually describes

The recorded cycle is invisible to the first. A guard built only on module-level imports passes on 0.11.0's tree, which I checked against a git archive copy rather than assumed, and my first draft did exactly that while its own docstring claimed otherwise.

Writing it found two things nobody had recorded:

  1. jwt_identity <-> revocation. revocation.py imported _NoRedirects from inside a method to avoid a second copy of a security-critical redirect handler. One copy was always right; the direction was wrong. The class now lives in revocation.py, which jwt_identity.py already imports, and it keeps warning on the ctrlrun logger so no existing handler or filter is silently re-routed.
  2. A phantom. My first resolver mapped from .. import transport inside gateway/ to gateway.transport instead of the top-level transport, and reported a cycle that does not exist. A detector that invents edges is worse than none, because the fix for a phantom cycle is a refactor nobody needed. That resolution is now the function's main documented concern.

What stays

policy <-> authority, named in RECORDED_LAYERING_CYCLES rather than left to a reader, with the reason it cannot be relocated. An allow-list of one fails the moment a second appears; a plain "no cycles" assertion softened to a skip never would.

Gate

All checks passed!            (ruff, ruff format, mypy --strict: 53 files)
4582 passed

The one failure in that run was the cookbook race, which is #207 and is unrelated to this change.

Pairing

Docs are v0.12/1-import-cycle. Do not merge before v0.11.0 is tagged.

🤖 Generated with Claude Code

§6 says dependencies point downward only, and from v0.7 to v0.11 that was
false: `state -> receipt -> policy -> authority -> state`. Nothing broke at
run time, because the two edges out of `policy.py` are function-level, so
`import ctrlrun` resolved in one order and the suite passed. That is why it
survived five milestones and why a reviewer had to find it by reading.

**The edge broken is `receipt -> policy`.** `Decision` and `POLICY_UNAPPROVED`
move to `decision.py`, which imports nothing from the package. That was the
whole of what a receipt needed from the decider, and it is vocabulary rather
than behaviour: an evidence type reaching up into the decider is the edge that
most contradicts §6's table, which lists `receipt.py` as used by everything
else.

**No public name moves.** `policy.py` re-exports both, so
`from ctrlrun.policy import Decision` is the same object and SPEC-v0.1 §8's
frozen `__init__` block is untouched. The roadmap called this a public-surface
question; with a re-export it is not one.

**The rule is now a test**, and writing it was worth more than the fix. It
distinguishes two graphs §6 kept conflating: import-order, which is
module-level imports, and layering, which counts deferred imports and is what
the table describes. The recorded cycle is invisible to the first, so a guard
built only on module-level imports passes on 0.11.0's tree, which I checked
rather than assumed.

That guard immediately found a second cycle nobody had recorded:
`jwt_identity <-> revocation`, where `revocation.py` imported `_NoRedirects`
from inside a method to avoid a second copy of a security-critical redirect
handler. One copy was always right and the direction was wrong. The class now
lives in `revocation.py`, which `jwt_identity.py` already imports, and it keeps
warning on the `ctrlrun` logger so no existing handler or filter is re-routed.

**One layering cycle stays, named rather than left to a reader.**
`policy <-> authority` cannot be removed by relocation: `_from_section`
constructs an `Authority` and `canonical_grants` consumes one, so neither moves
below `policy.py`, and moving the two callers up to `control.py` would change
what `policy_hash` is taken over, which is evidence in every receipt.
`RECORDED_LAYERING_CYCLES` holds that pair and nothing else, so a second one
fails the suite.

Signed-off-by: arpan <contact@arpanghoshal.com>
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: b4783802-5956-4339-9772-c3229dd3b9fb

📥 Commits

Reviewing files that changed from the base of the PR and between 8a81289 and 5f8e015.

📒 Files selected for processing (6)
  • src/ctrlrun/decision.py
  • src/ctrlrun/jwt_identity.py
  • src/ctrlrun/policy.py
  • src/ctrlrun/receipt.py
  • src/ctrlrun/revocation.py
  • tests/test_module_graph.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread src/ctrlrun/policy.py
# in `__init__.py`, and `adapter.py` imports `Decision` from here too. Both names moved down to
# break the module cycle §6 records, and both still resolve from this module because that block
# is a frozen public surface and a cycle is not a reason to move a published import path.
from .decision import POLICY_UNAPPROVED as POLICY_UNAPPROVED
@rohanrkamath
rohanrkamath merged commit ed7c879 into main Sep 14, 2026
15 of 16 checks passed
@rohanrkamath
rohanrkamath deleted the v0.12/1-import-cycle branch September 14, 2026 21:24
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.

3 participants