') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(core,errors): classify SDK encryption failures as RUNTIME_ERROR by TooTallNate · Pull Request #2145 · vercel/workflow · GitHub
Skip to content

fix(core,errors): classify SDK encryption failures as RUNTIME_ERROR - #2145

Merged
TooTallNate merged 7 commits into
mainfrom
nate/runtime-decryption-error
May 29, 2026
Merged

fix(core,errors): classify SDK encryption failures as RUNTIME_ERROR#2145
TooTallNate merged 7 commits into
mainfrom
nate/runtime-decryption-error

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Summary

Workflow runs that fail inside the SDK's AES-GCM encryption layer were being misclassified as USER_ERROR. SDK-level decryption is never user code — the user never directly invokes subtle.decrypt — so failures here should be RUNTIME_ERROR.

This PR adds a new error class and wires it into the run-failure classifier, without addressing the root cause of the decryption failure itself (that investigation is ongoing). The change is intentionally narrow: better classification + diagnostic context, so the next mystery report is properly categorized and immediately actionable.

What I observed

Production reports surface as:

[Workflow] Error while running workflow {
workflowRunId: 'wrun_01KSG6WQKPKNMH37C401KYHQ9M',
errorCode: 'USER_ERROR',
errorName: 'OperationError',
errorStack: 'OperationError: The operation failed for an operation-specific reason\n' +
' at AESCipherJob.onDone (node:internal/crypto/util:646:19)'
}

OperationError from AESCipherJob.onDone is what Node's Web Crypto API throws when an AES-GCM auth-tag verification fails. The bare native DOMException doesn't match any of classifyRunError's RUNTIME_ERROR_CHECKS (which are name-based duck checks), so it falls through to USER_ERROR.

Changes

@workflow/errors (packages/errors/src/index.ts):

  • New RUNTIME_DECRYPTION_FAILED slug.
  • New RuntimeDecryptionError (extends WorkflowRuntimeError) with optional structured context (operation, byteLength, formatPrefix).

@workflow/core:

  • packages/core/src/encryption.ts: wrap both encrypt() and decrypt() Web Crypto calls; rewrap any failure as RuntimeDecryptionError with diagnostic context (printable or hex prefix of the input header, byte length, operation). The existing length-precheck now also throws RuntimeDecryptionError.
  • packages/core/src/serialization/encryption.ts & packages/core/src/serialization.ts: the two "encrypted-but-no-key" throw paths now use RuntimeDecryptionError.
  • packages/core/src/classify-error.ts: RuntimeDecryptionError.is added to RUNTIME_ERROR_CHECKS so classifyRunError routes these failures to RUNTIME_ERROR.

