Skip to content

release: to prod - #2139

Merged
suisuss merged 88 commits into
prodfrom
staging
Aug 26, 2026
Merged

release: to prod#2139
suisuss merged 88 commits into
prodfrom
staging

Conversation

@OleksandrUA

@OleksandrUA OleksandrUA commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Promotes staging to prod. 86 commits, 22 PRs, 135 files, no DB migrations.

Scope grew after this PR was opened, because it tracks staging. It was 3 PRs at 2026-08-25 09:42Z, 16 at 2026-08-26 01:02Z, and is 22 now.

PR Author Title
#2129 dependabot chore(deps): bump next from 16.3.1 to 16.3.2 in /docs-site
#2132 suisuss perf: KEEP-1088 batch eth_getLogs for sub-second block chains
#2136 OleksandrUA fix: KEEP-1220 make docs-site its own pnpm workspace root
#2145 suisuss perf: trace workflow world via literal import instead of 104 tracing globs
#2141 OleksandrUA fix: KEEP-1218 read the API server target for the self-hosted egress policy
#2140 OleksandrUA docs(self-hosted): name the client-address trap on ServiceLB clusters
#2146 OleksandrUA fix: KEEP-1225 reuse the shared build artifact in the playwright e2e job
#2148 suisuss fix: scope output file tracing to one route and restore the world globs
#2149 suisuss fix: base58-encode pubkey fields in decoded Anchor event args
#2151 sanbotto chore: Increase requested CPU for keeperhub-solana-common
#2134 eskp fix: regenerate the docs-site lockfile for next 16.3.2
#2032 eskp fix(mcp): correct destructive annotations on value-moving tools
#2033 eskp fix: make the MCP per-org rate limit fleet-wide via Redis
#2031 eskp fix: apply a default organization spending cap instead of treating an absent cap as unlimited
#2030 eskp fix: propagate API key scope on the direct execution API
#2040 harshkas4na fix: #2108 default tools/call arguments to {}
#2144 suisuss fix: KEEP-1213 redact RPC credentials from the health payload
#2042 harshkas4na docs: document get_execution's response shape
#2154 suisuss fix: correct the API-key scope edges around the direct execution API
#2098 Damso74 fix(execute): #1930 fail closed on unsupported check results
#2081 IronicDeGawd fix: #2093 keep the chain on a run that failed before broadcast
#2065 Dami904 fix: #2049 pass global edgesBySource to nested For Each body scan

Blocked, one thing

protocol-coverage shard 4 fails, and it is a real break

Run 32962124438, shard 4: 2 of 71 tests failed, in
tests/e2e/vitest/protocol-coverage/frax-ether-v2/ethereum/coverage.test.ts.

FAIL  frax-ether-v2 (Ethereum) > write > mint-and-give
FAIL  frax-ether-v2 (Ethereum) > write > mint-and-stake
AssertionError: Daily spending cap exceeded: expected 'error' to be 'success'

#2031 causes this. It makes an absent organization spend cap resolve to a platform default
instead of unlimited. The default is DEFAULT_DAILY_VALUE_CAP_WEI = "20000000000000000" in
lib/execute/spend-cap-defaults.ts:52, which is 0.02 ETH.

The cap is per organization per day, and the coverage suite shares one organization across a whole
shard, not per file. createSharedCtx() (tests/e2e/vitest/protocol-coverage/_shared/setup.ts:45)
creates no organization at all; every suite runs as the seeded
PERSISTENT_TEST_ORG_SLUG = "e2e-test-org" (tests/utils/db.ts:33). So each shard has a single
daily ledger that every protocol in it draws down.

Enforcement is strictly greater, in lib/execute/value-ledger.ts:254:

const exceeded = totalWei + reservedWei > dailyCap;

so a request landing exactly on the cap is allowed, and only the next one is denied.

Shard 4 (.github/workflows/e2e-tests-ephemeral.yml:920) holds both wrapped/ethereum and
frax-ether-v2/ethereum, and files run serially within a shard (vitest fileParallelism is off).
wrapped spends first, so frax inherits a ledger that is already half consumed:

step sends running total running + sends > 0.02?
wrapped/wrap 0.01 0.01 no, allowed
frax/mint 0.01 0.02 no, allowed
frax/mint-and-give 0.01 0.02 0.03 > 0.02, denied
frax/mint-and-stake 0.01 0.02 0.03 > 0.02, denied

That is exactly the run: mint passed, the other two failed. Deterministic, not a flake, and it
will fail on every re-run while the shard layout holds.

The same model accounts for the three green shards. Shards 2 and 3 contain no payable fixtures.
Shard 1's only payable fixture is rocket-pool/deposit at 0.02, which passes solely because 0.02
is not greater than 0.02. Shard 1 has zero headroom: one more payable fixture, or any reshard
that moves wrapped into it, breaks it too.

e2e-tests / e2e-gate is red only as a consequence. Its log reports every other need as success
and closes on protocol-coverage-ephemeral(failure).

Why it reached staging: #2031's own CI skipped protocol coverage.
protocol-coverage-ephemeral and protocol-coverage-floor both report SKIPPED on that PR, and
e2e-gate passed because they were skipped rather than because they ran. The suite only runs in
full on the release PR.

The fix: seed the cap the fixture org should have had. scripts/seed/seed-test-wallet.ts
creates e2e-test-org and its Turnkey wallet but never writes an organization_spend_caps row,
which is why the org falls through to the platform default at all. Adding the row sets an explicit
daily_value_cap_wei of 1 ETH, well above per-shard demand (0.04 ETH on shard 4 today), so a new
payable fixture does not re-break the sweep. It also closes shard 1's zero headroom.

One caution for anyone touching this: organization_spend_caps has two similar columns,
daily_cap_wei (gas) and daily_value_cap_wei (native value moved). Only the second one governs
this failure. The existing precedents in scripts/seed/seed-analytics-data.ts:584 and
tests/e2e/playwright/utils/seed.ts:684 both write daily_cap_wei; copying either is a silent
no-op that leaves the suite failing identically.

The alternative, overriding EXECUTE_DEFAULT_DAILY_VALUE_CAP_WEI for the coverage job, also works
and getDefaultDailyValueCapWei() already honours it. It is the weaker option: it needs three
files rather than one, because .github/actions/start-app/action.yml has no generic env
passthrough and protocol-nightly.yml runs the same suite, it does not help local runs through
scripts/protocol-local.sh, and it disables #2031's new default across the whole CI app rather
than leaving it enforcing for orgs that set no cap, which is the behaviour #2031 shipped.

