# Module architecture
`tix` is split by **responsibility first, feature second**. The rule a new file
must follow: one responsibility per file, and a production file stays below the
size gate in `scripts/check-module-size.sh` (hard limit 1,200 lines, warning at
800; the allowlist is the debt ledger for #1723's remaining files — remove an
entry when its file is split, never add one for new code).
## Layers
| `src/model`, `src/config` | persistent records, frontmatter, configuration | I/O orchestration, rendering |
| `src/storage` | the issue tree: reads, writes, number claims, transactions | policy (workflow, WIP, capacity) |
| domain services (`src/health`, `src/wip`, `src/capacity`, `src/projects`, `src/epics`, `src/roadmap`, `src/views`, `src/planning`, `src/relationships`, `src/recurrence`, `src/cycle_schedule`, `src/analytics`) | the one calculation/decision for their scope | TUI state, CLI parsing |
| `src/commands` | CLI verbs: argument → service call → text/exit code | interactive state |
| `src/mcp` | JSON-RPC tool schemas and argument mapping over the same services | a second implementation of any rule |
| `src/ui` | widgets drawn from **sub-state** (`ui::board::render(&app.board, …)`) | `App` ownership, key bindings |
| `src/app` | `App` state, input dispatch, orchestration, frame composition glue | domain rules (it calls the services) |
## `src/app/`
`app.rs` is the root: the `App` struct, construction, config reload, and the
`mod`/`pub use` surface. Everything else is a child module:
- state and vocabulary: `mode.rs`, `state.rs`
- cross-cutting input: `shell.rs` (mode dispatch), `nav.rs` (rail), `mouse.rs`,
`render/` (frame composition; the widget bodies stay in `src/ui/`)
- feature slices: `triage`, `bulk`, `mentions`, `views`, `settings`, `planning`,
`cycle`, `stats*`, `schedule*`, `capacity-view`, `portfolio`, `roadmap`,
`library`, `board_input`, `detail_input`, `forms`, `pickers`, `filter`,
`actions`, `relationships`, `confirm`, `help`
- persistence/refresh: `refresh`, `editor_input`, `repair`, `session`
- `tests/` — the 295 TUI tests, one file per feature, fixtures in `helpers.rs`
Child modules may see `App`'s private fields (Rust privacy includes
descendants), so a split never widens field visibility. Items the parent or
siblings call are `pub(in crate::app)`; only the genuine external surface
(`App`, `Action`, `Mode`, `MoveTarget`, `LinkStep`, `StatsTab`,
`StatsTimeState`, `FormPickerField`, `render`, `run`, `run_new_issue`,
`render_once`) is `pub`/re-exported from `app.rs`.
## Rules
1. Move-only commits: no behavior, signature or formatting change in the same
commit as a file move.
2. A feature owns its state, its keys and its render glue; shared tables go to
`src/ui/` and take sub-state, not `&App`.
3. `clippy::too_many_lines` is active (threshold 120 in `clippy.toml`); a
function that genuinely is a table or a dispatch may carry a targeted
`#[allow]` with a reason.
4. `scripts/check-module-size.sh` runs in `mise run ready`.