Test coverage

  • packages/errors/src/runtime-decryption-error.test.ts (new, 6 tests): name, inheritance, docs URL, cause preservation, context shape, name-based is() duck check.
  • packages/core/src/encryption.test.ts (new, 8 tests): happy-path round-trip, length-check failure, GCM auth-tag tamper → RuntimeDecryptionError (cause = OperationError), wrong-key decryption → same, encrypt-only key used for encrypt → RuntimeDecryptionError, printable + hex format-prefix capture.
  • packages/core/src/classify-error.test.ts (extended): RuntimeDecryptionError → RUNTIME_ERROR, plus a documentation test that a bare native OperationError still classifies as USER_ERROR (proves the encryption module's wrap is what does the work).

All existing tests still pass:

  • @workflow/errors: 36/36 ✅
  • @workflow/core: 1024/1024 ✅
  • pnpm typecheck (full repo): 40/40 packages ✅

What this does NOT fix

The actual decryption failure. Root cause is still under investigation — see the prior analysis. Strongest current hypothesis remains transport-level corruption/truncation of ciphertext between storage and read (in particular the workflow-server /refs endpoint, where a guard against truncated bodies was prototyped on a feature branch but never landed on main).

The diagnostic context added here is specifically what we need to triangulate the source on the next occurrence: byte length distinguishes "truncated" from "tampered", and format prefix distinguishes "valid encr envelope with bad ciphertext" from "garbage bytes that happened to land in a decrypt path".

SDK-level AES-GCM encrypt/decrypt failures are never the user's fault,
but the run-failure classifier was tagging them as USER_ERROR because
the native Web Crypto OperationError (most commonly raised by
AESCipherJob.onDone on GCM auth-tag mismatch) does not match any
RUNTIME_ERROR_CHECKS entry.
Introduce a new RuntimeDecryptionError (subclass of WorkflowRuntimeError)
that the encryption module throws when subtle.encrypt/subtle.decrypt
fails, with the original DOMException as cause plus diagnostic context
(operation, byteLength, printable/hex format prefix of the input
header). classifyRunError now picks it up via RUNTIME_ERROR_CHECKS, so
these failures surface as RUNTIME_ERROR with a proper named class for
dashboards and triage.
CopilotAI review requested due to automatic review settings May 28, 2026 21:26
@TooTallNate
TooTallNate requested a review from a team as a code ownerMay 28, 2026 21:26
@changeset-bot

changeset-botBot commented May 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 17b9b57

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
NameType
@workflow/errorsPatch
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/world-vercelPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented May 28, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented May 28, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production125502191474
✅ 💻 Local Development165702191876
✅ 📦 Local Production165702191876
✅ 🐘 Local Postgres165702191876
✅ 🪟 Windows13400134
✅ 📋 Other7620176938
Total7122010528174

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro108026
✅ example108026
✅ express108026
✅ fastify108026
✅ hono108026
✅ nextjs-turbopack13202
✅ nextjs-webpack13202
✅ nitro108026
✅ nuxt108026
✅ sveltekit12707
✅ vite108026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable109025
✅ express-stable109025
✅ fastify-stable109025
✅ hono-stable109025
✅ nextjs-turbopack-canary115019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13400
✅ nextjs-turbopack-stable-lazy-discovery-enabled13400
✅ nextjs-webpack-canary115019
✅ nextjs-webpack-stable-lazy-discovery-disabled13400
✅ nextjs-webpack-stable-lazy-discovery-enabled13400
✅ nitro-stable109025
✅ nuxt-stable109025
✅ sveltekit-stable12806
✅ vite-stable109025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable109025
✅ express-stable109025
✅ fastify-stable109025
✅ hono-stable109025
✅ nextjs-turbopack-canary115019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13400
✅ nextjs-turbopack-stable-lazy-discovery-enabled13400
✅ nextjs-webpack-canary115019
✅ nextjs-webpack-stable-lazy-discovery-disabled13400
✅ nextjs-webpack-stable-lazy-discovery-enabled13400
✅ nitro-stable109025
✅ nuxt-stable109025
✅ sveltekit-stable12806
✅ vite-stable109025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable109025
✅ express-stable109025
✅ fastify-stable109025
✅ hono-stable109025
✅ nextjs-turbopack-canary115019
✅ nextjs-turbopack-stable-lazy-discovery-disabled13400
✅ nextjs-turbopack-stable-lazy-discovery-enabled13400
✅ nextjs-webpack-canary115019
✅ nextjs-webpack-stable-lazy-discovery-disabled13400
✅ nextjs-webpack-stable-lazy-discovery-enabled13400
✅ nitro-stable109025
✅ nuxt-stable109025
✅ sveltekit-stable12806
✅ vite-stable109025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack13400
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable109025
✅ e2e-local-dev-tanstack-start-109025
✅ e2e-local-postgres-nest-stable109025
✅ e2e-local-postgres-tanstack-start-109025
✅ e2e-local-prod-nest-stable109025
✅ e2e-local-prod-tanstack-start-109025
✅ e2e-vercel-prod-tanstack-start108026

📋 View full workflow run

@github-actions

github-actionsBot commented May 28, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro0.041s (-6.0% 🟢)1.005s (~)0.965s101.00x
💻 LocalExpress0.044s (~)1.006s (~)0.962s101.09x
🐘 PostgresNext.js (Turbopack)0.061s1.011s0.950s101.50x
🐘 PostgresNitro0.065s (-31.9% 🟢)1.012s (-3.0%)0.947s101.60x
🐘 PostgresExpress0.067s (+16.0% 🔺)1.012s (~)0.945s101.66x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.279s (+10.9% 🔺)2.546s (+9.1% 🔺)2.267s101.00x
▲ VercelExpress0.311s (+32.3% 🔺)2.196s (+2.8%)1.885s101.12x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.096s (-3.1%)2.006s (~)0.909s101.00x
💻 LocalExpress1.097s (-2.5%)2.006s (~)0.909s101.00x
🐘 PostgresExpress1.104s (-3.7%)2.010s (~)0.905s101.01x
🐘 PostgresNitro1.111s (-2.6%)2.009s (~)0.898s101.01x
🐘 PostgresNext.js (Turbopack)1.121s2.010s0.889s101.02x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.683s (-17.3% 🟢)3.502s (-8.6% 🟢)1.820s101.00x
▲ VercelExpress1.745s (-6.9% 🟢)3.604s (-5.3% 🟢)1.859s101.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro10.518s (-3.9%)11.022s (~)0.504s31.00x
💻 LocalExpress10.533s (-3.6%)11.023s (~)0.491s31.00x
🐘 PostgresNitro10.534s (-3.1%)11.018s (~)0.484s31.00x
🐘 PostgresExpress10.555s (-3.7%)11.019s (~)0.464s31.00x
🐘 PostgresNext.js (Turbopack)10.668s11.018s0.350s31.01x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express13.546s (-20.2% 🟢)15.595s (-22.1% 🟢)2.050s21.00x
▲ VercelNext.js (Turbopack)14.112s (-18.5% 🟢)15.890s (-18.1% 🟢)1.778s21.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro13.745s (-8.7% 🟢)14.026s (-12.5% 🟢)0.281s51.00x
💻 LocalExpress13.828s (-7.6% 🟢)14.027s (-6.7% 🟢)0.200s51.01x
🐘 PostgresNitro13.886s (-4.9%)14.020s (-6.7% 🟢)0.134s51.01x
🐘 PostgresExpress13.886s (-4.8%)14.022s (-6.7% 🟢)0.135s51.01x
🐘 PostgresNext.js (Turbopack)14.045s14.815s0.770s51.02x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express22.168s (-55.9% 🟢)23.750s (-54.8% 🟢)1.582s31.00x
▲ VercelNext.js (Turbopack)22.812s (-56.6% 🟢)24.730s (-54.7% 🟢)1.918s31.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro12.484s (-25.6% 🟢)13.026s (-23.5% 🟢)0.542s71.00x
🐘 PostgresNitro12.487s (-10.6% 🟢)13.020s (-9.0% 🟢)0.533s71.00x
💻 LocalExpress12.550s (-24.4% 🟢)13.025s (-23.5% 🟢)0.476s71.01x
🐘 PostgresExpress12.590s (-10.1% 🟢)13.026s (-10.7% 🟢)0.435s71.01x
🐘 PostgresNext.js (Turbopack)13.042s13.588s0.545s71.04x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express31.487s (-74.0% 🟢)33.795s (-72.7% 🟢)2.308s31.00x
▲ VercelNext.js (Turbopack)33.506s (-91.5% 🟢)36.264s (-90.8% 🟢)2.758s31.06x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.182s (-7.3% 🟢)2.008s (~)0.826s151.00x
🐘 PostgresExpress1.190s (-5.6% 🟢)2.007s (~)0.817s151.01x
💻 LocalNitro1.202s (-26.3% 🟢)2.006s (-3.3%)0.804s151.02x
🐘 PostgresNext.js (Turbopack)1.209s2.008s0.799s151.02x
💻 LocalExpress1.245s (-16.4% 🟢)2.006s (~)0.761s151.05x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.562s (-10.4% 🟢)4.159s (-10.0% 🟢)1.596s81.00x
▲ VercelNext.js (Turbopack)2.651s (-22.0% 🟢)4.816s (-2.4%)2.166s71.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.256s (-46.8% 🟢)2.007s (-33.3% 🟢)0.751s151.00x
🐘 PostgresNitro1.258s (-46.5% 🟢)2.007s (-33.3% 🟢)0.749s151.00x
🐘 PostgresNext.js (Turbopack)1.317s2.007s0.690s151.05x
💻 LocalNitro1.901s (-39.5% 🟢)2.316s (-40.4% 🟢)0.416s131.51x
💻 LocalExpress1.908s (-35.4% 🟢)2.315s (-33.0% 🟢)0.407s131.52x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.844s (+6.2% 🔺)5.256s (+2.8%)1.412s61.00x
▲ VercelNext.js (Turbopack)4.416s (-37.8% 🟢)6.385s (-28.3% 🟢)1.969s51.15x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.381s (-60.3% 🟢)2.008s (-49.9% 🟢)0.626s151.00x
🐘 PostgresExpress1.392s (-60.1% 🟢)2.008s (-49.9% 🟢)0.615s151.01x
🐘 PostgresNext.js (Turbopack)1.601s2.152s0.550s141.16x
💻 LocalExpress5.433s (-34.8% 🟢)6.013s (-33.4% 🟢)0.580s53.93x
💻 LocalNitro5.622s (-32.7% 🟢)6.214s (-31.1% 🟢)0.592s54.07x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)6.476s (-27.4% 🟢)9.077s (-17.2% 🟢)2.601s41.00x
▲ VercelExpress6.728s (+58.7% 🔺)8.660s (+41.3% 🔺)1.933s41.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.183s (-5.9% 🟢)2.008s (~)0.826s151.00x
🐘 PostgresNext.js (Turbopack)1.205s2.008s0.803s151.02x
🐘 PostgresExpress1.220s (-2.9%)2.009s (~)0.789s151.03x
💻 LocalNitro1.564s (-16.2% 🟢)2.006s (-14.3% 🟢)0.442s151.32x
💻 LocalExpress1.578s (-16.6% 🟢)2.007s (-15.1% 🟢)0.428s151.33x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.930s (~)4.613s (-0.6%)1.683s71.00x
▲ VercelExpress3.044s (+17.9% 🔺)4.348s (~)1.305s71.04x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.249s (-46.6% 🟢)2.008s (-33.3% 🟢)0.758s151.00x
🐘 PostgresExpress1.264s (-46.0% 🟢)2.008s (-33.3% 🟢)0.745s151.01x
🐘 PostgresNext.js (Turbopack)1.330s2.007s0.677s151.06x
💻 LocalNitro2.055s (-33.0% 🟢)2.508s (-35.5% 🟢)0.454s121.64x
💻 LocalExpress2.058s (-34.3% 🟢)2.592s (-31.1% 🟢)0.534s121.65x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.458s (+8.3% 🔺)5.409s (+12.9% 🔺)1.951s61.00x
▲ VercelNext.js (Turbopack)4.283s (+36.3% 🔺)6.152s (+36.0% 🔺)1.868s51.24x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.392s (-60.2% 🟢)2.008s (-49.9% 🟢)0.616s151.00x
🐘 PostgresNitro1.395s (-59.9% 🟢)2.007s (-49.9% 🟢)0.612s151.00x
🐘 PostgresNext.js (Turbopack)1.579s2.075s0.496s151.13x
💻 LocalNitro6.250s (-31.7% 🟢)6.815s (-32.0% 🟢)0.565s54.49x
💻 LocalExpress6.414s (-27.1% 🟢)6.817s (-26.5% 🟢)0.404s54.61x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express5.776s (-10.0% 🟢)7.567s (-7.5% 🟢)1.792s41.00x
▲ VercelNext.js (Turbopack)5.932s (-12.2% 🟢)8.092s (-5.3% 🟢)2.160s41.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.585s (-28.8% 🟢)1.023s (+1.7%)0.439s591.00x
🐘 PostgresExpress0.599s (-28.6% 🟢)1.024s (~)0.425s591.03x
💻 LocalNitro0.621s (-36.7% 🟢)1.005s (-8.1% 🟢)0.384s601.06x
💻 LocalExpress0.639s (-35.1% 🟢)1.022s (-5.1% 🟢)0.383s591.09x
🐘 PostgresNext.js (Turbopack)0.656s1.006s0.350s601.12x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)5.540s (-61.8% 🟢)7.384s (-54.1% 🟢)1.844s91.00x
▲ VercelExpress5.991s (-68.5% 🟢)7.568s (-64.5% 🟢)1.577s81.08x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.407s (-27.0% 🟢)2.054s (-2.2%)0.647s441.00x
🐘 PostgresExpress1.421s (-28.1% 🟢)2.030s (-10.1% 🟢)0.610s451.01x
💻 LocalNitro1.534s (-49.5% 🟢)2.007s (-46.6% 🟢)0.473s451.09x
💻 LocalExpress1.567s (-48.0% 🟢)2.028s (-43.4% 🟢)0.461s451.11x
🐘 PostgresNext.js (Turbopack)1.582s2.029s0.447s451.12x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)13.438s (-73.0% 🟢)15.529s (-70.0% 🟢)2.091s61.00x
▲ VercelExpress112.137s (+224.8% 🔺)114.470s (+211.0% 🔺)2.333s38.34x
▲ VercelNitro⚠️missing----