Per-file organizations are not a cheap alternative: the shared org owns the Turnkey wallet the
tests sign with (organization_wallets), so each per-file org would need its own provisioned and
funded wallet.

Lowering the frax ethValue fixtures would also clear it, but it weakens what the suite
exercises, so I would not.

Note this unblocks CI without settling whether 0.02 ETH per day is the right production default.
That is a separate product question for #2031, and it should not be decided by whatever makes a
release green.

Resolved: metrics-db-reviewed

metrics-db-review-gate reported a missing label earlier in this PR's life, over the
lib/metrics/ changes. It passes on the current head, so there is no action left.

No DB prep

git diff origin/prod...origin/staging touches no migration SQL, so db-prep-check passes with no db-prepped-prod label and there is no manual DB step.

docs-site lands consistent

prod currently has next 16.3.1 in both the manifest and the lockfile, with no pnpm-workspace.yaml. After this it has 16.3.2 in both plus the workspace file. #2129 is the manifest-only bump that caused the drift, #2136 and #2134 carry the matching lockfile.

harshkas4na and others added 30 commits August 12, 2026 14:30
…sing

A tools/call sent with `arguments` omitted or explicitly `null` fails the
SDK's `z.record().optional()` params schema with a raw Zod error, which the
transport surfaces as -32603 (Internal error) rather than a validation
error. That's indistinguishable from the server being down, and it lands on
exactly the call an agent tries first: a tool with no required parameters
(get_wallet_integration, list_integrations, ...), invoked with no arguments
object at all.

Normalize params.arguments to {} at the dispatch layer, before the request
reaches the SDK, on both the org-authenticated (/mcp) and anonymous
(/mcp/public) routes. Every per-tool schema still enforces its own required
fields downstream unchanged (get_wallet_integration still requires
integrationId, just with a proper -32602 instead of a -32603).

Found while building an MCP client for the Agents Onchain Hackathon; full
writeup at https://github.com/harshkas4na/chaos-keeper/blob/main/docs/teardown.md#kh-1.
/analytics/runs returns network: null for a workflow run that failed
pre-flight, even when the run's own error names the chain. The aggregate
only reads the network from a step that produced gasUsed:

    MIN(CASE WHEN gasUsed IS NOT NULL THEN input->>'network' END)

A run refused before broadcast - insufficient balance, spend cap, a bad
address - has no gasUsed on any step, so the CASE yields NULL for every row
and the run comes back with no chain at all.

Observed on a live organisation: three runs of one workflow, each failing
with "Insufficient BASE balance. Have: 0.0, Need: 0.000100231", all returned
network: null. The step's own log carries input.network = 8453.

Now prefers a gas-bearing step and falls back to any step that names a
network, so a successful multi-chain run is unchanged and a failed run keeps
its chain.

Why it matters beyond cosmetics: /analytics/runs is the audit trail an
external consumer reads to reason about an organisation's executions. A
failed run with no chain cannot be attributed, counted per network, or acted
on - and pre-flight failures are exactly the ones worth acting on, since
they cost nothing and repeat.

Not covered by a new automated test: this is SQL evaluated in Postgres, and
the repo's only test for this path (tests/unit/analytics-runs-route.test.ts)
mocks getUnifiedRuns, so nothing exercises the expression. Reproduction
against a live org: run a workflow whose transfer amount exceeds the wallet
balance on any chain, then GET /api/analytics/runs and read `network`.
KH-4 from the teardown referenced in #1949, #2040, #2041: get_execution's
response is described only as "combined status and step-by-step logs in one
response" -- the nested shape, and three ways it surprises a first-time
caller, are nowhere written down.

New reference page (docs/agent/mcp-get-execution.md), grounded in the
current route/schema code rather than guessed:

