# ADR-0003: Reject universal raise-without-from enforcement

**Date:** 2026-03-28
**Status:** Accepted

## Context

PR #3225 proposed a pre-commit hook (`check-raise-without-from`) to enforce
`raise X(...) from e` inside all `except` blocks, citing PEP 3134 (explicit
exception chaining). The hook would flag any `raise NewException(...)` inside
an except handler that omits the `from` clause.

### The conflict with existing security hooks

The codebase already enforces **exception sanitization** to prevent PII
leakage through three mechanisms:

1. **`check-sensitive-logging.py`** blocks exception variables in
   `logger.warning/error/critical()` calls and forbids `exc_info=True` on
   production log levels. Only `logger.exception()` and
   `logger.debug(..., exc_info=True)` are permitted.

2. **`fix-exception-logging.py`** auto-removes exception variable references
   from f-strings and `exc_info=True` from non-debug logs.

3. **Intentional chain-breaking pattern** used across the codebase: catch a
   broad exception, log full details with `logger.exception()`, then raise a
   sanitized application exception *without* `from e`:

   ```python
   except Exception as e:
       logger.exception("Error getting news feed")    # full details logged
       raise NewsFeedGenerationException(str(e), ...)  # no "from e"
   ```

   This pattern appears in `news/api.py`, `notifications/service.py`,
   `utilities/es_utils.py`, and others. Flask error handlers then return only
   `error_code` and `message` to the client — never stack traces.

### Why `raise X from e` is harmful here

`raise X from e` preserves the full original exception chain at the Python
runtime level. Even if logs are sanitized, the chain propagates to:

- Error tracking services (Sentry, DataDog) that capture `__cause__`
- Any downstream handler that logs with `exc_info=True`
- Test output and development tooling that prints full tracebacks

If the original exception contains PII (SQL errors with user data, API
responses with auth tokens, file paths with usernames), preserving the chain
defeats the sanitization strategy.

### When `from e` IS appropriate

Explicit chaining is valuable in infrastructure code where:

- The original exception contains no user data (e.g., config parsing errors)
- Both exceptions will be caught and handled before reaching any boundary
- Debugging requires understanding the root cause chain

The seven existing `from e` usages in the codebase (`mcp/client.py`,
`config/llm_config.py`, `security/path_validator.py`, `exporters/`) follow
this pattern correctly.

## Decision

Do not add a universal `raise-without-from` enforcement hook. The existing
pattern of intentional chain-breaking for PII protection takes precedence
over PEP 3134 compliance.

Developers should use their judgment:

- **Use `raise X from e`** when the original exception is safe to propagate
  (no user data, internal infrastructure errors)
- **Use `raise X from None`** when you want to explicitly suppress the chain
  and document the intent
- **Omit `from`** when wrapping user-facing exceptions to sanitize error
  details (the current default pattern)

## Consequences

- The allowlisted files in the rejected PR do not need remediation
- Exception chain decisions remain a code review concern, not an automated
  enforcement
- The existing `check-sensitive-logging` and `fix-exception-logging` hooks
  remain the primary defense against PII leakage through exceptions
- `raise X from None` is available for cases where developers want to
  explicitly document chain suppression, but is not required