🔍 Observability: Next.js (Turbopack) | Express

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.683s (-34.6% 🟢)3.058s (-33.6% 🟢)0.375s401.00x
🐘 PostgresExpress2.761s (-30.8% 🟢)3.112s (-28.8% 🟢)0.350s391.03x
🐘 PostgresNext.js (Turbopack)3.174s4.009s0.834s301.18x
💻 LocalExpress3.268s (-64.5% 🟢)4.009s (-60.0% 🟢)0.741s301.22x
💻 LocalNitro3.323s (-64.3% 🟢)4.043s (-59.6% 🟢)0.720s301.24x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express25.848s (-80.1% 🟢)27.966s (-78.8% 🟢)2.117s51.00x
▲ VercelNext.js (Turbopack)29.200s (-72.7% 🟢)31.692s (-70.9% 🟢)2.492s41.13x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.217s (-23.4% 🟢)1.006s (~)0.789s601.00x
🐘 PostgresExpress0.225s (-20.2% 🟢)1.006s (~)0.780s601.04x
🐘 PostgresNext.js (Turbopack)0.235s1.006s0.771s601.08x
💻 LocalExpress0.470s (-16.1% 🟢)1.021s (+1.7%)0.551s592.17x
💻 LocalNitro0.473s (-21.9% 🟢)1.022s (~)0.549s592.18x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.215s (+13.4% 🔺)3.876s (+6.6% 🔺)1.661s161.00x
▲ VercelNext.js (Turbopack)2.347s (+16.0% 🔺)4.082s (+7.6% 🔺)1.735s151.06x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.344s (-30.7% 🟢)1.006s (~)0.662s901.00x
🐘 PostgresExpress0.365s (-28.5% 🟢)1.007s (~)0.642s901.06x
🐘 PostgresNext.js (Turbopack)0.424s1.006s0.582s901.23x
💻 LocalExpress2.116s (-15.8% 🟢)2.657s (-11.7% 🟢)0.540s346.15x
💻 LocalNitro2.163s (-14.8% 🟢)2.821s (-6.2% 🟢)0.658s326.29x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express5.343s (+75.4% 🔺)7.136s (+48.4% 🔺)1.793s131.00x
▲ VercelNext.js (Turbopack)5.372s (+52.0% 🔺)7.161s (+37.9% 🔺)1.789s131.01x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.697s (-11.9% 🟢)1.006s (~)0.310s1201.00x
🐘 PostgresExpress0.706s (-13.8% 🟢)1.006s (-1.1%)0.301s1201.01x
🐘 PostgresNext.js (Turbopack)0.800s1.006s0.206s1201.15x
💻 LocalExpress9.994s (-10.7% 🟢)10.443s (-12.5% 🟢)0.449s1214.35x
💻 LocalNitro10.322s (-7.8% 🟢)10.938s (-6.2% 🟢)0.617s1114.82x
💻 LocalNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express13.712s (+84.8% 🔺)15.511s (+67.8% 🔺)1.798s81.00x
▲ VercelNext.js (Turbopack)14.076s (+36.3% 🔺)16.036s (+30.5% 🔺)1.960s81.03x
▲ VercelNitro⚠️missing----

🔍 Observability: Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Nitro1.164s (+444.7% 🔺)2.005s (+99.6% 🔺)0.013s (+0.8%)2.020s (+98.2% 🔺)0.856s101.00x
💻 LocalExpress1.166s (+485.4% 🔺)2.005s (+99.6% 🔺)0.012s (+1.7%)2.019s (+98.3% 🔺)0.854s101.00x
🐘 PostgresExpress1.167s (+468.8% 🔺)2.000s (+100.2% 🔺)0.002s (+6.2% 🔺)2.011s (+98.9% 🔺)0.845s101.00x
🐘 PostgresNitro1.172s (+471.8% 🔺)1.996s (+99.6% 🔺)0.002s (+13.3% 🔺)2.011s (+98.8% 🔺)0.839s101.01x
🐘 PostgresNext.js (Turbopack)1.181s2.001s0.001s2.009s0.828s101.01x
💻 LocalNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.333s (-66.0% 🟢)3.220s (-62.8% 🟢)1.844s (+191.8% 🔺)5.539s (-43.4% 🟢)3.206s101.00x
▲ VercelExpress2.421s (-3.4%)3.283s (-19.7% 🟢)2.090s (+117.5% 🔺)5.842s (+4.5%)3.421s101.04x
▲ VercelNitro⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.583s (+153.6% 🔺)2.006s (+99.3% 🔺)0.004s (-8.1% 🟢)2.024s (+98.0% 🔺)0.441s301.00x
💻 LocalExpress1.598s (+111.1% 🔺)2.010s (+95.3% 🔺)0.010s (+4.5%)2.022s (+94.4% 🔺)0.423s301.01x
💻 LocalNitro1.617s (+92.8% 🔺)2.010s (+98.6% 🔺)0.011s (+17.4% 🔺)2.023s (+81.3% 🔺)0.406s301.02x
🐘 PostgresExpress1.624s (+157.8% 🔺)2.004s (+99.1% 🔺)0.004s (+0.9%)2.026s (+98.1% 🔺)0.402s301.03x
🐘 PostgresNext.js (Turbopack)1.660s2.009s0.003s2.024s0.364s301.05x
💻 LocalNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)6.242s (-63.1% 🟢)7.679s (-57.9% 🟢)0.193s (-8.7% 🟢)8.404s (-55.6% 🟢)2.162s81.00x
▲ VercelExpress6.715s (+3.2%)8.593s (+7.3% 🔺)0.441s (+7.8% 🔺)9.531s (+7.9% 🔺)2.817s71.08x
▲ VercelNitro⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Express

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.744s (-22.6% 🟢)1.047s (-18.0% 🟢)0.000s (-19.3% 🟢)1.060s (-18.8% 🟢)0.317s571.00x
🐘 PostgresNitro0.755s (-22.0% 🟢)1.083s (-13.2% 🟢)0.000s (-15.8% 🟢)1.096s (-12.9% 🟢)0.340s571.02x
🐘 PostgresNext.js (Turbopack)0.770s1.073s0.000s1.091s0.321s551.04x
💻 LocalExpress1.401s (+14.4% 🔺)2.013s (~)0.000s (-10.0% 🟢)2.015s (~)0.614s301.88x
💻 LocalNitro1.453s (+18.8% 🔺)2.014s (~)0.000s (+300.0% 🔺)2.017s (~)0.564s301.95x
💻 LocalNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.723s (-63.4% 🟢)5.329s (-53.7% 🟢)0.000s (NaN%)5.990s (-50.3% 🟢)2.267s111.00x
▲ VercelExpress4.009s (+7.2% 🔺)5.328s (+4.4%)0.000s (-50.0% 🟢)5.759s (+4.1%)1.750s111.08x
▲ VercelNitro⚠️missing-----

🔍 Observability: Next.js (Turbopack) | Express

fan-out fan-in 10 streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.444s (-18.5% 🟢)2.100s (-3.6%)0.000s (NaN%)2.115s (-3.8%)0.671s291.00x
🐘 PostgresNitro1.460s (-18.5% 🟢)2.063s (-3.7%)0.000s (-3.4%)2.079s (-4.4%)0.618s291.01x
🐘 PostgresNext.js (Turbopack)1.591s2.180s0.000s2.199s0.608s281.10x
💻 LocalExpress3.093s (-10.8% 🟢)3.900s (-3.3%)0.000s (-60.9% 🟢)3.903s (-3.3%)0.809s162.14x
💻 LocalNitro3.731s (+10.1% 🔺)4.028s (~)0.001s (+74.1% 🔺)4.389s (+8.7% 🔺)0.658s142.58x
💻 LocalNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.296s (+37.3% 🔺)7.469s (+24.0% 🔺)0.001s (+Infinity% 🔺)7.917s (+22.6% 🔺)1.621s81.00x
▲ VercelNext.js (Turbopack)20.436s (+263.8% 🔺)21.771s (+211.8% 🔺)0.000s (+100.0% 🔺)22.320s (+196.0% 🔺)1.884s83.25x
▲ VercelNitro⚠️missing-----

