# PRD 0031: Simplify egress route merge and consolidate Route types

- **Status:** Active
- **Author:** didericis-claude
- **Created:** 2026-06-02
- **Issue:** #120

## Summary

Replace `_merge_provider_route`'s five-case nested conditional with a
flat provisioned-wins merge, and make the mapping between the host-side
`EgressRoute` and the addon's `Route` explicit in one place. Covers the
two remaining open tasks from the #117 hotspot review.

## Problem

### 1. `_merge_provider_route` branching

`_merge_provider_route` in `bot_bottle/egress.py` handles five distinct
cases in a single function with interleaved conditions:

1. **append-new** — host not in manifest; append a fresh route.
2. **upgrade-bare** — host found, no existing auth; adopt provider auth.
3. **no-op** — host found, same auth; return unchanged.
4. **tls-passthrough upgrade** — same as no-op but provider sets
   `tls_passthrough=True`; flip the flag on the existing route.
5. **conflict-die** — host found, different auth; hard error.

Cases 3 and 4 share a block with no-op as the invisible fall-through.
`_find_or_alloc_token_env` is duplicated between cases 2 and 1. In-place
replacements spell out every `EgressRoute` field explicitly, so a new
field added to the dataclass silently drops its value in any replacement
site that wasn't updated.

The root cause of the complexity is that the current merge tries to be
cooperative: it lets manifest routes coexist with provider routes and
attempts to upgrade bare manifest entries. This makes sense if the
manifest is authoritative, but the actual intended hierarchy is the
opposite — provider routes claim their hosts outright and the manifest
fills in what's left.

### 2. Three-way Route type fragmentation

`EgressRoute` (in `egress.py`) and `egress_addon_core.Route` are
separate dataclasses with overlapping but not identical field sets:

| Field | `EgressRoute` | addon `Route` |
|---|---|---|
| `host` | ✓ | ✓ |
| `path_allowlist` | ✓ | ✓ |
| `auth_scheme` | ✓ | ✓ |
| `token_env` | ✓ | ✓ |
| `token_ref` | ✓ (host-side) | — |
| `roles` | ✓ (host-side) | — |
| `tls_passthrough` | ✓ (pipelock concern) | — |

`egress_render_routes` serialises `EgressRoute` fields to YAML; the
addon's `load_routes` deserialises that YAML into `Route` objects. If a
field is added to `EgressRoute` that should appear in the YAML, both
`egress_render_routes` and `_parse_one` must be updated consistently.
The render function spells the field list out inline with no reference
to the addon's parser, so divergence is silent until runtime.

`egress_addon_core.Route` cannot be replaced by `EgressRoute` — the
addon file is copied flat into the sidecar container image (`/app/`) and
has no access to the `bot_bottle` package. The types must stay separate;
the risk is that they drift.

## Goals / Success Criteria

- `egress_routes_for_bottle` implements a flat provisioned-wins merge:
  provider routes claim their hosts; manifest routes for unclaimed hosts
  append. No upgrade logic, no conflict detection.
- Token slot assignment is a single pass over the merged list, not
  interleaved with the merge.
- `egress_render_routes` uses a single `_route_to_yaml_fields` helper
  that explicitly lists the addon-visible fields, creating one place
  where the `EgressRoute`→`Route` mapping is spelled out.
- All existing `TestProviderRouteMerge` and `TestRenderRoutes` tests
  pass (adjusting assertions for any semantics changes described below).
- No behaviour change for existing manifests that don't trigger the
  conflict-die or upgrade-bare paths.

## Non-goals

- Merging `EgressRoute` and `egress_addon_core.Route` into one class
  (impossible: addon runs in a stdlib-only container environment).
- Changing what the addon does with a route once it has one.
- Changing `decide()` or `is_git_push_request()` in `egress_addon_core`
  — those are already pure functions with good separation.

## Design

### Merge: provisioned wins

The new hierarchy: **provisioned routes own their hosts; manifest routes
fill the gaps.**

```
provisioned_hosts = {pr.host.lower() for pr in provider_routes}

effective = list(provider_routes)
effective += [r for r in manifest_routes if r.host.lower() not in provisioned_hosts]
```

Token slot assignment runs as a final pass over `effective` in order:

```python
def _assign_token_slots(
    routes: list[EgressRoute],
) -> tuple[EgressRoute, ...]:
    slot_for_ref: dict[str, str] = {}
    out: list[EgressRoute] = []
    for r in routes:
        if r.auth_scheme and r.token_ref and not r.token_env:
            token_env = slot_for_ref.get(r.token_ref)
            if token_env is None:
                token_env = f"EGRESS_TOKEN_{len(slot_for_ref)}"
                slot_for_ref[r.token_ref] = token_env
            r = dataclasses.replace(r, token_env=token_env)
        out.append(r)
    return tuple(out)
```

This replaces `_merge_provider_route`, `_find_or_alloc_token_env`, and
the slot-assignment loop inside `egress_manifest_routes`.

#### Semantics change

Under the old design, a manifest route for a provisioned host with a
*different* `auth_scheme` or `token_ref` raised a hard error. Under
provisioned-wins, the manifest entry is silently dropped. Operators who
relied on the conflict error to catch misconfigurations should audit
their manifests, but in practice this path was only reachable when a
manifest declared auth for `api.openai.com` or `chatgpt.com` with a
token ref other than `CODEX_HOST_CREDENTIAL_TOKEN_REF` while also
enabling `forward_host_credentials` — an unlikely combination.

Similarly, the "upgrade-bare" path (provider adopts a bare manifest
route's `path_allowlist`) is dropped: a provisioned host takes the
provider route's fields wholesale, and the manifest's `path_allowlist`
for that host is ignored.

### Route type mapping: `_route_to_yaml_fields`

Add a pure function in `egress.py`:

```python
def _route_to_yaml_fields(r: EgressRoute) -> dict:
    """Return the addon-visible fields for one route.

    This is the single authoritative mapping between `EgressRoute`
    (host-side) and `egress_addon_core.Route` (sidecar-side). If a
    field is added to `Route` that must appear in the YAML, add it
    here and in `egress_addon_core._parse_one` together."""
    fields: dict = {"host": r.host}
    if r.auth_scheme and r.token_env:
        fields["auth_scheme"] = r.auth_scheme
        fields["token_env"] = r.token_env
    if r.path_allowlist:
        fields["path_allowlist"] = list(r.path_allowlist)
    return fields
```

`egress_render_routes` delegates to it:

```python
for r in routes:
    f = _route_to_yaml_fields(r)
    lines.append(f'  - host: "{f["host"]}"')
    if "auth_scheme" in f:
        lines.append(f'    auth_scheme: "{f["auth_scheme"]}"')
        lines.append(f'    token_env: "{f["token_env"]}"')
    if "path_allowlist" in f:
        lines.append("    path_allowlist:")
        for p in f["path_allowlist"]:
            lines.append(f'      - "{p}"')
```

The docstring on `_route_to_yaml_fields` is the explicit callout to
update both it and `_parse_one` together when the schema changes.

### `egress_manifest_routes` / `egress_routes_for_bottle`

`egress_manifest_routes` becomes a pure lifter with no slot assignment:
it reads each manifest route entry and returns an `EgressRoute` with
`token_env=""` (the slot to be filled later). The function's docstring
currently promises slot assignment; that promise moves to
`egress_routes_for_bottle`.

`egress_routes_for_bottle` becomes:

```python
def egress_routes_for_bottle(
    bottle: Bottle,
    provider_routes: tuple[EgressRoute, ...] = (),
) -> tuple[EgressRoute, ...]:
    manifest = egress_manifest_routes(bottle)
    provisioned_hosts = {pr.host.lower() for pr in provider_routes}
    merged = list(provider_routes) + [
        r for r in manifest if r.host.lower() not in provisioned_hosts
    ]
    return _assign_token_slots(merged)
```

### Mitmproxy request logic

`egress_addon_core.decide()` and `is_git_push_request()` are already
pure functions; `egress_addon.EgressAddon.request()` is the minimal
mitmproxy glue (read host/path/headers from flow → call pure functions
→ apply result to flow). This split is already clean and requires no
structural change in this PRD.

## Test impact

- **`TestProviderRouteMerge`**: the `test_provider_route_upgrades_bare_manifest_route`
  test asserts that a provider route preserves a bare manifest route's
  `path_allowlist`. Under provisioned-wins that `path_allowlist` is
  dropped. Update the test to reflect the new semantics.
- **`test_provider_route_conflicts_with_different_authed_manifest_route`**:
  the conflict-die case no longer exists. Remove this test.
- All other merge and render tests should pass without modification.

## Implementation chunks

1. **PRD (this commit).** Sets the design.
2. **Merge refactor.** Replace `_merge_provider_route` and
   `_find_or_alloc_token_env` with `_assign_token_slots` and the
   flat provisioned-wins logic in `egress_routes_for_bottle`. Strip
   slot assignment from `egress_manifest_routes`.
3. **Render consolidation.** Add `_route_to_yaml_fields`; update
   `egress_render_routes` to use it.
4. **Test updates.** Adjust `TestProviderRouteMerge` for the semantics
   changes above; confirm all render tests pass.

## References

- Issue #120: Refactor `_merge_provider_route` (expanded to include
  Route type fragmentation).
- Issue #117: Complexity hotspots — source of both findings.
- PRD 0030: Deduplicate egress token resolution (prior egress cleanup).
