# Architecture Design: Rigor Global Configuration

**Review status**: Iteration 2 -- 4 reviewer gaps resolved, duplication prevention section added.

## System Context

The Rigor Global Configuration feature extends the existing rigor system to support a two-level config cascade: project-level `.nwave/des-config.json` (existing) and a new global-level `~/.nwave/global-config.json`. This eliminates repetitive `/nw:rigor` runs across multiple projects by allowing users to set rigor preferences once globally.

The feature touches three components: DESConfig (runtime resolution), `/nw:rigor` command (write scope), and `nwave-ai uninstall` (lifecycle cleanup).

## Capabilities

| ID | Capability | User Story |
|----|-----------|------------|
| C1 | Config cascade resolution | US-RGC-01 |
| C2 | Scope-aware rigor persistence | US-RGC-02 |
| C3 | Global config lifecycle on uninstall | US-RGC-03 |

## C4 System Context (L1)

```mermaid
C4Context
    title System Context: Rigor Global Configuration

    Person(user, "Developer", "Sets rigor profile, runs wave commands across projects")
    System(nwave, "nWave Framework", "AI workflow with rigor profile cascade")
    System_Ext(claude, "Claude Code", "LLM runtime, Agent tool, model parameter")
    System_Ext(fs_global, "User Home Filesystem", "~/.nwave/global-config.json")
    System_Ext(fs_project, "Project Filesystem", ".nwave/des-config.json")

    Rel(user, nwave, "configures rigor via /nw:rigor")
    Rel(user, nwave, "uninstalls via nwave-ai uninstall")
    Rel(nwave, claude, "dispatches agents with model parameter")
    Rel(nwave, fs_global, "reads/writes global config")
    Rel(nwave, fs_project, "reads/writes project config")
```

## C4 Container (L2)

```mermaid
C4Container
    title Container Diagram: Rigor Global Config Resolution

    Person(user, "Developer")

    Container_Boundary(nwave, "nWave Framework") {
        Container(rigor_cmd, "/nw:rigor Command", "Markdown", "Interactive profile selection with scope question")
        Container(wave_cmds, "Wave Commands", "Markdown", "deliver/design/execute/review/distill")
        Container(des_config, "DESConfig", "Python", "Cascading config resolver: project -> global -> defaults")
        Container(uninstaller, "Uninstaller", "Python", "nwave-ai uninstall with global config prompt")
    }

    ContainerDb(project_cfg, "des-config.json", "JSON", "Project-level rigor + DES settings")
    ContainerDb(global_cfg, "global-config.json", "JSON", "Global-level rigor settings")
    System_Ext(claude, "Claude Code", "Agent tool with model parameter")

    Rel(user, rigor_cmd, "selects profile and scope")
    Rel(user, uninstaller, "runs nwave-ai uninstall")
    Rel(rigor_cmd, project_cfg, "writes rigor block when scope=project")
    Rel(rigor_cmd, global_cfg, "writes rigor block when scope=global")
    Rel(wave_cmds, des_config, "reads resolved rigor profile")
    Rel(wave_cmds, claude, "dispatches agents with model from rigor")
    Rel(des_config, project_cfg, "reads project config first")
    Rel(des_config, global_cfg, "reads global config as fallback")
    Rel(uninstaller, global_cfg, "prompts keep/delete during uninstall")
```

## Component Architecture

### Component 1: DESConfig Cascade (US-RGC-01)

**File**: `src/des/adapters/driven/config/des_config.py`
**Change type**: Modify existing class
**Port interface**: `ConfigPort` exists at `src/des/ports/driven_ports/config_port.py` but covers a separate concern (`get_timeout_threshold_default()`). DESConfig does not implement `ConfigPort` -- it is a standalone driven adapter. No port changes are needed for this feature; DESConfig's public API (constructor + properties) is the de facto interface consumed by the application layer.

The DESConfig constructor gains an optional keyword-only `global_config_path` parameter (injectable for testing, defaults to `Path.home() / ".nwave" / "global-config.json"`). The keyword-only constraint (after `*` in the signature) prevents breaking existing positional callers `DESConfig(path, cwd)`.