🔍 Observability: Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNitro13/21
🐘 PostgresNitro14/21
▲ VercelExpress12/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres14/21
Next.js (Turbopack)🐘 Postgres21/21
Nitro🐘 Postgres15/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run


Some benchmark jobs failed:

  • Local: failure
  • Postgres: success
  • Vercel: failure

Check the workflow run for details.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Workflow run failures originating in the SDK's AES-GCM encryption layer (most notably Node's native OperationError from AESCipherJob.onDone on GCM auth-tag mismatch) were falling through to USER_ERROR because classifyRunError's name-based duck checks didn't match a raw DOMException. This PR introduces a RuntimeDecryptionError (subclass of WorkflowRuntimeError) that the encryption module always wraps Web Crypto failures in, plus diagnostic context (operation, byte length, header prefix), so failures classify as RUNTIME_ERROR and carry enough telemetry to triangulate root cause on the next occurrence. No root-cause fix is attempted.

Changes:

  • New RuntimeDecryptionError class + runtime-decryption-failed slug in @workflow/errors with optional structured context.
  • Wrap encrypt/decrypt Web Crypto calls in packages/core/src/encryption.ts and rewrap the two "encrypted-but-no-key" throws in serialization paths.
  • Add RuntimeDecryptionError.is to RUNTIME_ERROR_CHECKS and cover the new behavior with errors + core tests.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
packages/errors/src/index.tsAdds RUNTIME_DECRYPTION_FAILED slug and RuntimeDecryptionError class with name-based .is().
packages/errors/src/runtime-decryption-error.test.tsNew tests covering name, docs link, cause, context shape, and .is() duck check.
packages/core/src/encryption.tsWraps subtle.encrypt/decrypt failures and length precheck in RuntimeDecryptionError; adds printable/hex diagnostic prefix helper.
packages/core/src/encryption.test.tsNew 8-test module: round-trip, length-check, tamper, wrong key, encrypt-only-usage, prefix capture.
packages/core/src/serialization/encryption.tsSwitches "encrypted-but-no-key" throw to RuntimeDecryptionError with context.
packages/core/src/serialization.tsSame switch on the deserialize-stream path.
packages/core/src/classify-error.tsAdds RuntimeDecryptionError.is to RUNTIME_ERROR_CHECKS.
packages/core/src/classify-error.test.tsAdds tests for the new mapping and a bare-OperationError sanity check.
.changeset/runtime-decryption-error.mdPatch changeset for @workflow/errors and @workflow/core.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/errors/src/index.ts

@pranaygppranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Left three inline findings from local verification.

Comment threadpackages/errors/src/index.ts
Comment threadpackages/core/src/encryption.ts Outdated
Comment threadpackages/core/src/encryption.ts
@pranaygp

