Skip to content

[pull] main from langwatch:main - #279

Open
pull[bot] wants to merge 3096 commits into
erickirt:mainfrom
langwatch:main
Open

[pull] main from langwatch:main#279
pull[bot] wants to merge 3096 commits into
erickirt:mainfrom
langwatch:main

Conversation

@pull

@pull pull Bot commented Dec 17, 2025

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

@pull pull Bot locked and limited conversation to collaborators Dec 17, 2025
@pull pull Bot added ⤵️ pull merge-conflict Resolve conflicts manually labels Dec 17, 2025
@rogeriochaves
rogeriochaves force-pushed the main branch 5 times, most recently from 1e7b14c to 2209258 Compare January 21, 2026 01:15
rogeriochaves and others added 20 commits August 8, 2026 11:57
…rage means no truncation (#6729)

* fix(traces): default the oversized-span S3 spool on so configured storage means no truncation

The ADR-022 transient S3 spool is what keeps a span attribute value over
256 KB intact through the ingestion edge. It shipped behind
`release_trace_blob_offload` with a `false` registry default, which cloud
prod has long since overridden with a match-all operator rule. Every
self-hosted deployment with S3 configured was still silently landing on
`capOversizedAttributes`, losing content it had storage for.

Two things had to move for the default to mean anything. The registry
default is now `true`, and the edge resolves the flag through the new
`FeatureFlagStorePostgres.getOrRegistryDefault` instead of `get`: the raw
store returns `null` for an absent row on purpose, so reading it directly
stranded the registry default and made an unconfigured deployment read as
"off" no matter what the registry said. PostHog stays out of the per-span
path, as before.

Nothing changes for a deployment without reachable object storage: the
edge already fails open, and the warn it logs now says what the operator
loses and how to fix it.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* fix(featureflag): keep a rule-only override from shadowing the registry default

Addresses the CodeRabbit round on #6729.

`setRules` seeded a newly created row with `enabled: false`. That row then
beat the registry default for every context no rule matched, so an operator
writing a single per-project opt-out would have switched the fleet-wide
default off for everyone else. A rule is an override for the targets it
names, so the created row now seeds its row-level fallback from the flag's
registry default: unmatched callers keep resolving to exactly what they
resolved to before the rule was written. Default-off flags and unregistered
keys still seed `false`, so an org-scoped enable still cannot flip a flag on
cluster-wide.

The fail-open regression test now runs the command worker instead of the cap
helper: it takes the inline command `maybeSpool` hands back after a failed
PUT, feeds it to `RecordSpanCommand.handle`, and asserts the emitted span
carries the truncation marker. Verified it goes red when the worker's cap is
bypassed, which the previous direct-call assertion did not.

The new spec scenario drops the flag key, operator rows, registry resolution
and byte thresholds for the behavior a user can observe.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* style(traces): drop comments that restate the oversized-span test

The file header narrated the repo's own BDD naming conventions and a
helper docblock restated the helper's name; both said what, not why.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD
…, app login agent, VS Code Chat (ADR-039) (#5605)

* docs(governance): ADR-039 + feature specs — Copilot CLI as tracked coding assistant (#5604)

* feat(governance): copilot platform-tool-policy slug, both paths allowed (#5604)

* feat(governance): copilot env injection — BYOK gateway vars + native OTel block, copilot_cli source type (#5604)

* feat(governance): copilot ingestion-first defaults + seat-bypass billing notices (#5604)

* feat(governance): copilot prespawn warnings, gateway unset -f for scoped-function tools, command registration (#5604)

* feat(governance): copilot receiver-side extractor + fat-payload guards + scenario bindings (#5604)

* docs(adr): ADR-039 v4 — spike resolutions from copilot 1.0.69 binary sweep (#5604)

* feat(governance): copilot starter-pack tile so admins can publish it from the catalog (#5604)

* fix(governance): review pass — unset -f both modes, no silent tool_mode pin, gateway clears Path B env, scope-gated extractor (#5604)

* fix(governance): settle PR findings — PlatformToolSlug reuse, falsifiable span-type test, dedup shell quoting, starter-pack test counts (#5604)

* fix(governance): login ceremony fallback lists all six wrappers incl. copilot (#5604)

* test(governance): fix login-ceremony fallback filter — opencode contains 'langwatch open' (#5604)

* fix(governance): case-insensitive content-capture opt-out + cross-tool pin & real-shell unset-f tests (#5605)

- content-capture opt-out now honours FALSE/False/0/no/off (case-insensitive),
  closing a privacy gap where a non-'false' opt-out silently forced capture on
- add claude/codex regression coverage for the forced-mode tool_mode pin gate
  (previously only copilot was tested for an all-tools change)
- add a real-shell integration test that spawns $SHELL -i -c, sources a
  persisted tool() function, applies buildShellReapply, and proves the real
  binary runs with the reapplied env (falsifiable control included)

Addresses review from drewdrewthis on #5605.

* refactor(governance): migrate CLI error handling to the HandledError pattern (ADR-045) (#5605)

- cli-api.ts: HTTP error paths route through throwIfHandledError before the
  fallback throw (mirrors ApiKeysApiService) — named server domain errors
  surface as typed LangWatchHandledError carrying code/meta/traceId; unnamed
  failures stay a GovernanceCliError, now branded isLangWatchHandledError +
  httpStatus/meta so reportCommandError renders it first-class and
  err.code/err.status/instanceof control-flow (incl. tool_disabled) still work
- governance/ingest commands: hand-rolled stderr writes -> reportCommandError
  (unified render: --format json, suggestions, trace url, secret redaction)
- register the new copilot wrapper in the two CLI contracts main added:
  PLUMBING_COMMANDS (feature-map coverage) + output-tree holdouts (passthrough
  launcher, owns its stdio)
- cli-api tests assert the domain-vs-infrastructure split (named 401/404 ->
  handled error; unnamed 404 -> GovernanceCliError)

Addresses the ADR-045 error-convention alignment raised on #5605.

* test(governance): register copilot in the app capability-catalog coverage exclusion (#5605)

The langwatch app's capabilityCatalog.coverage test mirrors the SDK's
PLUMBING_COMMANDS wrapper-exclusion list; add `copilot` there too so the
new wrapper (a passthrough launcher that execs the tool, not a platform
resource) doesn't trip the 'CLI resources with no capability' drift check.
Third of three copilot-contract mirrors (SDK PLUMBING_COMMANDS + output-tree
holdouts were the other two).

* fix(governance): biome-format copilot extractor + drop shell-rc-persistence from LEGACY_INERT (#5605)

- biome format the copilot extractor + its test (the merge left them unformatted)
- shell-rc-persistence.feature now has bound @Scenario copilot tests, so it can
  no longer sit in the feature-parity LEGACY_INERT deny-list (enforce-all: a
  fully-bound file must be removed or CI fails)

* style(governance): biome-format aiToolEntry.service.ts (#5605)

* style(governance): scope biome complexity suppressions on the copilot extractor (#5605)

The lint diff-gate flags 2 new complexity violations on the new copilot files:
- copilot.ts apply(): cognitive complexity 13 vs 10 — a flat linear sequence of
  independent attribute-lift guards, not tangled control flow
- copilot.unit.test.ts: a 106-line top-level describe block (test grouping, not a
  production function)
Both are false signals for these baseline-tolerated rules (6240 repo-wide); scoped
biome-ignore with a reason per the gate's own guidance, rather than churning
reviewer-approved + tested code.

* style(governance): drop now-unused noExcessiveLinesPerFunction suppression on copilot test (#5605)

Merging main relaxed the rule for test describe blocks, so the biome-ignore on
the copilot extractor test suite no longer suppresses anything — biome flags it
as suppressions/unused (the lint diff-gate's 1 new violation). Removing it; the
copilot.ts cognitive-complexity suppression is still needed and stays.

* test(governance): starter-pack tile-count assertions track STARTER_PACK_TILES.length (#5605)

Adding the copilot tile (github-copilot) took the starter pack to 9 tiles, but
several provisioning tests still hardcoded 8 (created/skipped/length), failing
test-integration (5)/(6):
- aiToolEntry.defaultCatalog: 'provisions all standard tiles', idempotent, and
  concurrent-provisioner assertions now use STARTER_PACK_TILES.length
- aiTools router: 'provisions the standard catalog' length, 'adds only never-had
  tiles' skipped (length-1), and the merge-in-place counts (length-2 / length)
The exhaustive-snapshot test (explicit 9-slug list) keeps its literal count as a
deliberate canary.

* style(governance): biome-format the tile-count assertion edit (#5605)

* test(governance): add github-copilot to the standard-catalog slug snapshot (#5605)

The 'provisions the standard catalog on a fresh org's very first list' test
pinned the exact 8-slug set but never got github-copilot when the copilot tile
was added — the list now returns 9 slugs. Adds github-copilot in sorted
position; the other exhaustive slug snapshot already included it.

* docs(adr): ADR-039 v6 — #6343 universalizes ingestion-first, copilot carve-out superseded

* fix(governance): settle CodeRabbit review round — seat-bypass notice on explicit/pinned copilot gateway routes, honest content-survival test, object-param handleError, win32 prespawn coverage, ADR path refresh (#5604)

* fix(governance): copilot ruthless-review round — single-count invoke_agent rollup tokens, real github.copilot scope, reasoning-token alias, seat notice on prompt route, scoped-function key refresh for copilot (#5604)

* feat(governance): copilot app capture via direct OTLP at launch (ADR-039 §Extension, stacked on #5604) (#5784)

* docs(adr): extend ADR-039 to multi-surface Copilot capture (app + IDE), stacked on #5604 (#5783)

* docs(adr): lock ADR-039 §Extension via parc-ferme — GitHub Copilot app capture, codex-style, hybrid file source (#5783)

* docs(adr): red-team fold + reframe — sync reader, real dedup, no competitor mentions (#5783)

* docs(adr): E3 corrected — fused span, db-primary, time-ordered content match + mis-pairing guard (#5783)

* docs(adr): professional cleanup — jsonl-only app extension, terse changelog, strip inline journal markers (#5783)

* docs(adr): app extension fuses session-store.db usage with events.jsonl content (#5783)

* docs(governance): lock ADR-039 app extension — direct OTLP at launch (#5784)

* spec(governance): Copilot app capture feature files — connect, launch-agent, app OTLP (#5784)

* feat(governance): lift github.copilot.nano_aiu as metadata in copilot extractor (#5784)

* feat(governance): copilot-app capture core — OTLP env block + app detection + per-OS agent descriptors (#5784)

* feat(governance): langwatch copilot-app connect + per-OS login-agent installer + logout teardown (#5784)

* test(governance): non-empty mkdirp fake to satisfy no-empty-function lint (#5784)

* fix(governance): correct Linux/Windows copilot-app agents — quoted ExecStart, Windows env wrapper (no invalid <Environment>), 0600 descriptors (#5784)

* fix(governance): match real copilot scope 'github.copilot' + clear inherited BYOK vars in ingestion (#5784)

- extractor provenance now recognizes the 1.0.71 default scope
  'github.copilot' (was only '@github/copilot'), so an execute_tool span
  with no vendor attribute is still classified as a tool
- ingestion mode scrubs inherited COPILOT_PROVIDER_* so BYOK cannot route
  traffic off the Copilot seat or double-capture

* fix(governance): preflight copilot gateway mode for a required BYOK model (#5784)

GitHub documents COPILOT_MODEL as required for BYOK; gateway mode routes
copilot through BYOK provider env, so a run with no model fails opaquely.
Fail fast with an actionable message (pass --model or set COPILOT_MODEL).

* fix(governance): copilot-app agent — propagate register/unregister failures, honest connect copy, UTF-8 task XML (#5784)

- install throws on any non-tolerated service-manager failure (only the
  first-install launchctl unload is tolerated) so a failed registration
  never reports a mint + 'connected'
- remove throws + keeps the descriptor when unregister fails, so logout
  never orphans a live exporter or claims a clean removal
- connect reports agent-install-failed loudly; success copy no longer
  claims a Dock reopen captures (only agent/login launches do)
- Windows task XML declares UTF-8 to match the bytes writeFile emits
- failure-path + byte-level tests for each

* fix(governance): copilot-app ruthless-review round — launchctl bootstrap/bootout (legacy verbs exit 0 on failure), start-now semantics on every OS, graphical-session unit, battery-safe task, stray-credential cleanup, tighten-on-rewrite, honest connect copy (#5784)

* chore(governance): biome format on the merged copilot extractor test (#5784)

* feat(governance): capture VS Code Copilot Chat via a scoped code() function (ADR-039 §Extension #2, stacked on #5784) (#5831)

* docs(governance): ADR-039 §Extension #2 — VS Code Copilot Chat capture (Proposed) (#5813)

* docs(governance): ADR-039 §Ext#2 red-team — align VS Code token delivery to the scoped shell-function tier (copilot-CLI), demote login-agent (#5813)

* docs(governance): ADR-039 §Ext#2 function-alignment pass — buildScopedToolFunction not buildShellReapply, full v1 touch points, env carries config on code path (#5813)

* docs(governance): ADR-039 §Ext#2 — confirm env-only enables VS Code OTel; drop settings.json writer from v1 (#5813)

* docs(governance): lock ADR-039 §Extension #2 (VS Code Copilot Chat) — Accepted (#5813)

* spec(governance): VS Code Copilot Chat feature files — scoped code() env + copilot_vscode OTLP (#5813)

* feat(governance): code (VS Code) ingestion-only policy + copilot_vscode sourceType + copilot-chat OTLP env block (#5813)

* feat(governance): register code in SHELL_FUNCTION_TOOLS — scoped code() delivery tier (#5813)

* feat(governance): hidden `langwatch code` command routes VS Code through the code wrapper (#5813)

* test(governance): logout removes the scoped code() function via telemetry-targets scan (#5813)

* test(governance): copilot-chat span canonicalizes via GenAI core with copilot.ts inert — no VS-Code-specific extractor (#5813)

* fix(governance): surface `langwatch code` in --help + tool-parameterized capture opt-out notice (#5831)

- add `code` to the curated 'Coding assistants:' help block (was hidden
  but never re-surfaced, unlike the six sibling wrapped-tool commands)
- opt-out notice now names the actual tool (`${tool} traces`) and says
  'is falsey' instead of the stale '=false' (opt-out is case-insensitive);
  test asserts the notice names 'code'

Addresses Aryansharma28 review on #5831.

* fix(governance): clear the VS Code integrated-terminal telemetry env so the copilot_vscode token can't leak (#5831)

Closes the ADR-039 §Ext#2 integrated-terminal exposure open question (Aryansharma28 review). VS Code is long-lived and its integrated terminals inherit the code() env — including the ingest token — for the editor session; an un-wrapped OTLP tool run there could POST to LangWatch as copilot_vscode.

- new vscode-settings.ts: writes a narrow terminal.integrated.env.<os> clear
  (each telemetry key -> null) into VS Code User settings.json, preserving
  user settings; removes it on logout; platform paths + malformed-file safety
- wire the clear into the code() persist accept-path; add a logout
  telemetry-target so scanTelemetryTargets removes it
- drift guard: VSCODE_TELEMETRY_ENV_KEYS must equal buildOtelEnvBlock('code')
- extension host still gets the token (read at launch); only terminals cleared
- 15 new unit tests; ADR revised (narrow settings write, orthogonal to the
  rejected 'settings carries config' approach)

* fix(governance): vscode-chat ruthless-review round — JSONC-safe comment-preserving settings edits (jsonc-parser), hardening coupled to env injection, receiver scope-gate on copilot_vscode keys, server policy row + forced allowVk:false, gateway fallthrough guard, already-open-window notice, honest policy notices with pin self-heal (#5813)

* fix(governance): escape backslashes first in the systemd env lines and refuse unrepresentable values in the cmd wrapper (CodeQL js/incomplete-sanitization #249) (#5604)

* fix(build): keep main's lockfile resolution keys — add only the jsonc-parser entries (the pnpm-10 regen re-keyed vitest/msw peer instances and broke the SDK e2e) (#5604)

* ci(sdk): capture the backgrounded server/worker output and dump it when the e2e job fails (#5604)

* fix(server): realm-safe body limit — hono's bodyLimit rebuilds chunked requests via new Request(raw) and crashes cross-realm under the bundled server, 500ing every chunked OTLP POST (#6602 x #6557 interaction, latent on main since its sdk e2e is path-skipped)

* test(governance): name copilot span fixtures the way the wire does (#5605)

Copilot names spans by GenAI convention, so a chat span is "chat {model}"
("chat auto" on a 1.0.78 capture) and the agent rollup is "invoke_agent".
A bare "chat" never crosses the wire, and neither does "invoke_agent copilot".

The extractor keys on gen_ai.operation.name and the instrumentation scope,
never on the span name, so behavior is unchanged. The fixtures are what was
inaccurate, and a fixture that models a shape the wire does not produce
teaches the next reader the wrong thing.

The VS Code chat fixture takes its name from its own request model rather
than the CLI's, since the capture behind "chat auto" is a CLI capture.

The ingestion specs described a literal span name; they now describe the
gen_ai operation, which is what the code actually matches on.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

---------

Co-authored-by: Rogério Chaves <rogeriochaves@users.noreply.github.com>
… key (#6743)

* fix(traces): gate coding-agent log content by canonical event and per key

The log-record content gate matched the wire spelling of `event.name`, while
the transcript derivation resolves the same records through the canonical
vocabulary, which strips the agent namespace. Only Claude Code emits bare
names; codex and gemini namespace theirs, so their records matched no known
content key, took the gate's early return, and left with their payload intact.
A session-less caller (an API key resolves as a public viewer) therefore read
prompts, replies and tool content that a restrict or drop policy withheld.

Content keys now live in one table keyed on the canonical event, shared with
the read-path enrichment, and each key carries its own category: a codex
tool_result holds the call arguments (input) and its output (output) on one
record, so a single verdict per record could only be right in one direction.
Free text an agent writes about the session (an error, a sub-agent task, a
commit message) needs both categories, following the evaluator-details
precedent. Unknown events still fail closed.

Claude Code was gated correctly throughout, which is why this read as an
asymmetry rather than a hole.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* fix(traces): fail closed on unknown log events, and address review

The unknown-event fallback classified only `body`, so an event in neither
table carrying `prompt`, `response_text` or `output` passed the gate
untouched. That is the same bypass shape this branch closes for namespaced
events, moved from "namespaced" to "unaliased", and a new agent adapter would
have reopened it. The gate now withholds every content key the table knows for
an unrecognised event, behind both categories.

The enrichment probe keeps the plain `body` convention for an unknown event:
guessing a content key there would surface the wrong attribute as span
content. A test pins the gate as a superset of the probe, so the two defaults
cannot invert.

Review follow-ups: assert `bodyRedacted` on each branch of the mixed-category
tool case, type the policy fixtures with `DataPrivacyConfig` instead of a cast,
read each transcript once per fixture, rename `hideBody` to `shouldHideBody`,
pin the drift guard's own match count so it cannot pass by matching nothing,
and give tool-argument withholding its own scenario and binding.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD
…dmin email (#6738)

/api/auth/cli/bootstrap returned 500 for any organization holding an ADMIN
OrganizationUser whose User no longer exists. Prisma rejected the whole read
with "Inconsistent query result: Field user is required to return data, got
null instead" because the admin lookup joined the required user relation.

The schema runs relationMode = "prisma", so nothing in the database stops a
membership row from outliving its account. Membership rows and users are now
read separately, orphans are skipped, and the first admin that still resolves
to a user with an email wins. An organization where every admin is orphaned
keeps the existing no-admin behavior and reports a null address.

CliBootstrapService held a byte-identical private copy of resolveOrgAdminEmail
and now calls the shared helper, so the CLI ceremony, the budget-exceeded
payload and the "contact your admin" copy stay on one implementation.

Without a bootstrap response the CLI cached no tool_policies, so wrapper policy
enforcement (allowOtelDirect, allowVk, gateway preflight) had nothing to gate
on. The regression test asserts the full policy map alongside the admin email.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD
…Agent Plugins 1.0 (#6747)

* feat(agent-plugin): one plugin directory for Claude Code and Agent Plugins 1.0

plugins/langwatch is a Claude Code plugin (manifest, marketplace, SessionStart
and Stop hooks) and a valid Agent Plugins 1.0 portable package (closed-schema
root manifest plus one skill) at the same time, following the multi-manifest
pattern the ecosystem settled on.

The hooks run a zero-dependency script bundled from the SDK's ingestion hook
command, so a session's git context capture no longer depends on whichever
langwatch CLI is on PATH: a released CLI older than `ingest hook` answered
every session stop with `error: unknown command 'hook'`. The bundle keeps the
command's constraints (nothing on stdout, always exit zero) and adds a
misattribution gate so Agent Plugins clients that auto-discover .claude-plugin
directories never report their sessions as Claude Code.

resolveLogsEndpoint moves to src/internal/endpoint.ts so the bundle's module
graph stays off the CLI telemetry stack; its callers now import from there.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* feat(cli): install the Claude Code plugin on consent, keep raw hooks as the fallback

Saying yes to claude capture now installs the LangWatch plugin (marketplace add
plus user-scope install, interactive so trust prompts reach the user) and
removes the raw hook entries it replaces. A claude without the plugin
subcommand, a failed install, or a recent failure inside the 24 hour suppression
window all fall back to the raw settings-file hooks exactly as before, and the
install report says which seam ran. The silent re-assert path spawns nothing:
it only cleans leftover raw entries once the plugin is present, and the login
refresh no longer resurrects raw entries on a plugin device.

langwatch logout gains two symmetric targets: uninstall the plugin (disable in
settings when the subcommand cannot) and remove the marketplace, the latter
only when its source points at langwatch/agent-plugin with word boundaries, so
a same-named marketplace or a lookalike repository is never taken from the
user.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* chore(release): version the agent plugin and publish it on its release tag

release-please component agent-plugin at plugins/langwatch (manifest seeded
0.0.0 so the first release PR proposes the 0.1.0 already in the manifests),
bumping plugin.json and .claude-plugin/plugin.json alongside package.json.
agent-plugin-publish mirrors the built directory into the public
langwatch/agent-plugin repository on each agent-plugin@vX.Y.Z tag, with the
version cross-checked against the manifest and the destination checkout
guarded. agent-plugin-ci builds the bundle and runs the package tests whenever
the plugin or its bundle inputs change.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* fix(build): ship the agent plugin manifest in the npx server tarball

plugins/langwatch is a workspace member, so the lockfile names it and an
install from the tarball has to be able to resolve it. Only the manifest
ships, the way tests/agentic-e2e does: the package is private and dev-only,
so none of its content belongs on an end user's machine.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* fix(agent-plugin): address the first CodeRabbit review

CI gate and publish:
- agent-plugin-ci watches the detect-changes action it runs, so a change to
  the gate cannot silently skip the plugin's own checks.
- The publish job fails when the two plugin manifests disagree on the version,
  and refuses to push new content under a version tag that already exists.

Plugin seam:
- Marketplace ownership reads only the fields that say where a source comes
  from. A third-party marketplace mentioning our repository in a description or
  a commit message no longer hands its registration to our logout.
- A failure stamp dated in the future is not a recent failure, so a clock that
  went backwards no longer suppresses every install.
- A plugin an earlier logout already switched off reports as disabled rather
  than as a failed removal: it is the end state the caller asked for.
- One writer owns the settings file. app-settings exports writeAppSettingsFile
  and the env block, the hook entries and the plugin flag all go through it.

Docs and tests:
- The skill says which of its commands writes anything, instead of claiming
  everything below is read-only over a `langwatch login`.
- The hook bundle asserts an empty stderr beside the empty stdout.
- Absent environment variables are restored by deletion, and the forced
  isTTY descriptor is removed when there was none to put back, so neither
  leaks into the files that run next.
- The three suites that seed Claude Code's plugin state share one fixture
  module instead of three copies with positional parameters.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* fix(agent-plugin): address the second CodeRabbit review

- The publish job reconciles the version tag when the mirror has nothing to
  commit. A previous run can land the branch push and then fail the tag push,
  which left the released content under no version at all while the workflow
  reported success. The tag is now created when it is missing, and verified
  against the published commit when it is there.
- Split the plugin suite along what it exercises: reading state, installing,
  and removing. The setup they shared moves into the fixture module as one
  harness, so the split costs no duplicated preamble beyond the per-file
  spawnSync mock, which vitest hoists and cannot be registered elsewhere.
- Named object parameters for the last positional test helpers.
- The scenarios added for the review fixes read as behaviour: a clock that
  disagrees with the recorded failure, a second logout leaving capture off,
  a marketplace that only mentions our repository.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* fix(agent-plugin): address the third CodeRabbit review

- The test harness composes four focused helpers, one each for the
  programmable claude, the CLI config, what the spawn mock recorded, and the
  temp home lifecycle, so no function in the module runs past the size cap.
- Two Gherkin fixes in the scenarios added for the earlier review: the
  second-logout Given was not grammatical, and the marketplace Given wrapped
  onto a continuation line with no keyword.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* docs(claude): dogfood agent-usage tracking interactively in tmux, never headless

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* docs(claude): use US spelling afterward per repo misspell convention

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD
* fix(dev): take a dev stack down with whoever started it

An abandoned `pnpm dev` was never signalled: every process in it shares
one process group whose leader, the shell that ran it, is already dead.
Killing that shell killed one pid, pnpm forwarded nothing down its
8-deep script chain, and vite, tsx and both Go services kept running,
holding ports and querying ClickHouse. Three of those measured 35
processes and 1.27 GB, and since the abandoned stack keeps its port the
next run takes the next slot and the worktree runs twice.

dev:app and dev:worker now run the long-lived part under
dev/scripts/dev-supervisor.mjs, which puts the stack in a group of its
own and watches the group it was launched from.

* fix(dev): make the port-conflict advice actually free the port

`check-ports.sh` offered a one-liner that sends SIGTERM to the process
group holding the port. A `pnpm dev` stack survives it. `start.sh` runs
`concurrently --restart-tries -1`, whose whole job is to replace a lane
that dies, so the group comes back with a fresh set of lane pids and the
port still busy. Measured on a real stack: group intact, new lanes, port
held.

That is the other half of how a worktree ends up running the stack
twice. The port conflict is where an abandoned stack is actually met,
the offered fix does not clear it, so the developer takes the next port
slot instead and the old stack keeps running.

Waiting on the port is its own trap: between a lane being killed and its
replacement binding, the port is briefly free, so anything watching the
port reports success into that gap and the port comes back seconds
later. What has to go quiet is the group.

scripts/kill-dev-tree.sh resolves the given ports to the node processes
listening on them and to the group behind each, then asks, waits for
those groups, escalates to SIGKILL, and confirms. It never touches its
own group, so pasting it into the shell that is about to retry
`pnpm dev` cannot close that shell. Ports resolve through lsof or ss, so
it works on Linux hosts that ship iproute2 and no lsof.

Review fixes in the same pass: two doc-comments in dev-supervisor.mjs
described behaviour the code does not implement (an ancestor group
leader is deliberately still watched, and the opted-out branch spawns
rather than replaces this process); exitCodeFor now reads
os.constants.signals so SIGQUIT reports 131 rather than a flat 128; and
the test launcher writes a script instead of building an `sh -c` line
from an absolute path.

* fix(dev): stop kill-dev-tree reporting a port free that it never cleared

CI failed where the laptop could not: two live members of the stack group
left behind and an exit code of 0. The Linux runner image ships iproute2
and no lsof, so it takes the `ss` path, which nothing local exercised,
and the liveness check went through `kill -0 -<pgid>`, whose answer for a
whole process group is not portable.

Both are now unable to produce a false success:

- Group liveness is read from the process table, not from
  `kill -0 -<pgid>`. Zombies do not count, since they hold neither a port
  nor any memory, and a leader whose parent has not reaped it yet would
  otherwise read as alive forever.
- Resolving no groups is two different situations. When nothing is
  listening it stays a clean exit. When something IS listening that we
  could not attribute, that is now a loud failure rather than "free",
  which is exactly how a takedown reports success over a running stack.
- The final check asks whether the ports are busy at all, rather than
  re-running the node-only, not-our-group lookup, so a port held by
  something we did not start is reported instead of claimed.

Tests grew the two cases that would have caught this: the `ss` path,
driven with lsof hidden and a stand-in `ss` in the format iproute2
prints, and a port held by a process that is not a dev stack of ours.

Two test defects fixed alongside. It waited on an ephemeral port, which
the kernel can hand to an unrelated process before the stack binds it,
and the script takes down the group behind whatever holds the port it is
given; ports now come from below the ephemeral range and the lane itself
proves it bound one. And it treated a replacement lane appearing DURING
the takedown as a failure, when that is the behaviour under test; what
must not appear is one after it.

* docs(dev): drop the CLAUDE.md notes on the dev-stack takedown

It is not something an agent has to know to do its job: the stack going
down with its launcher, and the port-conflict command clearing the port,
are both meant to just work. The behaviour is specified in
specs/setup/dev-stack-lifecycle.feature and explained in the two scripts
themselves, which is where someone changing it will be.

* fix(dev): treat a failed port lookup as a failure, not as a free port

`ss` exits 0 with no output for a port that is genuinely free, so a
non-zero status means the lookup broke, not that nobody is listening.
Both were being read the same way, and the answer either produced was
"free": the empty-targets branch printed "nothing of ours is listening"
and exited 0, and the final check printed "ports free", neither having
looked at anything.

A too-old or broken iproute2 was therefore enough to make the script
report success over a running stack, which is the failure it exists to
prevent. It now says it could not look and exits 69.

lsof is deliberately left alone: it returns 1 both for "found nothing"
and for a real error, so its status carries nothing to act on.

* test(dev): build the test lane as a file rather than as JavaScript

CodeQL flagged js/bad-code-sanitization: the lane was a `node -e`
string with a path interpolated through JSON.stringify, which is not a
sanitizer for code construction. The lane is now a file that takes its
port and readiness path as arguments, so nothing here builds JavaScript
by interpolation and the escaping question does not arise.

* test(dev): make the ss stand-in the thing that actually runs

The stubbed PATH was `/usr/bin:/bin:<stub>`, which works on a Mac only
because macOS ships no `ss` at all. On Linux iproute2 puts a real one in
/usr/bin, so it wins the lookup and the stand-in never runs, and lsof
lives there too, which sends the script down the other branch entirely.

The failing-`ss` case would therefore have met a working `ss`, been told
the port is free, and failed on a runner while passing here. The passing
case would have quietly become a test of the host's tools.

The PATH is now assembled: one directory holding symlinks to just the
utilities the script shells out to, plus the stand-in, and no lsof. The
test asserts that shape rather than trusting it, since the failure mode
is a stub that silently is not used.

* test(dev): quote the paths these tests write into shell scripts

Both suites generate small bash scripts and interpolate real paths into
them: the node binary, the lane, the readiness file, the supervisor. All
of it was unquoted, so a TMPDIR or a node install containing a space
splits one argument into two, the command never starts, and the failure
reads as flaky rather than as wrong.

One helper, applied at every site in both files rather than only where it
was spotted. Verified by running both suites under a TMPDIR with a space
in it.
…ne (#6753)

* fix(dev): identify a dev lane by its command line, not by ps comm alone

kill-dev-tree.sh found the right listener on Linux and then refused to
act on it, reporting "something is listening that is not a node process
of ours" and exiting 1. Three of its tests fail on the runner and passed
on a Mac.

The classification was `ps -o comm=`, which is the full binary path on
macOS and the bare name on Linux, and on the runner it does not come back
containing "node". The test that pins the ss path proves the lookup
itself was fine: its stand-in cannot fail to emit a pid, and it failed
too, so the pid was found and only the classification rejected it.

Linux now answers from /proc/<pid>/cmdline, which is authoritative and
needs no output parsing. ps stays as the macOS fallback and is asked for
`args` as well as `comm`, since the command line always names the
interpreter.

Three things that hid it, fixed alongside:

- listening_pids ignored the ss exit status, so a broken lookup was
  indistinguishable from an empty one. It now stops, as port_busy does.
- "not a node process of ours" was an assertion the script could not
  support. It now reports what it actually saw, so the next failure of
  this kind diagnoses itself instead of blaming the listener.
- ps format specs are one column per -o. `-o pgid=,stat=` reads the
  comma as part of the previous column's header on some ps builds.

And the stand-in restarting stack now paces its loop. It stands in for
`concurrently --restart-tries -1`, so it deliberately replaces a lane
that dies; unpaced, a lane that cannot bind exits at once and the loop
reissues it as fast as the kernel will fork. An orphaned copy of exactly
this shape was measured sustaining ~1800 processes a second for hours,
starving the machine it was on. A fifth of a second between restarts is
far inside the takedown's grace period, so the behaviour under test is
unchanged.

* fix(dev): claim a port only for the node binary, and never over a failed lookup

Two ways the takedown could speak with more confidence than it had.

A listener it could see but not attribute was reported as somebody else's.
The refusal inside listening_pids ran in a pipeline subshell, so exit left
only the subshell and the script carried on over the answer it had just
said it did not have. The lookup now returns a status, is asked once, and
the result is shared by the attribution and the diagnostic.

And a command line merely containing "node" was read as a dev lane, which
is what node_exporter and anything running node-config.py look like. It
now matches the binary a pid was started as, argv[0] from /proc on Linux
and the same argv[0] as a full path from ps on macOS.

* refactor(dev): name the arguments to the stranger fixture

And record why lsof's exit status is the one lookup failure this script
cannot act on: 1 means both "found nothing" and "broke", and the flag that
would separate them is newer than the lsof macOS ships.
…y wrapped run (#6751)

* feat(cli): keep the LangWatch Claude Code plugin up to date from every wrapped run

Claude Code auto-updates its own marketplaces and leaves third-party ones
switched off by default, so a plugin installed once stays at that version
until somebody finds the toggle. The plugin carries the session context
hook, which means a stale copy is stale capture code: exactly the drift
moving the hook into a plugin was meant to end.

Every `langwatch <tool>` run now checks, at most once a day, whether the
installed plugin is behind what the marketplace publishes, and moves it
forward when it is. The check runs before the tool is spawned, so a new
version reaches the session this launch starts rather than the one after.

Not tied to the claude wrapper: the plugin is installed once per machine,
so a user who mostly wraps codex would otherwise never pick up a new hook.

Nothing here can fail a launch. A claude that cannot manage plugins, a
marketplace that will not refresh, an update that will not apply and a
version that cannot be read all warn or stay quiet and let the tool start.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* fix(cli): address review on the plugin update check

Six findings, all valid:

- `compareVersions` takes named parameters and drops the single-letter
  names, so call sites read `{ version: installed, against: published }`.
- `updateLangwatchClaudePlugin` was one function doing eligibility,
  refresh and apply-and-verify. The first and last are now private
  helpers; the public contract is unchanged.
- A failed update no longer reports a `to` version. Nothing was
  installed, and the version it was reaching for is already in the
  reason, where it reads as an intention rather than as fact.
- A config that will not parse now stops the check instead of running it
  on every launch forever: a stamp that cannot be written is a check that
  can never be recorded, so unreadable reads as "checked".
- The check announces itself before it touches the network, and the
  per-step timeout drops from 60s to 30s, so a network that has stopped
  answering costs a launch a bounded wait it has been told about.
- The spec scenario that had no `When` step has one.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* fix(cli): require a canonical marketplace identity before updating a plugin

Ownership was a substring match with word boundaries, which accepted
`github.com/langwatch/agent-plugin.evil`, `evil.example/?repo=langwatch/
agent-plugin`, and any local directory sitting at a path that happened to
contain our repository name. That gate decided what logout could
deregister, which was survivable, and now decides what a wrapped run may
pull new plugin code from without asking, which is not.

It is now parsed: the GitHub shorthand we register, or a URL on github.com
whose whole path is our repository, with no query and no fragment. A local
checkout is never ours. Twelve cases pin it, eight of them lookalikes.

Also from review:

- Eligibility reads the user-scope install record rather than "installed
  at any scope", so a machine carrying only a project pin spends no probe,
  no fetch and no stamp on it. Stamping it meant a real user-scope install
  later the same day waited until tomorrow.
- The scenarios describe what the user sees rather than which subprocess
  ran or which file was parsed.
- The unreadable-config scenario was unreachable: `runWrapped` loads the
  config first and throws on a parse failure long before this code. The
  reachable case is a config that reads but cannot be saved, so the gate
  is now "could the check be recorded", which covers both.

And one thing this PR's own dogfooding turned up: the logout scan resolved
"this directory" from `process.cwd()` with no way to override it, so the
suite scanned the checkout it ran in and its removal tests deleted a real
`.claude/settings.local.json` from it. That is what the intermittent
failure in this branch was. The scan takes a `cwd` now, defaulted, and the
suite passes a scratch one.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* test(cli): cover the lookalike marketplace from the user's side

The address checks live in the state-reading suite, which proves the
predicate but says nothing about what a user gets. This adds the case
where it matters: a listing that really does publish a newer version,
reachable, with only its address disqualifying it. The wrapped run leaves
the plugin where it is, spawns nothing and says nothing.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* test(cli): cover both marketplace shapes a lookalike can arrive as

`claude plugin marketplace add <owner>/<repo>` records the shorthand and
`claude plugin marketplace add <url>` records `{ source: "git", url }`,
verified against claude 2.1.226. A lookalike address can arrive as either,
so the case now runs through both.

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD

* test(cli): keep the lookalike fixture literal

Claude-Session: https://claude.ai/code/session_01XRYUK1mbAWQ18rUFdjminD
…re there are no backups (#6756)

CLICKHOUSE_BACKUP_METRICS_ENABLED gated collection on the variable being
"true". Production worker deployments do not set it, and nothing in the infra
repo ever has, so the gauges stopped on 2026-07-16 at about 09:30 UTC when the
gate shipped. The Grafana rules that read them split two ways: "ClickHouse
Backup Missing" went quiet because its query returns an empty vector, and
"ClickHouse Backup Reporting Absent" fired continuously for three weeks because
absent_over_time() returns 1 on a missing series. Both were paged against a
perfectly healthy cluster.

Invert it: collection is on unless a deployment explicitly turns it off.
Absence of configuration now means the behaviour that already exists, and only
the environments that genuinely have no system.backup_log say so.

- metrics.ts: shouldCollectBackupMetrics() parses the flag robustly. Unset,
  empty, whitespace and unrecognised values all collect; only false/0/no/off
  (trimmed, case-insensitive) opt out. Unit tests cover the whole table and the
  unset-still-collects case end to end through collectStorageStats.
- The places that know there are no backups now say so instead of relying on a
  default: the Helm chart renders the flag in both directions from
  clickhouse.backup.enabled (override clickhouse.backup.metricsEnabled),
  haven's overlay sets false for local worktrees, and infra/compose.yml opts
  its plain ClickHouse out. .env.example and the spec follow.

Why this matters beyond restoring a gauge: kube-state-metrics can only tell us
the backup Job exited 0. These gauges come from ClickHouse's own
system.backup_log and carry BACKUP_CREATED plus the backup size, which is the
difference between "the job ran" and "a restorable backup exists". A job that
exits 0 while writing 10 GB instead of 320 GB passes every kube_cronjob check.

Claude-Session: https://claude.ai/code/session_01VhanQVnnR2tijjtqhSLGAZ
…ute say which key it wants (#6647)

* fix(gateway): a team budget reaches the key that is scoped to that team

Which team a request belonged to was read entirely from where its traces
land. That answers a different question, and for a key not scoped to
exactly one project the answer is the organization's governance project.
A team-scoped key therefore reported the governance team, so a budget on
the team that owns the key matched nothing while both sides looked
correctly configured and neither said so.

The resolver now also reads the teams the key is itself scoped to. Doing
it inside the resolver rather than making each caller pass them is what
puts the fix on all four paths at once, the debit path included, and that
is the one that actually accrues spend. The set only grows, so a budget
that enforced before still enforces and none can stop matching.

The draft-key path hands its scopes in instead, because the key it is
previewing does not exist yet and the drawer has to show the set the key
will resolve once it is saved.

Note for operators: a team budget that was silently inert starts
enforcing. That is the intended behaviour, but a stale one will now
block.

The create-time refusal for a budget no key can reach is specified here
and marked unimplemented until it lands.

* feat(gateway): refuse a budget no active key can reach, and say where the traffic goes

Whether a completed request matches a team, project or group budget is
decided by the key that served it, not by anything chosen while writing
the budget. So the two sides can each look correct and never meet, and
the result is a spending control that silently never fires.

Create now refuses those three scopes when the organization has active
keys and none of them reach the target, naming the projects the traffic
actually lands in. An organization with no keys at all is never refused,
since budget first and key second is a normal setup order, and
allow_unreachable keeps a budget written ahead of the keys that will use
it. The drawer offers "Create it anyway" on the refusal rather than a
checkbox nobody could interpret before hitting it.

Reads gained scope_reach, which is the only field that tells a budget
nothing can reach apart from one that was simply never breached.

Resolving where a key's traces land was up to three queries per key, run
in a loop over every active key on every budget list. An organization
running a project per customer has hundreds of keys, so that is now one
batch: same rule, same code, in one place, and a single key still costs
exactly the one query it always did.

* feat(gateway)!: a key must say where its traces land, instead of having it guessed

Per-key spend is read off the trace path, so the project a key traces into
is the project its spend is attributed to. A key resolves that from an
explicit destination or from its single project scope; a key with neither
falls back to the organization's governance project.

The app has always required the destination for organization and team
ownership. The API did not, so a key created over REST could be attributed
to a project nobody named, and every project budget its creator had in mind
counted none of its traffic while both sides looked correctly configured.
Creation now refuses that shape with gateway_trace_project_ambiguous,
including a key scoped to several projects at once, which names several
destinations and would be given a third. An organization whose only project
is the governance one is exempt, since there is nothing else to name.

trace_project_source on every key says which of the three rules answered, so
the keys already carrying the fallback can be found and moved without
opening the app. It is derived from the key rather than resolved per row,
because a key listing must not cost a query per key to describe one.

trace_project_required was a bare TRPCError whose message was its own
discriminant, documented in the OpenAPI description and absent from the code
registry, so nothing rendered copy for it. It is a HandledError now.

Also pins the webhook URL-policy tests to the escape hatch being off. They
asserted a refusal that only held when the developer's own env agreed, so a
machine running the hatch turned the block green by admitting everything.

BREAKING CHANGE: POST /api/gateway/v1/virtual-keys refuses an organization-
or team-scoped key, and a key scoped to more than one project, when it does
not carry trace_project_id and the organization has projects to choose from.
Send trace_project_id, or scope the key to exactly one project. Existing
keys are unaffected until they are next edited.

* feat(api): say which credential class each route wants, and which one arrived

A project API key and an organization API key are not interchangeable, and
until a call was refused there was nothing to tell them apart by.

The refusal itself was the worst of it. One sentence answered a typo, a
revoked key and a genuine project key alike, and it asserted the last of
the three: "project API keys cannot be used here". Two of those three
readers went off to swap a credential that was never the problem. The
class is now named only when the token really does resolve as a project
key, which is a lookup rather than a guess about the shape of a secret we
could not read, and everything else says only that the credential was not
accepted.

The published API description had the same defect at scale. It declared
one document-level default, and a default is a claim about every operation
that does not override it: it claimed the project key for all forty
operations, including the sixteen organization-scoped spend and webhook
routes a project key can never reach. An integrator reading the document
got a refusal the document said was impossible.

So the credential class became a property of the route. The secured-app
builder derives it from the app a route is mounted on, the registry
records it, the OpenAPI generator stamps each operation's security from
that, and a fourth rule in the completeness gate fails the build if an
operation ever goes back to inheriting the default. Nothing is declared by
hand, so a route added tomorrow is classified without anyone remembering
to, and no operation can publish a class nothing enforces.

Also: /spend-summaries accepted a window running backwards and answered it
with a confident zero, which a reconciliation checksums against and
concludes the books agree. /spend-events has refused that since it
shipped; the two agree now.

And the escape hatch for local webhook receivers is in .env.example with
the reason it has to be set in every worktree, not just the one that
registered the endpoint: the outbox is claimed by whichever worker gets
there first across worktrees sharing a database.

* fix(gateway): a trace destination that is named has to be one that exists

Resolution tries the explicit destination, then the key's single project
scope, then the governance project, and each stage only answers for the
keys the previous one left. Right on the read path, where an outlived
destination should degrade rather than break dispatch. Wrong on the write
path: a key naming a deleted or foreign project was accepted with its
traffic quietly attributed to whichever later stage answered, while the
saved trace_project_id went on claiming otherwise, and nothing said so.

Refused now with gateway_trace_project_unknown, which never echoes the id
back: it belongs to a record in another organization, if it names a record
at all, and confirming which of the two would disclose somebody else's data.

Two more things the published API description was getting wrong:

  - An any-method route contributed "ALL /path" to the registry, which
    matched no documented verb, so an .all(...) route could be published
    inheriting the document default. It now answers for every verb the
    document gives its path, with a specific registration still winning.
  - session and internal have no security scheme, because neither is
    something a consumer of the public API holds. They used to stamp an
    empty requirement, which is OpenAPI for "no credential needed", so
    every generated client would have called them unauthenticated.
    Generation now refuses and names the operation.

Review follow-ups in the same pass: cleanup moved into finally in the team
reach test, since it leaves an ACTIVE key that every later reachability
assertion reads; the published-description test now checks each operation
against the family its mount puts it in, rather than counting schemes,
which passed while an organization endpoint published the project key;
named parameters on the two new test helpers; and the stale claim that a
project key is rejected "on shape", which stopped being true when the
resolver started telling a wrong credential class apart from a bad one.

* fix(api): a service app cannot name an API-key family, so stop guessing one

credentialClassFor read the credential for two of the four handler-managed
values and the app scope for the other two, so a handler declaring it takes
an API key while mounted on a service app fell between them and published
"internal". A service app resolves neither a project nor an organization, so
there is no family to name, and the published description would have been
asserting one. It throws now, with coverage over every credential and scope
pair rather than the two that happened to be interesting.

Also from review, each verified against the code before changing it:

  - meta.scope_type on gateway_budget_scope_unreachable is typed to the three
    scopes that can reach this error, so no fourth value can arrive on the
    wire that the documentation does not list.
  - The published create-budget description named six scope types; the body
    accepts seven. The list operation already said seven.
  - The errors table presented reachable_project_ids as complete. It is the
    first 10, with reachable_project_count carrying the real total.
  - The budgets scope table said a team budget covers that team's projects,
    which stopped being the whole rule when a team scope on the key started
    counting too.
  - The team-scope test asserted the first TEAM clause only, so a duplicate
    clause or an empty `in` would have passed, and "never reads the key" was
    a claim with nothing behind it. Both are checked now.
  - The rollback assertion counted the organization's keys, which held only
    while that test ran first. It counts its own key by name.
  - Named parameters on the reach and team-scope resolvers, `as const` on the
    security map and the completeness fixture, and one parse of the generated
    description instead of two casts that could drift.

* fix(api): the receiver surface takes a project key, so say that, not internal

The previous commit refused a handler-managed route that declares it takes an
API key while mounted on a service app, on the reasoning that a service app
resolves neither a project nor an organization so no key family answers for
it, and that no route was written that way. The second half was wrong. It is
what the entire receiver surface is: the collector, all three OTLP signals,
annotations, and the legacy trace, evaluation and experiment routes, nineteen
in all, sit on a service app and resolve X-Auth-Token in the handler.

There are only two key families and the organization one exists only where an
organization is resolved, so a handler resolving a key on an app that resolves
neither is resolving a project key. That is the answer now. Falling through to
the service app's own class, internal, would have said our most widely
integrated endpoints take a shared secret, which is backwards.

The throw took sixteen unit test files down with it, at import, because the
class is derived when the route registers. Caught by running the unit suite
across src/server, src/app/api and src/features rather than the directories
this branch touches; the OpenAPI generator was not enough, since it imports
the documented Hono apps and none of the receiver routes are among them.

* chore: regenerate the docs site and clear the five new Biome violations

The docs generated files are checked in and derived, so editing budgets.mdx
and the API description without regenerating left docs-ci failing on stale
output. Regenerated with the three commands it names.

The Biome gate counts violations the merge base does not have, and this branch
had added five. Each is fixed by extraction rather than an override, since
none of the five is a case where the rule is wrong:

  - the org-auth refusal branches become refusalForUnresolvedOrg, which is the
    one place that decides what an unresolved token is told
  - the app-scope-to-class mapping becomes a table, and handler-managed keeps
    its own small function
  - the budget scope predicates become scopePredicatesFor, whose groupIds
    return value turned out to have no reader and is gone
  - the operation walk in the stamper becomes a generator, so the stamper
    itself is one loop
  - the published-description assertion builds its operation list first and
    then filters, instead of nesting the comparison two loops deep

Verified with the gate itself, run locally against origin/main: 3777 base,
3777 head, no new violations. Note the two "format" diagnostics a local run
adds are the generated evaluator and langy-skills files, which are dirty in
a working tree after start:prepare:files and are not committed.

* fix(ingest): a request that declares no Content-Length must not 500

Every ingestion route carried `hono/body-limit`. For a request with no
declared length it measures the body by draining it and then rebuilding the
request with `new Request(c.req.raw, { body })`. That only works while the
global `Request` is the class the server adapter handed it, and `start.ts`
mounts `getRequestListener` with `overrideGlobalObjects: false` precisely so
the adapter never patches the process globals. The global is therefore
undici's while the request is `@hono/node-server`'s own, undici's constructor
reads a private field off it, and the call throws:

  TypeError: Cannot read private member #state from an object whose class
  did not declare it

The route answers 500 in about a millisecond, before auth or parsing.

A Content-Length means knowing the size before sending, so anything that
streams omits it and Node's HTTP client omits it by default. The
OpenTelemetry JS exporter sends Transfer-Encoding: chunked, so every OTLP
export from a Node SDK talking straight to the app was refused, along with
the collector, the legacy evaluation and guardrail routes, bug reports and
scenario-events. An ingress that buffers request bodies adds the header on
the way through, which is why this reproduces against the app and not
through a proxy in front of it.

Replaced with a drop-in that builds the replacement from url + init instead
of from the request object, so nothing foreign reaches the constructor. Same
options, same 413, same drain-count-replay, and a declared length still
settles the question without reading a byte.

Found by the SDK e2e suite, which is path-filtered and so had not run on main
since the middleware landed: 37 of its tests fail on main today, 20 with this
TypeError and 17 timing out on spans that ingestion never accepted. Both
groups pass locally against the fix.

The regression test drives a real listener mounted the way start.ts mounts
it, because that option is the whole trigger: on plain `serve()` or through
`app.fetch` the broken middleware passes. Hono's own version is asserted to
still crash there, so the day it stops, this module can go.

* fix(api): stamp only real operations, and name the field the schema has

Review round three.

The generator walked a Path Item by value shape, so anything object-valued
counted as an operation. `servers` and `parameters` are arrays, and an array
is an object to `typeof`, so a document carrying either would have had
`security` stamped onto it and stopped validating. Filtered by method name
now, in one predicate the generator and its test both import, since the test
had the same hole and would have failed on a correct document.

The end-user spend description named a nullable `cap` and the schema has
always had a non-nullable `caps` array. Its pinning test asserted the string
contained "cap", which "caps" satisfies, so the drift was never visible; it
pins the field name and the empty-array case now.

`resolveApplicableBudgets` and `memberGroupIds` take named parameters, like
everything else in that module after the earlier rounds. The debit path was
the one caller outside `src/`, and missing it would have been a typecheck
failure rather than a silent one.

`CLASS_BY_APP_SCOPE` keeps its literal types, matching the security map next
to it.

The refusal copy for an unreachable budget said it "would never spend and
never block", which reads as permanent when a later key can reach it.

Not taken, with reasons on the threads: deriving an API-key class for a
service or session app must not throw, which is the correction already made
in c5687fb for nineteen live routes; and the no-active-keys exemption is
not organization-scoped only, it covers team, project and group budgets too,
so narrowing the docs would describe behaviour the code does not have.

* fix(openapi): stamp the operations whose route pins its parameter to a pattern

Hono may pin a route parameter to a matcher pattern, `/:id{.+}`, and the
translation into the document's `{id}` spelling did not take the pattern
off. Eight operations under /api/prompts and /api/evaluators therefore
matched no registered route and kept the document-wide security default
instead of the class their route enforces. They happen to sit on project
apps, so the default was the right answer and nothing looked wrong; the
first such route on an organization app would have published a
credential class that cannot reach it, which is the defect the stamping
exists to prevent.

The translation moves to the security module, where it can be tested
without the generator's import graph, and a documented operation under an
app prefix that matches no route now fails the generator rather than
inheriting quietly. The two surfaces are added to the credential-class
property test, which is what makes the failure visible: it reports all
eight against the pre-fix document.

The same merge brought a second copy of the request body cap, added
independently on main as routes/_lib/body-limit.ts while this branch
added server/api/bodyLimit.ts. Both were imported in all six consumers,
which typechecks as a duplicate identifier. Main's copy stays: it is
stricter about a Content-Length it cannot trust and carries the abort
signal. Its tests gain the one guard this branch had and it lacked,
that hono's own middleware still answers 500 to the chunked request the
module exists to serve, and the spec scenarios rebind to it at the layer
that actually runs them.

Also excludes TruffleHog's Lob detector from the secrets gate. It matches
`test_` plus exactly 35 characters and its verifier accepts the match, so
two ordinary shell functions in charts/langwatch/tests arrived as
verified secrets that --only-verified could not filter and blocked pull
requests that had not touched them. Nothing here integrates Lob, so the
detector can only produce false positives; excluded by name rather than
by path, so a real credential in those files is still caught.

* fix(gateway): a project the customer deleted is not a trace destination

Project deletion is soft, so the three lookups behind resolveTraceProjects
constrained the id, the organization and the kind, and nothing else. An
archived project therefore still answered, which meant a key could name one
as its explicit destination and pass the write-path guard: the create was
accepted, the gateway went on exporting the key's traces into a project the
customer had deleted, and its spend went on being attributed there. That is
the gateway_trace_project_unknown case this change's own guard exists to
refuse.

archivedAt: null now applies to all three stages, so an archived project is
passed over as an explicit destination, as a single project scope, and as
the governance inbox. The write path refuses a key that names one, and the
read path degrades the way it always has for a destination that stopped
answering: it falls through to the next rule, because the deletion happened
on another screen and failing here would take the key's live traffic down
with it. The key's own stated destination stays visible next to the rule
that actually answered, so the disagreement is readable rather than hidden.

The ambiguity guard counts live projects for the same reason. Counting a
deleted one made the two refusals contradict each other: the key was told
to name a project while every project it could name was refused as unknown.

* fix(gateway): the offer to keep an unreachable budget stops at the scope it was refused for

"Create it anyway" resubmits the form as it stands with allowUnreachable
set. Picking a different scope kind cleared it, picking a different target
within the same kind did not, so the button stayed on screen and sent the
override for a scope the server was never asked about. The one budget the
admin had already been warned about was the one the guardrail then skipped.

Both selectors now clear the refusal, and the negative control is in the
test: with the target selector left as it was, the button survives the
change and the second submit carries allowUnreachable to the new scope.

* refactor(gateway): the budgets route asks the service for reach, not Prisma

The create route read reachability by calling resolveScopeReach with the
Prisma client directly, which is a route reaching past its service into
persistence. GatewayBudgetService.scopeReach now owns it, and the reason it
is a separate read rather than a value carried out of the create guard
moved onto the method with it: the guard only runs for the three scopes it
can refuse, and a create response missing the field would not equal the row
the very next read returns.

* docs(sdk): move the organization-key note out of the breaking PR

release-please applies a whole commit message to every component the change
touched, so a PR carrying a breaking marker and touching two components
proposes two majors. This one breaks the gateway API, at the root component,
and its only other files were two SDK doc comments, which would have taken
the published TypeScript SDK to 2.0.0 for a paragraph of prose.

The note lands on its own, in a PR with nothing breaking in it. See
dev/docs/RELEASES.md, "A breaking change belongs to one component".
…6757)

`SpendEventsApiService` and `WebhooksApiService` both said "ORGANIZATION API
key (sk-lw-*)" and left the rest to be found out from a 401. Neither said
what a project key gets back, that the refusal happens before any permission
is consulted, or that no header rescues it, so the natural next move on the
error is to go looking for a scoping header that does not exist.

They also now say the direction that does work: the organization key reaches
the project-scoped surfaces with `X-Project-Id`, so one key covers both
families and a project key covers only one.
…flow (#6565)

* feat(traces): trace edit overlay storage, appliers, dataset seam, suggestion dual-write

* feat(annotations): add-to-annotation-queue in traces v2 and selection add-to-dataset on annotations list

* feat(traces): drawer edit mode with saved corrections, edited-original toggle and raw diff

* feat(traces): green correction highlights with hover-to-see-original across the drawer

* feat(annotations): queue workflow with edit trace, dataset hand-off at queue end, and modern suggestion popover

* perf(annotations): read dataset marks apart from the queue and gate the edit trace button

Marked items get a light id-only query so the queue page stops enriching
completed history; the queue bar edit button now requires annotations:update
like the drawer entry. Includes biome-clean refactors of the edit mode
components with no behavior change.

* test(traces): enforce the edit trace entry gating scenarios

* fix(traces): resolve attribute paths in edit patches, stop the queue edit-trace URL loop, clear the done button

Attribute edits now write back through the path that produced the display key
instead of a flat dotted sibling, removals prune emptied ancestors, the queue
page opens the drawer in edit mode through the URL alone so the hydrator and
sync effects cannot fight, and the queue bar clears the launcher orb.

* fix(traces): popover save reachability, diff dialog defaults, edited span count, live rename preview, per-span delete labels

* fix(traces): privacy-gate correction reads and writes, clear suggestions, scope queue marks

Corrections are stripped to the viewer's protections at the read endpoint and
at the dataset seam through one shared redaction gate, and upserts restore
edits withheld from the saver so a restricted reviewer cannot clobber them.
Clearing a suggestion clears the corrected output, comment-only saves no
longer re-assert stale suggestions, the overlay merge runs before the
annotation write, queue mark mutations are caller scoped, the unreleased
migration drops its foreign keys to match relationMode prisma, and the
corrected dataset output column semantics are pinned by spec.

* fix(traces): guard every exit from a dirty edit session and seed editors from stored corrections

Browser back and in-drawer back now route through the discard guard instead
of silently dropping drafts, the edit URL parameter can no longer drift from
the store, editors and the waterfall seed from the saved correction so a
second session builds on the first, no-op edits leave the session clean,
chat input seeds strip the synthesized system message, saves rebase on a
fresh overlay read, and the guard scenarios are all bound.

* fix(prisma): move the trace edit overlay migration to a free timestamp key

* fix(prompts): stop the deploy prompt dialog render loop that wedged unit shard one

usePromptTags returned a new array identity every render, feeding an
unconditional setState effect in DeployPromptDialog, so the test file never
finished and the shard hard floor masked six unrun files as green on main.
The mapped list is memoized on the query data, the dialog test queries the
portaled body it always meant to assert on, and an identity-stability test
pins the fix.

* fix(traces): apply review feedback across the correction surfaces

Accessibility on the selection checkboxes, is-prefixed boolean naming,
guarded filter and close paths in the drawer header, corrected empty-string
handling, previous-trace leakage guards on summary output and adopted
baselines, a shared uneditable-view rule for deep links, single-pass span
patch indexing, caller-scoped test cleanups, and bound scenarios for the
drawer menu actions.

* test(traces): assert the stripped correction is persisted, fix test naming

Taking the corrected output off a correction that still has other edits must
write the stripped patch back, so the tests now assert the repository upsert
call rather than only the returned value. Also renames the rendered
DeployPromptDialog overflow test to integration naming to match its scenario
tag, and drops a decorative doc comment in AttributeTable.

* fix(traces): correct what edit mode writes and open up trace input and metadata

Touching one attribute serialized the whole attribute tree into the
correction, and a value the trace recorded as text was read back as a
structure, so rows nobody edited came back marked as edited. Attribute
editors now keep the shape the trace recorded, a correction carries only
what really changed, and a draft left on another trace can no longer be
saved.

The trace input and the trace metadata are editable alongside the output.
Metadata is corrected as a map of the keys that changed, with the keys
that place a trace in a conversation left read only.

* fix(traces): scope trace media to the message it came from and state when it cannot play

A voice turn recorded as a transcript put every recording in the trace it
touched on both summary strips, so one spoken message and one spoken reply
read as two recordings on each side. A media reference now remembers the
role of the message it came from, and each strip carries its own side.

A recording whose bytes are gone, or whose probe the viewer cannot run,
says so instead of leaving a player stuck at zero. Probing trace media
asks for trace access, matching the route that serves the file.

Adds a dogfood script that seeds a voice trace with real audio through
the ingestion path.

* feat(annotations): read annotations beside the conversation and walk the queue in it

Annotations were parked in their own tab, one step away from the message
they were written about. They now sit beside the turn they belong to, and
a reviewer writes, corrects and removes their own from there. Everyone's
annotations are visible: who wrote it, the comment, the score with its
reason, the thumb, and the output they suggested.

The rail keeps the message column readable and slims down before it moves
below the turn on a narrow drawer. It only takes room when there is
something to show.

The annotation queue reads its trace through the same conversation view as
the drawer, so translating, suggesting and correcting work the same in both
places, and a trace with no thread is still read as a conversation. Saving
an annotation now refreshes what the conversation, badges and counts read.

* feat(traces): read a turn's media, events and redaction in the conversation

A turn's recordings stayed out of the thread layout, so a spoken message
read as text with nothing to play. Each message now carries the media
recorded on its own side of the turn, and a redacted message carries none.

The turn ledger counts the events a turn recorded, and a conversation whose
content was redacted says so once at the top with a link to the setting,
rather than repeating it on every message.

* fix(traces): read a turn's events back for the conversation thread

The trace summary stopped carrying events, so a turn arrives with none and
its separator would always read as eventless. The thread reads them back in
one go for its turns, the way the trace table reads them for a page, sharing
the merge with the list hook rather than repeating it.

The list's own read is bound to its columns and filter range, neither of
which a thread has, so the thread reads over the span its turns cover.

* fix(ci): free the migration key taken on main and format the queue page

The gateway budget cycle anchor migration landed on main under the same
timestamp, so the correction overlay migration moves to the next free key.
It has not shipped anywhere, and the SQL is untouched.

* fix(traces): keep the annotation badge out of the turn ledger, split the annotation components

A turn carrying an annotation drew its count badge over the separator label,
hiding the turn number at a wide drawer and the duration at a narrow one. The
hover actions may float over the end of the line because they are only there
while the pointer is; the badge is on screen the whole time and now takes its
own room.

The annotation card, form body, rail and turn separator each carried a
function well past the repo's complexity and length limits. Their pure logic
moves to module scope, where it is not scored through the nesting of the
closures it sat in, and their sub-blocks become components of their own.

The voice seed script checks the storage destination the app resolves before
it synthesizes anything, so an unwritable root names the setting to change
instead of failing several layers down inside media extraction.

* style(traces): drop the em dashes from what this branch writes

* fix(annotations): stop the queue settling a page the reviewer already left

Moving to the next item releases the navigating state a beat after the route
resolves, so the bar does not flicker back before the item renders. That wait
was fired and forgotten: leaving the queue inside it left the timer running
against an unmounted page, which reads as a stray update in a browser and
takes a whole test run down when the environment is gone by then.

The timer is held and cleared on unmount.

* fix(traces): correct what a correction writes, reads and invalidates

Behavior fixes, each pinned by a test that fails without it:

- Clearing an output suggestion on a trace that also had a corrected
  metadata key dropped the metadata correction and deleted the whole
  overlay row, because the remaining-edits check only looked at `input`.
- `updateByTraceId` wrote `expectedOutput: input.expectedOutput ?? null`,
  so any client omitting the field cleared a stored suggestion. The
  carry-over in the same handler already treated absent as "leave alone".
- Saving a correction re-read the draft store after an await, so moving
  the drawer to another trace mid-read wrote the new trace's empty
  session under the old trace's id.
- `TracesMapping` asked for the corrected trace unconditionally, so the
  evaluator setup surface read corrections while its own trace columns
  stayed captured. Reading corrections is now opt-in and only the dataset
  path passes it; the evaluator surface omits the prop so it cannot.
- The annotation card invalidated its own queries but not the correction,
  so a suggestion saved from the card left a stale corrected trace.
- Turn annotation counts kept the previous conversation's rows while the
  next read was in flight, so a switch briefly showed the wrong count.
- Adding a span attribute whose key nests under an existing one silently
  overwrote whichever arrived first. Both directions are now rejected by
  name.
- The rail resize observer never attached when the scroller mounted
  later, which is what happens leaving a virtualized conversation.
- `drawer.edit=1` was compared as a literal instead of going through the
  parser the rest of the URL state uses.
- `checkProjectPermissionAny` re-ran the project and org-role lookups
  once per permission.
- A system prompt recorded as content blocks was dropped from the panel,
  and fixing only the display would have made a saved correction store
  the prompt twice. Display and editor seed now share one reader.
- Saving an edit before its read settled created a second annotation.

Accessibility: the annotation badge rows are native buttons that anchor
their own correction popover, replacing a hidden zero-size anchor that
gave keyboard users no way in and focus restore nowhere to land. A
corrected span in the waterfall now says "Edited" in words rather than
signalling only through colour.

Structure: split `applyTraceEditOverlay` into the canonical-trace side
and the view-projection side, split the redaction module into its read
and write halves, and split `AnnotationFormBody` into its form hook,
score fields and output diff, computing the diff once per change instead
of twice per keystroke. Boolean names take an `is` prefix where the rule
asks for one.

Specs cover the metadata carry-over, the corrected-span marker, the
system prompt read as content blocks, the nested attribute conflict, the
badge popover anchoring and who may read a corrected trace.

* chore(traces): move the overlay migration past main's newest

* feat(traces): comment on a part of a trace, not only on the whole one

A reviewer could only ever say something about a whole trace. Corrections
already had precision, the overlay patches spans and fields, but comments
did not, so "the tool call that misfired" had nowhere to be said. An
annotation can now carry an anchor: a span, a field on a span or on the
trace, or one message in a transcript. No anchor still means the trace as
a whole, which is every row that exists today.

The anchor is immutable. It is accepted when the comment is created and
absent from the update input, so re-anchoring is a delete and a create.
An anchor kind this build does not recognise reads as a comment about the
trace rather than failing the list, the same degrade-do-not-fail contract
the correction patch already uses.

Nine read surfaces decide whether an element comment belongs. Six read
trace-level only, so the annotations list, its export, the queue hand-off,
the dataset columns and the REST list endpoints keep meaning exactly what
they meant. Three stay unfiltered on purpose: a comment on a span is still
a human touching that trace, so the search facet, the trigger filters and
the usage count all still see it. The REST endpoints keep their existing
behavior by default and take an opt-in for everything, so no SDK, CLI or
MCP caller changes meaning.

A comment on a span field can carry a suggested correction, which merges
into that field of the correction overlay. Clearing one needed its own
removal path: routing it through the trace-output removal would have
stripped the trace's corrected output instead. Scores stay trace-level,
gated in the form rather than at the boundary, because rejecting a score
there would discard the reviewer's words along with it.

Message anchors need an identity that survives a re-read. The key is
content addressed per block with an occurrence suffix, not positional:
inserting a message must not orphan every message after it. Content that
changed matches nothing, and the comment reads as being on a part that is
no longer there, which is a designed state rather than an error.

Fixes found by reading the drawer in a browser, both of which the tests
were blind to:

- The annotation badge could not be clicked. The hover action cluster
  stayed anchored across the badge while transparent, and a transparent
  element still takes clicks, so every click landed on the dataset action
  behind it. The cluster now anchors to the badge instead of the row.
- The per-turn actions never appeared at all. The reveal is written
  against a hover group, which resolves through a class rather than the
  role that was set, so the row sat at zero opacity on every turn. It now
  arrives on a surface of its own so it does not read as text over the
  turn ledger, and it stops taking clicks while it is hidden.
- A span the correction deletes no longer also wears the changed colour,
  and a truncated span name carries its full text.

Tests pass through jsdom, which has no hover, so both hover defects are
pinned through the group condition rather than through :hover.

* fix(traces): keep the block key out of the file's bytes and sync the spec

Three CI failures from the anchored comments change:

- The message key joined its fields on a literal NUL byte, which is the
  right separator because no text carries one, but writing it as a raw
  byte makes git and GitHub treat the whole file as binary. It is the
  same character, written as an escape.
- The shared annotations read now sends the anchor scope, so the test
  pinning its query shape asserts it. That default is what keeps the
  annotations list reading whole-trace comments only, so it is worth
  asserting rather than relaxing.
- The docs API spec and the generated SDK client carry the new anchor
  query parameter.

* fix(traces): drop regenerated evaluator output and format the parser

`git add -A` swept a locally regenerated evaluators file into the branch.
It is generated from langevals, unrelated to this change, and unformatted,
which failed the blocking lint step. Restored to what main holds.

The block key separator constant is formatted the way the formatter wants.

* feat(traces): comment from the trace view, and jump to what a comment is about

Every part of the trace view that round 3 made commentable now carries its
affordance: waterfall rows, span and trace input/output, attribute rows and
section headers, with count chips where comments already live. The header's
annotations chip lists every comment with the part it is about and jumps to
it, unfolding and scrolling the waterfall to the span, which the waterfall
never did for any selection until now.

An anchor path names one field of ours and then the reader's own key, so the
breadcrumb keeps the key whole instead of splitting it on every dot.

* chore(traces): the anchor label reads server-side too, so it lives there

describeAnnotationAnchor is plain string logic over the anchor columns; the
dataset mapping is about to need it on the server, and nothing in it was ever
client code.

* fix(traces): a rating waits for OK, and the header stops double-pinning the model

Picking a score option used to commit and close the popover in one click,
leaving nowhere to write the reason; options now render as real radios and
checkboxes over a local draft, with Clear on the left and OK on the right,
and only OK commits. The metadata auto-pin sweep skips metadata.model and
metadata.models, which the Model pill one row above already states better;
a key the reviewer pinned explicitly still renders.

* fix(traces): closing a stacked drawer goes back, never forward into a ghost

Closing the add-to-dataset drawer now returns to the drawer that opened it
instead of tearing the whole stack down, and the trace drawer only walks the
shared stack when the stack actually describes it. A drawer appears in the
stack at most once, so clicking through the non-modal page can no longer
leave a dismissed drawer behind to resurrect, and an empty stack re-seeds
from the address bar rather than a router snapshot that may still name a
drawer the reader already left.

* fix(traces): a queue holds only traces that exist, and never blocks its own finish line

Selecting all while the table still shows loading placeholders used to commit
fifty synthetic ids into the selection, where a differential deselect could
never reach them again; the header checkbox now renders as a skeleton while
loading and placeholder ids are filtered where they are minted. Queueing for
annotation trims, dedupes and verifies every id against the project's traces,
reporting created and skipped, because a queue item is a promise there is
something to review.

An item whose trace is gone reads as exactly that, with Remove and Skip, and
the completion screen and the dataset hand-off count only items that can
still be reviewed. A queued trace whose thread is older than the conversation
window is read on its own instead of as an empty conversation.

* feat(datasets): every annotation reaches a dataset as one readable line

A new dataset's last column is now annotations, mapped from a new ai_readable
key: author, the part of the trace the note is on when anchored, thumbs,
scores by name with reasons, the suggested output and the comment, collapsed
to one line a person or a judge model can read without knowing the schema.
The add-to-dataset mapping reads anchored annotations too, so a note left on
a span is no longer invisible to datasets, while every other surface keeps
its trace-level scope.

* feat(annotations): the queue pages become one honest table

The four annotation pages already shared one component; now it is a TanStack
table that scrolls inside itself instead of dragging the whole page sideways,
with the status filter, a date filter that hides nothing until a range is
picked, and an export on every page. Rows carry a comments chip that reads on
hover, per-score columns that stay aligned when no score is active, and an
actions menu with view trace, add to dataset and remove from queue, which
also works from the selection bar and is scoped to the items the caller is
responsible for, as marking done now is too.

A queue created anywhere shows up everywhere without a refresh, queues come
before people when choosing where to send, sending reports what was actually
queued and what was skipped, and the confirmation links straight to the one
queue or inbox it concerns. Members read with the same colored avatars as
the rest of the app, and the sidebar finally knows which queue is open.

* feat(traces): annotate the message, not only the turn, in annotation mode

Hovering a message now offers its own comment: the user side anchors to the
turn's input, the assistant side to its output and only that side offers a
suggested correction, while the turn divider keeps speaking about the turn
as a whole. Each side wears its own count, cards and composers name the part
they are about, and in a turn's own rail that reads as plain Input or Output.
The rail itself now lives in bubbles layout too, at full pane width.

The pass is called annotation mode everywhere a reader meets it: the menu
offers to annotate, the bar counts corrections and comments apart, discard
speaks only of corrections because comments are already saved, and the
conversation view stays reachable throughout. Transcript messages take
comments through their stable block keys, and each action in the turn row
asks only for the permission its own work needs.

* fix(annotations): the queue bar speaks annotation mode too

* fix(dev): take a dev stack down with whoever started it (#6749)

* fix(dev): take a dev stack down with whoever started it

An abandoned `pnpm dev` was never signalled: every process in it shares
one process group whose leader, the shell that ran it, is already dead.
Killing that shell killed one pid, pnpm forwarded nothing down its
8-deep script chain, and vite, tsx and both Go services kept running,
holding ports and querying ClickHouse. Three of those measured 35
processes and 1.27 GB, and since the abandoned stack keeps its port the
next run takes the next slot and the worktree runs twice.

dev:app and dev:worker now run the long-lived part under
dev/scripts/dev-supervisor.mjs, which puts the stack in a group of its
own and watches the group it was launched from.

* fix(dev): make the port-conflict advice actually free the port

`check-ports.sh` offered a one-liner that sends SIGTERM to the process
group holding the port. A `pnpm dev` stack survives it. `start.sh` runs
`concurrently --restart-tries -1`, whose whole job is to replace a lane
that dies, so the group comes back with a fresh set of lane pids and the
port still busy. Measured on a real stack: group intact, new lanes, port
held.

That is the other half of how a worktree ends up running the stack
twice. The port conflict is where an abandoned stack is actually met,
the offered fix does not clear it, so the developer takes the next port
slot instead and the old stack keeps running.

Waiting on the port is its own trap: between a lane being killed and its
replacement binding, the port is briefly free, so anything watching the
port reports success into that gap and the port comes back seconds
later. What has to go quiet is the group.

scripts/kill-dev-tree.sh resolves the given ports to the node processes
listening on them and to the group behind each, then asks, waits for
those groups, escalates to SIGKILL, and confirms. It never touches its
own group, so pasting it into the shell that is about to retry
`pnpm dev` cannot close that shell. Ports resolve through lsof or ss, so
it works on Linux hosts that ship iproute2 and no lsof.

Review fixes in the same pass: two doc-comments in dev-supervisor.mjs
described behaviour the code does not implement (an ancestor group
leader is deliberately still watched, and the opted-out branch spawns
rather than replaces this process); exitCodeFor now reads
os.constants.signals so SIGQUIT reports 131 rather than a flat 128; and
the test launcher writes a script instead of building an `sh -c` line
from an absolute path.

* fix(dev): stop kill-dev-tree reporting a port free that it never cleared

CI failed where the laptop could not: two live members of the stack group
left behind and an exit code of 0. The Linux runner image ships iproute2
and no lsof, so it takes the `ss` path, which nothing local exercised,
and the liveness check went through `kill -0 -<pgid>`, whose answer for a
whole process group is not portable.

Both are now unable to produce a false success:

- Group liveness is read from the process table, not from
  `kill -0 -<pgid>`. Zombies do not count, since they hold neither a port
  nor any memory, and a leader whose parent has not reaped it yet would
  otherwise read as alive forever.
- Resolving no groups is two different situations. When nothing is
  listening it stays a clean exit. When something IS listening that we
  could not attribute, that is now a loud failure rather than "free",
  which is exactly how a takedown reports success over a running stack.
- The final check asks whether the ports are busy at all, rather than
  re-running the node-only, not-our-group lookup, so a port held by
  something we did not start is reported instead of claimed.

Tests grew the two cases that would have caught this: the `ss` path,
driven with lsof hidden and a stand-in `ss` in the format iproute2
prints, and a port held by a process that is not a dev stack of ours.

Two test defects fixed alongside. It waited on an ephemeral port, which
the kernel can hand to an unrelated process before the stack binds it,
and the script takes down the group behind whatever holds the port it is
given; ports now come from below the ephemeral range and the lane itself
proves it bound one. And it treated a replacement lane appearing DURING
the takedown as a failure, when that is the behaviour under test; what
must not appear is one after it.

* docs(dev): drop the CLAUDE.md notes on the dev-stack takedown

It is not something an agent has to know to do its job: the stack going
down with its launcher, and the port-conflict command clearing the port,
are both meant to just work. The behaviour is specified in
specs/setup/dev-stack-lifecycle.feature and explained in the two scripts
themselves, which is where someone changing it will be.

* fix(dev): treat a failed port lookup as a failure, not as a free port

`ss` exits 0 with no output for a port that is genuinely free, so a
non-zero status means the lookup broke, not that nobody is listening.
Both were being read the same way, and the answer either produced was
"free": the empty-targets branch printed "nothing of ours is listening"
and exited 0, and the final check printed "ports free", neither having
looked at anything.

A too-old or broken iproute2 was therefore enough to make the script
report success over a running stack, which is the failure it exists to
prevent. It now says it could not look and exits 69.

lsof is deliberately left alone: it returns 1 both for "found nothing"
and for a real error, so its status carries nothing to act on.

* test(dev): build the test lane as a file rather than as JavaScript

CodeQL flagged js/bad-code-sanitization: the lane was a `node -e`
string with a path interpolated through JSON.stringify, which is not a
sanitizer for code construction. The lane is now a file that takes its
port and readiness path as arguments, so nothing here builds JavaScript
by interpolation and the escaping question does not arise.

* test(dev): make the ss stand-in the thing that actually runs

The stubbed PATH was `/usr/bin:/bin:<stub>`, which works on a Mac only
because macOS ships no `ss` at all. On Linux iproute2 puts a real one in
/usr/bin, so it wins the lookup and the stand-in never runs, and lsof
lives there too, which sends the script down the other branch entirely.

The failing-`ss` case would therefore have met a working `ss`, been told
the port is free, and failed on a runner while passing here. The passing
case would have quietly become a test of the host's tools.

The PATH is now assembled: one directory holding symlinks to just the
utilities the script shells out to, plus the stand-in, and no lsof. The
test asserts that shape rather than trusting it, since the failure mode
is a stub that silently is not used.

* test(dev): quote the paths these tests write into shell scripts

Both suites generate small bash scripts and interpolate real paths into
them: the node binary, the lane, the readiness file, the supervisor. All
of it was unquoted, so a TMPDIR or a node install containing a space
splits one argument into two, the command never starts, and the failure
reads as flaky rather than as wrong.

One helper, applied at every site in both files rather than only where it
was spotted. Verified by running both suites under a TMPDIR with a space
in it.

* test(app): the guardrails drawer harness keeps the address bar honest

The drawer stack reconstructs a deep-linked parent from what the browser is
actually on, so a router mock that never writes the URL describes a browser
that cannot exist; the harness now mirrors every navigation and seed into
the jsdom address bar. Also sorts the imports the anchor label move left
unsorted.

* test(traces): projected cost equality tolerates the float text parser's ULP

* fix(app): the empty state keeps block content out of its paragraph

* refactor(traces): the composer builds its form state and toasts outside the function budget

* feat(annotations): edit a queue where the queue is listed, and border the table

The queue's own actions belonged to the header of one page, so they were
only reachable from inside that queue. They move to the sidebar entry that
names the queue, on hover, where every queue has them wherever the reviewer
is. The drawer they open now says Edit rather than Create when it is editing.

The list itself picks up the shared bordered card the other index tables
use, with the card scrolling its own columns so a wide table never breaks
out of the border around it.

* feat(traces): every annotation names its target

The whole-trace annotation splits into anchored comments on the turn's input
or output, and every surface follows: both messages carry the boxed Translate,
Annotate, Suggest cluster (suggest on input corrects the trace input through
the edit overlay), anchored composers offer the trace-level scores, the list
feeds, projection and REST default read every annotation with its target
named, the tables trade the expected output column for a suggestions chip,
the queue walk focuses the item's turn and collects a per-sitting session of
traces whose celebration waits for the dataset hand-off, and the trace list
dedups versions before filtering so a freshly annotated trace stops matching
annotated and unannotated at once.

Claude-Session: https://claude.ai/code/session_01ACydTjANYWSm7Yke8WUk42

* fix(traces): settle the in-drawer edit transition and the queue's arrival

Clicking the separator's Edit trace while the drawer was already open on the
conversation asked the URL to move a drawer that was on screen; the shell's
URL sync runs a commit ahead of the page hydrator, stripped the edit flag the
hydrator was granting, and the two rewrote the URL against each other five
times a second until the tab died. The drawer now moves store first, the way
every other in-drawer transition does, and the URL just confirms it; an
unsaved correction on another trace asks before the move, and the trace
already being corrected re-opens without dropping its drafts.

Also settles the queue's arrival scroll by re-centering until the focused
turn stops moving (the annotation cards above it measure in after the first
paint and used to leave it below the fold), labels the input-anchored
suggestion field as the input it corrects, and repairs the annotation
ClickHouse backfill task, which the multitenancy guard had broken: it now
walks projects one at a time, so lost has-annotation sync events can be
reconciled again.

Claude-Session: https://claude.ai/code/session_01ACydTjANYWSm7Yke8WUk42

* docs(api): sync the annotations anchor default into the docs spec copy

The docs site keeps its own copy of the canonical OpenAPI document, and
the anchor query parameter's new default (every comment unless narrowed
to trace) had only reached the canonical side.

Claude-Session: https://claude.ai/code/session_01ACydTjANYWSm7Yke8WUk42

* feat(annotations): the queue walk reads as one sitting, start to celebration

Round 6 of dogfooding the annotation queue. The turn's session checkbox
leads its separator and the turn the reviewer was sent to is counted in
from the start, so the dataset toggle reads what the sitting holds in
words, one trace or N traces, and goes dead when nothing is counted. The
bar keeps one way forward: Next finishes the item and moves on, turning
into Done on the last one. Done plays the hand-off out over the
conversation itself, the finished-queue screen is gone, and until the
records land or ending without them is confirmed the item is not
finished, so cancelling lands back on the same turns with every mark in
reach. The focus tint got lighter, hugs only the turn's messages with the
rail outside, skips single-turn conversations, and the carry to the turn
rests a beat first so the reader sees the conversation before being taken
through it. Edit trace now leaves with the pointer even on a counted turn
(the reveal keyed on group focus, and a ticked checkbox holds focus).

The waterfall's pin and comment actions stop reserving the name's room
while hidden: they float in on their own surface with the pointer, and
only a pinned or commented row keeps its mark roomed. The annotations
tables can queue again: Add to queue on the All page, Move to queue on a
queue's page, the same dialog opened on the membership the rows already
have, retitled for what it is doing; deselecting the page's own queue
moves the items out of it. The old thread_id integration hint is gone
from both conversations, and the dataset toast spells Successfully.

Claude-Session: https://claude.ai/code/session_01ACydTjANYWSm7Yke8WUk42

* refactor(annotations): carve the queue dialog's words and toast out of the component

The component function crossed the sixty-line limit when it learned to
move as well as add; the header wording and the success toast now stand
on their own.

Claude-Session: https://claude.ai/code/session_01ACydTjANYWSm7Yke8WUk42

* feat(traces): the row's actions hang below its name, and a counted turn reads as counted

Six changes from a dogfood pass over the queue and the waterfall:

The waterfall row's actions no longer lie over the name they belong to.
They hang under the span's name and model on hover, on a surface of
their own, so the name, the signal badges and the figures stay readable
while they are up. A row's own marks, a comment count or a removed span,
stay in flow where they always were, and an action already on screen is
not repeated below.

Pinning leaves the waterfall row entirely. Pinning makes a span a tab,
which only the tab says, so the row offers no pin and reports no pinned
state; the span tab bar keeps both.

A turn the sitting counts now reads the way the turn under review does,
the separator and its ledger going blue with the tick. A tick alone was
a small thing to find again on a long conversation.

The arrival scroll rests half a second rather than a full one before
carrying the reader to the turn, and the end-of-session question asks in
a smaller voice.

Claude-Session: https://claude.ai/code/session_01ACydTjANYWSm7Yke8WUk42

* fix(traces): center the waterfall row's actions under the row and sit them into it

They hung from the name column, which put them off to the left of a wide
pane, and met the row's edge exactly. They now hang from the row itself,
centered on the span pane, eight pixels into the row above so they read
as belonging to it.

The row's own transform makes it a stacking context, so nothing inside
it could paint over the row below; the hovered row is lifted from its
virtualizer wrapper instead, in CSS so moving down a long tree
re-renders nothing.

Claude-Session: https://claude.ai/code/session_01ACydTjANYWSm7Yke8WUk42

* fix(dev): start the NLP engine on the port the app dials

`pnpm dev` resolved the NLP address from the calling shell, which never
loads platform/app/.env, while the app loads it with override right after.
A pinned LANGWATCH_NLP_SERVICE was therefore invisible to the launcher and
authoritative for the app: on a worktree at PORT=5590 the launcher derived
5591, started a healthy engine there, and every studio or playground run
failed with "LangWatch NLP is unreachable" because the app was dialing the
pinned 5571 the whole time.

The launcher now resolves the address the way the app will, .env.portless
then .env then the shell, and prints which file it came from. Nothing
pinned still means its own PORT+1 derivation.

Claude-Session: https://claude.ai/code/session_01ACydTjANYWSm7Yke8WUk42

* fix(datasets): make the annotations column read like a review, not like our schema

The annotations column carried our stored rows: every null the reviewer
never filled in, an `email` field standing in for the author, our project
and user ids, and the readable line as a JSON list of strings.

It now carries what the reviewer left and nothing else. The whole-annotation
column speaks the same vocabulary as the single columns (author, on, comment,
is_thumbs_up, score by name, expected_output, suggested_input for a suggestion
on an input) and leaves out anything they did not fill in. `ai_readable` is one
text with a line per annotation rather than a list to parse.

The row leaves the product, so it names things for a reader with no trace on
screen: "web_search span (0af31b2c) · Output", "Trace (95bf974e) · Output". The
chips on screen are unchanged, where the reader is already looking at the trace
the comment is on.

Claude-Session: https://claude.ai/code/session_01ACydTjANYWSm7Yke8WUk42

* fix(datasets): rule off one review from the next in the annotations column

A line each was enough to parse mechanically and not enough to read: on a
trace with several reviews the eye has nothing to break on. The readable
column now separates them with `---`. A row carrying a single review has no
rule in it.

Claude-Session: https://claude.ai/code/session_01ACydTjANYWSm7Yke8WUk42

* feat(traces): show what reviewers left on a trace in the list

The trace explorer had no way to see which traces a team has reviewed
without opening each one. Adds an opt-in Annotations column that counts
what a review leaves behind, one chip per kind: comments, suggested
outputs, scores, and thumbs ratings. Each count opens the writing itself
on hover, with who wrote it, when, and which part of the trace it was
about.

Annotations live in Postgres while the rest of a row comes from the
trace summary in ClickHouse, so the two are never joined in a query.
The column is laid over the rows the same way the Events column is: its
own read, only when the column is on screen, merged onto the rows the
list already has. A page whose columns never mention annotations pays
nothing, a page turn shows a pending placeholder rather than the empty
marker, and a failed read says the column is unavailable rather than
reporting a reviewed trace as one nobody has looked at.

It reads every annotation about the trace, the ones left on its spans
included, through the same feed every annotation write already
invalidates, so annotating from the drawer updates the row in place.

Reviews are a team's own judgements: a reader without annotations:view
is not offered the column, and never asks for them.

Claude-Session: https://claude.ai/code/session_01ACydTjANYWSm7Yke8WUk42
Adds an always-on line under the Langy composer: 'Note: these chats are
used by LangWatch to improve Langy.' No flag, no env check, no viewport
gate — it renders in every composer variant.

Also drops a stale comment in LangyCredentialService that described the
default mirror tier as gated on a pending review. It never was.
…he queue overlap test racing its holder (#6764)

The dataset annotations column stopped being a JSON list of reviews and
became one text with a rule between reviews, but the integration test kept
parsing it as JSON and kept expecting the on-screen anchor wording. A
dataset row leaves the product, so its anchors carry span ids too.

The check queue's give-up test asserted an overlap that is only observable
while the holder still runs, then gave the holder three seconds. On a
loaded runner the impatient run needs longer than that to reach the
command, and the queue looks like it serialized the two runs.
…6759)

The guard printed a remediation it could not read: pin the components that must not go major, one commit per component editing only that component's shim and carrying one Release-As footer. Following it left the check red, so the only unblock was the multi-component-major label, which asserts majors that pinning exists to prevent.

A bumped component is now exempt when it is explicitly pinned, which takes both halves. Its shim has to be among the changed files, and some commit has to carry a Release-As footer naming the version that shim records after next:. A shim edit alone moves nothing, a footer alone reaches every component the pull request touched, and the recorded version is what attributes one to the other, since the guard sees one file list per pull request rather than one per commit.

The check passes once at most one bumped component is left unpinned, and says which pins it accepted. A shim that moved with no footer naming its version fails by name, so a pin that would have done nothing is caught before merge.
… refuses to lie (#6656)

* fix(gateway): a team budget reaches the key that is scoped to that team

Which team a request belonged to was read entirely from where its traces
land. That answers a different question, and for a key not scoped to
exactly one project the answer is the organization's governance project.
A team-scoped key therefore reported the governance team, so a budget on
the team that owns the key matched nothing while both sides looked
correctly configured and neither said so.

The resolver now also reads the teams the key is itself scoped to. Doing
it inside the resolver rather than making each caller pass them is what
puts the fix on all four paths at once, the debit path included, and that
is the one that actually accrues spend. The set only grows, so a budget
that enforced before still enforces and none can stop matching.

The draft-key path hands its scopes in instead, because the key it is
previewing does not exist yet and the drawer has to show the set the key
will resolve once it is saved.

Note for operators: a team budget that was silently inert starts
enforcing. That is the intended behaviour, but a stale one will now
block.

The create-time refusal for a budget no key can reach is specified here
and marked unimplemented until it lands.

* feat(gateway): refuse a budget no active key can reach, and say where the traffic goes

Whether a completed request matches a team, project or group budget is
decided by the key that served it, not by anything chosen while writing
the budget. So the two sides can each look correct and never meet, and
the result is a spending control that silently never fires.

Create now refuses those three scopes when the organization has active
keys and none of them reach the target, naming the projects the traffic
actually lands in. An organization with no keys at all is never refused,
since budget first and key second is a normal setup order, and
allow_unreachable keeps a budget written ahead of the keys that will use
it. The drawer offers "Create it anyway" on the refusal rather than a
checkbox nobody could interpret before hitting it.

Reads gained scope_reach, which is the only field that tells a budget
nothing can reach apart from one that was simply never breached.

Resolving where a key's traces land was up to three queries per key, run
in a loop over every active key on every budget list. An organization
running a project per customer has hundreds of keys, so that is now one
batch: same rule, same code, in one place, and a single key still costs
exactly the one query it always did.

* feat(gateway)!: a key must say where its traces land, instead of having it guessed

Per-key spend is read off the trace path, so the project a key traces into
is the project its spend is attributed to. A key resolves that from an
explicit destination or from its single project scope; a key with neither
falls back to the organization's governance project.

The app has always required the destination for organization and team
ownership. The API did not, so a key created over REST could be attributed
to a project nobody named, and every project budget its creator had in mind
counted none of its traffic while both sides looked correctly configured.
Creation now refuses that shape with gateway_trace_project_ambiguous,
including a key scoped to several projects at once, which names several
destinations and would be given a third. An organization whose only project
is the governance one is exempt, since there is nothing else to name.

trace_project_source on every key says which of the three rules answered, so
the keys already carrying the fallback can be found and moved without
opening the app. It is derived from the key rather than resolved per row,
because a key listing must not cost a query per key to describe one.

trace_project_required was a bare TRPCError whose message was its own
discriminant, documented in the OpenAPI description and absent from the code
registry, so nothing rendered copy for it. It is a HandledError now.

Also pins the webhook URL-policy tests to the escape hatch being off. They
asserted a refusal that only held when the developer's own env agreed, so a
machine running the hatch turned the block green by admitting everything.

BREAKING CHANGE: POST /api/gateway/v1/virtual-keys refuses an organization-
or team-scoped key, and a key scoped to more than one project, when it does
not carry trace_project_id and the organization has projects to choose from.
Send trace_project_id, or scope the key to exactly one project. Existing
keys are unaffected until they are next edited.

* feat(api): say which credential class each route wants, and which one arrived

A project API key and an organization API key are not interchangeable, and
until a call was refused there was nothing to tell them apart by.

The refusal itself was the worst of it. One sentence answered a typo, a
revoked key and a genuine project key alike, and it asserted the last of
the three: "project API keys cannot be used here". Two of those three
readers went off to swap a credential that was never the problem. The
class is now named only when the token really does resolve as a project
key, which is a lookup rather than a guess about the shape of a secret we
could not read, and everything else says only that the credential was not
accepted.

The published API description had the same defect at scale. It declared
one document-level default, and a default is a claim about every operation
that does not override it: it claimed the project key for all forty
operations, including the sixteen organization-scoped spend and webhook
routes a project key can never reach. An integrator reading the document
got a refusal the document said was impossible.

So the credential class became a property of the route. The secured-app
builder derives it from the app a route is mounted on, the registry
records it, the OpenAPI generator stamps each operation's security from
that, and a fourth rule in the completeness gate fails the build if an
operation ever goes back to inheriting the default. Nothing is declared by
hand, so a route added tomorrow is classified without anyone remembering
to, and no operation can publish a class nothing enforces.

Also: /spend-summaries accepted a window running backwards and answered it
with a confident zero, which a reconciliation checksums against and
concludes the books agree. /spend-events has refused that since it
shipped; the two agree now.

And the escape hatch for local webhook receivers is in .env.example with
the reason it has to be set in every worktree, not just the one that
registered the endpoint: the outbox is claimed by whichever worker gets
there first across worktrees sharing a database.

* fix(gateway): a trace destination that is named has to be one that exists

Resolution tries the explicit destination, then the key's single project
scope, then the governance project, and each stage only answers for the
keys the previous one left. Right on the read path, where an outlived
destination should degrade rather than break dispatch. Wrong on the write
path: a key naming a deleted or foreign project was accepted with its
traffic quietly attributed to whichever later stage answered, while the
saved trace_project_id went on claiming otherwise, and nothing said so.

Refused now with gateway_trace_project_unknown, which never echoes the id
back: it belongs to a record in another organization, if it names a record
at all, and confirming which of the two would disclose somebody else's data.

Two more things the published API description was getting wrong:

  - An any-method route contributed "ALL /path" to the registry, which
    matched no documented verb, so an .all(...) route could be published
    inheriting the document default. It now answers for every verb the
    document gives its path, with a specific registration still winning.
  - session and internal have no security scheme, because neither is
    something a consumer of the public API holds. They used to stamp an
    empty requirement, which is OpenAPI for "no credential needed", so
    every generated client would have called them unauthenticated.
    Generation now refuses and names the operation.

Review follow-ups in the same pass: cleanup moved into finally in the team
reach test, since it leaves an ACTIVE key that every later reachability
assertion reads; the published-description test now checks each operation
against the family its mount puts it in, rather than counting schemes,
which passed while an organization endpoint published the project key;
named parameters on the two new test helpers; and the stale claim that a
project key is rejected "on shape", which stopped being true when the
resolver started telling a wrong credential class apart from a bad one.

* fix(api): a service app cannot name an API-key family, so stop guessing one

credentialClassFor read the credential for two of the four handler-managed
values and the app scope for the other two, so a handler declaring it takes
an API key while mounted on a service app fell between them and published
"internal". A service app resolves neither a project nor an organization, so
there is no family to name, and the published description would have been
asserting one. It throws now, with coverage over every credential and scope
pair rather than the two that happened to be interesting.

Also from review, each verified against the code before changing it:

  - meta.scope_type on gateway_budget_scope_unreachable is typed to the three
    scopes that can reach this error, so no fourth value can arrive on the
    wire that the documentation does not list.
  - The published create-budget description named six scope types; the body
    accepts seven. The list operation already said seven.
  - The errors table presented reachable_project_ids as complete. It is the
    first 10, with reachable_project_count carrying the real total.
  - The budgets scope table said a team budget covers that team's projects,
    which stopped being the whole rule when a team scope on the key started
    counting too.
  - The team-scope test asserted the first TEAM clause only, so a duplicate
    clause or an empty `in` would have passed, and "never reads the key" was
    a claim with nothing behind it. Both are checked now.
  - The rollback assertion counted the organization's keys, which held only
    while that test ran first. It counts its own key by name.
  - Named parameters on the reach and team-scope resolvers, `as const` on the
    security map and the completeness fixture, and one parse of the generated
    description instead of two casts that could drift.

* fix(api): the receiver surface takes a project key, so say that, not internal

The previous commit refused a handler-managed route that declares it takes an
API key while mounted on a service app, on the reasoning that a service app
resolves neither a project nor an organization so no key family answers for
it, and that no route was written that way. The second half was wrong. It is
what the entire receiver surface is: the collector, all three OTLP signals,
annotations, and the legacy trace, evaluation and experiment routes, nineteen
in all, sit on a service app and resolve X-Auth-Token in the handler.

There are only two key families and the organization one exists only where an
organization is resolved, so a handler resolving a key on an app that resolves
neither is resolving a project key. That is the answer now. Falling through to
the service app's own class, internal, would have said our most widely
integrated endpoints take a shared secret, which is backwards.

The throw took sixteen unit test files down with it, at import, because the
class is derived when the route registers. Caught by running the unit suite
across src/server, src/app/api and src/features rather than the directories
this branch touches; the OpenAPI generator was not enough, since it imports
the documented Hono apps and none of the receiver routes are among them.

* chore: regenerate the docs site and clear the five new Biome violations

The docs generated files are checked in and derived, so editing budgets.mdx
and the API description without regenerating left docs-ci failing on stale
output. Regenerated with the three commands it names.

The Biome gate counts violations the merge base does not have, and this branch
had added five. Each is fixed by extraction rather than an override, since
none of the five is a case where the rule is wrong:

  - the org-auth refusal branches become refusalForUnresolvedOrg, which is the
    one place that decides what an unresolved token is told
  - the app-scope-to-class mapping becomes a table, and handler-managed keeps
    its own small function
  - the budget scope predicates become scopePredicatesFor, whose groupIds
    return value turned out to have no reader and is gone
  - the operation walk in the stamper becomes a generator, so the stamper
    itself is one loop
  - the published-description assertion builds its operation list first and
    then filters, instead of nesting the comparison two loops deep

Verified with the gate itself, run locally against origin/main: 3777 base,
3777 head, no new violations. Note the two "format" diagnostics a local run
adds are the generated evaluator and langy-skills files, which are dirty in
a working tree after start:prepare:files and are not committed.

* fix(ingest): a request that declares no Content-Length must not 500

Every ingestion route carried `hono/body-limit`. For a request with no
declared length it measures the body by draining it and then rebuilding the
request with `new Request(c.req.raw, { body })`. That only works while the
global `Request` is the class the server adapter handed it, and `start.ts`
mounts `getRequestListener` with `overrideGlobalObjects: false` precisely so
the adapter never patches the process globals. The global is therefore
undici's while the request is `@hono/node-server`'s own, undici's constructor
reads a private field off it, and the call throws:

  TypeError: Cannot read private member #state from an object whose class
  did not declare it

The route answers 500 in about a millisecond, before auth or parsing.

A Content-Length means knowing the size before sending, so anything that
streams omits it and Node's HTTP client omits it by default. The
OpenTelemetry JS exporter sends Transfer-Encoding: chunked, so every OTLP
export from a Node SDK talking straight to the app was refused, along with
the collector, the legacy evaluation and guardrail routes, bug reports and
scenario-events. An ingress that buffers request bodies adds the header on
the way through, which is why this reproduces against the app and not
through a proxy in front of it.

Replaced with a drop-in that builds the replacement from url + init instead
of from the request object, so nothing foreign reaches the constructor. Same
options, same 413, same drain-count-replay, and a declared length still
settles the question without reading a byte.

Found by the SDK e2e suite, which is path-filtered and so had not run on main
since the middleware landed: 37 of its tests fail on main today, 20 with this
TypeError and 17 timing out on spans that ingestion never accepted. Both
groups pass locally against the fix.

The regression test drives a real listener mounted the way start.ts mounts
it, because that option is the whole trigger: on plain `serve()` or through
`app.fetch` the broken middleware passes. Hono's own version is asserted to
still crash there, so the day it stops, this module can go.

* fix(api): stamp only real operations, and name the field the schema has

Review round three.

The generator walked a Path Item by value shape, so anything object-valued
counted as an operation. `servers` and `parameters` are arrays, and an array
is an object to `typeof`, so a document carrying either would have had
`security` stamped onto it and stopped validating. Filtered by method name
now, in one predicate the generator and its test both import, since the test
had the same hole and would have failed on a correct document.

The end-user spend description named a nullable `cap` and the schema has
always had a non-nullable `caps` array. Its pinning test asserted the string
contained "cap", which "caps" satisfies, so the drift was never visible; it
pins the field name and the empty-array case now.

`resolveApplicableBudgets` and `memberGroupIds` take named parameters, like
everything else in that module after the earlier rounds. The debit path was
the one caller outside `src/`, and missing it would have been a typecheck
failure rather than a silent one.

`CLASS_BY_APP_SCOPE` keeps its literal types, matching the security map next
to it.

The refusal copy for an unreachable budget said it "would never spend and
never block", which reads as permanent when a later key can reach it.

Not taken, with reasons on the threads: deriving an API-key class for a
service or session app must not throw, which is the correction already made
in c5687fb for nineteen live routes; and the no-active-keys exemption is
not organization-scoped only, it covers team, project and group budgets too,
so narrowing the docs would describe behaviour the code does not have.

* feat(spend): one filter vocabulary on both reads, and a grouping that refuses to lie

A reconciliation checksums the rollups and then diffs the events when a
checksum disagrees. It can only do that if it can ask both surfaces the same
question, and it could not: /spend-events offered four filters, /spend-summaries
offered one, and neither offered provider, principal, request type, label,
metadata, team or the customer's own external id.

One module now owns the vocabulary, the domain type and the SQL, and both
routes spread it, so the two cannot drift. A test asserts the parity against
the published document rather than the source, since the document is what a
caller reads.

Three things worth naming:

A filter that is present but resolves to nothing emits its predicate anyway.
Collapsing an empty list into an absent predicate would hand back the whole
organization's spend under a narrowing the caller asked for, which is the
worst possible answer to "show me this one team".

Metadata is filtered through a MATERIALIZED expression column rather than a
stored one the fold writes. Verified against the pinned server that the
expression is evaluated for parts written before the ALTER, so history is
correct from the moment this lands with no backfill window during which a
filtered reconciliation silently agrees on a subset. The rejected alternative
is recorded in the migration: a real Map backfilled by re-insert would carry a
higher EventTimestamp, which is this table's replacement version, and would
erase the fold's next legitimate write.

Grouping gained model, provider, project, principal, request type and an
hour/day bucket, and with them a guard. The fold replaces the requested model
and provider with the ones that actually served the request, and its admitted
handler sets the occurred-at unconditionally while every outcome handler
preserves it, so over an unsettled window a row can change group mid-walk and
be served twice or skipped. Those groupings are refused until the window is
older than the settlement grace, which is read from the settlement process
manager so an operator who widens it widens this too. allow_unstable serves it
anyway for a caller who only needs the shape.

Nine skip indices back the new filters. They apply to parts written after the
migration and MATERIALIZE INDEX is left as an ops task, so an unmaterialized
index costs a granule read and never drops a row.

* docs(spend): describe the filters and say when a grouping is refused

The route descriptions still described a rollup that could group one way and
a walk that took four filters. Both now name the shared vocabulary, how
repeating a parameter widens it, and the `key:value` metadata form.

The refusal gets the most words because it is the one thing a reconciliation
script cannot discover by trying: it only fires on recent windows, so a script
written and tested against last month's data meets it first in production.
`meta.settles_at` is there so a scheduler can wait rather than guess.

Also fixes the docs-to-metrics contract test, which had four documented
`gateway_*` REST error codes that no collector registers. Three predate this
change and one is new here. The test reads them as undocumented metrics, and
because it iterates a map it names a different one on each run, so it has to
be swept rather than patched. It stayed invisible because the Go job is
path-filtered and neither this work nor the budget-reach work before it
touches Go: the failure would have surfaced on whichever unrelated Go PR
landed next.

* feat(sdk): carry the spend filters and the new grouping through the SDK and CLI

The API grew a shared filter vocabulary and a wider grouping; a customer
scripting a reconciliation reaches them through the SDK, so they land here in
the same shape rather than as a set of query strings to hand-assemble.

`SpendFilterOptions` is one interface both reads take, mirroring the server's
one module. Every field accepts one value or many, and many means any of
these, which is what repeating a query parameter does on the wire. Metadata is
a record rather than `key:value` strings, because an SDK caller already has
the pair as two values and joining them only to have the server split them
again is a step that can go wrong.

`SpendSummariesOptions` carries the grouping, and its docstring is where the
refusal is explained: a caller meets `gateway_spend_group_by_unstable` only on
recent windows, so a script written and tested against last month's data
meets it first in production.

`SpendSummaryRow` gains `group` and `bucket_start`. `key` keeps its meaning as
the first dimension's value, so code reading it today is untouched.

On the CLI, `--group-by` takes one or two dimensions and still refuses a typo
rather than silently reporting on virtual keys, which on a reconciliation
surface is the failure worth being loud about. `--metadata` is written
`key=value` rather than the wire's `key:value`, because a metadata value may
itself contain a colon and `key=value` is what a shell user expects.

Formatting note: this package is on eslint, not the repo-root biome, so it is
deliberately left in its own style.

* feat(sdk): carry the spend filters through the python SDK, and name group_by in its own refusal

The python SDK offered five singular filters while the TS SDK and the REST
surface took the whole vocabulary, so a python reconciler could not narrow a
walk to what a checksum had covered. It now takes the same twelve filters,
each as one value or many, plus the multi-dimension grouping, the time bucket
and its timezone.

A comma-split group_by piped into an array schema reported an invalid value
against `group_by.0`. The caller sent one string, so an index they never wrote
maps onto nothing a client can point at, which is the whole purpose of
meta.fields. It is validated inside the transform now and names `group_by`.

The Billing events screen test asserted the pre-vocabulary tRPC input shape.

* test(sdk): pin the python spend filters to the published contract

The SDK lagged the REST surface by seven filters and nothing failed. A
reconciler just could not narrow a walk to what its checksum had covered,
which is the one divergence a reconciliation cannot detect on its own.

Asserted against the published OpenAPI document rather than the server
source, because the document is what a caller reads, and it fails loudly
when the document is missing rather than skipping.

* fix(spend): the ways a filter could quietly answer the wrong question

Review round on the filter vocabulary. Four of these returned wrong spend
rather than failing, which on a reconciliation surface is the expensive kind.

`allow_unstable=false` turned the guard OFF. `z.coerce.boolean()` is
JavaScript `Boolean()`, so every non-empty string is true and the most obvious
way to spell "off" meant "on". A caller opting out of an inexact walk got one.
Query booleans now name the spellings they accept and refuse the rest.

`metadata=tier:` matched every row with no `tier` at all. ClickHouse answers a
missing Map key with the value type's default, so an empty value read as
`'' IN ('')`. Both halves of a pair must now be non-empty, in the query
spelling, the structured one and the SDK.

The summaries cursor decided its format by looking for a leading bracket.
Group keys are caller data, so an end-user id or model opening with `[`
refused that caller's own cursor and restarted their walk from page one. The
format is decided by parsing now, never by sniffing.

No filter bounded how many values it could name, so one request could build an
unbounded query. Capped at 100 per filter, which the published contract now
states as `maxItems`.

Also: an unknown `timezone` is a 400 naming the parameter rather than a
ClickHouse error the caller cannot act on; the summaries window gets the
`.safe()` guard the events window always had; `group_by` publishes what it
accepts; the CLI validates `--bucket` and prints the whole row identity now
that a row can be two dimensions and a bucket; and a metadata key carrying a
colon is refused rather than sent to address a different key.

Tests: a second organization with real spend of its own, because "a key from
another organization" and "a key nobody minted" were the same test and only
one of them exercises the tenant fence. Provider, end-user and label filters
were seeded but never asserted. The fixture window is anchored to the run, so
it cannot age out of the 13-month ledger. The parity check refuses to pass by
finding nothing, and compares schemas rather than only names.

The migration says IRREVERSIBLE and why, which is the form the rule accepts
for a down step we deliberately do not run unattended.

* fix(spend): close the last gaps between the two metadata spellings

The structured input still accepted a metadata key with a colon, so the
Billing events screen could set a filter no reconciliation script could
reproduce over REST. That gap is the thing this module exists to prevent, so
the domain schema now enforces the same limit the query spelling documents.

The CLI refused an empty metadata key but not an empty value, and passed
`--metadata key=` straight to a server that has refused it since the last
commit. Refused locally now, with the reason.

Docs: the grouping example says out loud that its window is the closed month
declared above it, since the same call on a recent window is refused. And the
event-level diff compares per-id counts rather than set membership, because
one request dropped and another booked twice leaves the id sets looking close
and the money wrong.

* docs(spend): say the whole filter list, and name the option each client spells

The events read still advertised five filters after gaining twelve, and the
paragraph next to it said both grains take the same ones, so the page
contradicted itself. The live-view escape hatch was written only in its
TypeScript spelling, which a REST caller can copy and get nothing.

Reconciliation: matching per-id counts rule out drops and duplicates, not a
repricing, so "the ids whose counts disagree are your fix list" hands an empty
fix list to the one divergence counts cannot see.

The two structured-metadata refusals asserted only that something threw, which
would keep passing if the rule under test were deleted and an unrelated one
tripped instead. They now name the field the refusal has to land on.

* ci(gateway-matrix): stop release PRs from evicting real ones from the VK lock

The matrix serializes on one concurrency group because every run dials the
same matrix-* virtual keys. With cancel-in-progress false GitHub keeps a
single pending run per group and cancels the older one, so anything queued in
that group evicts whatever was already waiting.

Release PRs land in it by accident: release-please bumps the chart version,
charts/gateway/** is in the path filter, and the branch re-pushes on every
merge to main. Twenty of the last sixty runs came from one, each spending live
provider calls to prove a version string still answers, and five runs were
evicted, including two consecutive pushes of one feature branch.

They now skip the job and sit in a group of their own. The two go together: a
run outside the shared group must be one that never touches a shared key.

* fix(test): the pre-migration probe built a table name it could not parse

The name is an identifier, so it is spelled into the DDL rather than bound,
and it carried the suite's nanoid suffix straight through. nanoid's alphabet
includes a hyphen, which ends an unquoted identifier mid-token, so about a
fifth of runs died with a syntax error instead of exercising the property.

It reproduces on the pinned server: CREATE TABLE probe_a-S04f_ is rejected
with code 62, and probe_a_S04f_ is accepted. The local runs that passed had
simply drawn hyphen-free suffixes, which is what made this read as a CI-only
failure of the ClickHouse behaviour under test rather than a flake in the
harness around it.

* ci(gateway-matrix): make the lock exemptions match the guard's, all four

The previous commit said the concurrency group had to stay in step with the
job guard and then moved one of the guard's three classes out of it. Drafts
and forks kept queueing in the shared group, and a run parked there evicts a
real PR's queued run whether or not its own job ever executes, because the
eviction happens when the run queues, not when it starts. Finishing in
seconds is no mitigation. Eleven of the last sixty runs were cancelled, and
they were not all release PRs: a draft PR and two feature branches are in
that list.

Dependabot was in neither the guard nor the group, and its runs are not
skipped at all. One took five minutes and forty-five seconds inside the
shared lock to bump the action SHAs pinned in this file, reading the
Dependabot secret store rather than the Actions one, so it either bills real
provider calls for a SHA bump or reports green on empty credentials.

Both expressions now list the same four exemptions, and the release prefix
carries the double dash release-please actually emits so a hand-written
release-please-config branch is not swept up with it. YAML cannot share an
expression between `concurrency` and a job `if`, so the comment saying they
move together is the only thing holding them together; it now names all four
rather than describing a rule the code did not implement.

* fix(openapi): stamp the operations whose route pins its parameter to a pattern

Hono may pin a route parameter to a matcher pattern, `/:id{.+}`, and the
translation into the document's `{id}` spelling did not take the pattern
off. Eight operations under /api/prompts and /api/evaluators therefore
matched no registered route and kept the document-wide security default
instead of the class their route enforces. They happen to sit on project
apps, so the default was the right answer and nothing looked wrong; the
first such route on an organization app would have published a
credential class that cannot reach it, which is the defect the stamping
exists to prevent.

The translation moves to the security module, where it can be tested
without the generator's import graph, and a documented operation under an
app prefix that matches no route now fails the generator rather than
inheriting quietly. The two surfaces are added to the credential-class
property test, which is what makes the failure visible: it reports all
eight against the pre-fix document.

The same merge brought a second copy of the request body cap, added
independently on main as routes/_lib/body-limit.ts while this branch
added server/api/bodyLimit.ts. Both were imported in all six consumers,
which typechecks as a duplicate identifier. Main's copy stays: it is
stricter about a Content-Length it cannot trust and carries the abort
signal. Its tests gain the one guard this branch had and it lacked,
that hono's own middleware still answers 500 to the chunked request the
module exists to serve, and the spec scenarios rebind to it at the layer
that actually runs them.

Also excludes TruffleHog's Lob detector from the secrets gate. It matches
`test_` plus exactly 35 characters and its verifier accepts the match, so
two ordinary shell functions in charts/langwatch/tests arrived as
verified secrets that --only-verified could not filter and blocked pull
requests that had not touched them. Nothing here integrates Lob, so the
detector can only produce false positives; excluded by name rather than
by path, so a real credential in those files is still caught.

* fix(gateway-spend): read allow_unstable off the wire, and test that it is read

The critical defect this branch already fixed was that
`z.coerce.boolean()` made `allow_unstable=false` mean true, so the
clearest way to decline an inexact read turned the guard off. It was
fixed without a test that could observe it: the four grouping scenarios
were bound to a unit test calling the grouping check directly with a
boolean argument, which cannot see how a query string becomes that
boolean. They now run through the route, and one of them sends the
`false` that used to be served.

The parameter also refused `True`. `requests` renders a Python bool with
a capital letter where `httpx` renders it lower case, and this parameter
is documented for Python callers, so the surface was rejecting the exact
request the documentation asks for. Case is folded now, while a spelling
it does not know is still refused by name rather than guessed at, and
the published parameter says so.

The scenarios move to the layer that runs them rather than being counted
at one and executed at another. check-feature-parity treats @Unit and
@integration as one set and never checks that the binding test matches
the tag, so nothing else would have caught the mismatch.

* fix(gateway): a project the customer deleted is not a trace destination

Project deletion is soft, so the three lookups behind resolveTraceProjects
constrained the id, the organization and the kind, and nothing else. An
archived project therefore still answered, which meant a key could name one
as its explicit destination and pass the write-path guard: the create was
accepted, the gateway went on exporting the key's traces into a project the
customer had deleted, and its spend went on being attributed there. That is
the gateway_trace_project_unknown case this change's own guard exists to
refuse.

archivedAt: null now applies to all three stages, so an archived project is
passed over as an explicit destination, as a single project scope, and as
the governance inbox. The write path refuses a key that names one, and the
read path degrades the way it always has for a destination that stopped
answering: it falls through to the next rule, because the deletion happened
on another screen and failing here would take the key's live traffic down
with it. The key's own stated destination stays visible next to the rule
that actually answered, so the disagreement is readable rather than hidden.

The ambiguity guard counts live projects for the same reason. Counting a
deleted one made the two refusals contradict each other: the key was told
to name a project while every project it could name was refused as unknown.

* fix(gateway): the offer to keep an unreachable budget stops at the scope it was refused for

"Create it anyway" resubmits the form as it stands with allowUnreachable
set. Picking a different scope kind cleared it, picking a different target
within the same kind did not, so the button stayed on screen and sent the
override for a scope the server was never asked about. The one budget the
admin had already been warned about was the one the guardrail then skipped.

Both selectors now clear the refusal, and the negative control is in the
test: with the target selector left as it was, the button survives the
change and the second submit carries allowUnreachable to the new scope.

* refactor(gateway): the budgets route asks the service for reach, not Prisma

The create route read reachability by calling resolveScopeReach with the
Prisma client directly, which is a route reaching past its service into
persistence. GatewayBudgetService.scopeReach now owns it, and the reason it
is a separate read rather than a value carried out of the create guard
moved onto the method with it: the guard only runs for the three scopes it
can refuse, and a create response missing the field would not equal the row
the very next read returns.

* fix(gateway-spend): the rollups refuse a status they could only answer with zero

The summaries query drops in-flight rows with a fixed predicate, and the
shared filter contract let a caller narrow to exactly that status. The
conjunction is provably empty, so /spend-summaries?status=admitted returned
a confident zero while /spend-events?status=admitted returned the envelopes.
A reconciliation that checksums against that zero decides the books agree.

The two reads now publish two status enums derived from one tuple: the
events read takes the whole vocabulary, the rollups take it minus the
in-flight status, by exclusion so a status added to one reaches both. The
boundary refuses with the ordinary validation_error naming the status field,
the published parameter says why and where the answer lives, and the
repository throws rather than serving the empty intersection to any other
caller. The TypeScript SDK narrows the same way through Exclude, and the
docs, the route descriptions and the Python facade name the one exception
instead of promising a symmetry that no longer holds.

Bound by a cross-route regression test through both routes: the same query
serves admitted envelopes on the events read and is refused on the rollups,
while a completed status still rolls up.

* perf(gateway-spend): give the rollup walk a deadline

LIMIT bounds the rows a page returns, not the rows it aggregates: under
FINAL the whole group set is rebuilt on every page before all but one page
of it is discarded, so a 30-day window bucketed by hour and grouped on two
dimensions is paid for once per page of the walk.

max_execution_time caps that. Memory is left to the server profile and the
shared query defaults, which already enforce a per-query ceiling and spill
GROUP BY state to disk; a client-side max_memory_usage could only raise the
ceiling, which is why queryDefaults.ts omits it deliberately.

* docs(sdk): move the organization-key note out of the breaking PR

release-please applies a whole commit message to every component the change
touched, so a PR carrying a breaking marker and touching two components
proposes two majors. This one breaks the gateway API, at the root component,
and its only other files were two SDK doc comments, which would have taken
the published TypeScript SDK to 2.0.0 for a paragraph of prose.

The note lands on its own, in a PR with nothing breaking in it. See
dev/docs/RELEASES.md, "A breaking change belongs to one component".

* docs(gateway-spend): fold the two group_by doc blocks into one

* chore(release): pin typescript-sdk at 1.5.0 under the inherited breaking marker

This stacked branch still carries the base PR breaking-marked commit, and the squash body would major every component this PR touches. The TypeScript SDK changes here are additive filters, a minor.

Release-As: 1.5.0

* chore(release): pin python-sdk at 1.2.1 under the inherited breaking marker

Nothing is pending for the Python SDK since 1.2.0, and this PR adds tests and docstring constraints only, a patch.

Release-As: 1.2.1

* chore(release): pin langwatch at 3.11.0 under the inherited breaking marker

The open release PR already proposes 3.11.0 and this PR app surface is a feature, so the pin holds the version the release was already going to be.

Release-As: 3.11.0

* fix(spend): close the review round, from the cursor arity to the zone the store cannot load

A cursor whose arity does not match the grouping was ignored, so the
walk served page one again under a fresh cursor with nothing saying it
had reset, and a reconciliation folded those groups into its checksum
twice. Refused at the route now, naming what to do instead, with the
repository throwing behind it.

A fixed offset like +05:00 passed the timezone check because the runtime
builds a formatter for it; ClickHouse loads zones by name only and
answers "Cannot load time zone", so a value the caller chose came back
as an unknown error from somewhere they cannot see. Refused at the door.

`normalizeStatusFilter` restated the four lifecycle statuses instead of
deriving them, so a status added to the published vocabulary would pass
the boundary and then throw inside. `parseMetadataFilters` cut on an
absent colon, turning "tier" into key "tie": both now derive or refuse.

`from` and `to` published Zod's safe-integer bounds, documenting a
negative epoch as acceptable while the server refused it, and never said
whether the unit was seconds or milliseconds. They now publish
exclusiveMinimum 0 with the unit and an example.

The Python facade accepted a metadata key carrying a colon that the
other two clients refuse, and the CLI called N model-by-hour rows
"N models". Both corrected, both pinned.

Workflow: the two automation exemptions take the opening account as well
as the branch prefix, so a human branch named dependabot/... keeps its
gateway coverage, and `ready_for_review` joins the triggers so a draft
marked ready runs the matrix without waiting for another push.

Regenerated the OpenAPI document, the docs copy, llms-full and the
TypeScript client; a further run produces no diff.
dependabot Bot and others added 30 commits August 18, 2026 10:26
…es (#6888)

Bumps the langchain group with 3 updates in the /sdks/python directory: [langchain-core](https://github.com/langchain-ai/langchain), [langchain](https://github.com/langchain-ai/langchain) and [langchain-openai](https://github.com/langchain-ai/langchain).


Updates `langchain-core` from 1.4.8 to 1.5.3
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](langchain-ai/langchain@langchain-core==1.4.8...langchain-core==1.5.3)

Updates `langchain` from 1.3.11 to 1.3.14
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](langchain-ai/langchain@langchain==1.3.11...langchain==1.3.14)

Updates `langchain-openai` from 1.3.3 to 1.4.2
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](langchain-ai/langchain@langchain-openai==1.3.3...langchain-openai==1.4.2)

---
updated-dependencies:
- dependency-name: langchain
  dependency-version: 1.3.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: langchain
- dependency-name: langchain-core
  dependency-version: 1.5.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: langchain
- dependency-name: langchain-openai
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: langchain
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
… page (#7155)

* docs(agent-simulations): use the skill card on the connect-your-agent page

The page carried the connect-agent setup prompt as a raw text block,
written before the skill card pipeline could render outside docs/skills.
Manifest keys are now docs-root-relative paths, so any docs page can
carry a generated section, and the page shows the same accordion card as
the skills directory: install via CLI, slash command, copy full prompt,
download SKILL.md. The prompt content now comes from the compiled skill,
so the page can no longer drift from the released skill text.

* docs(agent-simulations): adopt the traceparent in middleware, not in the handler

The adoption snippet opened a span with the extracted context inside the
handler body. That pattern cannot cover a handler decorated with
langwatch.trace(): the decorator opens its root span before the body
runs, so the agent's spans land in a separate trace and the judge sees
nothing. Verified against the python SDK across tracing styles, with
regression tests in sdks/python (test_traceparent_adoption.py). The
skill and the page now put attach(propagate.extract(headers)) in a
middleware that runs before any tracing starts, which covers decorators,
context-manager traces, autotrack and plain OpenTelemetry spans.

* docs(agent-simulations): commit the regenerated skill card artifacts

* docs(agent-simulations): state the OpenTelemetry runtime prerequisite for the TypeScript middleware

The context.with and propagation.extract calls are no-ops without a
registered context manager and propagator. The skill and the page now
say so and name what registers them: the LangWatch SDK's
setupObservability() or the OpenTelemetry NodeSDK.
#7156)

* feat(cli): create the HTTP agent on the spot when agent dev finds none

Without --agent, the dev session already reuses the agent remembered for
the directory, auto-picks a lone HTTP agent, and offers a picker over
several. The remaining edge failed with instructions even in a terminal:
a project with no HTTP agents yet. An interactive session now offers a
name and registers the agent pointing at the local server, so the first
agent dev run needs no separate create step. Sessions without a terminal
keep the exact fail messages that name the command to run instead.

* fix(cli): refuse credentialed local URLs and harden the create prompt

Review fixes: resolveLocalUrl now rejects a --url carrying userinfo,
since the session stores that URL in the agent's platform config and
prints it; the offered agent name falls back to my-agent when the
current directory has no basename; the --agent help text states that
on-the-spot creation needs an interactive terminal; the resolve tests
restore the original isTTY descriptors instead of forcing false into
the shared worker process; and the directory-name default is now pinned
by the spec and asserted against the prompt call.

* test(cli): bind the my-agent fallback for a rootless directory name

The prompt assertion now mirrors the code's fallback expression, and a
new case stubs the working directory to / and expects the offered name
to be my-agent, so the fallback cannot regress unnoticed.
* test(python-sdk): pin traceparent adoption across tracing styles

LangWatch simulations send a W3C traceparent on every scenario call and
the judge fetches the agent's spans by that trace id. These tests pin
which extraction pattern makes each SDK tracing style join the caller's
trace: attach() before any tracing starts covers plain OpenTelemetry
spans, the langwatch.trace() context manager with nested spans, and
handlers decorated with @langwatch.trace(), whose root span opens before
the handler body runs. A with-block carrying the extracted context only
covers traces started inside it, and a decorated handler without early
extraction starts its own trace, which is why the docs put the
extraction in middleware.

* test(python-sdk): make traceparent adoption tests independent of suite order

The "no early extraction" test read the first exported span and asserted it
had no parent. That holds only when nothing is current when the test starts,
and the suite leaves an evaluation.loop_iteration span active, so the handler
opened its root under that span and the test failed in the full run while it
passed on its own. The handler now runs under an empty context, which is the
condition the scenario describes.

The with-block test also proves its boundary: a span opened before the block
keeps a different trace id, while the block's own span and the trace started
inside it carry the remote one.
…26 in the anthropic group across 1 directory (#6546)

chore(deps-dev): bump @anthropic-ai/claude-code

Bumps the anthropic group with 1 update in the / directory: [@anthropic-ai/claude-code](https://github.com/anthropics/claude-code).


Updates `@anthropic-ai/claude-code` from 2.1.202 to 2.1.226
- [Release notes](https://github.com/anthropics/claude-code/releases)
- [Changelog](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md)
- [Commits](anthropics/claude-code@v2.1.202...v2.1.226)

---
updated-dependencies:
- dependency-name: "@anthropic-ai/claude-code"
  dependency-version: 2.1.220
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: anthropic
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…es (#6789)

Bumps the types group with 2 updates in the / directory: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [@types/semver](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/semver).


Updates `@types/node` from 22.20.1 to 26.2.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/semver` from 7.7.1 to 7.8.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/semver)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: types
- dependency-name: "@types/semver"
  dependency-version: 7.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: types
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [pi-heif](https://github.com/bigcat88/pillow_heif) from 1.1.0 to 1.3.0.
- [Release notes](https://github.com/bigcat88/pillow_heif/releases)
- [Changelog](https://github.com/bigcat88/pillow_heif/blob/master/CHANGELOG.md)
- [Commits](bigcat88/pillow_heif@v1.1.0...v1.3.0)

---
updated-dependencies:
- dependency-name: pi-heif
  dependency-version: 1.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…group (#7136)

* fix(scim): stop a group PATCH that names no members from emptying the group

A SCIM `replace` operation that was not a `displayName` rename fell through
into the full-member-replace block, which read the absence of a member list as
`[]` and computed every current member as the set to remove. Replacing an
unrelated attribute — or renaming a group with the no-path form Entra ID
writes — revoked access for everyone in the group, with a 200 and a valid
ScimGroup body to show for it.

`extractMemberList` now returns `null` when the operation carries no member
list at all, and the replace block returns early on it. An explicit
`members: []` still clears the group; that is a legitimate request.

The suggested `return` after the no-path rename is deliberately not taken: a
no-path value object carrying both `displayName` and `members` is a single
operation that must do both, and returning early would drop the membership
half of it.

Fixes #7131

* test(scim): pin the bare-array PATCH value, where an array is an object but names no members

* fix(scim): a malformed member list no longer reads as an instruction to clear the group

Review findings on the previous commit. All of them land in the same few lines
of extractRequestedMemberIds and its call site, so they share a commit rather
than splitting one function rewrite across five.

Naming "members" was not enough on its own: `{"op":"replace","path":"members"}`
with no value, or a string where an array belongs, still reached
extractMemberIds, which returns [] for anything non-array. That [] read as a
written-out empty list and removed every member — the exact defect this branch
set out to close, one shape further in. Only an explicit null is now honoured as
a written-out empty list.

Also here:
- `if (!members)` is now `if (members === null)`. [] is falsy, and truthiness is
  the idiom that erases the absent-vs-empty distinction this whole change rests on.
- extractMemberList -> extractRequestedMemberIds. Both it and extractMemberIds
  return user ids; the difference is nullability, not list-vs-ids.
- A warn line when a replace matches no known attribute. Doing nothing is the
  safe answer but a silent one, and the old behaviour at least failed loudly. An
  IdP sending a shape we do not parse should be findable without an access-loss
  incident pointing at it.
- Tests for the three malformed shapes, for an explicit null, and for a
  no-member operation preceding a real member replace in one request — proving
  the early return ends the operation and not the loop over Operations.

Verified: the three malformed-shape tests fail against the previous commit and
pass here; ee/scim is 61/61 green.

* docs(scim): say what a malformed member list does, and mark the externalId gap as a gap

The supported-operations note now covers the malformed case, and the test that
uses externalId as its 'unrelated attribute' example says so is a gap (#7141)
rather than a decision it is pinning in.

* fix(scim): stop warning about a rename that was understood perfectly well

The warn added for unparseable group PATCH payloads fired on ordinary
renames too. A no-path `{"value":{"displayName":"..."}}` — the form Entra
ID writes — was applied, then fell through to a check that found no
member list and logged "matched no known attribute; leaving the group
unchanged". Both halves were false: the attribute was recognized and the
group was renamed. A warning on the common case is a warning nobody
reads, which costs the log the one job it was added for.

Membership answers are now three-way instead of two: a list, a malformed
member value, or nothing said about members at all. The last of those
only warns when the operation went entirely unrecognized, and a
malformed member list gets its own message, because "you named members
but did not give a list" is a payload worth fixing while "this was about
something else" is not.

Also corrects the docs on two points CodeRabbit was right about: an
explicit null clears a group, so "you have to say so with a list" was
wrong; and only the member replacement is left unapplied, so a combined
operation still renames.

* fix(scim): treat a member array with malformed entries as malformed, not empty

A non-empty array whose entries lack a string `value` key (e.g. `[{}]`)
was silently filtered to `[]` and treated as 'clear the group'. Now any
array where extracted ids don't match the input length returns malformed,
preserving the group's membership. Only `[]` and explicit `null` clear.

Also fixes the docs contradiction where `null` was included in the set of
non-list values that preserve membership, even though it clears membership.

* fix(scim): a member list nobody can read is not an empty member list

The same bug this PR exists to fix, surviving in a narrower shape.
`{"path":"members","value":[{"display":"Alice"}]}` is plainly an
attempt to name somebody, but no entry carries a `value`, so the id
filter reduced it to `[]` — which then read as a written-out empty list
and emptied the group. A list is only a list we can act on when every
entry in it can be read; the entries that fall out are precisely the
members that would be removed.

Only `[]` and an explicit `null` clear a group now. A partly-readable
list is malformed and leaves membership alone, warning like any other
malformed member value.

Docs updated to match, and to stop describing `null` as a non-list value
that preserves membership when it is the one non-list value that clears.

* fix(scim): a blank member id names nobody, so it cannot mean "these are the members"

`[{"value":""}]` is a well-formed member list by every check we had: the
entry is an object, it carries a string `value`, and the list is not
empty. It also names nobody, so every current member falls outside the
requested set and gets removed — a group emptied by a mapping expression
that returned blank.

Blank and whitespace-only ids are now malformed, alongside entries with
no `value` at all. An id that refers to somebody outside the
organization stays allowed: that is a membership question and it is
resolved later. This one is a shape problem, and only `[]` and an
explicit `null` may clear a group.
… + log error body (#7178)

* fix(governance): anthropic_admin cost_report 400 — default too recent + log error body

The cost_report puller always 400s because:

1. defaultStartingAt() returns 24h ago, but Anthropic's daily cost buckets
   have a ~1-day processing lag — no completed bucket exists, so the API
   returns 'Invalid date range: ending date must be after starting date'.
   Fix: cost reports now default to 3 days back at midnight UTC.

2. The HTTP error handler discards the response body. HTTP/2 has no
   statusText, so the log shows 'HTTP 400 ' with no explanation.
   Fix: read response.text() and include it in the thrown error.

3. Form hint said 'Empty = 24 hours back' — updated to reflect the
   report-specific defaults.

Closes #7177

* fix(governance): limit ingestion source detail events to 20

The event list on the per-source detail page fetched 50 rows, producing
an excessively long list.  Cap at 20 — the backend already respects the
limit parameter.

* fix(governance): extract error helpers to fix cognitive complexity + bound error body + clarify hint

* fix(governance): fix typecheck (duck-type Response) and formatting
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.2.0 to 12.3.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](python-pillow/Pillow@12.2.0...12.3.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…6524)

Bumps [setuptools](https://github.com/pypa/setuptools) from 80.9.0 to 83.0.0.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](pypa/setuptools@v80.9.0...v83.0.0)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-version: 83.0.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…6532)

Bumps [cryptography](https://github.com/pyca/cryptography) from 48.0.1 to 50.0.0.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](pyca/cryptography@48.0.1...50.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [pyasn1](https://github.com/pyasn1/pyasn1) from 0.6.3 to 0.6.4.
- [Release notes](https://github.com/pyasn1/pyasn1/releases)
- [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst)
- [Commits](pyasn1/pyasn1@v0.6.3...v0.6.4)

---
updated-dependencies:
- dependency-name: pyasn1
  dependency-version: 0.6.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
#6538)

Bumps [haystack-ai](https://github.com/deepset-ai/haystack) from 2.30.2 to 3.0.0.
- [Release notes](https://github.com/deepset-ai/haystack/releases)
- [Commits](deepset-ai/haystack@v2.30.2...v3.0.0)

---
updated-dependencies:
- dependency-name: haystack-ai
  dependency-version: 3.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.16.0 to 11.16.1.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.16.0...mermaid@11.16.1)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.16.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) from 8.1.0 to 8.3.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@8.3.0/packages/react-router)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 8.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…#6547)

Bumps the posthog group with 2 updates in the / directory: [posthog-js](https://github.com/PostHog/posthog-js) and [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node).


Updates `posthog-js` from 1.396.9 to 1.415.1
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.396.9...posthog-js@1.415.1)

Updates `posthog-node` from 5.39.4 to 5.48.1
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/commits/posthog-node@5.48.1/packages/node)

---
updated-dependencies:
- dependency-name: posthog-js
  dependency-version: 1.407.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: posthog
- dependency-name: posthog-node
  dependency-version: 5.46.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: posthog
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…6796)

Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.9.1 to 7.0.1.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](testing-library/jest-dom@v6.9.1...v7.0.1)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [commander](https://github.com/tj/commander.js) from 14.0.3 to 15.0.0.
- [Release notes](https://github.com/tj/commander.js/releases)
- [Changelog](https://github.com/tj/commander.js/blob/master/CHANGELOG.md)
- [Commits](tj/commander.js@v14.0.3...v15.0.0)

---
updated-dependencies:
- dependency-name: commander
  dependency-version: 15.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@react-email/components](https://github.com/resend/react-email/tree/HEAD/packages/components) from 0.0.42 to 1.0.12.
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/@react-email/components@1.0.12/packages/components/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/components@1.0.12/packages/components)

---
updated-dependencies:
- dependency-name: "@react-email/components"
  dependency-version: 1.0.12
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…updates (#7179)

Bumps the opentelemetry group with 8 updates in the /sdks/python directory:

| Package | From | To |
| --- | --- | --- |
| [openinference-instrumentation-haystack](https://github.com/Arize-ai/openinference) | `0.1.34` | `0.1.36` |
| [openinference-instrumentation-langchain](https://github.com/Arize-ai/openinference) | `0.1.67` | `0.1.70` |
| [openinference-instrumentation-openai](https://github.com/Arize-ai/openinference) | `0.1.52` | `0.1.54` |
| [opentelemetry-api](https://github.com/open-telemetry/opentelemetry-python) | `1.43.0` | `1.44.0` |
| [opentelemetry-instrumentation-crewai](https://github.com/traceloop/openllmetry) | `0.62.1` | `0.62.3` |
| [openinference-instrumentation-dspy](https://github.com/Arize-ai/openinference) | `0.1.37` | `0.1.39` |
| [opentelemetry-instrumentation-langchain](https://github.com/traceloop/openllmetry) | `0.62.1` | `0.62.3` |
| [openinference-instrumentation-litellm](https://github.com/Arize-ai/openinference) | `0.1.34` | `0.1.36` |



Updates `openinference-instrumentation-haystack` from 0.1.34 to 0.1.36
- [Release notes](https://github.com/Arize-ai/openinference/releases)
- [Commits](Arize-ai/openinference@python-openinference-instrumentation-haystack-v0.1.34...python-openinference-instrumentation-haystack-v0.1.36)

Updates `openinference-instrumentation-langchain` from 0.1.67 to 0.1.70
- [Release notes](https://github.com/Arize-ai/openinference/releases)
- [Commits](Arize-ai/openinference@python-openinference-instrumentation-langchain-v0.1.67...python-openinference-instrumentation-langchain-v0.1.70)

Updates `openinference-instrumentation-openai` from 0.1.52 to 0.1.54
- [Release notes](https://github.com/Arize-ai/openinference/releases)
- [Commits](Arize-ai/openinference@python-openinference-instrumentation-openai-v0.1.52...python-openinference-instrumentation-openai-v0.1.54)

Updates `openinference-semantic-conventions` from 0.1.30 to 0.1.32
- [Release notes](https://github.com/Arize-ai/openinference/releases)
- [Commits](Arize-ai/openinference@python-openinference-semantic-conventions-v0.1.30...python-openinference-semantic-conventions-v0.1.32)

Updates `opentelemetry-api` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-python/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python/blob/main/CHANGELOG.md)
- [Commits](open-telemetry/opentelemetry-python@v1.43.0...v1.44.0)

Updates `opentelemetry-exporter-otlp-proto-http` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-python/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python/blob/main/CHANGELOG.md)
- [Commits](open-telemetry/opentelemetry-python@v1.43.0...v1.44.0)

Updates `opentelemetry-sdk` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-python/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python/blob/main/CHANGELOG.md)
- [Commits](open-telemetry/opentelemetry-python@v1.43.0...v1.44.0)

Updates `opentelemetry-instrumentation-crewai` from 0.62.1 to 0.62.3
- [Release notes](https://github.com/traceloop/openllmetry/releases)
- [Changelog](https://github.com/traceloop/openllmetry/blob/main/CHANGELOG.md)
- [Commits](traceloop/openllmetry@0.62.1...0.62.3)

Updates `openinference-instrumentation-dspy` from 0.1.37 to 0.1.39
- [Release notes](https://github.com/Arize-ai/openinference/releases)
- [Commits](Arize-ai/openinference@python-openinference-instrumentation-dspy-v0.1.37...python-openinference-instrumentation-dspy-v0.1.39)

Updates `opentelemetry-instrumentation-fastapi` from 0.64b0 to 0.65b0
- [Release notes](https://github.com/open-telemetry/opentelemetry-python-contrib/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python-contrib/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-python-contrib/commits)

Updates `opentelemetry-instrumentation-langchain` from 0.62.1 to 0.62.3
- [Release notes](https://github.com/traceloop/openllmetry/releases)
- [Changelog](https://github.com/traceloop/openllmetry/blob/main/CHANGELOG.md)
- [Commits](traceloop/openllmetry@0.62.1...0.62.3)

Updates `openinference-instrumentation-litellm` from 0.1.34 to 0.1.36
- [Release notes](https://github.com/Arize-ai/openinference/releases)
- [Commits](Arize-ai/openinference@python-openinference-instrumentation-litellm-v0.1.34...python-openinference-instrumentation-litellm-v0.1.36)

---
updated-dependencies:
- dependency-name: openinference-instrumentation-dspy
  dependency-version: 0.1.39
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: opentelemetry
- dependency-name: openinference-instrumentation-haystack
  dependency-version: 0.1.36
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: opentelemetry
- dependency-name: openinference-instrumentation-langchain
  dependency-version: 0.1.70
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: opentelemetry
- dependency-name: openinference-instrumentation-litellm
  dependency-version: 0.1.36
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: opentelemetry
- dependency-name: openinference-instrumentation-openai
  dependency-version: 0.1.54
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: opentelemetry
- dependency-name: openinference-semantic-conventions
  dependency-version: 0.1.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: opentelemetry
- dependency-name: opentelemetry-api
  dependency-version: 1.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: opentelemetry
- dependency-name: opentelemetry-exporter-otlp-proto-http
  dependency-version: 1.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: opentelemetry
- dependency-name: opentelemetry-instrumentation-crewai
  dependency-version: 0.62.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: opentelemetry
- dependency-name: opentelemetry-instrumentation-fastapi
  dependency-version: 0.65b0
  dependency-type: direct:development
  dependency-group: opentelemetry
- dependency-name: opentelemetry-instrumentation-langchain
  dependency-version: 0.62.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: opentelemetry
- dependency-name: opentelemetry-sdk
  dependency-version: 1.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: opentelemetry
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…6802)

Bumps [pino-opentelemetry-transport](https://github.com/pinojs/pino-opentelemetry-transport) from 3.0.0 to 4.0.2.
- [Release notes](https://github.com/pinojs/pino-opentelemetry-transport/releases)
- [Changelog](https://github.com/pinojs/pino-opentelemetry-transport/blob/main/CHANGELOG.md)
- [Commits](pinojs/pino-opentelemetry-transport@v3.0.0...v4.0.2)

---
updated-dependencies:
- dependency-name: pino-opentelemetry-transport
  dependency-version: 4.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [nanoid](https://github.com/ai/nanoid) from 5.1.16 to 6.0.0.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](ai/nanoid@5.1.16...6.0.0)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 6.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@opentelemetry/core](https://github.com/open-telemetry/opentelemetry-js) from 2.9.0 to 2.10.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md)
- [Commits](open-telemetry/opentelemetry-js@v2.9.0...v2.10.0)

---
updated-dependencies:
- dependency-name: "@opentelemetry/core"
  dependency-version: 2.10.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 5.2.1 to 5.2.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](nodeca/js-yaml@5.2.1...5.2.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.2.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…2 PR 1) (#7143)

* docs(authz): stage C sign-offs close, and permissions are never called scopes

D2 and D3 were the only two open blockers on stage C, and both closed on
2026-08-17 in a way that removes the customer-visible change the ADR had
been carrying.

D2: no legacy-key sunset. Old keys keep working indefinitely, so C3 stops
being a deprecation and becomes a compatibility backfill — bindings mirror
what each key can do today, and a key the backfill missed mints its own on
first use. The permissions JSON becomes a dormant source rather than a
decision path, which retires the bypass branch without a deadline anyone
has to meet. AUTHZ_LEGACY_KEY_ENFORCE is retired unshipped.

D3: an empty custom role means deny. Measured against production first:
of 464 custom roles, zero are empty and zero carry only strings outside
the 126-permission registry, so the semantics change lands with nobody
behind it and needs no remediation path.

Also records the vocabulary ruling. traces:view reads like an OAuth scope
and on a key it even behaves like one, but scope is already the customer's
word for where — ScopeChipPicker offers organization, team and project on
every scoped-resource surface. Calling a permission a scope would put two
senses of the word in one settings page, so it is permission for the what
and scope for the where, in code, API docs and UI copy alike.

Two things found while checking rather than assuming: the plan pointed C2
at scim.service.ts, which no longer exists, and the shadow soak cannot
currently produce evidence — it logs nothing on agreement and logs its own
failures at debug while cloud runs at info, so a shadow throwing on every
call looks exactly like the silence we have. Both are written down where
the next person will meet them.

* docs(authz): the rollout becomes build-dark, cut over per org, contract at the end

Alex's restructure. The stages A-F still describe the work but stop being
the unit of rollout, because the thing we actually want is to run one of
our own organizations on the engine in production, end to end, before any
customer moves.

Three rules replace the staged fleet-wide soak. Everything ships dark
behind a per-organization gate. An organization then cuts over all at
once - one composite migration rewrites its data, proves parity, and flips
it - so a tenant is on the old flow or the new one and never half. Every
deletion waits for 100 percent, which is why the roleKey tighten, the four
quirk branches and rbac.ts itself all collect into a final contract phase
rather than riding the next stage's PR.

The fork replaces the codemod. Stage D was going to move ~380 call sites
onto .permission() before enforcement could flip, and it does not need to:
the shadow already found every decision point. Ten call sites across
rbac.ts and role-binding-resolver.ts carry every legacy permission
decision and each already calls authzShadowFor, so making them ask the
per-org gate which engine answers is a change inside two files. The
codemod becomes cosmetic cleanup once the direction is proven.

Only the doctrine and the shape are rewritten here. The per-stage sections
still read as the old sequence, and finishing them waits on the open
question of whether the migration itself is event-sourced - that decision
changes what the migration section says.

* docs(authz): the final plan - a grants ledger, born clean, one writer, two views

The delivery plan is rewritten end to end around the decisions of
2026-08-17 and is now the consolidated reference: fifteen dated decisions,
the final data structure, the event vocabulary with occurredAt carrying
business time so 'member since 2024' survives the migration, the two
dispatch disciplines, a four-plus-one PR map, the pre-flight fact table
with the two production measurements already banked, and the testing
doctrine - real containers, one scenario per failure mode, revocation
proven with Redis stopped.

The mandate that shaped the last pass: this is the final rewrite of this
flow, so correct beats expedient wherever they diverge. Two compromises
died for it. Grant and Role are born as new clean tables in PR 1 rather
than evolving RoleBinding in place and renaming it live at the end. And
nothing ever dual-writes - the ledger is the single writer with two
projections, the future-shaped tables and a legacy-shaped compat view,
until the contract PR deletes the latter. That opens the door to the
genesis import: every existing binding, role and floor fact becomes
events backdated to their real creation time, so the whole grants state
is replayable from the beginning of history.

* docs(authz): review resolutions land in the plan - subscriber audit log, SCIM reconciler, the epoch stays until contract

* docs(adr): ADR-092 storage and rollout become the grants ledger, in place; ADR-007 gains the immediate-discipline amendment

* spec(rbac): ledger scenarios land - immediate revocation, collectives, cutover facts, SCIM reconciler; stale stage pointers swept

* feat(authz): the grants ledger core - pure reducer, deterministic ksuid grant identity

* docs(authz): dispatch is the queue, best-effort FIFO - revocation is instant as enforcement, and the two-discipline idea leaves the record

* feat(ops): the operator rollback lands on the migrations page - finalized to rolled_back, with handled refusals

* docs(authz): the Redis-loss breaker becomes ADR-007's shared amendment, and the identity programme's doors are named

* feat(authz): the ledger pipeline takes shape - event schemas registered, Grant/Role/cursor/cutover tables born, decisions 22-23 recorded

* feat(authz): the ledger pipeline goes live dark - commands, reducer-backed projection, the two-headed store, and the org tenancy guard

* feat(authz): shadow mode proves itself in the logs

Enabling shadow (or moving the sample rate) is announced once; every
comparison logs its outcome - info on agreement, warn on disagreement -
and a failed comparison is a warning, never a debug line invisible at
production log level. The info line is the denominator: silence now
means "not comparing", never "no news". Three bound scenarios in
in-place-authz-migration.feature; plan updated - reads stay on the
legacy tables through PR 1, the collector repoint is PR 3's.

* feat(authz): the backfill speaks ledger - attachGrants batches, a waited projection, and the parity proof as a fact

TeamUserBackfillMigration no longer writes role bindings: it emits
batched attachGrants commands (source backfill-b, business time = the
legacy row's own createdAt, deterministic commandIds), waits for the
projection's compat head to land every expected row, and records a
clean sweep as migration_parity_proved before finalizing. The epoch
bump stays exactly as before (decision 19). Held organizations record
no proof fact. The app binds the emitter to the authz_grants pipeline
lazily, and a disabled event-sourcing stack parks the organization
with an honest report instead of letting DisabledPipeline swallow the
send. in-place-authz-migration.feature scenarios pass unchanged.

* feat(authz): runner lifecycle transitions become ledger facts, and the breaker becomes doctrine

Every SystemMigrationTenantState transition is witnessed as a
migration_tenant_state_changed event through a decorating repository -
the synchronous write stays the finalized latch (it never waits on a
queue), the witness is best-effort, and the projection re-applies
transitions under a monotonic updatedAt guard so a lagging fold can
never regress a newer direct write. Replay onto an empty table rebuilds
it; onto a live one it converges to no-ops. The runner package stays
generic - witnessing is this migration's behaviour.

The Redis-loss breaker is simplified to doctrine (ADR-007 amendment
rewritten): when Redis is down, appends still land, revocation-class
operations enforce synchronously - the enforceGrantRevocation seam
ships here, its caller arrives with PR 2 - and everything else waits.
No inline processing, and no replays run during an outage.

* test(authz): the replay proof - one stream, byte-identical rows, every run

PR 1's definition of done: the pure chain from legacy rows to projected
rows (emission mapping, attachGrants handler, wire schemas, fold, row
mappings) runs twice and produces byte-identical Grant and compat rows,
equivalent to the imperative M1 writer's output - equivalent, not
byte-identical, because ids are deterministic now and a custom binding's
role column normalizes to CUSTOM, neither of which can change a
decision. Applying a stream twice folds to the same state as once.

* fix(authz): command classes must carry the aggregate-id static the queue binds

* fix(authz): give the projection test helper the union the wire schema already knows

* fix(authz): the package root is browser-reached, so grant identity moves behind a server-only subpath

* refactor(authz): the grants projection rides the typed fold helper like every other pipeline

* fix(authz): the registry's grants store dep speaks the fold state type

* fix(authz): close the review's real holes - a permanent park, an ungated rollback, and two lying contracts

Three of these change behaviour:

- A legacy TeamUser row of CUSTOM with no custom role parked its
  organization forever. `roleKeyForTeamRole` is lossy (CUSTOM and VIEWER
  both map to `viewer`), so the row projected back as VIEWER and
  `bindingKey` - comparing legacy rows against projected ones on the raw
  enum - never recognised it. Both sides now normalise through the same
  mapping. Regression test included, and it fails without the fix.
- `rollBackSystemMigrationTenant` pinned a whole organization onto the
  legacy authorization path with neither of the guards `deleteBlob`
  requires. `requireBlobStoreWriteAuth` generalises to
  `requireDestructiveOpsAuth` and now covers all three sites.
- The cutover fields are last-write-wins over one row, unlike the grant
  heads, so the reducer takes a monotonic guard (`changedAtMs`,
  persisted) and stale cutover facts no longer take an organization back
  off the engine.

And two contracts that promised what the code did not do: `wireToFact`
said "an event type outside this aggregate throws" with no default arm on
any of its three switches, and the command/event schemas accepted
`tenantId != organizationId`, a null-id `user` principal, and resource
terms at a non-RESOURCE scope.

The grant-identity finding was a false positive worth keeping: the hash
pre-image already joins on a real 0x1f byte, but written as a literal it
renders invisibly and reads as `join("")`. Now an escape, with a test
that pins the boundary case.

Also: rollback guidance and a both-or-neither CHECK on the new migration,
the principal-scan index made partial while the table is empty, ADR-092's
Redis paragraph reconciled with ADR-007's amendment (folds pause; there is
no in-memory processor), a bounded wait on the ledger witness, memoized
and typed command senders, the glossary table un-split, and coverage for
`writeMigrationStates`.

* fix(authz): the projection store test builds the fold state, stamps and all

`store()` is typed against `AuthzGrantsFoldState` - the reducer's state
plus the base class's three bookkeeping stamps - not the bare
`GrantsLedgerState` the new helper was returning. Caught by
`typecheck:tests`, which runs a different project to the one the language
server answers from.

* fix(authz): the resource tier is a scope AND a principal set, enforced at both ends

Follow-up review pass. Two invariants were half-stated:

- `Grant_resource_terms_check` only enforced that token and permission
  travel together, so a tokenized TEAM row still passed - a share
  credential for a scope no share link may reach. Tied to `scopeType`
  now, so neither that nor a termless RESOURCE row is representable.
- The `anyone` and `project` principals are resource-tier only (the
  delivery plan's `Grant` shape says so outright). `anyone` names no
  subject, so an `anyone` grant at ORGANIZATION scope is a standing
  public grant over the whole tenant, held by nobody and revocable by no
  principal. Refused at the wire boundary on both the event schema and
  `attachGrantEntrySchema`.

`wireInvariants.unit.test.ts` covers all of it - 12 cases over the
identity, principal and resource-shape rules, which had no direct tests
before.

* chore(authz): the scoped typecheck project was scratch, not a deliverable

tsconfig.tmpcheck.json rode in on f3a4634. It is a local scoped
project for typechecking the three touched test trees, referenced by
nothing and ignored by nothing - a working file, not a change.

* fix(authz): a grant id is content, never the ambient environment

deriveGrantId passed getEnvironment() into the KSUID, so the id it
returned carried whatever ENVIRONMENT the deriving process happened to
read. Two processes that disagree - a worker and a web pod, a backfill
and the fold that replays it - derived two different ids for one legacy
row, and every "idempotent by deterministic id" upsert in the projection
stopped converging.

The environment is now a pinned constant ("prod", the library's own
default, i.e. no prefix), so every byte of the id comes from the
arguments. The determinism test drives setEnvironment() across two
values and proves the id does not move.

* fix(authz): the shadow match line drops to debug

Agreement is the expected outcome of a comparison that runs on the
permission-check hot path, and it was logging at info - one line per
sampled check, which is production traffic multiplied by the sample
rate. Mismatches stay at warn and the rate announcement stays at info,
so the proof that shadow mode is running still costs one line per
change rather than one per check.

* fix(authz): the ledger's tables carry business time and a legacy role, and keep the scan index partial

Three schema corrections, all inside PR 1's own undeployed migration.

SystemMigrationTenantState gains occurredAt. The projection guarded
folded transitions on updatedAt, which is wall clock: a replay onto an
empty table created its first row with updatedAt = now, every LATER fact
in the same stream then failed the lte guard, skipDuplicates dropped it,
and the table converged to the OLDEST status in the stream. Business
time, stamped by whichever writer moved the row, orders a folded
transition against a directly written one honestly.

Grant gains legacyRole - the role column an IMPORTED custom binding
carried, which roleKey cannot express and the legacy resolver still
reads. Persisting it is what lets load() reconstruct the fact rather
than a lossy copy of it.

And the principal-scan @@index comes OFF the Grant model. The index is
partial in the database (WHERE scopeType <> 'RESOURCE'); declaring it in
schema.prisma means the next prisma migrate dev drops the partiality and
rebuilds it over every share-link row - the one thing the WHERE clause
exists to avoid. RoleBinding's six partial uniques are handled the same
way: a comment pointing at the migration, no declaration.

* fix(authz): an imported custom binding keeps the role the resolver falls back to

The compat mapping turned any custom:<id> roleKey into role = CUSTOM,
discarding the legacy row's real role. That is not cosmetic: when the
custom role's permission list is EMPTY, matchers.ts falls through to
builtinRoleGrants(roleKeyForTeamRole(role)), and CUSTOM resolves to
viewer where the legacy row would have answered admin. The retired M1
writer copied row.role verbatim; the ledger silently downgraded it.

The legacy role now rides the FACT - legacyRole on GrantFact, on the
attachGrants entry, on the grant_attached event, and through wireToFact
- so it comes from event content and never from a read at fold time.
The backfill sets it from the legacy row it is importing; ledger-born
custom grants have no legacy row and stay CUSTOM.

replayDeterminism's third block now expects the legacy role on every
row, and its description says what it actually does: it pins the compat
row's shape against a hand-written expectation, it does not execute the
retired writer to compare against.

* fix(authz): store() writes a delta, guards every legacy-owned write, and never prunes on an unreconstructed fold

store() re-upserted EVERY grant's compat RoleBinding on EVERY event for
the organization, and re-wrote migration-state rows it had merely read.
Each of those cost something real:

- a compat row the legacy write paths had deleted or edited (they still
  own those rows until PR 2) was resurrected or reverted by the next
  unrelated event;
- a member removed and re-added at the same role gets a fresh grant id,
  and re-upserting the OLD id's row violates
  RoleBinding_user_builtin_role_scope_key - a P2002 thrown before
  writeCursor, which parks that organization's projection lane forever;
- every event bumped updatedAt on all of the tenant's
  SystemMigrationTenantState rows, which both lies to the ops page's
  "last transitioned" and made the witness ordering unsound.

store() now reads what storage holds once, under the queue's per-org
lock, and writes only what the fold changed - fingerprinted per grant,
per role, per (status, occurredAt). Identical state stored twice issues
no head or compat write at all.

Four more holes closed in the same pass:

- P2002/P2003 on a compat binding or a compat custom role are warned and
  stepped over, never thrown. Neither is retryable, and a throw parks
  the whole lane over one conflicting row.
- Roles are upserted BEFORE the grant compat rows that reference them
  and deleted AFTER the grants, so a batch carrying role_deleted with
  grants still on custom:<id> cannot raise a foreign-key violation. Per
  fact, the compat row is written before its head and deleted after it,
  which makes the head the fact's commit marker.
- A departed CustomRole is only deleted when NO RoleBinding and no
  TeamUser still references it. Those are SetNull relations and imported
  roles keep their legacy CustomRole id, so deleting one nulled
  customRoleId on legacy rows whose role column stays CUSTOM - and
  CUSTOM with no custom role is viewer. A projection may not author a
  permission downgrade.
- When there is no cursor row, load() returned null and the executor
  folded from EMPTY - so the state in hand describes this batch alone.
  Pruning against it would have deleted every other grant and compat row
  the organization owns. Departed-fact pruning is now skipped entirely
  in that case.

The migration-state guard moves onto the occurredAt column both writers
stamp, so a replay converges to the newest status instead of the oldest,
and the direct write in PrismaSystemMigrationStateRepository stamps it
too - otherwise a replayed fact from last year could overwrite the latch
a live transition had just set.

The store's write paths had only the migration guard under test. They
now cover idempotent convergence, the delta write order, departed-fact
deletion, both conflict guards, the unreconstructed-fold guard and the
CustomRole reference guard.
…7183)

* refactor(lwql): rename governed-sql server core to lwql

Moves src/server/analytics/governed-sql -> lwql and renames the vocabulary:
GOVERNED_SQL_* -> LWQL_*, GovernedSql* -> LangWatchQL*, governedSql* -> lwql*.

Postgres read-model views are renamed governed_* -> lwql_* so they match the
'viewname LIKE lwql\_%' grant predicate in langwatch-saas#1060. An unprefixed
view receives no grants and fails silently, so the prefix is load-bearing.

Refs #7128

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

* refactor(lwql): rename analytics-query feature surface to LangWatchQL

Components, hooks and logic under src/features/analytics-query, plus the 26
data-testid values (governed-* -> lwql-*) that the browser and integration
lanes select on. Error codes in features/errors move governed_sql_* -> lwql_*.

Refs #7128

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

* refactor(lwql): rename API, tRPC and wiring vocabulary to lwql

Route handlers under src/app/api/analytics-sql (directory name deliberately
unchanged — see below), tRPC routers, feature-flag registry
(release_governed_sql_workbench -> release_lwql_workbench, value stays false),
and the project/org call sites.

The public REST surface is unchanged: api-router.ts imports the Hono app
explicitly rather than by filesystem routing, and app.ts hardcodes
basePath /api/v1/projects, so the on-disk directory name is not a URL.

Refs #7128

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

* refactor(lwql)!: rename Project.governedSqlKey to lwqlKey

Adds migration 20260818120000_rename_governed_sql_key_to_lwql_key, which DROPS
and RECREATES the column rather than renaming it. The column ships behind a
flag that is false everywhere, so no key has been used and none is worth
preserving. The shipped migration 20260814120003 is left byte-identical.

BREAKING CHANGE: migration and code must ship in the same release. Prisma
enumerates every Project column on ordinary project fetches, so a pod on the
previous image against this schema raises 42703 application-wide.

Refs #7128

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

* docs(lwql): rename governed-sql vocabulary in specs, ADRs and config

Renames the three specs/analytics feature files and five ADRs, and updates
biome.jsonc path overrides and .env.example. Released CHANGELOG entries are
deliberately left untouched — the shipped release really was called
governed-sql.

Refs #7128

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

* style(lwql): restore biome import ordering after the rename

Renaming the identifiers changed their alphabetical position, so the
organizeImports assist failed on 59 files. Applying biome's safe fix
also reflows a handful of assertions that now fit on one line, since
the new identifiers are shorter.

Also replaces the "lwql-SQL" phrasing the rename produced with
"LangWatchQL" — the conventions name that spelling for prose.

* test(lwql): pin the approved-view prefix the reader's grants match

The infrastructure bootstrap grants the reader role SELECT by matching
relation names against `viewname LIKE 'lwql\_%'`, so an approved view named
outside that prefix is created and granted to nobody — and reads as empty
rather than failing. Every other suite takes the view names from the same
catalog that would carry the mistake, so none of them can disagree with it.

Writes the prefix literally and asserts it over the catalog's mappings, the
names the grants are built from, and the relation each emitted CREATE VIEW
actually creates, with a negative control on a pre-rename name.

Refs #7128

* chore(lwql): remap the test-duration manifest onto the renamed paths

The manifest is keyed by test-file path and the rename orphaned twenty of
them, so every renamed file would have been sharded at the default weight
until a full refresh happened to land. Remapped from the rename map itself
and re-sorted, matching what the reporter writes.

Refs #7128

* fix(lwql): restore the alphabetical order of the renamed error codes

The rename left the seven codes at the position governed_* used to sort
into, but lwql_* sorts after lite_member_viewer_only. APP_ERROR_CODES
pins its own order so a hand edit lands next to the right neighbours.

Refs #7128

* fix(lwql): regenerate the API document and repin the renamed fixture digests

Two under-reaches from the rename sweep, both invisible to a grep for the
old vocabulary in the sources.

The checked-in OpenAPI document is a build artifact the sweep excluded from
rewriting, so it kept describing "Analytics / Governed SQL" while the
`describeRoute` annotations it is generated from had already been renamed.
Regenerated with `pnpm run task generateOpenAPISpec`. The change is confined
to tags, summaries and descriptions: the path set (167) and the operationId
set (254) hash identical before and after, so the public contract is
untouched.

The three pinned sha256 constants were digests of fixture secrets the sweep
had renamed, so each one still pinned the pre-rename preimage. Recomputed
with coreutils rather than through `lwqlTenantCapability`, which keeps them
the independent oracle their comments describe. The digest of the fixture
`apiKey` is unchanged and stays as it was, and the capability the service
actually sent matched the renamed lwqlKey rather than the apiKey, so the
decoupling control these tests exist to hold was never broken.

Refs #7128

* fix(lwql): sync the docs mirror of the regenerated API specification

`check_generated_files` regenerates the docs site's artifacts and fails on
any drift. Regenerating the canonical specification in the previous commit
left the mirror under `docs/` describing the old vocabulary, so the gate
went red on a diff of exactly the lines that commit changed.

Ran all three commands the gate names — `docs/scripts/sync-prompts.sh`,
`node llms.txt.cjs`, and `make sync-api-spec && make generate-api-reference`
— rather than only the one that was stale, so the working tree is checked
against the gate's whole procedure. Only the API mirror moved: the prompt
accordions, `llms.txt` and `docs.json` all regenerated byte-identical.

The mirror is now byte-identical to the canonical specification, and the
16 changed lines are the same tag and description renames.

`docs-complete` failed only as a rollup of this job.

Refs #7128

* refactor(lwql): carry the rename into the generated SDK client's docstrings

`api-client.ts` is generated from the specification this branch just
regenerated, so renaming the specification left the published SDK
describing the old vocabulary in nine JSDoc strings. No CI gate
regenerates this file, so it would have shipped stale rather than red.

Applied the rename by deriving the substitutions from the specification
commit's own diff rather than hand-writing them, then verified each of the
nine changed lines is byte-identical to the corresponding string in the
canonical specification.

Deliberately did not run a full `generate:openapi-types`: that also pulls
in drift this branch did not cause (SCIM descriptions, a `timeWindow`
widening, a new 422), which was already reverted once as out of scope and
is reported separately. Every changed line here sits inside a comment, so
the emitted types are untouched.

Refs #7128

* Revert "refactor(lwql): carry the rename into the generated SDK client's docstrings"

This reverts commit 10ba25b.

The `breaking change stays in one component` gate went red on it, and the
gate is right. This branch carries a breaking marker on 5f3d5fd, the
Prisma column drop, which breaks the `langwatch` component only.
release-please splits commits by path but applies the whole commit message
to every component a commit touched, so keeping nine JSDoc lines in
`sdks/typescript` here would attach that break to `typescript-sdk` and
major it for a comment-only edit.

The gate offers a `multi-component-major` label as the escape hatch. That
would be a false declaration: nothing about the SDK change is breaking.
Splitting is the correct branch of its advice.

The docstrings are still stale against the specification, and no CI gate
regenerates that file, so it stays stale silently rather than red. It
belongs in its own non-breaking pull request.

Refs #7128

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

⤵️ pull merge-conflict Resolve conflicts manually

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants