docs(prd): revise PRD 0031 — provisioned-wins merge + Route type consolidation
test / unit (pull_request) Successful in 42s
test / integration (pull_request) Successful in 1m0s

Expands scope to cover both remaining egress hotspot tasks from #117:
- Replaces the named-helper design with a flat provisioned-wins merge
  (provider routes own their hosts; manifest fills gaps; no upgrade or
  conflict-detection logic needed).
- Adds _route_to_yaml_fields as the single authoritative EgressRoute→Route
  mapping to prevent silent type drift between host and addon.
- Notes that the mitmproxy pure-function split is already clean (decide +
  is_git_push_request) and requires no structural change.
This commit is contained in:
2026-06-02 05:26:15 +00:00
parent f596464f3f
commit ae33d1abfb
+184 -116
View File
@@ -1,4 +1,4 @@
# PRD 0031: Split `_merge_provider_route` into named case helpers # PRD 0031: Simplify egress route merge and consolidate Route types
- **Status:** Draft - **Status:** Draft
- **Author:** didericis-claude - **Author:** didericis-claude
@@ -7,167 +7,235 @@
## Summary ## Summary
Refactor `_merge_provider_route` in `bot_bottle/egress.py` to replace its Replace `_merge_provider_route`'s five-case nested conditional with a
five-outcome nested conditional with a top-level host-lookup dispatch and flat provisioned-wins merge, and make the mapping between the host-side
one named private function per outcome. No behaviour change. `EgressRoute` and the addon's `Route` explicit in one place. Covers the
two remaining open tasks from the #117 hotspot review.
## Problem ## Problem
`_merge_provider_route` handles five distinct cases in a single function: ### 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. 1. **append-new** — host not in manifest; append a fresh route.
2. **upgrade-bare** — host found, no existing auth; adopt provider auth. 2. **upgrade-bare** — host found, no existing auth; adopt provider auth.
3. **no-op** — host found, existing auth matches provider auth exactly; 3. **no-op** — host found, same auth; return unchanged.
return unchanged.
4. **tls-passthrough upgrade** — same as no-op but provider sets 4. **tls-passthrough upgrade** — same as no-op but provider sets
`tls_passthrough=True` and the existing route doesn't; flip the flag. `tls_passthrough=True`; flip the flag on the existing route.
5. **conflict-die** — host found, existing auth differs from provider; 5. **conflict-die** — host found, different auth; hard error.
hard error.
These cases are currently identified by interleaved `if`/`continue` Cases 3 and 4 share a block with no-op as the invisible fall-through.
conditions rather than named dispatch. The control flow is: `_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
for idx, route in enumerate(routes): # host lookup cooperative: it lets manifest routes coexist with provider routes and
if route.host != pr.host: continue attempts to upgrade bare manifest entries. This makes sense if the
if route.auth_scheme or route.token_ref: # already-authed branch manifest is authoritative, but the actual intended hierarchy is the
if same auth: opposite — provider routes claim their hosts outright and the manifest
if tls upgrade needed: replace # case 4 fills in what's left.
return # case 3
die(...) # case 5
[token_env alloc]
routes[idx] = ... # case 2
return routes
[token_env alloc]
routes.append(...) # case 1
return routes
```
Specific problems: ### 2. Three-way Route type fragmentation
- Cases 3 and 4 share the same `if same auth:` block; case 3 is the `EgressRoute` (in `egress.py`) and `egress_addon_core.Route` are
implicit fall-through after the inner `if`, making it invisible by separate dataclasses with overlapping but not identical field sets:
name.
- `_find_or_alloc_token_env` is called twice with identical arguments in | Field | `EgressRoute` | addon `Route` |
the upgrade-bare path (case 2) and the append-new path (case 1). |---|---|---|
- Adding a sixth case requires reading the whole function to find the | `host` | ✓ | ✓ |
right insertion point, with no structural hint about case boundaries. | `path_allowlist` | ✓ | ✓ |
- The in-place index write (`routes[idx] = EgressRoute(...)`) spells out | `auth_scheme` | ✓ | ✓ |
every field explicitly; a new field added to `EgressRoute` silently | `token_env` | ✓ | ✓ |
drops its value on any in-place replacement that wasn't updated. | `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 ## Goals / Success Criteria
- Each of the five outcomes is implemented in its own named private - `egress_routes_for_bottle` implements a flat provisioned-wins merge:
function with a docstring stating the precondition. provider routes claim their hosts; manifest routes for unclaimed hosts
- `_merge_provider_route` reads as a dispatch table: find the host, then append. No upgrade logic, no conflict detection.
call the right helper. - Token slot assignment is a single pass over the merged list, not
- In-place replacements use `dataclasses.replace` instead of full interleaved with the merge.
constructor calls. - `egress_render_routes` uses a single `_route_to_yaml_fields` helper
- All existing `TestProviderRouteMerge` tests pass without modification. that explicitly lists the addon-visible fields, creating one place
- No behaviour change. 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 ## Non-goals
- Changing the public API of `egress_routes_for_bottle` or - Merging `EgressRoute` and `egress_addon_core.Route` into one class
`egress_manifest_routes`. (impossible: addon runs in a stdlib-only container environment).
- Changing merge semantics (what counts as a conflict, what upgrade rules - Changing what the addon does with a route once it has one.
apply). - Changing `decide()` or `is_git_push_request()` in `egress_addon_core`
- Consolidating any other complexity in `egress.py`. — those are already pure functions with good separation.
## Design ## Design
### Dispatch structure ### Merge: provisioned wins
```python The new hierarchy: **provisioned routes own their hosts; manifest routes
def _merge_provider_route( fill the gaps.**
routes: list[EgressRoute], pr: EgressRoute,
) -> list[EgressRoute]: ```
for idx, route in enumerate(routes): provisioned_hosts = {pr.host.lower() for pr in provider_routes}
if route.host.lower() == pr.host.lower():
return _merge_at_index(routes, idx, route, pr) effective = list(provider_routes)
return _append_provider_route(routes, pr) effective += [r for r in manifest_routes if r.host.lower() not in provisioned_hosts]
``` ```
### Per-case helpers Token slot assignment runs as a final pass over `effective` in order:
**`_merge_at_index`** — dispatches on whether the existing route already
carries auth:
```python ```python
def _merge_at_index( def _assign_token_slots(
routes: list[EgressRoute], idx: int, route: EgressRoute, pr: EgressRoute, routes: list[EgressRoute],
) -> list[EgressRoute]: ) -> tuple[EgressRoute, ...]:
if route.auth_scheme or route.token_ref: slot_for_ref: dict[str, str] = {}
return _merge_authed(routes, idx, route, pr) out: list[EgressRoute] = []
return _upgrade_bare(routes, idx, route, pr) 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)
``` ```
**`_merge_authed`** — existing route has auth; either no-op/tls-upgrade This replaces `_merge_provider_route`, `_find_or_alloc_token_env`, and
or conflict-die: 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 ```python
def _merge_authed( def _route_to_yaml_fields(r: EgressRoute) -> dict:
routes: list[EgressRoute], idx: int, route: EgressRoute, pr: EgressRoute, """Return the addon-visible fields for one route.
) -> list[EgressRoute]:
"""Precondition: route.auth_scheme or route.token_ref is set.""" This is the single authoritative mapping between `EgressRoute`
if route.auth_scheme != pr.auth_scheme or route.token_ref != pr.token_ref: (host-side) and `egress_addon_core.Route` (sidecar-side). If a
die(...) # case 5 field is added to `Route` that must appear in the YAML, add it
if pr.tls_passthrough and not route.tls_passthrough: here and in `egress_addon_core._parse_one` together."""
routes[idx] = dataclasses.replace(route, tls_passthrough=True) # case 4 fields: dict = {"host": r.host}
return routes # case 3 (implicit no-op) 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
``` ```
**`_upgrade_bare`** — existing route has no auth; adopt provider auth `egress_render_routes` delegates to it:
and preserve `path_allowlist` + `roles`:
```python ```python
def _upgrade_bare( for r in routes:
routes: list[EgressRoute], idx: int, route: EgressRoute, pr: EgressRoute, f = _route_to_yaml_fields(r)
) -> list[EgressRoute]: lines.append(f' - host: "{f["host"]}"')
"""Precondition: route has no auth_scheme or token_ref.""" if "auth_scheme" in f:
token_env = _find_or_alloc_token_env(routes, pr.token_ref) if pr.auth_scheme else "" lines.append(f' auth_scheme: "{f["auth_scheme"]}"')
routes[idx] = dataclasses.replace( lines.append(f' token_env: "{f["token_env"]}"')
route, if "path_allowlist" in f:
auth_scheme=pr.auth_scheme, lines.append(" path_allowlist:")
token_env=token_env, for p in f["path_allowlist"]:
token_ref=pr.token_ref, lines.append(f' - "{p}"')
tls_passthrough=pr.tls_passthrough,
)
return routes
``` ```
**`_append_provider_route`** — host not in manifest; append a new route The docstring on `_route_to_yaml_fields` is the explicit callout to
(no `path_allowlist` or `roles` to preserve): 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 ```python
def _append_provider_route( def egress_routes_for_bottle(
routes: list[EgressRoute], pr: EgressRoute, bottle: Bottle,
) -> list[EgressRoute]: provider_routes: tuple[EgressRoute, ...] = (),
"""Precondition: no existing route matches pr.host.""" ) -> tuple[EgressRoute, ...]:
token_env = _find_or_alloc_token_env(routes, pr.token_ref) if pr.auth_scheme else "" manifest = egress_manifest_routes(bottle)
routes.append(dataclasses.replace(pr, token_env=token_env)) provisioned_hosts = {pr.host.lower() for pr in provider_routes}
return routes merged = list(provider_routes) + [
r for r in manifest if r.host.lower() not in provisioned_hosts
]
return _assign_token_slots(merged)
``` ```
Note: `dataclasses.replace(pr, token_env=token_env)` preserves any ### Mitmproxy request logic
future fields added to `EgressRoute` without requiring updates here.
### Import `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.
Add `import dataclasses` to `egress.py` (currently uses the `dataclass` ## Test impact
decorator from `dataclasses` but not `dataclasses.replace`).
- **`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 ## Implementation chunks
1. **PRD (this commit).** Sets the design. 1. **PRD (this commit).** Sets the design.
2. **Refactor.** Apply the dispatch + four helpers in `egress.py`; add 2. **Merge refactor.** Replace `_merge_provider_route` and
`dataclasses` import; delete the old `_merge_provider_route` body. `_find_or_alloc_token_env` with `_assign_token_slots` and the
3. **Tests.** Existing `TestProviderRouteMerge` suite is the acceptance flat provisioned-wins logic in `egress_routes_for_bottle`. Strip
gate — no new tests needed beyond confirming all pass. 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 ## References
- Issue #120: Refactor `_merge_provider_route`. - Issue #120: Refactor `_merge_provider_route` (expanded to include
- Issue #117: Complexity hotspots — source of the finding. Route type fragmentation).
- Issue #117: Complexity hotspots — source of both findings.
- PRD 0030: Deduplicate egress token resolution (prior egress cleanup). - PRD 0030: Deduplicate egress token resolution (prior egress cleanup).