Skip to content

The dropped-upstream warning hedges about a table it already has (#676) - #678

Open
philcunliffe wants to merge 5 commits into
fix/issue-653from
fix/issue-676
Open

The dropped-upstream warning hedges about a table it already has (#676)#678
philcunliffe wants to merge 5 commits into
fix/issue-653from
fix/issue-676

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Deferred review findings from PR #658, per issue #676. Two of the three are fixed here; the third is a human decision and is deliberately untouched.

Base is fix/issue-653 (PR #658), not master. The code all three findings point at (gatewayDroppedUpstreams, the gateway_upstreams_dropped kind, LLP 0195) does not exist on master - PR #658 is still open and unmerged. Basing on master would have made this PR carry all 8 of #658's commits and supersede it. This is stacked instead, so its diff is exactly one commit. Retarget to master once #658 merges.

Item 1 (fixed): the partial-drop warning hedges where it could be definitive

The message said traffic for a dropped upstream is not proxied "unless an adapter preset already covers the same name". The daemon already publishes the answer. details.registered_presets lists what adapter plugins registered at activation, and mergeUpstreams backfills a preset into exactly the names the compiled config table is missing - which is what a dropped entry's name is, by definition. The intersection now decides per name:

Case Before After
No preset covers it unless an adapter preset already covers the same name, traffic meant for it is not proxied and nothing is captured nothing is proxied or captured for openai (no adapter preset covers that name)
A preset covers it same hedge anthropic is still proxied by the adapter preset registered under the same name, at the preset's default endpoint rather than the base_url this config meant to set
Mixed same hedge both clauses, silence first

Those are two different faults wanting two different fixes, and the preset-covered one is not silence: the warning still fires (the operator's base_url silently did not take effect) but no longer miscalls it.

The hedge is confined, not deleted. It survives verbatim for the two shapes that genuinely withhold the answer, because this reads a file another build may have written:

  • No registered_presets key. Absent is not empty; reading a missing field as "no presets registered" would manufacture a confident claim of silence.
  • A drop with no name. name is one of the two keys whose absence drops an entry, so an unnamed drop has nothing to intersect. The same names.length === dropped guard covers the deduped case (two same-named entries dropping yield one name for two drops).

Item 2 (fixed): the (names) parenthetical is ambiguous

1 of its 2 configured upstreams (openai) hung the dropped name off the configured noun. On the bound branch the names now sit at the consequence they belong to, and the surviving hedge labels its parenthetical (dropped: openai).

The idle branch is deliberately untouched, avoiding the churn the issue flagged: there every configured entry is also a dropped one, so 2 upstreams (anthropic, openai) are configured states a fact.

Item 3 (NOT fixed - human decision, by design)

Whether overall should flip to degraded for a fully broken gateway-only install. Issue #653 reserved this for a human; LLP 0195 §consequences records it as an open product-policy question that "this document does not settle". That is a decision, not a defect: inventing a behaviour change would settle it by patch, and editing an Accepted LLP to close it is exactly what the LLP conventions forbid. Left entirely alone - no code, no test, and no LLP edit. It still needs a human to either confirm the open question is the intended record or answer it with a follow-up LLP, using the seam #658 left (gateway_idle_no_upstreams and gateway_upstreams_dropped are already distinct kinds).

Evidence

Six new tests, written before the fix and failing on fix/issue-653's head (a62c75c):

not ok 18 - a dropped upstream covered by no preset is reported as silent, definitively
not ok 19 - a dropped upstream a registered preset covers is reported as still proxied, definitively
not ok 20 - a mixed drop separates the covered name from the silent one
not ok 21 - the dropped names do not read as the configured set
not ok 22 - a status file with no preset list keeps the hedge
ok   23 - an unattributable drop keeps the hedge even with a preset list
# pass 18 / fail 5

All pass after. The existing a dropped upstream whose name matches a registered adapter preset... test pinned the hedge, so it is updated to pin the definitive sentence instead - that assertion was the old contract this change replaces.

They drive the real gateway source and the real compileUpstreams/mergeUpstreams (via realGatewayDetails), not hand-written details, so they fail if the drop rule and the published preset list ever disagree.

  • npm test: 3795 pass / 0 fail / 6 skipped (was 3789 pass; +6 new tests)
  • npm run typecheck: clean
  • @ref anchors resolve (#visible-when-unintended, #consequences in LLP 0195)

LLP

No LLP change. LLP 0195 is Accepted and decides when the two kinds fire and that they are non-degrading; neither moves here. The new @ref is [constrained-by] and says so. Note that the flat gloss in §visible-when-unintended ("traffic for the dropped upstream is silently unrouted and uncaptured") was already imprecise relative to shipped code on this branch - commit eee845c in #658 introduced the hedge for exactly the preset-backfill case. This moves further in the direction #658 already went rather than opening a new gap.

Fixes #676

test and others added 2 commits August 7, 2026 23:29
`gateway_upstreams_dropped` told the operator that traffic for a dropped
upstream is not proxied "unless an adapter preset already covers the same
name". True on every branch, but the daemon publishes the answer:
`details.registered_presets` is the list of presets adapter plugins
registered at activation, and `mergeUpstreams` backfills one into exactly
the names the compiled config table is missing - which is what a dropped
entry's name is, by definition. Intersecting the dropped names with that
list decides, per name, between two very different faults that wanted
different fixes:

- no preset covers it: requests match no route, nothing is proxied and
  nothing is captured.
- a preset covers it: traffic still flows, to the preset's default
  endpoint rather than the `base_url` the operator's entry meant to set.
  The warning still has to fire, because the override silently did not
  take effect, but calling that silence was wrong.

The hedge is confined rather than deleted, to the two shapes that still
withhold the answer: a status file with no `registered_presets` key at
all (absent is not empty, and reading a missing field as "no presets"
would manufacture a confident claim of silence), and a drop this build
could attach no name to (`name` is one of the two keys whose absence
drops an entry, so an unnamed drop has nothing to intersect; the same
guard covers the deduped case, where two same-named entries drop and
yield one name for two drops).

Also moves the names off the configured-upstreams noun on the bound
branch. "1 of its 2 configured upstreams (openai)" lists the *dropped*
name against the *configured* phrase, which reads for a moment as the
configured set; the names now sit at the consequence they belong to, and
the surviving hedge labels its parenthetical `(dropped: openai)`. The
idle branch keeps its wording untouched: there every configured entry is
also a dropped one, so the parenthetical states a fact.

No change to when either kind fires, to their non-degrading severity, or
to LLP 0195, which decides the configured-vs-compiled comparison and not
what the resulting sentence says. Issue #676 item 3 (whether a fully
broken gateway-only install should flip `overall`) is untouched: LLP 0195
§consequences records it as an open product-policy question, and it needs
a human, not a patch.

Tests: the silent drop, the preset-covered drop and a mixed one each
assert their own definitive sentence and the absence of the other's;
both withholding shapes assert the hedge survives; and one test pins that
no parenthetical hangs off "configured upstreams". The existing
preset-backfill test, which pinned the hedge, now pins the definitive
sentence instead.

Co-Authored-By: Claude <noreply@anthropic.com>
…y can settle

The commit under review replaced a hedge with two per-name assertions. Both
overreached past what the status file knows, because the gateway routes by
`path_prefix` and `match()` while the attribution is an intersection of *names*:

- "nothing is proxied or captured for openai" is false whenever a surviving
  upstream written with no `path_prefix` compiles to the `/` catch-all, which
  `pathMatchesPrefix` matches every path against. The dropped entry's traffic
  is then proxied and recorded under the other upstream's name. The gateway
  source already says so where it logs this very fault ("falls through to
  whatever the remaining routes match (or nothing)"). Now scoped to the name,
  with the fall-through spelled out.
- "at the preset's default endpoint rather than the base_url this config meant
  to set" names the wrong single field. `mergeUpstreams` backfills the preset's
  *whole* entry, so its `path_prefix` is in force too and a client still
  pointed at the operator's prefix gets a 404 from a gateway this line calls
  healthy. Now names both fields.
- The covered clause pluralised only its verb, describing two presets at two
  endpoints as "the adapter preset ... under the same name".

Three tests, each failing on e482b56: they pin the routing facts (the `/`
catch-all match, and `mergeUpstreams` reverting `path_prefix`) rather than the
sentence alone, so a later edit that reaches back for the stronger claim fails
here instead of in front of an operator.

npm test 3798 pass / 0 fail / 6 skipped, typecheck clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 1 (neutral, own-PR) - findings, fixed and pushed

Reviewed e482b56 in a clean worktree. Stack confirmed: base fix/issue-653, exactly one commit on top (git merge-base --is-ancestor origin/fix/issue-653 HEAD, rev-list --count = 1). Reviewed only that commit. Base choice not touched. CI on e482b56 was already green, so nothing to repair there.

Verdict: findings (2 major, 1 minor). All fixed on the branch at a0a9633.

The change is right in kind: the daemon does publish the answer, attributeDroppedUpstreams derives it correctly, and the two withholding guards (no registered_presets key, names.length !== dropped) are the right two. What was wrong is that both definitive clauses claimed more than the intersection of names can support.

1. MAJOR - the "silent" clause is false whenever a surviving catch-all takes the traffic

src/core/daemon/status.js:297 (at e482b56)

nothing is proxied or captured for openai (no adapter preset covers that name) is an unconditional claim about traffic. Routing is not by name. compileUpstreams defaults an absent path_prefix to '/' (ai-gateway config.js:64) and pathMatchesPrefix(anything, '/') === true (proxy.js:298), so this two-entry config:

[[upstreams]]
name = "anthropic"
base_url = "https://api.anthropic.com"   # no path_prefix -> the '/' catch-all

[[upstreams]]
name = "openai"
url = "https://api.openai.com"           # typo -> dropped
path_prefix = "/openai"

emits the sentence above, while POST /openai/v1/chat/completions is proxied to anthropic and recorded. The gateway source already documents exactly this where it logs the same fault (source.js:210-212: "falls through to whatever the remaining routes match (or nothing)"), so the codebase contradicted its own new diagnostic.

The text was equally wrong before this commit - but the hedge was the escape hatch, and this commit removed it and added (no adapter preset covers that name), which actively confirms the wrong theory. Converting a hedge into an assertion is exactly where the assertion has to be checked.

Fixed: the clause is now scoped to the name, which is what the intersection actually settles, and names the fall-through:
nothing is proxied or captured under the name openai (no adapter preset covers that name), so a request aimed at it gets a 404 or falls through to whatever surviving route its path matches

2. MAJOR - the "covered" clause names the wrong single lost field

src/core/daemon/status.js:302 (at e482b56)

... at the preset's default endpoint rather than the base_url this config meant to set implies base_url is what was lost. mergeUpstreams (source.js:376-394) backfills the preset's whole entry - base_url, path_prefix, provider, priority - because the operator's entry never compiled and contributes nothing. Real presets bind narrow prefixes (@hypaware/claude at /v1/messages, @hypaware/codex at /v1 and /backend-api/codex), so an operator who wrote path_prefix = "/claude" alongside a url = typo gets a 404 on the prefix they configured, from a gateway this line describes as still proxying that name.

Fixed: ... so that preset's own base_url and path_prefix are what is in force, not the ones this config meant to set.

3. MINOR - covered clause pluralised only its verb

src/core/daemon/status.js:302 (at e482b56)

With two covered names it read anthropic, codex are still proxied by the adapter preset registered under the same name - two presets at two endpoints described as one. The silent clause handled its plural, so this was an oversight, not a choice.

Fixed: ... by the adapter presets registered under the same names, so each preset's own base_url and path_prefix ....

Tests added

Three, in test/core/status-gateway-idle.test.js, all three failing against e482b56's status.js with the new tests in place (verified by stashing only status.js: not ok 24, 25, 26). They pin the routing facts rather than the sentence, so a later edit that reaches back for the stronger claim fails here rather than in front of an operator:

  • pathMatchesPrefix('/openai/v1/chat/completions', '/') === true and an absent path_prefix compiling to '/' (finding 1)
  • mergeUpstreams(compileUpstreams([...]), state) yielding ['anthropic', '/v1/messages'], proving the operator's /claude is gone (finding 2)
  • two registered presets, two dropped names (finding 3)

Evidence check on the PR body

Verified rather than trusted. The six new tests were run against fix/issue-653's head a62c75c: tests 18-22 fail, 23 passes - exactly as the body claims, including the honest ok 23 (that one guards the retained hedge, so it is a regression guard, not a new-behaviour pin). One correction: the pasted # pass 18 / fail 5 was actually 17 pass / 6 fail, because the modified existing test 12 also fails there. The body describes that modification separately, so this is a stale figure, not a misrepresentation. Note the body's before/after table and its quoted strings are now stale relative to a0a9633.

Item 3 (the overall flip): correctly left entirely alone

Confirmed by inspection: no code, no test, no LLP edit touches it. That is right. LLP 0195 §consequences records it as a product-policy question "this document does not settle", and #653 reserved it for a human. Not raised as a finding.

Judgement on the LLP 0195 gloss tension (the author's own flag)

The tension is real, the conclusion is right, and the stated reasoning is the weaker half of the argument.

Also checked, clean

Duplicate names (both drop -> guard withholds; one compiles -> dedupe removes the name, guard withholds), empty/non-string names (stringList and stringField agree), droppedUpstreamConsequence cannot return an empty string (silent.length + covered.length === dropped >= 1), preset registration timing (all in plugin activate(), before source start), reload() swapping activeCtx, the idle branch never reaching attribution. Conventions: no semicolons, no U+2014, JSDoc-only types, DroppedUpstreamAttribution is an interface in types.d.ts imported through the root-anchored .js specifier, no @typedef, no inline import('...'). @ref LLP 0195#visible-when-unintended and #consequences both resolve to live anchors.

Not fixed, deliberately: the dropped names printed into this message are not run through sanitizeLabel/capped the way recentEntrypointsFromSources is, even though both read the same untrusted file. That predates this commit (the old parenthetical printed the same list) and belongs to a separate change.

Checks

  • npm test: 3798 pass / 0 fail / 6 skipped (was 3795; +3 new tests)
  • npm run typecheck: clean
  • CI on a0a9633: all 9 checks green

test and others added 3 commits August 8, 2026 00:47
…and asserted the traffic one

Round 1 removed "traffic meant for it is not proxied and nothing is
captured" from the definitive branch because a surviving upstream with no
`path_prefix` compiles to the `/` catch-all and takes the dropped entry's
traffic. It left the identical sentence in the hedged branch, whose JSDoc
then called it "a weaker sentence, not a wrong one".

It is a wrong one, and on a current build rather than only on an old status
file: `name` is one of the two keys whose absence drops an entry, so a
nameless drop is an ordinary config that reaches the hedged branch (nothing
to intersect with `registered_presets`). Not knowing which preset covers a
name says nothing about whether a catch-all takes the path.

The hedged sentence is now bounded to the name the same way the definitive
one is, and pluralises with the drop count.

Co-Authored-By: Claude <noreply@anthropic.com>
…ch cannot reach

"X is still proxied by the adapter preset registered under the same name" is
a claim about routing, and the only evidence behind it is an intersection of
two name lists. Two ordinary configs falsify it:

- The backfilled preset can be shadowed outright. `mergeUpstreams` appends
  presets after the config entries and proxy.js's `compileUpstreams` breaks a
  rank tie on that order, so a surviving config upstream at an equal
  `path_prefix` wins every path the preset would have taken. The preset is in
  the table and routes nothing.
- "the preset's own base_url and path_prefix are what is in force" is wrong
  for every bundled adapter preset, because they all carry a `match()` and
  `matchUpstream` never consults `prefix` when one is present. The claude
  preset takes `/v1/complete` and any anthropic-headered path; naming
  `path_prefix` understates its reach as much as it overstates the
  operator's.

The clause now states the table fact it can settle ("in the routing table
only as the adapter preset registered under the same name"), names the
routing surface rather than one field of it, and does not deny the
outranking. Tests assert `matchUpstream` over the real sorted table, which
is the thing the sentence is about, rather than the preset entry's presence.

Also: the hedged branch drove its name plurals off the dropped-entry count,
which the dedupe path makes disagree with the names it prints.

Co-Authored-By: Claude <noreply@anthropic.com>
… 181-char line

Both are the same edit as the clause change: a test named "... is still
proxied" is a contract statement, and that is the contract this round
removed.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 2 (neutral, own-PR) - findings, fixed and pushed. LAST ROUND.

Reviewed a0a9633 in a clean worktree. Stack unchanged and correct: base fix/issue-653, rev-list --count origin/fix/issue-653..a0a9633 = 2 (the original commit plus round 1's fix). Base choice not touched. CI on a0a9633 was green, so nothing to repair there.

Verdict: findings (3 major, 1 minor). All fixed. Head is now b0a9e41.

Round 1's fix did not hold. It correctly identified that a status message must not assert more than the status file supports, then applied that rule to exactly one of the three sentences that violate it. Two of the three findings below are the same defect class as round 1's own finding 1, in the sibling branch it declared sound and in the clause it rewrote.


1. MAJOR - the covered clause asserts routing that a name match cannot reach

src/core/daemon/status.js:345 (at a0a9633)

anthropic is still proxied by the adapter preset registered under the same name is a claim about routing. The only evidence behind it is an intersection of two name lists.

mergeUpstreams guarantees the preset gets an entry in the table, nothing more. matchUpstream (proxy.js:235) returns the first entry that matches over a table compileUpstreams (proxy.js:336-348) sorts by priority desc, then prefix length, then merge order - and mergeUpstreams appends presets after the config entries, so a config entry wins every tie. A surviving config upstream can therefore shadow the backfilled preset completely:

[[upstreams]]
name = "chatgpt"
url = "https://chatgpt.com"                 # typo -> dropped
path_prefix = "/backend-api/codex"

[[upstreams]]
name = "mirror"
base_url = "http://127.0.0.1:9"
path_prefix = "/backend-api/codex"          # equal prefix, equal priority, earlier seq

with @hypaware/codex active. Executed against the real merge + compile + match:

table:  [ ['mirror','/backend-api/codex',0,0], ['chatgpt','/backend-api/codex',0,1] ]
POST /backend-api/codex/responses -> mirror
POST /backend-api/codex           -> mirror
MSG:  ... chatgpt is still proxied by the adapter preset registered under the same name ...

The chatgpt preset routes zero requests. A priority-based variant reaches the same place. This is the exact converse of round 1's finding 1: that finding reasoned carefully about the / catch-all for the silent side, then left the unhedged routing assertion standing on the covered side.

Fixed: the clause now states the table fact the intersection supports and does not deny the outranking:
chatgpt is in the routing table only as the adapter preset registered under the same name, so that preset's own base_url and routing rules are in force, nothing this config set for it took effect, and a surviving upstream can still outrank the preset on any path

2. MAJOR - round 1's own replacement wording, path_prefix ... in force, is wrong for every bundled preset

src/core/daemon/status.js:345 and src/core/daemon/types.d.ts:129-130 (at a0a9633)

Round 1 replaced "base_url" with "base_url and path_prefix" to say the whole entry reverted. But when a preset carries a match(), matchUpstream calls the function and never consults prefix - the prefix survives only as a sort key (prefixRank). Every bundled adapter preset carries one: @hypaware/claude and @hypaware/openclaw's anthropic, @hypaware/codex's openai. Codex's own comment says so (codex/src/index.js:75-79: the prefix "stays for the sort rank"). Against the real anthropicUpstreamPreset():

preset prefix: '/v1/messages', match: function
POST /totally/elsewhere  {x-api-key: sk-ant-...}       -> anthropic
POST /v1/complete        {anthropic-version: ...}      -> anthropic

So naming path_prefix as "what is in force" understates the preset's reach as badly as it overstates the operator's. registerUpstreamPreset (api.js:48-54) also accepts a match()-only preset with no path_prefix at all, where the sentence names a field that does not exist. Round 1's own regression test dodged this by registering a deliberately match-less preset (test/core/status-gateway-idle.test.js:679-711).

Fixed: ... so that preset's own base_url and routing rules are in force, nothing this config set for it took effect .... "Routing rules" is true for a prefix preset and a match() preset alike.

3. MAJOR - the hedged branch hedged the preset question and asserted the traffic one anyway

src/core/daemon/status.js:305 (at a0a9633)

Round 1 removed traffic meant for it is not proxied and nothing is captured from the definitive branch, correctly, because a surviving upstream with no path_prefix compiles to the / catch-all. It left the identical sentence verbatim in the if (!attribution) branch, and its new JSDoc called that sentence "a weaker sentence, not a wrong one".

It is a wrong one, and not only on an old status file. name is one of the two keys whose absence drops an entry, so a nameless drop is an ordinary current-build config that reaches this branch (nothing to intersect with registered_presets):

[[upstreams]]
name = "anthropic"
base_url = "https://api.anthropic.com"    # no path_prefix -> the '/' catch-all

[[upstreams]]
provider = "openai"                       # no name -> dropped
base_url = "https://api.openai.com"
path_prefix = "/openai"

hyp status printed "traffic meant for it is not proxied and nothing is captured" while POST /openai/v1/chat/completions was being proxied to anthropic and recorded. Not knowing which preset covers a name says nothing about whether a catch-all takes the path: the catch-all is a fact about the routing table, not about the preset list, so hedging one is not licence to assert the other.

Fixed: the hedged sentence is now bounded to the name exactly as the definitive one is -
... so unless an adapter preset already covers the same name, nothing is proxied or captured under that name, and a request aimed at it gets a 404 or falls through to whatever surviving route its path matches

4. MINOR - the hedged branch pluralised off the entry count, not the names it prints

src/core/daemon/status.js:305 (at a0a9633, and reintroduced by my own fix for #3)

Every plural was driven by dropped, while the (dropped: ...) parenthetical prints names, which the dedupe in readConfiguredUpstreams makes shorter. Two same-named entries both dropping yield dropped = 2, names = ['openai'], so the fix for #3 first read those entries ... (dropped: openai), so unless an adapter preset already covers the same names ... under those names.

Fixed: entry nouns count entries, name nouns count names (oneEntry / oneName), with the entry count standing in when there are no names at all.


Every branch, rendered

Generated by driving the real collectHypAwareStatus:

branch message tail
hedge, no names that entry is not in the routing table, so unless an adapter preset already covers the same name, nothing is proxied or captured under that name, and a request aimed at it gets a 404 or falls through to whatever surviving route its path matches
hedge, 2 names those entries are not in the routing table (dropped: x, y), so unless ... the same names, nothing is proxied or captured under those names, and requests aimed at them get a 404 or fall through to whatever surviving route their paths match
silent x1 nothing is proxied or captured under the name openai (no adapter preset covers that name), so a request aimed at it gets a 404 or falls through ...
silent x2 ... under the names openai, gemini (no adapter preset covers those names), so requests aimed at them get a 404 or fall through ...
covered x1 anthropic is in the routing table only as the adapter preset registered under the same name, so that preset's own base_url and routing rules are in force, nothing this config set for it took effect, and a surviving upstream can still outrank the preset on any path
mixed silent clause ; covered clause

Tests

Five new, plus five existing ones updated to the new contract (a test named ... is still proxied ... is a contract statement, and that is the contract this round removes). They assert matchUpstream over the real sorted routing table, which is what the sentence is about, rather than the preset entry's mere presence in mergeUpstreams's output - which is precisely the gap that let findings 1 and 2 through round 1.

  • a covered name is not claimed to be proxied, because a surviving upstream can shadow the preset (finding 1) - builds the real table, asserts mirror wins both paths, then asserts the message
  • a covered name does not claim path_prefix is in force, which a match()-carrying preset never routes on (finding 2) - uses the real anthropicUpstreamPreset(), asserts it takes /totally/elsewhere
  • the hedged branch does not claim the traffic is dead either, because a catch-all still takes it (finding 3)
  • the hedged branch pluralises for a multi-entry drop and ... names off the names, not off the entry count (finding 4)

All five fail against a0a9633/013b500 and pass on b0a9e41. Verified by checking out only status.js at the earlier sha with the new tests in place.

Checked and sound: round 1's guard reasoning

Verified against the code rather than accepted. The two withholding guards cannot be defeated:

  • Array.isArray(details.registered_presets) - the source publishes it as Array.from(state.presets.keys()) (source.js:101); the number shape at source.js:195/203 is a log attribute, not status details, and is correctly rejected.
  • names.length !== dropped - droppedNames (source.js:418-446) is the set of distinct names for which no entry compiled, so each contributes >= 1 dropped entry: |droppedNames| <= dropped always, with equality iff every dropped entry is named and all names are distinct. Checked the three cancellation candidates explicitly (duplicates where one compiles -> kept swallows the name -> mismatch -> hedge; duplicates where neither compiles -> dedupe -> mismatch -> hedge; non-object array entries -> hedge). Sound.
  • registerUpstreamPreset (api.js:47-53) does reject a preset with neither match() nor path_prefix, and state.presets.set appears only there (api.js:55), so mergeUpstreams's filter is unreachable via the capability API. Round 1 was right about this.
  • Preset registration timing: all bundled adapters register in activate(), and the daemon starts sources only after every plugin activates (daemon/runtime.js:91). Confirmed.

The guards were never the problem. The sentences downstream of them were.

Not fixed, deliberately

  • Item 3 of Follow-up: deferred review findings from PR #658 #676 (should overall flip for a fully broken gateway-only install). Untouched: no code, no test, no LLP edit. Reserved product-policy question per Follow-up: deferred review findings from PR #650 #653 and LLP 0195 §consequences. Confirmed by inspection.
  • "that name" has no printed antecedent on a nameless drop. The hedge branch can fire with no name in the message at all, so "the same name" / "that name" refer to a name the operator never sees, and the hedge is arguably vacuous there (an entry with no name cannot collide with a preset). Real but not false, and closing it needs the status file to distinguish a nameless drop from a deduped one, which it cannot: both surface only as names.length !== dropped. That is a source-side change (upstreams_dropped_unnamed, say), not a wording one.
  • registered_presets is snapshotted at status() time, the table at launchListener time. A preset registered after source start would report covered while absent from the table. Unreachable with bundled plugins (all register in activate()), same class as finding 1, and not fixable from this side.
  • Dropped names are still not run through sanitizeLabel / capped the way recentEntrypointsFromSources is, though both read the same untrusted file. Round 1 deferred this; it predates the whole change and still belongs to a separate one.
  • PR body is stale. Its before/after table and quoted strings describe e482b56, three commits back. Worth a refresh before merge; not blocking.

LLP 0195 gloss erratum: I agree with the disposition

Verified independently: git cat-file -e origin/master:llp/0195-... fails - LLP 0195 does not exist on master. It was minted inside #658, which is open, so its Accepted status is branch-local and nothing in the merged corpus is contradicted yet.

The gloss in §visible-when-unintended - "traffic for the dropped upstream is silently unrouted and uncaptured" - is wrong for the no-preset case (the / catch-all takes it) and the preset-covered case (a preset backfills it), and after finding 1 it is wrong in a third way: even a preset-covered name may be shadowed and route nothing. Three ways, one clause.

#658 should carry the one-clause erratum before it merges, and #678 should not touch the doc. A stacked PR must not rewrite a doc its base introduces - the edit would land in the wrong PR and dirty the eventual retarget to master. And the window matters: while #658 is open the doc is still a proposal, so an erratum costs nothing; once it merges the gloss is a record and correcting it needs a new LLP or at minimum an Extended-by: forward-ref. The erratum settles nothing new - which comparison fires which kind, and that both are non-degrading, are untouched.

Conventions

No semicolons, no U+2014 anywhere in the three files, JSDoc-only types, no inline import('...'), no @typedef. DroppedUpstreamAttribution is an interface in types.d.ts reached through the existing root-anchored '../../../src/core/daemon/types.js' specifier. Both @ref anchors resolve to live targets (llp/0195-...md:67 and :89). One 181-char line in the round-1 tests split.

Checks on b0a9e41

  • npm test: 3803 pass / 0 fail / 6 skipped (was 3798 at a0a9633; +5 new tests)
  • npm run typecheck: clean
  • npm run smoke -- status_diagnostics: ok
  • CI: all 9 checks green

For the human disposing of this

The ladder caps at 2 rounds, so this is the last automated pass. What is left for you:

  1. Item 3 of Follow-up: deferred review findings from PR #658 #676 still needs an answer (confirm the open question stands, or settle it in a follow-up LLP). A partially dropped gateway upstream is visible in status, and LLP 0193 records why the gateway idles (#653) #658 left the seam: gateway_idle_no_upstreams and gateway_upstreams_dropped are already distinct kinds.
  2. A partially dropped gateway upstream is visible in status, and LLP 0193 records why the gateway idles (#653) #658 needs the LLP 0195 erratum before it merges.
  3. Retarget The dropped-upstream warning hedges about a table it already has (#676) #678 to master once A partially dropped gateway upstream is visible in status, and LLP 0193 records why the gateway idles (#653) #658 merges.
  4. The PR body wants a refresh against b0a9e41.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage (neutral): PR can ship - no unresolved blocking findings at b0a9e41

The two-round fix loop hit its cap, so this is the triage disposition, verified against the committed tree at head b0a9e41730282b84ccb1480486ee0a4b4a062cc5 in a clean worktree, not against the review prose.

Findings status

Every numbered finding from both rounds is resolved at head:

  • Round 1 (on e482b56, 2 major + 1 minor) and round 2 (on a0a9633, 3 major + 1 minor) all land in src/core/daemon/status.js and src/core/daemon/types.d.ts, and all are present at b0a9e41: the hedged branch is bounded to the name (line 329), the covered clause states the routing-table fact plus the outranking caveat instead of "still proxied" (line 345), "routing rules" replaces the wrong path_prefix claim (also reflected in the DroppedUpstreamAttribution doc), and pluralisation is driven by oneEntry/oneName.
  • Independently re-verified the fixes are load-bearing: with only status.js reverted to a0a9633, 10 tests in test/core/status-gateway-idle.test.js fail; restored to head, npm test is 3803 pass / 0 fail / 6 skipped and npm run typecheck is clean. All 9 CI checks green on b0a9e41.

Deferred non-blocking residue

Three items round 2 deliberately left, all preference/hardening class, tracked in follow-up issue #680: (1) dropped names printed without sanitizeLabel/cap (pre-existing), (2) the nameless-drop hedge's missing antecedent (needs a source-side upstreams_dropped_unnamed distinction), (3) registered_presets snapshot timing vs the routing table (unreachable with bundled plugins).

Awaiting the human (out of scope here, by design)

  1. Item 3 of issue Follow-up: deferred review findings from PR #658 #676 - whether overall should flip for a fully broken gateway-only install. Reserved product-policy question per Follow-up: deferred review findings from PR #650 #653 and LLP 0195 §consequences; both rounds correctly left it untouched. Needs a human to either confirm the open question stands or settle it with a follow-up LLP.
  2. The LLP 0195 gloss erratum. LLP 0195 exists only inside PR A partially dropped gateway upstream is visible in status, and LLP 0193 records why the gateway idles (#653) #658 (not on master), so A partially dropped gateway upstream is visible in status, and LLP 0193 records why the gateway idles (#653) #658 should carry the one-clause correction to §visible-when-unintended before it merges. This stacked PR correctly does not touch the doc.

Housekeeping for merge

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral 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.

1 participant