Copy link
Copy Markdown
Contributor

Follow-up thought after tracing the runtime path: I think this PR is appropriately scoped to improving attribution (RUNTIME_ERROR rather than USER_ERROR), and it should not be required to solve retry behavior as part of this change.

That said, we should follow up by applying the same bounded-redelivery precedent used for replay timeouts to RuntimeDecryptionErrors encountered while replaying remotely fetched persisted data. An AES-GCM authentication failure is terminal for the bytes/key in the current attempt, so we must not continue execution; but if the bytes came from a transiently truncated or corrupted /refs response, a fresh queue delivery can re-fetch them successfully. Today we commit run_failed immediately, which turns a potentially recoverable read failure into a terminal workflow failure.

Concretely, for managed worlds we should let the queue redrive a small bounded number of times (re-fetching the events/ref payload each delivery), then commit terminal run_failed as RUNTIME_ERROR if the decryption failure persists. Longer term, detecting response truncation or integrity failure at the /refs transport boundary would let us classify the retryable case more directly. This feels like a focused follow-up PR rather than a blocker for the attribution fix here.

…x, propagate through serialization wrappers
Addresses review feedback on #2145:
- Add a RuntimeDecryptionError reducer/reviver (+ SerializableSpecial
entry + globalThis registration) so its `context` (operation,
byteLength, formatPrefix) survives the dehydrate/hydrate run-error
round trip instead of being dropped by the generic Error reducer.
- Stop capturing `formatPrefix` in the low-level encryption layer, which
only sees the stripped AES payload (nonce bytes), not the outer `encr`
marker. The serialization layer now attaches the real envelope prefix.
- Rethrow RuntimeDecryptionError unchanged from the serialize/dehydrate
catch blocks instead of reframing it as a SerializationError, so an
encryption failure during dehydration stays a RUNTIME_ERROR rather than
being misclassified as USER_ERROR.
@TooTallNate

Copy link
Copy Markdown
MemberAuthor

Agreed on both points — keeping this PR scoped to attribution, and treating bounded redelivery as a focused follow-up.

The reasoning is sound: an AES-GCM auth failure is terminal for the current bytes/key, but if those bytes came from a transiently truncated/corrupted /refs response, a fresh queue delivery can re-fetch and succeed. Committing run_failed immediately turns a recoverable read failure into a terminal one.

I'll open a follow-up to apply the bounded-redelivery precedent (the same one used for replay timeouts) to RuntimeDecryptionErrors encountered while replaying remotely-fetched persisted data on managed worlds: redrive a small bounded number of times (re-fetching the events/ref payload each delivery), then commit terminal run_failed as RUNTIME_ERROR if it persists. The RuntimeDecryptionError class + diagnostic context landed here give that follow-up a clean signal to branch on, and the longer-term /refs transport-boundary integrity check would let us classify the retryable case even more directly.

The review feedback on this PR has been addressed in the latest commits:

  • RuntimeDecryptionError.context now round-trips through dehydrateRunError/hydrateRunError (reducer/reviver + globalThis registration).
  • formatPrefix is captured at the serialization layer (real encr marker) instead of the low-level layer (which only saw nonce bytes).
  • Encrypt-side failures now propagate as RuntimeDecryptionError through the dehydrate wrappers instead of being reframed as SerializationError (→ USER_ERROR).

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: no blocking issues

Comment threadpackages/core/src/serialization.ts
- Mirror the catch/enrich/rethrow block from serialization/encryption.ts
around the stream-path aesGcmDecrypt() call so auth-tag failures on
encrypted stream frames also carry context.formatPrefix = 'encr'
(addresses review feedback). Add a tampered-frame test.
- Fix all auto-fixable Biome lint findings in the touched files
(template literals, useless try/catch wrappers, optional chaining,
non-null assertions).
@github-actionsgithub-actionsBot mentioned this pull request May 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #2165. Merge conflicts were resolved by AI — please review carefully. (backport job run)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@TooTallNate@pranaygp@VaguelySerious