**Constructor signature constraint**:
```
def __init__(self, config_path, cwd, *, global_config_path=None)
```
The `*` separator ensures `global_config_path` cannot be passed positionally. All existing call sites (`DESConfig()`, `DESConfig(config_path=p)`, `DESConfig(cwd=c)`, `DESConfig(config_path=p, cwd=c)`) remain valid without modification.

Both project and global configs are loaded using the SAME `_load_json_file(path)` static method (see Duplication Prevention section). No separate `_load_global_configuration()` method -- the existing `_load_configuration()` is refactored into a reusable static function that both call sites invoke.

The existing `_rigor()` method is the single modification point. Current behavior returns `self._config_data.get("rigor", {})`. New behavior:

1. Check `self._config_data` (project config) for `rigor` key
2. If present, return it (full block, no merge with global)
3. If absent, check `self._global_config_data` for `rigor` key
4. If present, return it (full block)
5. If absent, return `{}` (hardcoded standard defaults apply via property-level `.get()` calls)

**Resolution order**: project rigor -> global rigor -> hardcoded defaults

All existing `rigor_*` properties remain unchanged. They call `_rigor()` which now cascades. No new public API surface is added to DESConfig beyond the keyword-only constructor parameter.

**Error handling for global config**:
- File does not exist: return `{}`
- File contains invalid JSON: return `{}`
- File is unreadable (permissions): return `{}`
- No exception raised to caller in any case

### Component 2: /nw:rigor Scope Question (US-RGC-02)

**File**: `nWave/tasks/nw/rigor.md`
**Change type**: Modify existing command

A scope selection step is inserted at the beginning of all three modes (interactive, quick switch, custom). The step appears after displaying current profiles and before profile selection.

**Scope question flow**:

1. Display current project profile (from `.nwave/des-config.json`) and current global profile (from `~/.nwave/global-config.json`)
2. Ask: "Where do you want to save this configuration?" via AskUserQuestion
   - Option 1: "Globally (~/.nwave/global-config.json)"
   - Option 2: "This project only (.nwave/des-config.json)"
3. Based on answer, the write step (Mode 1 Step 6 / Mode 2 Step 4 / Mode 3 Step 9) targets the selected file

**Write behavior by scope**:

| Scope | Write target | Read-modify-write | Directory creation |
|-------|-------------|-------------------|-------------------|
| Global | `~/.nwave/global-config.json` | Yes, preserve non-rigor keys | Create `~/.nwave/` with `parents=True` if missing |
| Project | `.nwave/des-config.json` | Yes, preserve non-rigor keys (existing behavior) | No (must already exist) |

**Directory creation on first global save**: When scope=global AND `~/.nwave/` does not exist, the command MUST create the directory with `parents=True` before writing `global-config.json`. This ensures the first-ever global save works without requiring the user to manually create the directory. The directory creation step occurs immediately before the file write, not as a separate pre-check. This mirrors the existing pattern in `DESConfig.save_update_check_state()` (line 236: `self._config_path.parent.mkdir(parents=True, exist_ok=True)`).

**Quick switch with argument** (`/nw:rigor lean`): The scope question still appears. The user explicitly confirmed this in the DISCUSS wave -- quick switch should not assume project scope. The question is concise (2 options) and adds minimal friction.

**Summary output** includes the scope and file path:
```
Config: ~/.nwave/global-config.json (global)
```
or
```
Config: .nwave/des-config.json (project)
```

### Component 3: Uninstall Global Config Handling (US-RGC-03)

**File**: `scripts/install/uninstall_nwave.py`
**Change type**: Modify existing class

A new method on `NWaveUninstaller` checks for `~/.nwave/global-config.json` before the removal sequence begins. If the file exists and `--force` is NOT active, the user is prompted with "Keep" or "Delete" options.

**Method placement**: After `check_installation()` and `confirm_removal()`, before `create_backup()`. This ensures the user sees the global config question as part of the confirmation flow.

**Behavior by choice**:

