[pull] main from langwatch:main - #279
Open
pull[bot] wants to merge 3096 commits into
Open
Conversation
rogeriochaves
force-pushed
the
main
branch
5 times, most recently
from
January 21, 2026 01:15
1e7b14c to
2209258
Compare
…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.
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 : )