- totalSteps/completedSteps/duration are numbers in status.progress
  (parsed in the /status route) but strings in logs.execution (straight off
  the workflow_executions row, where they're text/numeric columns).
- logs.logs is ordered newest-first (desc by timestamp), not execution
  order. logs.execution.executionTrace is the field that actually carries
  run order, and it's populated on every execution -- unlike
  status.errorContext.executionTrace, which only appears on error/
  system_error runs.
- status.errorContext is null (not omitted) on every non-error status.
- The cross-organization / public-share-view case returns logs: null
  rather than an empty object.

Linked from the get_execution row in the MCP tools table
(docs/agent/mcp-server.md) and added to the docs/agent/_meta.ts nav,
matching how mcp-validate-workflow.md is wired in.

Docs only, no behavior change.
status.progress never had a duration field; it exists solely on
logs.execution. Corrects the heading and body claim per review.
The log-summary subquery in fetchWorkflowRuns filtered on
gasUsed IS NOT NULL. WHERE runs before GROUP BY, so a run whose steps
all lack gas contributed no rows, formed no group, and left the join
NULL - a pre-flight failure came back with network null and networks
empty, even though logging.ts records the chain at step start and the
run's own error names it. The runs table rendered "--" under Network
for exactly the runs an operator opens when something broke.

- Drop the gas predicate from the subquery. gasUsedWei is unaffected:
  SUM skips NULLs and the resulting 0 still maps to null, so "spent no
  gas" stays distinguishable from "chain unknown".
- Read the denormalised network and gas_used_wei columns rather than
  re-parsing the double-encoded input/output JSONB, which matters more
  now that the subquery covers every step row of the page.
- Restate the networks contract: it now lists the chains a run's steps
  targeted, not only those it wrote on.
- Cover both with a Postgres-backed test that seeds a gas-free step log
  and asserts the returned network and networks.

Tested against Postgres 16: a run seeded with a chain-bearing, gas-free
step returns network base and networks [base]; a run whose gas-bearing
step is on one chain and read-only step on another keeps base as its
network and lists both.
executionTrace has no default (jsonb, no default()) and the pending-
execution insert in app/api/workflow/[workflowId]/execute/route.ts
does not set it, so an execution that never dispatches or fails
before any step finishes leaves it null, not an empty array. Also
re-attached the numeric-serializes-as-string parenthetical to
duration, the actual numeric column -- total_steps/completed_steps
are text, not numeric.
Only null hits the raw -32603 path; omitting arguments entirely already
fails one layer in with a proper tool-scoped error. No behavior change.
Bumps the minor-and-patch group with 1 update in the /docs-site directory: [next](https://github.com/vercel/next.js).


Updates `next` from 16.3.1 to 16.3.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](vercel/next.js@v16.3.1...v16.3.2)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
checkMcpRateLimit tracked its sliding window in a module-level Map, so
every replica enforced its own budget and the real ceiling was
LIMIT * num_replicas. A caller spreading requests across pods got a
multiple of the intended 120/min.

The organization window now lives in a Redis sorted set behind the
existing best-effort client in lib/redis.ts, keyed through
lib/redis-keys.ts so the deployment namespace cannot drift.

Sorted set rather than INCR plus EXPIRE: a fixed window admits 2 * LIMIT
across a boundary, and the sliding form is what the existing
reset/retryAfter metadata and the X-RateLimit-* headers already describe,
so callers and tests keep the same RateLimitResult shape. Trim, count and
add run in one Lua script; splitting the count from the increment across
round trips would let concurrent requests each read a count under the
limit and all be admitted, which is a bypass the size of the concurrency.

When Redis is absent or the command fails the limiter falls back to the
per-pod window instead of allowing the request. A Redis outage therefore
degrades the ceiling to what it was before this change rather than
removing it; failing open would leave the MCP surface unmetered exactly
when the platform is least healthy. The fallback map is capped at 10k
organizations, sweeping stale entries and then evicting the least
recently active one, so an outage cannot be turned into memory growth.
Command failures log through logSystemWarn, throttled to once a minute
because this sits on the request hot path.

checkMcpRateLimit is now async; both call sites await it. checkIpRateLimit
is unchanged and still per-pod.
Review of the Redis-backed per-organization window found three gaps.

Cold start was reported as an outage. The shared client is built with
lazyConnect and enableOfflineQueue=false, so ioredis refuses the first
command on a fresh client while the socket is still opening: sendCommand
kicks off connect() from status "wait" and then rejects the write. Every
process start therefore served its first decision from the per-pod
fallback and sent "Redis unavailable" to Sentry, and since that warning
is throttled to one a minute it also suppressed any genuine outage
report for the first minute of a pod's life - training operators to mute
the one alert that says the fleet-wide ceiling has reverted to
LIMIT * num_replicas. The connection is now opened explicitly, warmed at
boot from instrumentation, and awaited before the first command.

Degradation had no signal a dashboard could read. Every degraded
decision now increments ratelimit.mcp.degraded.total, labelled by
reason, so whether the shared ceiling is actually being enforced is
answerable without reading throttled Sentry warnings. The
unexpected-reply branch carries the reply shape instead of reporting an
Error titled "undefined", which is the only diagnostic that branch has.

The Lua had no coverage at all. The unit fake reimplements the
sorted-set semantics in JS and never looked at the script, so the suite
stayed green with the script blanked - a state in which production would
fail every EVAL, fall back to the per-pod window forever, and silently
restore the ceiling this work exists to remove. Added an integration
test that runs the shipped script against a real Redis (skipped when
none is reachable) covering admit-exactly-LIMIT, TTL set on admit and
not extended on refusal, same-millisecond members not colliding, integer
reply precision and window trimming; plus a golden hash in the unit
suite so the Lua cannot change without being re-verified.

Also from review:

- The window is scored by Redis's own TIME rather than the calling pod's
  clock. Replicas only shared a window to the accuracy of their local
  clocks, and a pod drifting past the window length would have written
  entries every other replica immediately trims, quietly buying itself a
  private budget from an infrastructure fault.
- Fallback eviction is an O(1) insertion-order LRU instead of two full
  map scans on the request path, and the cap now covers the IP map too -
  the one an unauthenticated caller can actually flood between sweeps. A
  key at its limit is written back on refusal so it cannot be evicted
  into a fresh window.
- Route tests mock the limiter with a resolved promise, so a call site
  that forgets the await turns into a blanket 429 that fails CI rather
  than only showing up in production.
MCP clients batch approvals from readOnlyHint and destructiveHint. The
spec default for destructiveHint is true, so writing false is an explicit
assertion that a tool is only additive, and destructiveHint is ignored
entirely when readOnlyHint is true. Twelve tools plus every marketplace
listing made a safety claim they could not support, so agent workspaces
auto-approved them with no human in the loop.

Audited all 44 tools on the authenticated surface. Flipped destructiveHint
to true on execute_workflow, execute_protocol_action, call_workflow,
tempo_sign_and_hold, tempo_cancel_hold, tempo_release_hold,
update_workflow, list_workflow and update_workflow_listing.
execute_protocol_action routes both reads (chronicle/eth-usd-read) and
writes (aave-v3/supply) through one actionType argument, so the static
annotation takes the worst case; the tool is not split here because that
breaks a public MCP surface.

create_workflow is also destructive. It takes an unconstrained nodes array
alongside enabled, so one call arms a schedule/event/block/webhook trigger
over arbitrary node content, which is a superset of execute_workflow.
test_notification is destructive too: type and config are caller-supplied
and reach the plugin unfiltered, sending an unrecallable message to a
caller-named target or opening a database connection to a host it names.
Persisting nothing does not make an outbound emission additive.

createWorkflowMcpServer no longer derives its hints from
listing.workflowType. That field does not describe side effects and points
the wrong way: the call route sends a "read" listing to handleReadWorkflow
and runs the whole body server-side on the owner's wallet and credentials,
while a "write" listing only returns unsigned calldata for the caller to
sign. deriveWorkflowType compounds it by classifying "write" only for
write-contract and protocol-write nodes, so a listing that transfers funds,
approves a spender, signs typed data or sends mail stays "read". No
per-action read/write metadata exists to classify an author-controlled
body, so every listing now advertises readOnlyHint false and
destructiveHint true rather than claiming a safety we cannot establish.

ai_generate_workflow claimed readOnlyHint true while /api/ai/generate
hard-requires mcp:write, so the hint contradicted the scope model. It is
now a non-destructive write.

ai_generate_workflow, create_project, create_tag and deploy_template keep
destructiveHint false: each only inserts, and the deploy_template clone
falls to the schema default of disabled because the duplicate route sets no
enabled column. That set is an explicit allowlist enforced by the new
exhaustiveness test, so a future tool cannot quietly join it. idempotentHint
and openWorldHint stay at their spec defaults, which are already the
conservative values here.

Documented the policy above registerTools. No existing test asserted the
old values.
The header comment and the PR description both described the pre-change
ceiling as LIMIT * 2. Prod runs replicaCount 4
(deploy/keeperhub-stack/prod/values.yaml), so the advertised 120/min was
really 480/min and this change cuts the effective ceiling by three
quarters rather than half.

Staging runs a single replica, so its limit is already 120/min and this
change is a no-op there -- worth stating, because it means staging cannot
surface the impact before prod does.
Deriving destructiveHint from the read-only check propagated an existing
gap into a second annotation. hasMutatingNode only matched
write-contract/protocol-write plus three named web3 types, so a listing
whose only action was a Tempo transfer was typed read, passed the check,
and advertised readOnlyHint true with destructiveHint false. Tempo carries
no native gas token, so its writes never register on the daily native
value cap either -- the annotation was the only thing between an MCP
client and an auto-approved stablecoin transfer.

Adds a second predicate rather than stretching the first.
isMutatingActionType keeps meaning chain state, and its docstring's warning
about staying off the calldata path still holds. hasIrreversibleEffect is
what the annotations use: chain mutation or an outbound message. The MCP
spec defines readOnlyHint as not modifying the environment, which is wider
than not modifying chain state -- an email is as unrecallable as a
transfer. tools.ts already annotates test_notification destructive on
exactly that reasoning, so a listing doing the same send now agrees with
it instead of contradicting it.

Both sets stay allowlists, so an unrecognised action type still reads as
side-effect-free. That residual is now a test rather than an assumption;
a declared side-effect field on PluginAction is what would close it.

Also reverts ai_generate_workflow to readOnlyHint true. It persists
nothing, and withScopeCheck enforces mcp:write regardless of any
annotation, so the flip bought no enforcement and cost an accurate signal:
clients allowlisting read-only tools would prompt for a call that mutates
nothing. Model spend is a cost the annotation vocabulary cannot express.
The suite now allows read-only tools to require a write grant, since
effect and grant are separate axes, and guards that exception list.

Adds an assertion that the duplicate route creates the clone disabled,
which is the invariant deploy_template's non-destructive annotation rests
on and which nothing previously enforced.
…site/staging/minor-and-patch-9d5bc31b60

chore(deps): bump next from 16.3.1 to 16.3.2 in /docs-site in the minor-and-patch group across 1 directory
processBlock issued one eth_getLogs per block per 500-address batch, driven
off each newHeads push, so request volume scaled linearly with block rate. At
Ethereum's 12 s that is 7,200 calls/day/batch and at Base's 2 s it is 43,200,
but Robinhood Chain produces a block every 100 ms: 864,000 calls/day/batch,
twice the entire platform's current RPC volume across all 26 chains, from a
single subscriber batch on one chain.

Blocks from a chain observed to be producing them faster than
BATCHING_BLOCK_INTERVAL_THRESHOLD_MS are now buffered into a BATCH_WINDOW_MS
window and served by a single ranged request, taking a 100 ms chain to 86,400
calls/day/batch - a bounded factor of two against Base rather than twenty.

Cadence is measured rather than configured. No per-chain block time reaches
this process, and an observed value needs no migration when a chain is added
or its cadence changes. A chain dispatches per block until twenty intervals
have been folded into the estimate, so a misjudged cadence at startup can only
fail towards the existing behaviour, and the estimate resets on every
block-listener attach: carrying it across a reconnect would let a stale value
decide batching for what may be a different upstream.

The threshold sits at 500 ms rather than the 1 s round number. The integration
rig runs anvil at --block-time 1, and a chain sitting exactly on the boundary
would drift across it on timer jitter alone and batch nondeterministically.
500 ms leaves 5x margin below to the chain this exists for and 2x above to the
fastest cadence anything here produces, so every chain supported today keeps
one request per block and the latency that goes with it.

The window is measured from its first block, bounding the delay any single
block waits rather than sliding forward as blocks keep arriving, and is capped
at BATCH_MAX_BLOCKS so a burst cannot widen the range without bound. A ranged
request also covers blocks the subscription skipped inside the window, which
recovers logs the per-block loop dropped; the dedup layer absorbs the
duplicates that produces. Open windows are discarded in detachBlockListener,
the single teardown chokepoint, so no request fires against a provider that is
about to be destroyed.

BLOCK_STALENESS_TIMEOUT_MS was slack measured in wall-clock rather than in
blocks: 120 s is ten blocks on Ethereum but 1.2 million on a 100 ms chain,
where a silently dead subscription would go unnoticed far longer in the terms
that matter. Only a chain fast enough to be batching now derives its own
threshold, max(30 s, interval x 10), so every chain dispatching per block
keeps the historical value exactly and this cannot introduce reconnect churn
on a chain that behaves today.
…ing it

The coalescing window was discarded whenever the block listener detached.
That is right for unsubscribe and destroy, where no subscriber remains to
dispatch to, but wrong for a reconnect: its subscribers are still attached and
still expect those logs, so discarding loses events that per-block dispatch
never could.

Worse, the window was not reaching that point at all. reconnectLoop sleeps
INITIAL_RECONNECT_DELAY_MS before its first attempt, and that delay is the
same width as BATCH_WINDOW_MS, so the window timer fired against the
connection that had just failed: the request threw, and the buffered blocks
were lost to a caught warning before reconnect() ever ran its teardown.

Disarm the timer in triggerReconnect, alongside the heartbeat it already
stops, and keep the blocks. detachBlockListener now takes the discard decision
from its caller rather than assuming it, and the reconnect serves the carried
range on the replacement connection - the heights are historical by then, so
it answers for them as well as the original would have.

Found by a second review pass on the batching change; the test written for it
failed against the first fix, which is what exposed that the timer was firing
early rather than being discarded late.
Batching block requests is a cost change, and nothing outside this process
can measure whether it worked.

The package ships no prom-client, no OpenTelemetry and no /metrics route,
and the health server binds HEALTH_PORT (default 3001) while the deployment
declares only 3000, so an endpoint added here would not be reachable.
Aetherlay cannot stand in either: aetherlay_endpoint_proxy_requests_total
increments once per WebSocket connection (server.go:761, under "WebSocket
connection succeeded", returning immediately after) and per request only on
the HTTP path, while the proxy's own message loop counts nothing. Every
eth_getLogs this class issues rides a long-lived socket, so none of them
appear in that series on any cluster.

That leaves the log stream, which the package logger already targets: it
emits canonical single-line JSON specifically so Loki can aggregate across
the app and its satellites.

Count calls at the request rather than the range, since one range spanning
more than GETLOGS_ADDRESS_BATCH addresses is still several requests and
requests are what a provider bills. Report one line per chain that did work,
resetting the interval counters and carrying a cumulative total, and stay
silent for idle chains so a quiet tracker does not emit a line per chain per
minute forever. The counters live on the chain entry rather than the
connection, so a reconnect re-learns cadence but does not reset cost.

blocksCovered against getLogsCalls is self-comparing: roughly equal while
dispatching per block, blocks far ahead of calls once a window engages. That
matters because nothing has ever counted this quantity, so a later reading
has no historical baseline to be compared against.

Also surfaced on ChainHealth so the same numbers are reachable without log
search if that endpoint ever becomes reachable.
Three corrections from review of the previous commit.

blocksCovered and ranges were incremented before the request, so a range
whose eth_getLogs threw still counted every block it never fetched logs for.
That inflates the blocks-per-call ratio precisely when a chain is failing,
making the reading look most efficient when the chain is least working. Both
now increment only after the range's requests return. getLogsCalls stays at
issue: a request that failed was still made and still billed. A failing
chain therefore reports calls and errors with no coverage, which is the
honest shape.

The stats timer no longer calls unref. No other timer in this class does,
destroy clears it either way, and unref is not on setInterval's return type
under every lib configuration this package compiles against - so it was the
one line here whose type safety depended on ambient resolution.

The counters are packed as key=value inside the JSON msg string, because the
logger has no structured-field API. The previous commit message implied
`| json` alone surfaces them; it does not, it yields msg as one string. The
query needs the inner parse and the comment now carries it.

Adds a getLogs failure path to MockProvider, following the convention
blockNumberResponses already uses, so the coverage-on-failure behaviour is
tested rather than asserted.
Review of the windowed-batching approach returned fourteen findings, six of
which were the same shape: a mechanism that measured the chain, decided
whether it qualified, and then had to defend that decision against bursts,
threshold drift, reconnects and its own timers. Replacing the mechanism
removes them rather than patching each.

Each chain now keeps `lastProcessedBlock`, and a drain fetches `(mark, head]`
at most once per GETLOGS_MIN_INTERVAL_MS. Nothing branches on how fast a chain
is. A chain whose blocks arrive further apart than the interval always finds
it already elapsed and dispatches immediately - one request per block, the
historical behaviour and latency, with no threshold deciding it. A faster one
finds it has not, its head runs ahead of the mark, and the accumulated range
is served in one request. A 100 ms chain is still bounded to one call per
second per chain.

What that deletes, and the finding each one closes:

- The batching threshold, the EWMA gate and the warm-up count. Dispatch no
  longer consults cadence, so a burst of queued newHeads messages cannot flip
  a 12 s chain into batching, and no chain can oscillate across a boundary.
- The window timer. It could be re-armed by any block arriving during a
  reconnect backoff, because disarming it in triggerReconnect stopped the
  timer and not the work. `drain` refuses to run while isReconnecting, so the
  guard is now on the work itself.
- pendingBlocks and the retainWindow special case at detach. There is no
  buffer to decide the fate of; the mark records what is owed, survives a
  reconnect, and the replacement connection resumes from it.
- Math.min/Math.max over buffered heights, which bounded the block count but
  not the span it queried. A contiguous range from the mark is capped at
  GETLOGS_MAX_BLOCK_SPAN with the remainder left owed, and a gap past
  GETLOGS_MAX_CATCHUP_BLOCKS is abandoned with a warning rather than walked at
  one request per second while current blocks wait behind it.

Three further fixes:

processBlockRange now reports whether the range was served, and the mark only
advances when it was. A failed range stays owed and the next drain re-queries
it, where before a single failure dropped every event in up to 25 blocks with
no retry. A partial multi-chunk failure dispatches nothing rather than
delivering the chunks that happened to return and never fetching the rest.

reconnect() no longer awaits the flush. dispatchLog awaits its handlers, each
of which may sleep seconds of EventListener jitter, so awaiting it held
isReconnecting true long after the socket was healthy - reporting the chain
degraded on /healthz, blocking every getOrCreateProvider behind
entry.reconnectPromise, and suppressing the heartbeat and staleness checks
that would have caught a second drop.

The staleness threshold is clamped at both ends and its multiplier raised to
60. max(30 s, interval x 10) could never return anything but the floor: the
branch required an interval under 500 ms, so the product was always under 5 s,
which made the constant dead and the documented rule unobservable. At 60
blocks with a 120 s ceiling, Base and Ethereum keep exactly the 120 s they had
and a 100 ms chain takes the 30 s floor, with the multiplier deciding
everything between. Trusting the estimate now also requires the samples to
span real wall clock - measured across the samples, not to now, since silence
would otherwise supply the span at exactly the moment the threshold is used.

Also: ChainHealth.getLogsCalls is renamed getLogsCallsTotal to match its
source, since the log line's getLogsCalls is a per-interval count and one name
for both invited reading a rate as a counter. blocksCovered is documented
against `ranges` rather than `getLogsCalls`, which counts one per address
chunk and so halves the ratio for a chain past GETLOGS_ADDRESS_BATCH addresses
for reasons unrelated to coalescing. shutdownRegistry now destroys the
provider manager - nothing in src ever called destroy(), so providers and the
stats interval outlived the listeners they existed for.

Tests import the production constants instead of re-declaring them as
literals, share one set of helpers, and cover each finding: per-block
dispatch on a slow chain, coalescing on a fast one, the request-rate bound,
skipped blocks, the span cap and its remainder, the abandoned gap, overlap
under an in-flight request, retry of a failed range, no advance past a failed
chunk, resumption from the mark across a reconnect, no request against the
replaced connection, isReconnecting cleared without waiting on dispatch, and
both clamp ends plus burst immunity on the staleness threshold.
…batching

perf: KEEP-1088 batch eth_getLogs for sub-second block chains
docs-site/package.json moved to next 16.3.2 while docs-site/pnpm-lock.yaml
still pinned 16.3.1. The Dockerfile installs that directory with
--frozen-lockfile, which refuses a manifest the lockfile does not match, so
every image build failed at the deps stage:

  ERR_PNPM_OUTDATED_LOCKFILE
  - next (lockfile: 16.3.1, manifest: 16.3.2)

Staging is red for the same reason, so this blocks the build job on every
open PR rather than any one branch.

Regenerated with --ignore-workspace. Without that flag pnpm resolves the
root workspace instead and leaves this lockfile untouched, which is the
trap that makes the two drift apart: a bump lands in the manifest, a plain
install appears to succeed, and nothing regenerates the file the image
actually uses.

Second occurrence in three days; KEEP-1212 fixed the same drift at 16.2.12
to 16.3.1. Worth a CI check that the docs-site manifest and lockfile agree,
so the next dependency bump fails its own PR instead of staging.
Dependabot opens manifest-only bumps for /docs-site. It edits
docs-site/package.json and leaves docs-site/pnpm-lock.yaml behind. The docs
image installs with --frozen-lockfile, so the build then fails with
ERR_PNPM_OUTDATED_LOCKFILE and stays red on every branch. This happened four
times since June.

Cause: pnpm walks up from the install directory to find a workspace root.
Commit e192a74 added the repo-root pnpm-workspace.yaml on 2026-04-23, and it
lists only "." and "sandbox". An install started in docs-site reaches that file
and resolves the root workspace, so it never writes docs-site/pnpm-lock.yaml.
Dependabot runs that install with no --ignore-workspace flag, sees no lockfile
diff, and ships the manifest change alone.

The dates match. PR #945 is the last docs-site bump that carried a lockfile and
it merged about 10 hours before the root workspace file landed. keeperhub-events
keeps its lockfile updated because it has its own pnpm-workspace.yaml.

This adds the same file to docs-site, so pnpm stops its upward walk there. The
Dockerfile deps stage copies it too, so the image install reads the same
workspace context that produced the lockfile.

Reproduced and verified with the pinned pnpm 10.33.3:

- Before the change, `pnpm install --lockfile-only` inside docs-site reports
  "Scope: all 2 workspace projects", exits 0, and leaves docs-site/pnpm-lock.yaml
  byte-identical while the manifest declares next 16.3.2 and the lockfile pins
  16.3.1. That is the command Dependabot runs.
- After the change, the same command resolves 497 packages and updates
  docs-site/pnpm-lock.yaml.
- The regenerated lockfile is byte-identical to the one PR #2134 produced with
  --ignore-workspace, so the change alters no resolution.
- The root workspace still reports 2 projects and its lockfile passes
  --frozen-lockfile, so docs-site did not join it.
- `docker build -f docs-site/Dockerfile --target deps .` and `--target builder .`
  both complete.
A cluster with no cloud load balancer answers a LoadBalancer Service with a
proxy on each node. A request that arrives at a node without the ingress
controller pod is forwarded, and the source address becomes an address from
the cluster's own pod range. The install still works, so nothing points at
the ingress controller.

Step 2b now states the trap and gives the remedy, which is to run the
controller as a DaemonSet.

Measured during the M0 acceptance run (KEEP-1114). KEEP-1219.
eskp and others added 19 commits August 26, 2026 10:35
app/api/execute/_lib/auth.ts returned the scope on the OAuth branch but
dropped it on the API-key branch, even though authenticateApiKey already
resolves and returns organization_api_keys.scope. scopeSatisfies(undefined, X)
is unconditionally true, so every requireScope() call across the seven
app/api/execute/* routes was a no-op for kh_ keys: a key minted mcp:read
could broadcast via /transfer, /contract-call, /check-and-execute, /node and
the protocol dispatcher. Forward result.scope so the gates actually apply.

Undefined stays full access. Every requireScope caller was enumerated: four
auth resolvers feed them, and getDualAuthContext, resolveOrganizationId and
resolveCreatorContext already propagated apiKeyAuth.scope while returning no
scope on their session branch. validateApiKey was the outlier. Cookie
sessions and internal callers never set a scope and are untouched.

Not a no-op for existing credentials. The scope column is nullable, but the
dashboard mint dialog sends the Permissions selection on POST /api/keys, so
org keys created through the UI do carry a scope. The default has all three
boxes checked, which satisfies write, but a key created with Write unchecked
goes from working to 403 at the execute endpoints. That is the intended
behaviour - a read-only key must not broadcast - and it needs a count of
affected rows before deploy:

  SELECT count(*) FROM organization_api_keys
  WHERE revoked_at IS NULL AND scope IS NOT NULL
    AND scope NOT LIKE '%mcp:write%' AND scope NOT LIKE '%mcp:admin%';

Keys with scope NULL are coerced to undefined and keep full access, so the
kh CLI device grant (which hardcodes null) is unaffected.

Also in this change:

- requireScope emits a logSecurityEvent on denial, with the required and
  granted scope plus an optional organization/credential/endpoint context the
  execute routes now pass. The deny path was silent, which made a spike in
  403s indistinguishable from a stolen read-only key probing a broadcast sink.
- The Permissions copy in the key-creation dialog now says that Read cannot
  run workflows or send transactions, and the wallet step-up mint path sends
  the selected scopes. It previously discarded them, so a wallet user who
  unchecked Write still received an unscoped, full-access key.
- Public docs corrected: docs/api/direct-execution.md claimed "API keys are
  unaffected by scope", and docs/api/api-keys.md did not document the scopes
  field on key creation.
- Route-level regression coverage for the five sinks that actually broadcast,
  driving the real handlers through the real validateApiKey and the real
  requireScope, and pinning the polarity of the simulate ternary in both
  directions. Verified failing against the unpatched auth.ts.
specs/api-coverage.json records the line each documented route sits on in
docs/api/direct-execution.md. This branch corrects the scope statement on
that page, shifting the lines below it, which leaves the checked-in
coverage file stale and fails the lint job.
requireScope passed a Sentry payload to logSecurityEvent on a path any
caller can drive at its own request rate. lib/logging.ts states the rule
in logUserError's own body: user errors are deliberately kept out of
Sentry because they are expected, high-volume, and would drown actionable
system errors. A scope denial is the caller using a credential outside its
grant, not a platform fault, so it belongs on that side of the line. The
per-scope fingerprint collapsed the issue but every event still billed
against quota, and nobody can action 'an integrator is misconfigured'.

The description also claimed the denial became countable. It did not:
logSecurityEvent writes Sentry and Loki and never touches Prometheus, so
there was no series to alert on. Adding logUserError emits the counter,
which is what Grafana can actually threshold.

The structured security line still lands in Loki, so a detection query
over repeated denials from one credential is unchanged.

Also reorders the wallet create-key payload so the step-up spread cannot
override the operator's scope selection, and adds logUserError to the four
suites whose hand-rolled logging mocks would otherwise throw and turn the
403 under test into a 500.
filterLabelsForMetric keeps only ERROR_LABELS, which carries endpoint and
the error_context taken from the message prefix. required_scope,
granted_scope and organizationId are not in that list, so they are dropped
before Prometheus and survive only in the Loki line.

The deny rate is therefore alertable per endpoint but not breakable down
per scope or per organization, which is not obvious from the call site
passing all three. Noted so nobody builds a per-scope panel against labels
that never arrive.

Adding required_scope to ERROR_LABELS would enable that slice cheaply --
three possible values. organizationId should stay out: per-org labels are
exactly the cardinality the allowlist exists to keep off Prometheus.
fix: propagate API key scope on the direct execution API
…all arguments

A JSON-RPC 2.0 array `params` is not a shape MCP uses, but spreading one
into an object silently rewrote it before the SDK ever saw it. Guard on
Array.isArray so it passes through and the SDK rejects it on its own terms.
The -32603 fix is the dispatch-layer wiring, not the helper. A pure-function
test stays green if either route goes back to handing `await request.json()`
straight to the transport, so assert on what the transport actually receives:
parsedBody.params.arguments is {} for both the null and omitted shapes, on
/mcp and /mcp/public. Verified red without the wiring.
The session branch is the only path a tools/call takes on /mcp/w/[slug], and
it handed the live Request to the SDK, so `arguments: null` still surfaced as
-32603 on the marketplace surface external agents actually call. Parse the
body up front and pass it through parsedBody, mirroring /mcp; ensureMcpAcceptHeader
no longer forwards the (now consumed) body stream, which also drops the duplex
ts-expect-error. Malformed JSON on a session POST now returns the route's own
400 instead of reaching the transport, matching /mcp.
…ments

fix: #2108 default tools/call arguments to {} — only null hits the opaque -32603, omitted already fails cleanly
Propagating scope on /api/execute (#2030) turned four latent edges into
live ones. Each was correct for OAuth and wrong for kh_ API keys, which
could not reach these paths until scope started being enforced.

The 403 body told every denied caller the ceiling is raised by an admin
under Settings > Developer > Agents. mcpMaxScope is read only inside
authenticateOAuthToken, and an API key's scope is fixed in the row at
creation, so for a key that sentence names a control which does not
apply and omits the one that does. requireScope now takes the credential
family and picks the matching remediation, claiming none when the caller
does not say. The shared part of the message no longer says "OAuth".

parseScopeInput returned null for an explicitly empty scopes field, and
null means no restriction, so asking for the narrowest key produced the
widest one. Only omitting the field reaches null now; a supplied value
floors at mcp:read, as ["bogus"] already did.

The Permissions block rendered on both key tabs, but nothing reads
apiKeys.scope: the webhook route looks a key up by hash and dispatches
without consulting it. "Cannot run workflows or send transactions" was
false there. It is now shown only for organisation keys, and webhook
creation omits scopes so the stored column says what is true.

The denial also incremented errors.system.auth.total, which the errors
dashboard sums into its System Errors panels unfiltered - a caller using
a credential outside its grant is not a platform fault. It moves to a
user-side authorization category. errorCategory is a text column, not a
pg enum, so the schema union tracks it without a migration.

No dashboard charts errors.user.authorization.total yet, so the denial
rate is not visible anywhere until a panel is added.
require-scope.test.ts pinned the denial's error category as "auth". That
was the point of the change: "auth" maps to errors.system.auth.total,
which the errors dashboard sums into its System Errors panels, and a
caller using a credential outside its grant is not a platform fault.

execute-auth-anonymous.test.ts deep-equals what validateApiKey returns,
so adding credentialType to the context broke three cases that assert
the shape rather than the fields they care about. The OAuth branch and
both API-key branches now name the family they came back from.
…ing path

Review of the previous commit found the metric change made the deny rate
worse, not better. errorCounterMap in the Prometheus collector had no
entry for errors.user.authorization.total, so recordErrorCounter logged
"Unknown error metric" and dropped the count - where errors.system.auth
.total had at least been recorded, in the wrong panel. On a path a
caller drives at its own request rate that is a lost series plus a warn
line per denial. The counter is now defined and mapped, so the claim in
require-scope.test.ts that the rate is countable holds again.

parseScopeInput still returned null for anything that was neither array
nor string. scopes is unvalidated request body, so an object - the shape
the creation overlay holds internally - or a boolean reached the same
full-access default the change set out to remove. Only undefined and
null are read as omission now; anything else present floors at mcp:read.

Also from review: schema.ts tracks ErrorCategory by reference instead of
a hand-copied union, so the next category added does not have to be
remembered in two files. logSecurityEvent carries the credential family,
without which a Loki query cannot separate an OAuth grant clamped by a
ceiling change from a leaked kh_ key probing above its scope. The 403's
API-key sentence no longer reads as an instruction to mint more scope,
since nothing clamps key creation against the org ceiling. The direct
execution docs printed the old message verbatim on the page whose
primary credential is an API key. ScopeDenialContext reuses AuthMethod
rather than declaring a parallel union with the same string values.
specs/api-coverage.json records doc line numbers, so rewriting the 403
example in docs/api/direct-execution.md moved them and the lint gate
went stale.
Verified each assertion against lib/mcp/tools.ts, both execution routes,
lib/workflow/execution-access.ts and lib/db/schema.ts.

- executionTrace is [] once progress is initialized (before the first step
  runs), not null until the first step completes; null now means progress was
  never initialized, the same condition under which totalSteps is null
- totalSteps has no column default, so logs.execution.totalSteps is null (not
  a string) on a run that was never dispatched, while status.progress coerces
  it to 0
- duration also exists per step on every logs.logs entry, and survives nodeIds
  filtering; it is not a logs.execution-only field
- includeData: false omits three blobs per log entry only; every row is still
  returned and logs.execution keeps its own input/output
- logs.execution embeds the full workflow row including nodes and edges, which
  none of the three arguments reduce
- the cross-organization redaction is an allowlist: transactionHashes keeps
  only hash and chainId, and errorContext loses executionTrace and error;
  qualified the owned-path claims that contradicted it
- the deprecated aliases 404 on a public-share execution rather than sharing
  get_execution's caveats, and v1.13 is their removal release, not the start
  of deprecation
- nodeIds survivor list was missing nodeId, id, executionId, network,
  gasUsedWei; truncateData is a positive integer, not any number
- status and logs are two concurrent HTTP reads, not one table each, so
  completedSteps can differ between them mid-run
- errorContext stated as a rule rather than a closed status list
- wired the page into docs/agent/index.md and moved it after the authoring
  trio in _meta.ts so both navigation surfaces agree
The previous commits fixed the REST sinks and left four gaps behind.

MCP tools denied with the same OAuth-only remedy the 403 body no longer
uses. A kh_ key is the credential that reaches those tools most often,
and it was handed an upgrade_url to /settings/mcp/reauthorize plus a
hint to request the scope on the consent screen - a page with nothing
behind it for a key whose scope was written at creation. registerTools
and registerMetaTools now bind the family alongside the scope, so the
envelope drops upgrade_url for an API key and names the real remedy. The
MCP route derives the family from the credential id in one place, since
a session rebuilt from its token has nothing else left to read.

The ~35 requireScope sites outside /api/execute passed no context, so
after the last commit an OAuth caller there got no remediation at all -
correct, but less than it had. Each one's auth helper already returns
authMethod, so all 39 now pass it. Two local Ok types widened it to
string on the way through; narrowed so the compiler keeps them honest.

Webhook keys still displayed a scope on both read surfaces. The list in
the overlay and the settings table showed "Scope: read" for a credential
the webhook route dispatches on without ever consulting the column. Both
now show it only where it is enforced. The table's "--" for a null scope
also read as "no permissions" when it means the opposite; it says
unrestricted.

Key creation pre-checked all three scopes, so the least-effort mint was
a full-access mcp:admin key - the widest-by-default outcome this branch
removed from parseScopeInput. Read alone is checked now.
…rpc-urls

fix: KEEP-1213 redact RPC credentials from the health payload
…-shape

docs: document get_execution's response shape
PR #2031 made an absent organization_spend_caps row resolve to a platform
default of 0.02 ETH per day instead of unlimited. Every protocol-coverage
suite executes as the one persistent org e2e-test-org, so the native value
they move accumulates against a single daily ledger per shard.

Shard 4 sends 0.04 ETH: 0.01 for wrapped/wrap, plus 0.01 for each of the
three frax-ether-v2 mints. wrapped runs first, so frax/mint lands exactly on
the 0.02 cap and passes, and mint-and-give and mint-and-stake are denied.

Seed the org an explicit daily_value_cap_wei of 1 ETH. CI then keeps the same
platform default as staging and prod, which both pin 0.02 ETH, and this org
carries its own ceiling instead. The figure is 25 times the current worst
case, so a new payable fixture does not break the suite again.

The write is an upsert, not an insert-if-absent: the reservation path creates
a row with both cap columns NULL on the org's first value-moving request, and
such a row still resolves to the platform default.
suisuss and others added 10 commits August 26, 2026 18:18
fix: correct the API-key scope edges around the direct execution API
The previous commit switched the log_summary subquery onto
workflow_execution_logs.network and gas_used_wei. Migration 0117 added both
columns null and only the executor writes them, so every row written before it
has them NULL - including the pre-flight failures this branch exists to repair,
and every historical gas-bearing step. A column-only read therefore returns no
chain for an old failed run and no gas for an old successful one.

Read COALESCE(column, JSONB extract) in all three aggregates instead.
Correctness stops depending on an operational backfill having run, and the JSONB
arm goes cold on its own as the columns fill. The re-parse cost that motivated
the column-only read belongs to fetchNetworkBreakdown, which is unbounded; this
subquery is already restricted to one page of executions, which is why it could
afford the JSONB extract before this branch touched it.

Widen the backfill so the columns can eventually carry every row. It keyed on
gas-bearing rows only, on the stated reasoning that nothing read network on a
gas-free row - which this branch makes false, since a pre-flight failure is
exactly a row with a network in input and no gasUsed in output. Its SQL moves to
scripts/lib/ so the database-backed test can run a batch rather than assert the
widening in prose.

Verified against Postgres 16. The two new legacy-row cases fail on 6245e1c
(expected null to be 'base'; expected null to be '31000') and pass here; the
backfill case returns 0 rows written under the old gas-only predicate and 1
under the widened one. tsc --noEmit clean.
The file is emitted by scripts/discover-plugins.ts on every dev/build run, so
the hand edits were discarded on the next build. They were also wrong: the
slug list named file stems rather than the slugs the definitions declare, and
the yearn import was renamed to yarnV3Def, which the generator cannot produce.
Re-running discover-plugins reproduces this restored file byte for byte.
The body-scan map choice now lives in resolveNestedForEachEdgeMap, next to
identifyLoopBody and planIterationContinuation. It takes both candidate maps
and returns the one a nested scan must use, so the reason the outer loop's
partial map cannot serve the inner scan is stated once, at the point of the
decision, instead of being implied by an argument name at the call site.

The tests run the recursion around that resolver rather than choosing a map
themselves: scan the outer loop, hand its real bodyEdgesBySource and the
global map to the resolver, scan the inner loop with what comes back. Return
outerBodyEdgesBySource from the resolver and three assertions fail, at two
and three levels of nesting.

Also imports the executor's own buildEdgesBySource instead of a local copy
that lacked the duplicate-target guard, and gives condition-due the true edge
that makes it gate the inner loop rather than dangle.
# Conflicts:
#	specs/api-coverage.json
runs-table.tsx decided "Composed" from run.networks.length, and its own comment
states the criterion it means to apply: a run that spent on more than one chain
cannot sum into one token. That is a claim about which chains bore gas, not
which chains the run targeted. The two sets coincided only while the logSummary
subquery gated networks behind gasUsed IS NOT NULL, so the guard was reading the
right values through the wrong field.

Removing that gate makes networks the targeted set for real, and a workflow that
writes on one chain and reads on another renders "Composed" instead of a total
that is perfectly summable. Narrowing networks back would re-hide the mismatch,
and would restore a contract already false on the direct half of the union:
queries.ts builds a direct run's networks from directExecutions.network, which
is recorded whether or not gas landed.

Add gasNetworks to the runs payload - the subset of networks that spent gas,
built in the subquery from fragments already in scope - and key both the guard
and the token symbol off it. The symbol matters independently: line 144 picked
an arbitrary element of networks, which under the widened contract can be the
read-only chain, so a Base amount could have printed with another chain's token.
The old guard short-circuited before that line, so it was latent rather than
live, but it was correct by accident either way.

Ledger-only gas names no chain of its own and still borrows the run's, which is
unambiguous only on a single-chain run; that path is unchanged.

Verified against Postgres 16 and in unit. The two new gas-attribution cases fail
against the old guard (expected 'Composed' to be '0.10 ETH'; expected 'Composed'
to be '0.10 POL'). 6 of 6 unit, 6 of 6 database, biome clean on the touched
files, tsc --noEmit exits 0 with no diagnostics.
fix(execute): #1930 fail closed on unsupported check results
…eflight-failure

fix: #2093 keep the chain on a run that failed before broadcast
fix: #2049 pass global edgesBySource to nested For Each body scan
…-cap

fix: KEEP-1239 give the e2e test org an explicit daily value cap
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

metrics-db-reviewed Reviewer sign-off: metrics aggregate queries optimised + tables indexed (KEEP-680) no-issue-required PR exempt from the issue-first gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants