Repository navigation
Decouple the config facade: a 222-module dependency cycle undermines the core-path guarantee #6386
Description
Area
Multiple areas
What are you trying to accomplish?
Keep the proxy module layering intact so that a user who configures one provider and one model executes no optional-subsystem code, and so that a change in one domain stays locally scoped instead of being able to reach almost every other module. Today a single strongly-connected component of 222 runtime modules means nearly any edit can reach nearly any other module, and the three files this repository explicitly protects from Compatibility Lab (src/router.ts, src/server/lifecycle.ts, src/server/responses/core.ts) all sit inside that component.
What prevents this today?
src/config.ts is a 386-line facade that re-exports the already-split src/config/ submodules (config/paths, config/subagent-models, config/salvage, config/diagnostics, config/load-degrade, config/provider-name, config/warn-memo and others). The implementations were physically moved out, but about 198 modules, the highest fan-in in the tree, still import everything through the single config entry rather than from the module that actually owns the concern. That is what keeps the cycle closed.
Measured on the runtime import graph (value imports only; import type is excluded because it is erased at runtime), across 1598 modules and 7655 edges:
- 6 strongly-connected components with more than one module; 238 modules sit inside a cycle.
- The largest component holds 222 modules: bridge, config, router, oauth/index, all 30 codex/catalog modules, providers/quota, server/lifecycle, service, storage and usage/log among them.
- Of 228 back edges, 92 (about 40 percent) terminate at config. The next highest target is codex/main-account with 26, then oauth/index with 19.
Because the facade is the single return target, splitting config further cannot break the cycle: the split already happened and the cycle survived it.
What should OpenCodex do?
Callers that need exactly one concern should import from the src/config submodule that owns it, or from a narrow per-concern facade, so that src/config.ts shrinks to a thin compatibility re-export and the import graph becomes layered instead of cyclic. Concretely, break the 222-module component: start with the small cycles, then move per-domain callers off the config facade. The Compatibility Lab decoupling already proved the slot-registration pattern works (devlog/_fin/260814_lab_core_decoupling/); the same technique has simply never been applied to config. Note that bun run structure:check passes today, so no structure doc is stale: the layering is just not recorded as an invariant anywhere.
Example usage or interface
Before, in about 198 modules:
import { getConfigDir, loadConfig } from "../config";
After, importing the owning module directly:
import { getConfigDir } from "../config/paths";
import { loadConfig } from "../config/load-degrade";
Smaller cycles worth fixing first, each verified by hand against the source:
- oauth/chatgpt and oauth/chatgpt-device (2 modules)
- server/claude-messages and server/messages-native (2 modules)
- claude/desktop-first-party to desktop-picker to intercept/picker-runtime to intercept/runtime (4 modules)
- client/machine-listener and client/runtime (2 modules)
One real cycle inside the 222-module component, every link source-checked: usage/log to codex/account-label to codex/main-account to codex/native-profile-startup to codex/native-profile-manager to codex/native-profile-processes to codex/app-server-processes to codex/catalog/parsing to oauth/index to providers/api-keys to providers/api-key-selection to router to routing/evaluator to routing/health to routing/history/indexer and back to usage/log.
Alternatives or workarounds
Leaving src/config.ts as the single entry point and documenting the cycle is the status quo. It works, but it means the guarantee that an optional subsystem stays off the core path rests on a foundation where every protected file is cyclically coupled to every other one. Splitting config into more submodules without changing call sites was already tried and did not break the cycle.
Additional context
Related but separate: issue 6297 (native Anthropic passthrough drops anthropic-ratelimit response headers), verified still present on main. That is a bug, not this refactor.
The numbers above come from walking the runtime import graph with the same import matcher that tests/lab/core-lab-boundary.test.ts uses; type-only imports are excluded.
Checks
- I searched existing issues and documentation.
- This request describes a concrete OpenCodex workflow rather than merely naming a desired technology.
- I removed secrets and personal data.
Activity
github-actions commentedon Oct 1, 2026
Automated translation bookkeeping — detected language: English.
Ingwannu commentedon Oct 2, 2026
Thanks for the concrete graph and source-checked cycles. Treating this as a layering proposal, not proof by itself of a Compatibility Lab activation regression: the core-path import boundary and synchronous activation tests enforce a separate contract, and a strongly connected component alone does not establish Lab execution or startup cost. The reported 222-module/198-importer counts need a pinned revision and a current-dev repeat before they become acceptance targets. A reviewable first step would be one ownership concern (for example pure path access), preserving the public config facade, plus a runtime-value-import graph regression and the owning structure/ADR update. Type-only imports, lazy imports and registration seams should be distinguished; no broad 222-module rewrite or performance claim is implied. Separately, #6297 was addressed on dev by #6356; a report about the release/main tree must not be treated as evidence that the same passthrough defect is still present on current dev. This proposal remains open for the owner to agree scope and a measured acceptance criterion.
test: ratchet src/ import cycles and keep core entrypoints off them
lidge-jun commentedon Oct 15, 2026
Status on current dev
7640a942b, measured with the same value-import edge rules astests/lab/core-lab-boundary.test.ts(static edges only;import typeandimport()excluded): 189 modules on cycles in 6 components (largest 168).tests/ci-workflows/import-cycle-ratchet.test.ts. It lists every module still on a cycle, fails when a new one joins, requires the list to shrink when one leaves, and keepssrc/config.ts,server/lifecycle.ts,responses/core.tsandmanagement-api.tsoff every cycle.src/router.tsto that list. After both, none of the protected entrypoints or the config facade is cyclic.That gives this issue the measured, pinned acceptance criterion asked for above. The remaining 35 modules (quota/oauth/auth-api, the inject/service loop, native-profile accounts, and three small pairs) can come down incrementally, and the ratchet holds each step.
lidge-jun commentedon Oct 16, 2026
Resolved on dev and released in v2.87.0. #6454 (b3cd913, "Closes #6386") broke the config-facade cycle from 189 to 59 static cyclic modules, and #6594 (7a42860) took the remaining OAuth/auth-api cycle from 59 to 35. After both, neither the config facade nor the protected core entrypoints are cyclic, which was this issue's acceptance bar. The remaining small cycles are tracked by the import-cycle ratchet as incremental follow-ups. Thank you @lcxhh521 for the analysis. Closing manually because PRs target dev, not the default branch.