Skip to content

Codex's openai preset carries the x-hypaware-upstream rung, and the preset-slot race gets a regression test - #537

Merged
philcunliffe merged 3 commits into
masterfrom
fix/issue-536
Jul 31, 2026
Merged

Codex's openai preset carries the x-hypaware-upstream rung, and the preset-slot race gets a regression test#537
philcunliffe merged 3 commits into
masterfrom
fix/issue-536

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Item 1 (T6) of #536: the openai upstream preset slot race between @hypaware/codex and @hypaware/openclaw.

The chain, re-verified

Triage's reasoning holds. I re-derived it from the code rather than taking it on faith:

  • registerUpstreamPreset is a name-keyed last-write-wins Map.set (hypaware-core/plugins-workspace/ai-gateway/src/api.js:55). Two plugins registering openai share one slot; the one that activates last owns it.
  • toposort() is Kahn's with an alphabetical tie-break (src/core/dep_graph.js:248,259). @hypaware/codex and @hypaware/openclaw declare only requires.capabilities, with no edge between them: the only bundled manifests with a requires.plugins block are @hypaware/ai-gateway-graph and @hypaware/hermes, and neither names either adapter.
  • Empirically, over the real bundled manifests: the full boot order puts codex at index 2 and openclaw at index 10, and resolveDependencies() over the ai-gateway/codex/openclaw trio returns [ai-gateway, codex, openclaw] in all 6 input permutations.
  • SIGHUP reload() never adds or removes plugins (src/core/daemon/runtime.js), so there is no way to activate codex after an already-running openclaw without a full restart, which re-derives the same order.

So the 404 is unreachable today, and only because of an alphabetical accident that nothing declared and nothing tested.

What changed

Half 1, the tests.test/plugins/gateway-openai-upstream-slot.test.js, two tiers that fail for different reasons on purpose:

  • Activation order. Drives the real resolveDependencies() over manifests read off disk by discoverBundledPlugins(), and asserts codex resolves before openclaw, in the full bundled set and in every input permutation of the trio. A plugin rename, a new requires.plugins edge, or a change to the tie-break reddens this instead of silently 404-ing users.
  • Routing. Runs the plugins' real activate() against one gateway API in both activation orders, compiles the surviving preset table, and asserts a route matrix covering /v1/chat/completions, /v1/responses, /v1, /backend-api/codex/responses, /v1/messages, /v1/messages/count_tokens, the anthropic header signature off-path, the /v1foo prefix look-alike, and unsteered /chat/completions. The existing shape test (openclaw-projector.test.js) compares literal copies and never calls Codex's registration, which is why two review rounds could not see this.

