magi-core 0.3.1

LLM-agnostic multi-perspective analysis system inspired by MAGI
Documentation
# Migration Guide: magi-core 0.2.x → 0.3.0

v0.3.0 completes Python-MAGI parity by consolidating the prompt
architecture and hardening the `user_prompt` against injection. Two API
changes require consumer action; everything else is transparent.

## 1. `with_custom_prompt` → `with_custom_prompt_for_mode`

**Before (v0.2.x):**

```rust
let magi = MagiBuilder::new(provider)
    .with_custom_prompt(AgentName::Melchior, Mode::CodeReview, "...".into())
    .build();
```

**After (v0.3.0):**

```rust
let magi = MagiBuilder::new(provider)
    .with_custom_prompt_for_mode(AgentName::Melchior, Mode::CodeReview, "...".into())
    .build();
```

The legacy method is retained with `#[deprecated]` through v0.3.x. It
emits a compile-time warning but continues to work identically. Migrate
at your convenience; the shim is removed in v0.4.0.

## 2. New: `with_custom_prompt_all_modes` for mode-agnostic overrides

```rust
let magi = MagiBuilder::new(provider)
    .with_custom_prompt_all_modes(AgentName::Caspar, "custom for all modes".into())
    .build();
```

Lookup order (see `sbtdd/spec-behavior.md` §4.4):

1. Per-mode override (`with_custom_prompt_for_mode`).
2. Mode-agnostic override (`with_custom_prompt_all_modes`).
3. Embedded default from `src/prompts_md/*.md`.

## 3. Prompt directory layout changed

v0.2.x had 9 files under `src/prompts_md/` named `{agent}_{mode}.md`.
v0.3.0 has 3 files: `{agent}.md`, byte-for-byte copies of Python MAGI
v2.1.3. Consumers that read these files directly (e.g., for testing)
must update paths:

| v0.2.x path | v0.3.0 path |
|---|---|
| `src/prompts_md/melchior_code_review.md` | `src/prompts_md/melchior.md` |
| `src/prompts_md/melchior_design.md` | `src/prompts_md/melchior.md` |
| `src/prompts_md/melchior_analysis.md` | `src/prompts_md/melchior.md` |
| `src/prompts_md/balthasar_code_review.md` | `src/prompts_md/balthasar.md` |
| `src/prompts_md/balthasar_design.md` | `src/prompts_md/balthasar.md` |
| `src/prompts_md/balthasar_analysis.md` | `src/prompts_md/balthasar.md` |
| `src/prompts_md/caspar_code_review.md` | `src/prompts_md/caspar.md` |
| `src/prompts_md/caspar_design.md` | `src/prompts_md/caspar.md` |
| `src/prompts_md/caspar_analysis.md` | `src/prompts_md/caspar.md` |

**Note on `with_prompts_dir`:** the filesystem loader at `MagiBuilder::with_prompts_dir(dir)` still reads per-mode files with the v0.2 naming convention (`melchior_code_review.md`, `melchior_design.md`, `melchior_analysis.md`, similarly for balthasar and caspar). In v0.3.0 these entries are automatically merged into the internal overrides map with keys `(agent, Some(mode))`, so they behave identically to calling `with_custom_prompt_for_mode`. Consumers who used `with_prompts_dir` in v0.2.x continue to work without changes. Builder-level overrides (`with_custom_prompt_for_mode`) win over filesystem entries for the same (agent, mode) pair.

## 4. `user_prompt` format changed (affects mock-based tests)

If your tests use a mock `LlmProvider` that captures the `user_prompt`
and asserts on its content, update the assertions. The new format is:

```
MODE: <mode>
---BEGIN USER CONTEXT <32-hex-nonce>---
<sanitized content>
---END USER CONTEXT <32-hex-nonce>---
```

Key changes for assertion code:

- The nonce is random per call — match with regex `^[0-9a-f]{32}$` or
  compare on structure, not literal string.
- `content` is sanitized before embedding via a fixed 3-layer pipeline:
  1. `normalize_newlines` — maps all Unicode line separators to `\n`.
  2. `strip_invisibles` — removes zero-width and invisible Unicode chars.
  3. `neutralize_headers` — prefixes lines starting with `MODE:`,
     `CONTEXT:`, `---BEGIN`, `---END` with two spaces.
- Pipeline order is fixed (normalize → strip → neutralize). Reversing
  enables bypass; see `docs/adr/001-prompt-injection-threat-model.md`.