| User choice | Action |
|-------------|--------|
| Keep | Skip file entirely; log "Preserved global config at ~/.nwave/global-config.json" |
| Delete | Remove `~/.nwave/global-config.json`; if `~/.nwave/` is empty afterward, remove the directory |
| No file found | No prompt shown, proceed normally |

**Dry-run mode**: Logs "Would prompt about global config at ~/.nwave/global-config.json" without modifying files.

**Force mode (`--force`)**: Automatically preserves global config without prompting. The prompt is skipped entirely; the method logs "Preserved global config (--force: skipping prompt)" and returns. This maintains the semantics that `--force` means "skip all interactive prompts" while being conservative with user data. The `--force` flag already skips `confirm_removal()` in the existing uninstaller -- this follows the same pattern.

## Component Boundaries

| Component | Responsibility | Boundary |
|-----------|---------------|----------|
| `DESConfig._rigor()` | Config cascade resolution | Read-only; loads project then global; never writes |
| `/nw:rigor` command | Scope question + profile persistence | Writes to one of two locations based on user choice |
| `NWaveUninstaller` | Global config lifecycle | Prompts keep/delete; manages `~/.nwave/` directory |

## Technology Stack

| Technology | Purpose | License | Rationale |
|-----------|---------|---------|-----------|
| Python 3.10+ | DESConfig + uninstaller changes | PSF | Existing stack |
| JSON | Config storage format | N/A | Same schema as des-config.json |
| Markdown | Command file modification | N/A | Existing format |
| pathlib | `Path.home()` for global path | PSF (stdlib) | Cross-platform home directory |

No new dependencies. No new technologies. This is a configuration plumbing change within the existing stack.

## Integration Patterns

### Config resolution (read path)

```
DESConfig.__init__(config_path, cwd, *, global_config_path)
    |
    v
_load_json_file(project_path) -> self._config_data        [shared static method]
_load_json_file(global_path)  -> self._global_config_data  [same method, different path]
    |
    v
_rigor() called by rigor_* properties
    |
    +-- project has "rigor" key? -> return project rigor dict
    |
    +-- global has "rigor" key? -> return global rigor dict
    |
    +-- neither -> return {} (standard defaults via .get() in properties)
```

### Config persistence (write path via /nw:rigor)

```
/nw:rigor -> scope question -> profile selection -> confirm
    |
    +-- scope=global  -> read-modify-write ~/.nwave/global-config.json
    |                     (create ~/.nwave/ if missing)
    |
    +-- scope=project -> read-modify-write .nwave/des-config.json
                          (existing behavior, unchanged)
```

### Uninstall lifecycle

```
nwave-ai uninstall
    |
    v
check_installation() -> confirm_removal() [skipped if --force]
    |
    v
check_global_config()  [NEW]
    |
    +-- file missing -> skip (no prompt)
    |
    +-- file exists + --force -> auto-preserve, log, skip prompt
    |
    +-- file exists + interactive -> prompt keep/delete
    |   +-- keep   -> skip, log "Preserved global config"
    |   +-- delete -> remove file, clean empty dir
    |
    v
create_backup() -> remove_agents() -> ... -> validate_removal()
```

## Quality Attribute Strategies

| Attribute | Strategy |
|-----------|----------|
| Maintainability | Single modification point (`_rigor()`); all rigor_* properties unchanged |
| Testability | `global_config_path` injectable; all 5 cascade scenarios testable in isolation with `tmp_path` |
| Reliability | Silent fallback on corrupt/missing global config; no crash path |
| Simplicity | Full block override (no per-field merge); same JSON parsing pattern reused |

## Duplication Prevention

This section documents how the design avoids logic duplication across the three components. Every shared concern has exactly one owner.

### JSON Config File Reading

**Risk**: Project config loading (`_load_configuration()`) and global config loading could duplicate the "read file, parse JSON, fallback to empty dict" pattern.

**Resolution**: Refactor `_load_configuration()` into a static method `_load_json_file(path: Path) -> dict[str, Any]` that both project and global config loading call. The constructor invokes this method twice (once per path). No second loading method is created.

| Concern | Single owner | Consumers |
|---------|-------------|-----------|
| JSON read + parse + fallback | `DESConfig._load_json_file(path)` | Project config init, global config init |