Half 2, the hardening. Codex's openai registration gets match: matchOpenaiUpstream, two rungs:

  1. x-hypaware-upstream: openai (LLP 0157 #adapter-rework: "upstream routing is per request, selected by the x-hypaware-upstream metadata ... via the gateway's existing header match functions").
  2. The /v1 path anchor, reproducing pathMatchesPrefix(path, '/v1') exactly, because a preset that declares match() never falls back to its path_prefix.

No behaviour change for existing traffic

The full route matrix above is asserted identical in both activation orders, and the same matrix run against the pre-change tree produces the same answers. Exactly one cell moves, and it is the defect:

requestbeforeafter
/v1/chat/completionsopenaiopenai
/v1/responsesopenaiopenai
/v1/messagesanthropicanthropic
/backend-api/codex/responseschatgptchatgpt
/chat/completions (unsteered)404404
/chat/completions + x-hypaware-upstream: openai404openai

Claude and Codex traffic never sends x-hypaware-upstream, so rung 1 is inert for every route that exists today.

Deliberate deviation: no priority: 100

The triage suggested priority: 100 alongside the match(). I did not add it, and there is a test pinning that decision, because it is not behaviour-neutral:

composePickerConfig writes hyp init's gateway upstreams straight from the picker compose blocks, which carry no priority (so they compile at 0), and a config upstream beats a preset of the same name. On an install that declares anthropic in config and leaves openai to Codex's preset, priority: 100 sorts openai above anthropic, and openai's /v1 anchor then swallows /v1/messages:

today order: anthropic,openai | /v1/messages -> anthropic
p0+match order: anthropic,openai | /v1/messages -> anthropic
p100+match order: openai,anthropic | /v1/messages -> openai

Priority is not needed for the safety property either: with the header rung on both copies, the steered turn routes whichever registration wins the slot, so the 404 is gone regardless of activation order. The registrations now differ only in priority, and that difference changes no route in any table where the anthropic side is itself a preset (both copies of it are priority: 100).

Fail-then-pass evidence

New file green, then reddened three ways:

  • Fix reverted, tests unchanged: pass 6, fail 2 - both "a steered openai turn is routable ..." cases fail, in both activation orders.
  • ready.sort() changed to ready.sort().reverse() in dep_graph.js: pass 6, fail 2 - both activation-order cases fail.
  • priority: 100 added back to Codex's openai preset: pass 7, fail 1 - the config-anthropic ordering case fails.

With the fix in place: pass 8, fail 0.

npm test: 3118 pass, 8 fail, all 8 the known test/core/leave-command.test.js baseline (#512, held in #527). npm run typecheck: clean.

Interference with #510

No textual conflict. integration/openclaw-full-capture touches no file under hypaware-core/plugins-workspace/codex/, and none of the files it touches overlap the two files here.

A merge-base pass is needed for three semantic reasons, all on #510's side:

  1. openclaw/src/projector.js's comment on openaiUpstreamPreset() says closing this race "needs the rung on Codex's registration too, which is a change to Codex's routing and so is not made here." That is now done; the comment should be rewritten to say the two registrations differ only in priority.
  2. test/plugins/openclaw-projector.test.js's "the openai upstream preset matches the shape of the codex plugin registration" asserts priority === 100 against literals and never reads Codex's registration, so its name will be wrong once merged. The tests here are the drift detector it was trying to be.
  3. Worth a maintainer's look before OpenClaw full capture (LLP 0157-0162) #510 merges: OpenClaw full capture (LLP 0157-0162) #510's openaiUpstreamPreset() carries priority: 100, and openclaw activates second, so it wins the slot on a real boot. That reintroduces exactly the /v1/messages hazard shown above on an install with a config-declared anthropic and no config openai. Dropping priority: 100 from OpenClaw full capture (LLP 0157-0162) #510's copy makes the two registrations identical and removes the hazard.

Items 2 and 3 of #536 (OpenClaw settlement as the only .hypignore gate, and the stale write_home permission) are untouched here: both live in code #510 rewrites, and item 2 is explicitly deferred to a live acceptance run.

Fixes#536

… pinned by tests
`registerUpstreamPreset` is a name-keyed last-write-wins `Map.set`, so two
adapters that both register `openai` share one slot and the plugin that
activates last owns it. Codex's registration declared neither a `match()`
nor the `x-hypaware-upstream` rung, so if it ever won that slot a steered
`openai-completions` turn (whose shadow base URL is the bare gateway origin,
making the path `/chat/completions`) would match no upstream and the gateway
would answer 404, killing the user's turn. LLP 0157 R5 forbids that.
The safety net today is that `toposort()` breaks ties alphabetically and
`@hypaware/codex` sorts before `@hypaware/openclaw`, so codex always
activates first and never wins the contested slot. That is accidental and
was untested. Two halves:
- `matchOpenaiUpstream()` gives Codex's `openai` registration the same two
rungs any copy of it needs: the `x-hypaware-upstream` header, then the
`/v1` path anchor reproduced byte-for-byte from the `path_prefix` a
`match()` supersedes. No traffic that exists today sends that header, so
every current route is untouched.
- `test/plugins/gateway-openai-upstream-slot.test.js` pins the real boot
order off the shipped manifests via `resolveDependencies()`, and pins the
compiled routing table built from the plugins' real `activate()` calls in
both activation orders.
The registration deliberately keeps the default priority rather than the
`priority: 100` the triage suggested. `hyp init` writes gateway upstreams
with no priority, so on an install that declares `anthropic` in config and
leaves `openai` to Codex's preset, a priority of 100 sorts `openai` above
`anthropic` and its `/v1` anchor then swallows `/v1/messages`. A test pins
that ordering so the hazard cannot be reintroduced silently.
Co-Authored-By: Claude <noreply@anthropic.com>
The reintroduction guard read Codex's own preset, so it could only
catch `priority: 100` coming back in Codex's file. The constant is
hazardous wherever it is written: a sibling adapter that registers
`openai` activates later, wins the name-keyed slot, and reintroduces
the misroute with the guard still green. Assert against whichever
registration survived, under every activation order.
Also pin the two bytes either side of the `/v1` segment boundary
(`/v1/` and `/v1x`), since a `match()` supersedes the `path_prefix`
it replaced and nothing else holds it to that shape.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

REVIEW round 1: approve, with one finding fixed in-branch

Verdict: approve once 52ea8f9 (pushed to this branch) is included. The routing change is correct, the deviation from issue #536 is right and I verified it independently, and all three of the author's mutations redden. One finding: the reintroduction guard was scoped to the wrong thing. Fixed here.

Baseline: npm test = 8 failures, all test/core/leave-command.test.js (#512, held in #527), no others. npm run typecheck clean. Both before and after my commit.


1. The match() reproduces path_prefix exactly (verified exhaustively)

A match() supersedes path_prefix, so this had to be byte-exact. pathMatchesPrefix is pathname === prefix || pathname.startsWith(prefix + '/') (proxy.js:297); the new rung 2 is input.path === '/v1' || input.path.startsWith('/v1/').

I compared the registered preset's real match() (header absent) against pathMatchesPrefix(path, '/v1') over 132,895 paths, using a structured corpus of /v1-adjacent segment combinations plus 200k random strings over /v1xabc-_.%.

0 divergences. Spot-checked cases all agree: /v1 ✓, /v1/ ✓, /v1/chat/completions ✓, /v1/messagesfoo ✓, /v1x ✗, /v1foo ✗, /v10 ✗, /V1 ✗, /v2/messages ✗, /backend-api/codex/... ✗ (falls through to the chatgpt preset), / ✗.

2. Nothing but steered traffic moved

I compiled the real preset table on origin/master and on this head and diffed the full routing decision for 6,624 cells = 23 paths × 9 header sets × 4 config shapes (no-config, config-declared anthropic at /v1/messages, at /v1, and a catch-all local at /) × 4 activation orders.

Every changed cell carries x-hypaware-upstream: openai. Non-steering cells changed: 0. Since nothing in the tree sends that header (it appears only in LLP 0157 and this PR), the change is inert for all traffic that exists today, and the steered /chat/completions case resolves to openai in all 16 config×order shapes. This is stronger than the "one cell moves" bar.

3. Declining priority: 100 is correct (independently confirmed)

I reproduced the hazard rather than taking it on trust. With priority: 100 added back, on an install with a config-declared anthropic and no config openai:

config-anthropic-messages|*|none|POST|/v1/messages -> openai (was anthropic)
config-anthropic-messages|*|none|POST|/v1/messages/count_tokens -> openai (was anthropic)

It holds under anthropic-version, x-api-key, and Bearer sk-ant- headers too, so a real Claude turn, prompt and Anthropic API key included, would be sent to api.openai.com. Blast radius: 128 cells with anthropic at /v1/messages, 512 with anthropic at /v1. The mechanism is exactly as the author stated: compileUpstreams sorts descending priority first (proxy.js:336-337), config upstreams compile at priority 0 (:329), and config beats a same-named preset (source.js:264-282).

I also checked the converse, that declining it costs nothing. It does not: the steering rung reaches openai on the bare-origin /chat/completions path in every config shape at priority 0. priority: 100 buys no reachability and carries the misroute.

The premise checks out too: composePickerConfig types wizard-written upstreams as { name, base_url, path_prefix, provider? } with no priority (walkthrough.js:634), and the manifests' gateway_upstream blocks declare none (codex/hypaware.plugin.json:41-44, openclaw/hypaware.plugin.json:42). Issue #536's recommendation was wrong; declining it was the right call.

4. The test is non-vacuous, and it is the opposite of the #510 pattern

Confirmed it drives the real resolveDependencies() over manifests read off disk, runs both plugins' real activate(), compiles the surviving table, and asserts routes. That is the right shape, and it is precisely what test/plugins/openclaw-projector.test.js:293-299 fails to do. That test asserts the two anthropic presets have equal literal fields (assert.equal(ours.priority, theirs.priority)) without ever compiling a table or routing a request, which is why it could not see a slot collision and why the sibling defect survived two rounds and a triage.

All three author mutations redden, each on a distinct test:

mutationresult
remove match: matchOpenaiUpstream2 fail, both "a steered openai turn is routable"
reverse toposort()'s ready.sort() tie-break (dep_graph.js:248,259)2 fail, both activation-order tests
add priority: 100 back1 fail, the config-anthropic outrank test

FINDING (moderate, fixed): the reintroduction guard was keyed to the plugin, not the slot

test/plugins/gateway-openai-upstream-slot.test.js:154 read presetsFromActivate([CODEX]), so it could only catch priority: 100 returning in Codex's file. But the whole premise of this PR is that openai is one name-keyed last-write-wins slot that a sibling adapter can win. The constant is hazardous wherever it is written, and the tier-2 routing tests that do compile the surviving table run with no config upstreams, the one shape where priority: 100 is harmless. So the guard had a hole exactly where the known live hazard sits.

I demonstrated it: I locally added #510's openaiUpstreamPreset() (priority: 100, /v1-wide match) to the OpenClaw plugin. OpenClaw activates last, wins the slot, and the misroute is back, and the test suite stayed green, 8/8. The defect #510 is currently neutral:stuck on would have walked straight through this PR's pin.

Fixed in 52ea8f9: the assertion now runs against whichever registration won the slot, across [CODEX], [CODEX, OPENCLAW], and [OPENCLAW, CODEX], and also asserts /v1/messages/count_tokens and a header-signature-carrying /v1/messages. Under the same simulated #510 reintroduction it now fails on the codex then openclaw order. Also added /v1/ and /v1x to the ROUTES table: the two bytes either side of the segment boundary, which nothing else held the hand-written anchor to.

5. @ref annotations resolve, and earn their place

LLP 0157#adapter-rework and #requirements both exist. #adapter-rework reads "Upstream routing is per request, selected by the x-hypaware-upstream metadata the steering plugin attaches, via the gateway's existing header match functions", which is exactly what the code does, and it tells a reader why a Codex plugin carries a header rung nothing currently sends, which the code and filename cannot. The R5 [constrained-by] ref is a mild extension (R5 is written about turns the plugin cannot steer, not steered-but-unroutable ones) but "the user's turn MUST NOT fail because of capture" is literal R5 text and applies directly. The file-level @ref in the test sits above a blank line, which matches established house practice (usage-policy-fold.test.js:24, query-local-only-visibility.test.js:9, and others).

Also verified the comment's claim that the header never leaves the gateway: forwardHeaders drops everything matching x-hypaware- after the recorder has captured the original headers (proxy.js:385). True as written.

6. House rules

Clean. No em dashes, no semicolon-terminated code (the one ; in the diff is prose inside a comment), JSDoc only, @import at file top, no @typedef, no inline import('...') types, no ./types.d.ts specifiers. typecheck passes, which is what proves the AiGatewayRouteInput import resolves.


Notes for the eventual #510 merge-base pass (no action here)

  • No textual conflict.OpenClaw full capture (LLP 0157-0162) #510 touches neither codex/src/index.js nor the new test file; git merge-tree of this head against origin/pr-510 produces no conflict markers.
  • There is a semantic one.OpenClaw full capture (LLP 0157-0162) #510's openaiUpstreamPreset() has a match() semantically identical to this PR's (its isOpenaiPath and steersTo are line-for-line equivalent). The only difference is priority: 100. Since OpenClaw activates after Codex, OpenClaw full capture (LLP 0157-0162) #510's copy wins the slot and this PR's deliberate priority-0 choice is silently overwritten. Dropping that constant from OpenClaw full capture (LLP 0157-0162) #510 is what makes the merge safe, and after 52ea8f9 the merged tree will say so in red rather than silently.
  • Steering is subordinate to the table sort, not absolute. A steered openai request on /v1/messages still resolves to anthropic (priority 100 preset sorts first), and on /backend-api/codex/* to chatgpt. This is the fail-closed direction and the bare-origin path the seam actually uses is unaffected, but it does mean LLP 0157's shadow base URL must stay at the bare gateway origin, since a /v1-rooted shadow URL would be swallowed by anthropic on any install that declares it at /v1.
  • A steered request also overrides an operator's catch-all config upstream (config-catchall cells move local -> openai). Correct for an explicit upstream-naming header, but worth stating in LLP 0157 so an operator pointing everything at a local proxy is not surprised.

The tier-2 no-change tests walk `ROUTES` with a `for` loop, so an
emptied table routed nothing and both tests still passed. Verified:
with `ROUTES` set to `[]` on 52ea8f9 the file was green 10/10.
Pin the coverage the table claims (a route per upstream, a 404 case, and
the `/v1` boundary plus the bare-origin path) separately from walking
it, so thinning the table reddens instead of quietly disarming the guard.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

REVIEW round 2: approve, with one finding fixed in-branch

Verdict: approve once 8d79e9f (pushed to this branch) is included. Round 1's fix is real, not cosmetic: I reproduced the exact demonstration that exposed the hole and confirmed the guard now catches it. One new finding, low severity, fixed here.

Baseline: npm test = 8 failures, all test/core/leave-command.test.js (#512, held in #527), no others. npm run typecheck clean. Both at 52ea8f9 and after my commit.


1. The reintroduction guard genuinely reddens now (the check this round existed for)

I did not take round 1's fix on trust. I re-ran its own experiment: added PR #510's openaiUpstreamPreset() (priority: 100, /v1 match, x-hypaware-upstream rung) to @hypaware/openclaw's activate(), mirroring openclaw/src/projector.js:245-257 on origin/integration/openclaw-full-capture, and ran the file against both test versions:

test filesimulated #510 sibling registeredresult
c47e8d5 (round 1's version)yespass 8, fail 0 - the misroute walks straight through
52ea8f9 (round 1's fix)yespass 9, fail 1

The failure is the surviving openai preset does not outrank a config-declared anthropic upstream (@hypaware/codex then @hypaware/openclaw), which is the real boot order (resolveDependencies() puts codex at index 2, openclaw at 10). The openclaw then codex case correctly stays green, because Codex then wins the slot at priority 0 and there is no hazard. Reverted the local change; the tree I pushed is clean.

So the guard is a guard, not the appearance of one. That was the open question and it is closed.

2. Guard coverage: every way a sibling can win the slot today is covered

I enumerated the shapes a competing openai registration can take and compiled each against a config-declared anthropic:

sibling preset shape/v1/messages routes toguard sees it
priority: 100 + match() (#510's actual shape)openaiyes, reddens
priority: 100, path_prefix: '/v1', no match()openaiyes, reddens (presetAsUpstream carries priority and prefix, and compileUpstreams sorts on priority before prefixRank)
neither match() nor path_prefixn/aunregisterable: registerUpstreamPreset throws either match() or path_prefix is required (ai-gateway/src/api.js:50-54), and mergeUpstreams skips the shape defensively too (source.js:271-273)
priority: 0 + a wider match()anthropicnot a hazard: same priority, and the config entry's longer prefixRank sorts it first

Registering later in activation order is covered directly: the guard now runs all three of [CODEX], [CODEX, OPENCLAW], [OPENCLAW, CODEX] and reads whichever registration survived state.presets. Those are the only two writers of the openai slot in the tree (grep registerUpstreamPreset across hypaware-core/plugins-workspace/ returns claude, codex, openclaw, and claude's is anthropic).

Residual gap, informational, not fixed:presetsFromActivate wires a hard-coded activators map of exactly those two plugins, so a third adapter that later registers openai would escape the guard while tier 1 (which reads manifests off disk) would not notice either. The obvious derivation source is unreliable: the manifests' gateway_upstream blocks are declarative and nothing validates them against the real registrations, and #510 proves it - it registers an openai preset while its hypaware.plugin.json:35 still declares only anthropic. Adding a manifest-derived detector would ship a known false negative, so I left it. Worth a line in the eventual #510 merge-base pass rather than here.

3. The two new ROUTES entries do hold the segment boundary

Mutated the anchor in both directions and confirmed each is load-bearing:

mutation to matchOpenaiUpstreamresult
widen to path.startsWith('/v1')2 fail (/v1x and /v1foo become openai)
narrow to path.startsWith('/v1/')2 fail (/v1 itself stops routing)

So /v1/ must match and /v1x must not, and both are now pinned. Round 1's 132,895-path equivalence proof said the anchor was right; these two cells are what keeps it right.

4. The header value matches what the counterparty actually sends

Round 1's differential asserted the rung fires on x-hypaware-upstream: openai, but assumed that string. I checked the writing side. resolveSteering returns requestMeta: { 'x-hypaware-upstream': candidate.provider } only after gating candidate.provider === CANONICAL_PROVIDER_FOR_SHAPE['openai-completions'], which is the literal 'openai' (openclaw-steering-plugin/src/steering.js:28-30,137-152 on #510's branch). So this PR's exact-match === 'openai' agrees with the only producer, and the 404 this PR exists to remove is genuinely removed rather than removed for a header nobody sends.

5. npm test picks the file up

Confirmed by name in the full run, not inferred: subtests 2756-2765 at 52ea8f9, all passing, inside the test/**/*.test.js glob npm test is scoped to.

FINDING (low, fixed): the route table could be disarmed by deleting it

test/plugins/gateway-openai-upstream-slot.test.js:113 walks ROUTES with a bare for loop and asserts nothing outside it. An emptied or thinned table therefore routes nothing and both "existing routes are unchanged" tests pass having proved nothing. I verified rather than asserted this: with const ROUTES = [] at 52ea8f9, the file is green 10/10.

That matters more here than in an ordinary test, because this file's entire job is to be the thing that stops a /v1-wide openai preset from swallowing /v1/messages, and a table that quietly shrinks in a future refactor takes the guard with it. Everything else in the file already has this covered - bundledManifests() asserts a non-empty discovery, the permutation loop asserts each manifest was found, the order loops are literals - so ROUTES was the one remaining hole.

Fixed in 8d79e9f: a separate test pins the coverage the table claims (a route for each of openai, chatgpt, anthropic and the 404 case, plus the /v1, /v1/, /v1x boundary and the bare-origin /chat/completions), independently of walking it. Under the same ROUTES = [] mutation the file now fails.

6. House rules

Clean at this head. No em dashes, no statement-terminating semicolons (the three ; in the diff are comment prose, a template literal, and a for header), JSDoc only, @import at file top, no @typedef, no inline import('...') types, repo-root-anchored .js specifiers. npm run typecheck passes.


Standing notes for the #510 merge-base pass (no action here)

Unchanged from round 1, plus one addition: #510 registers an openai upstream preset but its hypaware.plugin.json still declares gateway_upstream: { name: "anthropic", ... } only. Nothing enforces that block, so it is documentation drift rather than a defect, but it is why a manifest-derived slot-writer detector would not work.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

TRIAGE rung (LLP 0017): review rounds are exhausted, so before parking this PR I re-read every finding both rounds left unresolved at the current head (8d79e9f) and re-verified independently rather than taking either round's word for it.

Re-verified, not retaken on faith:

The one residual: the reintroduction guard's third-writer gap (presetsFromActivate, test/plugins/gateway-openai-upstream-slot.test.js:240, hard-codes exactly the codex/openclaw activators). Classified preference, not a true blocker:

  • Not reachable in what the shipped daemon loads today — confirmed above, only one plugin writes the openai slot right now.
  • The alternative (deriving the writer set from manifests) is proven unreliable by OpenClaw full capture (LLP 0157-0162) #510 itself, which registers openai from openclaw while openclaw/hypaware.plugin.json's gateway_upstream block still declares only anthropic — a manifest-derived detector would ship a worse, silent false negative. Declining it, as round 2 did, was the right call.
  • The PR strictly improves on master in every case it covers and introduces no new hazard (round 1's 132,895-path match() equivalence proof and 6,624-cell zero-non-steering-diff differential, both spot-checked here and held).
  • What was missing: this scope limit was recorded only in the round-2 review comment, not in the committed code, so a future reader of the test file wouldn't learn of it. That's a documentation gap, not a behavioral one.

Filed #538 (neutral:fix) to add a short in-code comment on the activators map recording the limitation and why manifest-derivation was rejected. Non-blocking — this PR is safe to ship as-is.

@philcunliffe
philcunliffe marked this pull request as ready for review July 31, 2026 09:29
@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 31, 2026
@philcunliffe
philcunliffe merged commit c954342 into masterJul 31, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-536 branch July 31, 2026 18:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: deferred review findings from PR #510

1 participant

@philcunliffe