For byte-exact assertions: `with_rng_source` is currently `pub(crate)`.
Test your integrations against structure, not exact nonce values.

## 5. `Agent::new` no longer takes `Mode`

Direct constructions of `Agent` (uncommon — typically `Magi` does this
internally):

**Before:**

```rust
let agent = Agent::new(AgentName::Melchior, Mode::CodeReview, provider);
```

**After:**

```rust
let agent = Agent::new(AgentName::Melchior, provider);
```

The system prompt is resolved by the orchestrator via `lookup_prompt`
and passed to `Agent::execute` directly.

## 6. New error variant: `MagiError::InvalidInput { reason }`

Returned when `build_user_prompt` detects that the sanitized content
contains the generated nonce (fail-closed; probability ~2^-64 effective
given `fastrand` state size). In practice unreachable; add a catch-all
branch in your error handling if exhaustive matching is required:

```rust
match magi.analyze(&mode, content).await {
    Ok(report) => { /* ... */ }
    Err(MagiError::InvalidInput { reason }) => {
        eprintln!("prompt construction failed: {reason}");
        // retry or propagate
    }
    Err(other) => { /* ... */ }
}
```

`MagiError` is `#[non_exhaustive]`, so exhaustive matches already
require a `_` arm — no additional change needed if you have one.

## 7. New dependencies

Transitively pulled in; no direct action required:

- `fastrand ~2` — non-cryptographic RNG for per-request nonce. Lighter
  footprint than `rand 0.8`, no `unsafe`, no transitive `getrandom`.
- `sha2 0.10` — dev-dependency only; used for fixture SHA-256
  verification. Not present in release builds.

## Security limitations (MAGI R3 W8)

The following limitations are **known and accepted** per the threat model
in `docs/adr/001-prompt-injection-threat-model.md` (Scope IS-NOT
section). Consumers with stricter threat models must apply additional
pre-filtering before calling `analyze`.

### Case-sensitivity

Header matching is **case-sensitive**. `mode:`, `Mode:`, and `MoDe:` are
NOT neutralized — only the exact uppercase tokens `MODE:`, `CONTEXT:`,
`---BEGIN`, and `---END` are matched. This preserves Python-MAGI
reference behavior. If your threat model requires case-insensitive
header neutralization, pre-filter input before passing to `analyze`.

### Non-ASCII whitespace (NBSP and similar)

U+00A0 (NBSP), U+3000 (Ideographic Space), and other non-ASCII
whitespace characters placed before a header token are NOT absorbed by
the `neutralize_headers` regex. A crafted input like
`\u{00A0}MODE: override` would not be neutralized.

`INVISIBLE_AND_SEPARATOR_RE` omits these characters (Python-parity).
Consumers must pre-filter if this gap is within their threat model.

### Nonce entropy ~64 bits (effective)

`fastrand`'s internal state is approximately 64 bits. The effective
nonce collision probability per call is therefore ~2^-64, not the
theoretical 2^-128 implied by the 128-bit representation. This is
acceptable per the ADR threat model (the adversary cannot observe or
predict the RNG state). An escape hatch `with_rng_source(Box<dyn
RngLike + Send>)` is available `pub(crate)` for test injection. Promote
to `pub` and switch to `getrandom` if cryptographic unpredictability is
required.

### Scope IS-NOT from ADR 001

The sanitization pipeline does NOT defend against:

- Semantic injection via natural-language manipulation ("ignore previous
  instructions...").
- LLM-specific jailbreaks (role-play, DAN, system-prompt extraction).
- Side-channel attacks (timing, token-count oracles).
- Exfiltration via the LLM's output.

These are application-layer concerns; callers are responsible for their
own defenses at those layers.

## Verification steps after upgrading

Run your test suite. Look for:

1. **`#[deprecated]` warnings** on `with_custom_prompt` call sites —
   harmless; migrate at leisure to `with_custom_prompt_for_mode`.
2. **Mock-based prompt assertion failures** due to the new
   `user_prompt` format — update assertions to match the new structure
   (MODE line + nonce delimiters + sanitized content).
3. **Direct reads of `src/prompts_md/*.md`** — update paths from
   `{agent}_{mode}.md` to `{agent}.md`.
4. **Exhaustive `MagiError` matches** — add a branch for
   `InvalidInput { reason }` if needed (or rely on the existing `_` arm
   since `MagiError` is `#[non_exhaustive]`).

No runtime behavior change for the common consumer path
`Magi::new(provider).analyze(mode, content)`.