Note: `des_plugin.py` has its own `_read_json_config(path)` method (line 576) which is structurally identical. This is an existing duplication in the installer layer. Unifying it with DESConfig's method is out of scope for this feature (the installer cannot import from `src/des/` at install time -- it runs before DES is installed). The duplication is acknowledged and contained to two separate deployment contexts (runtime vs install-time).

### Rigor Default Values

**Risk**: Hardcoded standard defaults (sonnet, haiku, 5-phase, etc.) could appear in multiple places.

**Resolution**: Defaults are defined exactly once -- in each `rigor_*` property's `.get()` call (e.g., `self._rigor().get("profile", "standard")`). The `_rigor()` method returns a raw dict; it never injects defaults. The cascade (`_rigor()`) and the defaults (property `.get()`) are separate responsibilities. No default values are duplicated in `_rigor()`, in the constructor, or in global config loading.

| Concern | Single owner | Consumers |
|---------|-------------|-----------|
| Default rigor values | `rigor_*` property `.get()` calls | All rigor consumers (wave commands, DES hooks) |
| Cascade resolution | `_rigor()` method | All `rigor_*` properties |

### Config File Write (read-modify-write pattern)

**Risk**: `/nw:rigor` writing to project config and global config could duplicate the read-modify-write-JSON pattern.

**Resolution**: The `/nw:rigor` command is a markdown file executed by the main Claude instance. The write logic is described once in the command instructions as a parameterized procedure: "Read `{target_file}`, parse JSON, set `config["rigor"]` to profile object, write back." The `target_file` is the only variable (determined by scope selection). The command instructions do NOT duplicate the write procedure for each scope -- the scope selection determines the file path, then a single write procedure uses it.

### Config File Existence Check

**Risk**: Uninstaller checking for `~/.nwave/global-config.json` could duplicate path resolution logic from DESConfig.

**Resolution**: The uninstaller checks `Path.home() / ".nwave" / "global-config.json"` directly using `Path.exists()`. This is a simple file existence check for cleanup purposes -- it does NOT parse or interpret the config. DESConfig resolves the same path via its constructor default, but for a different purpose (loading and parsing). These are different operations (existence check vs load-and-parse) so the shared concern is only the path literal. The path `~/.nwave/global-config.json` appears in:
1. DESConfig constructor default (`global_config_path` parameter default)
2. `/nw:rigor` command write target (markdown instruction text)
3. Uninstaller existence check

This path is a convention, not logic. It cannot be further deduplicated without introducing a shared constant module that all three components import, which would create a coupling dependency between the uninstaller (which runs standalone) and the DES runtime. The cost of that coupling exceeds the benefit for a single string literal.

## Error Handling Summary

| Error Case | Component | Behavior |
|------------|-----------|----------|
| Global config file missing | DESConfig | Silent fallback to defaults |
| Global config invalid JSON | DESConfig | Silent fallback to defaults |
| Global config unreadable | DESConfig | Silent fallback to defaults |
| `~/.nwave/` dir missing on save | /nw:rigor | Auto-create directory |
| Global config file missing | Uninstaller | Skip prompt, proceed normally |

## Files Summary

### Modified Files

| File | Change | Story |
|------|--------|-------|
| `src/des/adapters/driven/config/des_config.py` | Add `global_config_path` param; modify `_rigor()` for cascade | US-RGC-01 |
| `nWave/tasks/nw/rigor.md` | Add scope question to all 3 modes; add global write path | US-RGC-02 |
| `scripts/install/uninstall_nwave.py` | Add `check_global_config()` method; prompt keep/delete | US-RGC-03 |

### New Files

None. All changes are modifications to existing files.

### Test Files (new, for DISTILL/DELIVER)

| File | Purpose | Story |
|------|---------|-------|
| `tests/des/unit/adapters/driven/config/test_des_config_global_cascade.py` | 5 cascade resolution scenarios | US-RGC-01 |
| `tests/installer/unit/test_uninstall_global_config.py` | Keep/delete/absent scenarios | US-RGC-03 |
