Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions devlog/_plan/260829_green_pr_merge_train/000_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 260829 — Green-PR merge train

Eight rebased pull requests reached a fully green test matrix on `dev@e546c160b` and are
candidates to land. This unit records why each one is safe to merge, the order the merges
must happen in, and the two integration designs that have to be built before their PRs can
Comment on lines +1 to +5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Archive the completed merge-train record

Move this restored unit to _fin and add its terminal disposition: a repository-wide history check shows that all eight candidates named here (#2365, #2429, #1756, #2050, #2827, #2364, #2712, and #2854) landed before this commit, so introducing their historical plan under _plan incorrectly presents completed work as an open unit.

AGENTS.md reference: AGENTS.md:L83-L86

Useful? React with 👍 / 👎.

land at all.

## Why this needs a written analysis rather than eight merge clicks

The eight diffs are not independent. Five pairs touch the same file, and three of those
pairs touch `src/config.ts` — the shared config parser every provider path reads. Merging
in arrival order would produce conflicts that a later merge resolves blindly, which is the
failure mode that produced the #2850 → #2851 follow-up: a merge that looked clean and
needed a security repair one hour later.

A second reason is drift. `dev` moved from `e546c160b` to `8d1dc1f5d` while this set was
being prepared (#2861, #2862, #2865, #2868, #2869). Every green result recorded earlier
belongs to the head that produced it, not to the head the merge will land on.

## Regression-impact inventory

Source files each PR touches, ignoring docs and tests:

| PR | Subject | `src/` surface |
|---|---|---|
| #2365 | usage cache metrics | `usage/summary.ts` |
| #2429 | `test:changed` local check | `AGENTS.md` only |
| #1756 | Grok per-model reasoning effort | `grok/{catalog,effort,inject,models}.ts`, `server/index.ts` |
| #2050 | combo routing strategies | `combos/*`, `cli/*`, `providers/quota*.ts`, `router.ts`, `types/config.ts` |
| #2827 | trusted Responses request id | `server/index.ts`, `server/request-log.ts` |
| #2364 | Vercel AI Gateway routing | `adapters/openai-chat.ts`, `config.ts`, `providers/vercel-gateway-routing.ts`, `server/auth-cors.ts`, `types.ts`, `types/provider.ts` |
| #2712 | xAI `x_search` opt-in | `adapters/{openai-responses,xai-web-search}.ts`, `config.ts`, `server/auth-cors.ts`, `server/responses/core.ts`, `types/provider.ts` |
| #2854 | blocked-model redirection | `config.ts`, `lib/shadow-call.ts`, `router.ts`, `types/config.ts` |

## Overlap matrix

Computed by intersecting the `src/` file sets, not by reading titles:

```
#1756 x #2827 src/server/index.ts
#2050 x #2854 src/router.ts, src/types/config.ts
#2364 x #2712 src/config.ts, src/server/auth-cors.ts, src/types/provider.ts
#2364 x #2854 src/config.ts
#2712 x #2854 src/config.ts
```

Collision degree per PR: `#2854=3`, `#2364=2`, `#2712=2`, `#1756=1`, `#2050=1`,
`#2827=1`, `#2365=0`, `#2429=0`.

## Derived merge order

Ascending collision degree, so each merge lands against the largest possible amount of
already-settled `dev`, and the diff most likely to conflict resolves last against a tree
that already contains everything it must coexist with:

```
#2365 -> #2429 -> #1756 -> #2050 -> #2827 -> #2364 -> #2712 -> #2854
```

`#2854` merging last is the load-bearing part of this order. It touches `config.ts`
alongside both #2364 and #2712, and `router.ts` alongside #2050 — it is the only PR that
collides with more than one other cluster, so it is the only one whose conflicts are
cheaper to resolve once rather than three times.

Waves, because `dev` CI is the gate and a wave is the smallest useful unit to verify:

- **Wave A** — `#2365`, `#2429`, `#1756`: zero or single collisions, no shared config surface.
- **Wave B** — `#2050`, `#2827`: single collisions each.
- **Wave C** — `#2364`, `#2712`, `#2854`: the `config.ts` / `auth-cors.ts` cluster.

**Corrected after audit.** An earlier draft claimed wave B's collisions were "already
settled by wave A". They are not: `#2050` collides with `#2854`, which is in wave C, and
`#2827` collides with `#1756` in wave A. Only `#2827`'s is settled by A. `#2050` is placed
in B because its one collision partner merges later, so `#2854` absorbs the resolution —
which is the same reason `#2854` is last.

## Per-merge mechanics (added after audit)

Merge order alone does not make a later PR land against settled `dev`; it only decides who
resolves the conflict. All eight heads currently share merge base `e546c160b`, and `dev` is
already five commits past it, so each merge must carry its own freshness step:

1. Rebase the PR onto the then-current `dev`.
2. Push and let CI run on that exact head.
3. Merge only on a green technical matrix.
4. Re-read `dev` CI before starting the next merge.

Skipping step 1 would also drift heads past the repository's ten-commit readiness
allowance as the train advances, so the freshness step is a gate requirement and not only
a correctness preference.

#2429 and #2827 cannot enter their wave until the two designs below are built.

## What this unit does not cover

Five rebased PRs are excluded because CI found real defects in them, not stale-base
artifacts: #2716 (display name leaks into the opencode selector), #2351 (management route
not declared in the registry), #2213 (xAI wire defaults), #2496 (residual failures), and
#1829 (macOS launcher flake, unrelated to its own diff). They stay open.
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# wp1 — #2429: the privacy scanner rejects its own test fixture

## Symptom

`gates` fails on #2429's head. The failing step is `Privacy scan`, not a test:

```
Privacy scan failed:
tests/test-runner.test.ts:42 email: test<at>opencodex.invalid
error: script "privacy:scan" exited with code 1
```

Every test shard, `macos`, and all three `npm-global` matrices pass. The only red checks
are `gates` and the two draft-checklist gates (`hygiene`, `enforce-target`), which are
process gates rather than code failures.

## Cause

The PR's test helper commits a fixture repository and needs a git identity to do it:

```ts
runGit(
cwd,
"-c", "user.name=OpenCodex Test",
"-c", "user.email=test<at>opencodex.invalid",
"commit", "-m", message,
);
```

`scripts/privacy-scan.ts` matches `/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi` across the
tree. The fixture address satisfies that pattern, and `.invalid` is not on the
allow-list, so the scanner is behaving correctly — the literal really is an email-shaped
string in a tracked file.

(This document writes the address as `test<at>opencodex.invalid` for exactly the same
reason the fix exists: quoting the literal verbatim would make this file trip the scanner
too. It did, on the first commit of this unit.)

This is not a false positive worth loosening the scanner for. The scanner's value comes
from having almost no exceptions; every added exception is a hole someone's real address
can later fall through.

## Design

Use the idiom the repository already uses for exactly this problem. `privacy-scan.ts` and
its own fixtures avoid self-matching by never writing an email as one literal:

```ts
["1", "gmail.com"].join("@")
["stranger", "third-party.example.org"].join("@")
```

So the fix is a join at the call site:

```ts
const TEST_COMMIT_EMAIL = ["test", "opencodex.invalid"].join("@");
```

The value handed to git is byte-identical, so the fixture commits exactly as before and no
test expectation changes. The scanner no longer sees an email literal because there is no
longer one in the source.

### Rejected alternatives

- **Add `tests/test-runner.test.ts` to the scanner's allow-list.** The allow-list currently
holds two narrowly-argued entries (`a@b.com` in tests, a URL-userinfo fixture that reads
as `pw@host`). Adding a whole file would exempt every future email added to it.
- **Allow the `.invalid` TLD globally.** `.invalid` is reserved and safe in principle, but
the exemption would apply repository-wide and the scanner's job is to be boring, not
clever.
- **Drop the git identity and rely on ambient config.** CI runners have no global
`user.email`, so the fixture commit would fail. The identity is load-bearing.

## Verification

- `bun run privacy:scan` exits 0 locally.
- `bun x tsc --noEmit` clean.
- `gates` returns success on the pushed head.
- No local full-suite run: the user has forbidden it, and CI covers the shards.
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# wp2 — #2827: the request id a browser cannot read

## Symptom

#2827 is green across every check. The defect is not a failing test — it is a feature that
silently does nothing for its stated consumer, found by review rather than by CI.

The PR adds a response header carrying the request-log id:

```ts
const REQUEST_LOG_ID_RESPONSE_HEADER = "x-opencodex-request-id";

function withRequestLogId(response: Response, requestId: string): Response {
const headers = new Headers(response.headers);
headers.set(REQUEST_LOG_ID_RESPONSE_HEADER, requestId);
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
```

## Cause

`corsHeaders()` in `src/server/auth-cors.ts` emits `Access-Control-Allow-Origin`,
`-Allow-Methods`, `-Allow-Headers`, and `Vary` — but no `Access-Control-Expose-Headers`.

The CORS default is that cross-origin JavaScript may read only the seven CORS-safelisted
response headers. A custom `x-` header is not one of them, so `response.headers.get(
"x-opencodex-request-id")` returns `null` in a browser even though the header is on the
wire and visible in devtools.

The tests pass because they call the handler directly. Server-side fetches see every header;
the restriction is enforced by the browser, and nothing in the suite is a browser. This is
the same shape of gap as a feature guarded by a flag no test sets — the code is right and
unreachable.

`-Allow-Headers` does not help: it governs what the **request** may send, not what the
**response** may reveal.

## Design

Add the response-header allow-list next to the request one, naming exactly the header this
proxy adds:

```ts
"Access-Control-Expose-Headers": REQUEST_LOG_ID_RESPONSE_HEADER,
```

Two constraints on how:

1. **The constant moves to `auth-cors.ts` and `server/index.ts` imports it.** Two string
literals that must agree will eventually disagree; the header name has one owner.
2. **`Vary` does not change.** `Expose-Headers` here is a constant, not a function of the
request, so it introduces no new cache dimension. Adding it to `Vary` would fragment the
cache for no reason.

### Scope note

**Corrected after audit.** The first draft said `managementCorsHeaders()` was "separate and
not touched". That was wrong, and the audit caught it:

```ts
export function managementCorsHeaders(req?: Request, config?: OcxConfig): Record<string, string> {
const headers = corsHeaders(); // <- inherits everything, including a new Expose-Headers
...
}
```

Adding the key inside `corsHeaders()` would therefore have propagated it to every
management response, which is the opposite of the scope the design claimed. The two
options are to set it only in the data-plane wrapper, or to add it in `corsHeaders()` and
strip it in `managementCorsHeaders()`.

Take the first. `withCors()` is the data-plane wrapper and the only path that serves
`/v1/responses`, so exposing the header there grants exactly the reach the feature needs.
Stripping a key the shared helper just added would leave two places that must stay in
agreement about a header neither of them owns.

Concretely: `corsHeaders()` is left alone, and `withCors()` sets
`Access-Control-Expose-Headers: x-opencodex-request-id` after copying the shared keys.

## Regression test

The existing suite cannot catch this class of defect, so the test asserts the header
contract directly rather than the behavior of a browser we do not have:

- `withCors(new Response(...), req, policy)` output contains `Access-Control-Expose-Headers`
naming `x-opencodex-request-id`. The assertion targets the wrapper, not `corsHeaders()`,
because the amended design deliberately leaves the shared helper untouched.
- The exposed name matches the header `withRequestLogId` actually sets — one assertion
comparing the two, so a future rename of either side fails here instead of shipping a
header nobody can read.
- `managementCorsHeaders()` output does NOT contain the key. This assertion is the one that
would have failed under the original design, so it is the reason the test exists.

## Wrapper order (verified)

The route composes the two wrappers as:

```ts
return withRequestLogId(
withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, policy),
requestId,
);
```

`withCors()` runs first and `withRequestLogId()` wraps its result, copying headers through
`new Headers(response.headers)`. So an expose header set inside `withCors()` survives onto
the final response. Checked on #2827's head at `src/server/index.ts:1397` and `:1441`; no
success path carrying the request-id header bypasses `withCors()`.

## Verification

- `bun x tsc --noEmit` clean.
- Focused run of the CORS and request-log tests only.
- CI green on the pushed head, including `gates`.
Loading
Loading