Skip to content

release: v3.4.0 — develop → stage - #2405

Merged
dk-rocketride merged 35 commits into
stagefrom
release/v3.4.0-develop-to-stage
Sep 23, 2026
Merged

dk-rocketride merged 35 commits into
stagefrom
release/v3.4.0-develop-to-stage

Conversation

@kwit75

@kwit75 kwit75 commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

Replaces #2403, which conflicted, and a conflicting PR runs no CI here.

This branch is develop (9d07cef5, with the #2400 version bump and changelog cut) plus a merge of stage, and the conflicts are resolved toward develop. Its tree is identical to develop: zero files differ. So this PR moves stage onto exactly what develop holds.

The four conflicts, and why develop's side is correct

That the resolved tree equals develop confirms what the patch-id check found earlier: all 19 stage-only commits are already in develop by content.

Versions after this lands on stage

server 3.4.0, vscode 1.4.0, client-typescript 1.4.0, client-python 1.4.0, client-mcp 1.5.0.

This publishes nothing. Publishing happens at #2404 (stage → main), which stays a draft with its approval checklist until this merges and its diff is re-read.

Watch the first CI run. CI has never run on this promotion before.

🤖 Generated with Claude Code

https://claude.ai/code/session_015nTVr6jfSFYm1GppxbjghP

dylan-savage and others added 30 commits September 16, 2026 12:32
…2298)

* docs(agents): restore the user task battery beside the agent docs it grades

The 256-item acceptance sheet for the ROCKETRIDE_* context docs was dropped
by the docs revamp (#2139). Bring it back at docs/agents/task-battery.md,
outside context/ so the /client/docs bundle does not ship it, point its
scoring-pass note at the post-revamp paths, and describe it in the README.

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

* docs: surface the Drizzle ORM driver in the agent bundle and SDK references

The Drizzle-over-pipes driver (#1908) updated the public guide pages but
not the agent-facing context, the pipeline-skill doc map, or the SDK
reference tables, which still steered readers to the deprecated
Sequelize binding.

- ROCKETRIDE_typescript_API.md: add a `drizzle()` subsection, mark
  `sequelize()` deprecated, document `rowMode` on `query()`
- ROCKETRIDE_DOC_MAP.md: link the Drizzle page
- typescript/reference.md: add `drizzle` row and types, flag Sequelize
  as deprecated, add `rowMode` to the `query` signature
- python/reference.md: point the cross-reference at Drizzle

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

* docs: address review on the Drizzle agent-doc surfacing

- Point the agent bundle at the absolute docs-site URL for the Drizzle
  guide; the bundle ships into user workspaces where repo paths do not
  resolve
- `debug` is an npm package, not a Node built-in: name `util` and the
  `tty`/`util` pulled in via `debug` as the actual browser-bundle blockers
  (agent doc and public Sequelize guide)
- Note the `unknown[][]` return shape under `rowMode: 'array'` and that
  `rowMode` is TypeScript-only

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
After a Python lane handler returns normally, the engine also forwards the handler's input on the same lane. Only `preventDefault()` stops that. Four nodes returned normally, so downstream received the raw input next to the node's own output.

- `context_optimizer`: both exits of `writeQuestions` now end in `return self.preventDefault()`. Downstream no longer gets the untrimmed original next to the trimmed question.
- `summarization`: `writeText` and `writeTable` collect the text, then suppress the forward. Only the summary from `closing()` goes on.
- `tool_guild`: `writeText`, `writeQuestions` and `writeDocuments` collect, then suppress. The docstring claiming "overriding suppresses raw passthrough" is corrected.
- `tool_n8n`: the same for `writeText`, `writeQuestions`, `writeDocuments`, and every `writeImage` / `writeAudio` / `writeVideo` chunk.
- Missing docstrings added in the four node files.

Tests:

- `test_lane_forward_once_nodes.py`: delivery-count cases for every changed handler.
- New `summarization/test_summarization.py`: the node had no unit tests.
- `context_optimizer/test_all.py` and `test_tool_n8n.py`: direct handler calls now require the `PreventDefault` exception.
…rs (#1682)

* fix(memory_persistent): prevent double-forward of questions and answers

* test(memory_persistent): assert preventDefault is called on explicit forwards

* docs(memory_persistent): note preventDefault() unreachability and test stub caveat

* fix(memory_persistent): cover all six exits with delivery-count tests

- Every exit of `writeQuestions` and `writeAnswers` now ends in `return self.preventDefault()`. The separate `return` after `preventDefault()` could never run, because `preventDefault()` raises.
- `test_node.py`: two tests for the invalid `session_id` paths, which no test reached before. Removing `preventDefault()` from either path now fails a test.
- `test_lane_forward_once_nodes.py`: six delivery-count cases, one per exit (no store, invalid session, normal path), under the engine's forward rule. On the normal path, only the enriched or stored copy reaches downstream, never the original.
- Docstrings in `IInstance.py` now document their arguments.

---------

Co-authored-by: Alexandru Sclearuc <asclearuc@gmail.com>
#2306)

`stop()` queues its own `None` sentinel when it starts waiting before the audio callback has reached the decoder's. The callback stops at the first `None`, so the second one stays in the queue. `test_begin_write_end_still_drains_normally` still required an empty queue, so it failed whenever `stop()` won that race, which happens on a loaded CI runner.

- The drain test now accepts `[]` or `[None]` as the leftover. Unplayed audio still fails it.
- New `test_stop_waiting_before_the_callback_leaves_only_its_own_sentinel` forces the `stop()`-first order. It requires exactly `[None]` to be left, all audio played, and `stop()` returned.
- The shared setup moved into `_player_with_queued_audio()` and `_start_simulated_hardware()`.
Mint a release-bot GitHub App token and pass it to create-pull-request so the weekly Sync LLM Models PR triggers CI on its own instead of waiting for a manual approve. Falls back to github.token if the token can't be minted. release-bot was granted Pull requests: write on the org install. Fixes #2311.
…work (#2299)

* feat(ai): break the profiler report down by thread

stop() now also captures the session per yappi context, next to the flat
capture and before clear_stats(). threads() lists them busiest first with
their counts, and report_tree() takes an optional thread id; without one it
behaves as before. DAP: new rrext_cprofile_threads, plus an optional thread
argument on rrext_cprofile_report_tree, in the engine subprocess and the
task-server proxy.

Two yappi traps shape the capture:
- get_func_stats(ctx_id=...) tests the id for truthiness, so thread 0 would
  silently get every thread's functions. The filter goes in as a dict.
- The default context name is the thread's class, so workers read 'Thread'
  and engine workers '_DummyThread'. start() installs a callback reading
  Thread.name from threading._active; current_thread() would deadlock when
  the profiled call is _active_limbo_lock.acquire() (yappi #48). yappi keeps
  the first name it gets, so the thread ident is reported as well.

Cost on a parse -> preprocessor pipeline over 12 and 36 documents: the
per-thread copy holds 2.1-3.5 MB against 0.5 MB flat (13-25 threads), and
the capture under _lock grows from ~117 ms to ~250-275 ms.

* perf(ai): cache _relativize_path

stop() calls it under _lock for every entry and callee of each capture:
about 5,900 calls per stop on a parse pipeline over 12 documents, since the
per-thread capture repeats the flat one's paths, over a few hundred
distinct paths. The result depends only on the path, so cache it.

On the same pipeline the capture under _lock drops from ~250-275 ms to
~160-170 ms, against ~117 ms before the per-thread capture existed. Memory
drops only ~10%: every entry of a path now shares one string, but the
per-entry dicts are most of the size.

* fix(ai): stop the call tree from pruning away real work

report_tree() dropped every node whose own time fell under min_pct, and with
it the whole subtree. Two kinds of node read light while real work hangs
below them: frames still running at stop() (thread entry points, executor
worker loops) read ~0 because yappi books time on return, and the event
loop step that resumes a coroutine is short while the coroutine is booked
its whole lifetime. On a parse pipeline this emptied the merged tree, the
event-loop thread and every executor thread started mid-session; threads
that predated the session survived only because their zero-time frames sat
at depths 0 and 1, which are never pruned.

A node is now pruned only when nothing reachable below it reaches the
threshold either. The threshold was also inflated: total_time summed ttot,
counting nested calls again (a 5 s session summed to 412 s). It is now the
time the threads ran, as yappi measures it per thread - the selected one,
or all summed - falling back to the sum of self times without thread data.

Same session after the fix: project code shows in every view at the
default threshold, and trees stay at 4-168 nodes, built in under 60 ms.

* feat(client): list profiled threads and fetch one thread's tree

Python: cprofile_threads() and a thread parameter on cprofile_report_tree().
TypeScript: cprofileThreads() and a trailing thread parameter on
cprofileReportTree(), with CProfileThreadInfo / CProfileThreadsResponse.
Thread 0 is a real id, so both clients test for absence, not truthiness.

The shell re-exports the SDK's types, so the two new ones are new shell-api
names and shell:check fails until they are frozen: this freezes shell-api
v4. Its delta over v3 is exactly these types and the two method signatures.

The contract test runs data through a task's pipeline and checks that no
thread carries yappi's class-based names, that one thread's tree totals that
thread's calls, and that an unknown id is refused, which proves the
argument reached the engine.

test-ui's API sweep and the SDK references list the new method; the Python
reference also claimed include_system defaults to False, which it never did.

* feat(profiler-ui): pick a thread to visualise

A Thread dropdown heads the viz controls: all threads (the old view) or one,
labelled with name, thread id and time, since names repeat. The views get
the same tree shape either way. The list is refetched with the report, a
selection missing from the new list falls back to all threads, and a server
without rrext_cprofile_threads just hides the dropdown. Start clears the
list, since ids are per session.

Bumps the app to 1.3.0: an OSS server reseeds a built-in app only when its
apps.json version changes, so an upgraded server would keep serving the
old profiler otherwise.

* fix(profiler-ui): prune the charts by subtree weight

The client-side cutoff compared a child's own cumtime with its parent's,
so a zero-time frame the server now keeps was dropped again at the root,
taking the work under it along. It now compares the largest cumtime in
each subtree, and runs before the depth limit so that weight covers the
whole subtree.

* fix(profiler-ui): clear the two type errors tsc reports

initConnectionStore took the shell's settings as Record<string, string>,
but they are Record<string, SettingValue>, and a port setting may well be
a number; the seeded host and port are now coerced to strings. The
sunburst filtered on x0/x1 before casting to HierarchyRectangularNode,
which the flame graph already does first. Neither changes behaviour: the
app build does not type-check, which is how both went unnoticed.

* fix: CodeRabbit
…, engine URI, tools) (#2315)

* fix(mcp): refuse query-string credentials on /mcp and never fall back to the shared client for authenticated callers

AuthMiddleware falls back to `?auth=` when no Authorization header is sent,
but auth.authorize() only read the header. `POST /mcp/?auth=<key>` therefore
passed the middleware, skipped every /mcp check (tk_/pk_/cd_ refusal, JWT
signature, issuer, audience) and ran tools on the server's shared engine
client.

- authorize() now refuses any request carrying an `auth` query parameter
  (parsed as Starlette parses query_params), per the MCP authorization spec.
- Defence in depth: a request the middleware authenticated
  (scope['state']['account']) that carries no header credential is refused
  instead of served by the shared client. The credential-less dev bypass
  (public route, no account) is unchanged.

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

* fix(mcp): reserve /mcp for the MCP API — move the shell page to /mcp-server and route bare /mcp to the handler

The shell registered a public GET page at /mcp. The MCP endpoint is a
Starlette Mount, which only matches /mcp/..., so the page won the bare
path: POST /mcp answered 405 with no WWW-Authenticate challenge (spec
clients POST to the advertised resource .../mcp and never saw the OAuth
challenge), and AuthMiddleware treated every /mcp request as public.
add_route's duplicate check never saw the clash because the Mount goes
straight onto the router.

- shell: the MCP marketing page moves to /mcp-server (still unlisted).
- mcp: an exact, method-agnostic Route serves bare /mcp alongside the
  Mount, so /mcp and /mcp/ reach handle_mcp identically (no 405/307).
- mcp: initModule refuses to start if any route already claims /mcp or
  /mcp/*, or a public pattern matches the endpoint.
- mcp: MCP_DEV_NO_AUTH now opens exactly /mcp and /mcp/ (loopback only).

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

* feat(mcp): default the engine URI to the public MCP origin, or the local address on loopback binds

ROCKETRIDE_URI had no default, so on staging/prod every MCP tool call
raised "Missing engine URI". The URI is now resolved once in initModule
and injected into the config every engine client is built from, and it
also drives the widget CSP origin and the upload/dropper links:

1. explicit: config rocketride_uri / env ROCKETRIDE_URI, used as-is
2. loopback default: ws://127.0.0.1:<port> (ws://[::1]:<port>) so a
   laptop engine never silently calls RocketRide's cloud
3. public default: the MCP_RESOURCE_IDENTIFIER origin, https->wss /
   http->ws (default wss://api.rocketride.ai)

One startup log line names the resolved URI (userinfo/query stripped)
and the rule that chose it. ROCKETRIDE_AUTH/APIKEY handling is unchanged.

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

* fix(mcp): only offer send_files on loopback-bound engines — it reads paths on the engine host

The SDK's send_files checks and opens each caller-supplied path on the
machine running the MCP module. On a deployed engine that is our server, so
any authenticated caller could probe file existence ("File not found") and
upload server files into their own pipeline.

initModule now decides local_engine = is_loopback_bind(bind_host) once and
passes it to build_mcp_server -> ToolRegistry(local_engine=...). Tools
registered local_engine_only (send_files) are neither listed nor dispatched
on a deployed engine: a call by name gets an Unavailable refusal that ignores
the client and arguments. The registry defaults to the deployed surface.
Loopback binds are unchanged.

Also corrects the send_files description (engine-host paths, not
store-relative), drops the stale "or filepath" from run_pipeline, and
updates the MCP docs and agent skill contract.

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

* fix(mcp): send no SDK env from deployed engines so caller secrets keep precedence

WsEngineClient built RocketRideClient(uri, auth) with no env, so the SDK
loaded os.environ plus ./.env and use() forwarded every ROCKETRIDE_* var as
the run's env. The engine applies caller env last, over org/team/user
secrets, so on a deployed engine the server's own ROCKETRIDE_* vars
overrode a customer's same-named secrets for MCP-started runs.

initModule injects local_engine (the same loopback decision that gates
send_files) into config alongside the resolved URI; make_engine_client
builds the client with env={} unless it is set. Loopback binds keep the SDK
default. Explicit uri/auth are unaffected.

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

* fix(mcp): rewrite deploy tools against the current SDK deploy API

The MCP deploy tools (#1880) were written against a Python SDK deploy API
that deploy-2 (#1764, 0dfdc8b) had already removed. In production every
call failed: deploy_add passed a `schedule` kwarg add() no longer takes,
deploy_status/deploy_update called methods that no longer exist,
deploy_remove omitted the now-required team_id, and deploy_list counted
the keys of the {rows,total,page,pageSize} envelope. The suite stayed
green because FakeEngine and the seam tests mirrored the old API.

The seam (WsEngineClient) is now one keyword-argument passthrough per
DeployApi method, and the tools are thin wrappers in the
teams-as-environments model:

- deploy_add -> add (pipeline, comment, deploy_to)
- deploy_list -> list (team_id, paging, search, filters, sort); returns
  deployments + count/total/page/pageSize
- deploy_status -> get (project_id, team_id)
- deploy_versions -> versions (project_id, paging)
- deploy_to_team -> deploy (project_id, version, team_id)
- deploy_set_schedule -> set_schedule (project_id, source_id, schedule,
  team_id, ttl)
- deploy_enable / deploy_disable / deploy_remove -> enable / disable /
  remove (project_id, team_id)

deploy_update is gone; its operations are deploy_add + deploy_to_team and
deploy_set_schedule. The rocketride://pipelines resource returns the
unwrapped envelope. Tool count: 33 (32 on a deployed engine).

Drift guard: test_engine.py calls every deploy_* seam method against a
recording fake and binds each recorded call to the real
rocketride.deploy.DeployApi signature (method must exist, arguments must
bind, every seam argument must arrive under the same name). It fails on
the old seam code. A second test pins the EngineClient protocol and the
conftest FakeEngineClient to the seam's deploy signatures.

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

* fix(task_http): don't re-send engine env as caller env from task_Execute on deployed engines

task_Execute built RocketRideClient(uri, auth) with no env, so the SDK
snapshotted os.environ plus ./.env and use() forwarded every ROCKETRIDE_*
var as the run's caller env. The engine applies caller env last, over the
org/team/user secrets from get_merged_env, so on a deployed engine the
server's own ROCKETRIDE_* vars overrode a user's same-named secrets for
runs started via POST /task.

Apply the rule MCP already uses (7670865): the client is built with
env={} unless the server's configured bind host is loopback
(ai.modules.mcp.auth.is_loopback_bind, falling back to the same
CONST_DEFAULT_WEB_HOST WebServer binds with). Loopback binds keep the SDK
default so a local developer's .env still applies. The MCP import is
deferred so loading task_http doesn't pull in the MCP package.

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

* fix(mcp): close the credential-less auth hole and harden the MCP boundary (#2315)

Addresses the four review findings on PR #2315.

1. Log the resolved bind mode at startup. `is_loopback_bind` judges the
   CONFIGURED host and `CONST_DEFAULT_WEB_HOST` is 'localhost', so a
   deployment that never sets `host` silently runs as a local engine.
   `is_loopback_bind` is unchanged (as the reviewer asked); instead the first
   lines of the log now state the mode, the host it was judged from, and what
   that grants (dev-bypass eligibility, `send_files`, caller-env forwarding),
   with a WARNING when the bind is loopback only by fallback.

2. Refuse credential-less MCP requests. A public route pattern matching an
   MCP descendant (`/mcp/{path}`) makes AuthMiddleware skip the request while
   the Mount still forwards it to `handle_mcp`, where `authorize()` used to
   return None and run tools on the shared, server-identity engine client.
   Both ends are fixed: `authorize()` now refuses a request with no credential
   unless the loopback dev bypass is genuinely active, and the startup guard
   probes representative descendant paths through `is_public_route`, not just
   `/mcp` and `/mcp/`. The bypass rule itself lives in one place --
   `auth.dev_bypass_active` -- used by both `initModule` and `authorize`.

3. Require encrypted engine transport on a non-loopback bind. `handle_mcp`
   hands the caller's credential to `WsEngineClient`, which sends it in the
   first DAP `auth` message, so a `ws://`/`http://` engine URI (explicit or
   derived from the MCP resource identifier) now fails boot with a message
   naming the value and the variable it came from. Loopback binds keep
   cleartext; the https staging/prod defaults are unaffected.

4. Reconcile MCP_TOOL_CONTRACT.md with the registrations. The tables listed
   ~23 of the 33 registered tools: `resolve_config` and `scaffold_node` were
   undocumented and grouped capability rows hid individual tools. Every tool
   now has its own row derived from its real schema and result, and the stale
   `origin/develop @ eb67dde` pin is replaced with what is accurate now.

Test-first: 27 new tests fail against the previous implementation. Three
existing tests encoded behaviour findings 2 and 3 deliberately change and were
updated; the WebServer-backed route tests now clear ROCKETRIDE_URI /
MCP_RESOURCE_IDENTIFIER after construction so a developer's dist/server/.env
no longer decides what they resolve.

717 passed, 0 skipped (was 683).

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

* fix(mcp): key the startup guards on patterns and targets, not probes and binds (#2315)

Three follow-ups to the review findings on PR #2315.

1. The startup guard now inspects the registered public PATTERN STRINGS, not
   just probe literals. Public entries are compiled with `compile_path`, so a
   converter-typed pattern (`/mcp/{id:int}`, `/mcp/{x:uuid}`) narrows the regex
   to values no fixed probe satisfies -- the guard missed it while
   AuthMiddleware still skipped auth for every `/mcp/<digits>` the Mount
   forwards. `_registered_public_patterns` reads `_public_paths` (real
   WebServer) and `public` (test double), the same pair the dev bypass already
   writes to, and degrades to nothing when neither is present.
   `_claims_mcp_pattern` normalises (whitespace, trailing slashes) and refuses
   only a whole-segment match, so `/mcp`, `/mcp/` and `/mcp/{...}` trip while
   `/mcpx` and `/mcp-server` do not. Message shape and per-claimant naming are
   unchanged.

2. The cleartext refusal now keys on the engine URI's TARGET HOST rather than
   the bind. A credential sent to `ws://127.0.0.1:5565` never reaches a wire
   anyone can tap, and the bind-keyed rule was a deployment hazard:
   `WebServer.__init__` loads `dist/server/.env`, `.env.template` ships
   `ROCKETRIDE_URI=http://localhost:5565`, and `docker/Dockerfile.engine`
   copies `dist/server/` into the image, so a pod carrying that file would have
   failed to boot. `auth.is_loopback_bind` judges a configured bind string
   against exact literals and does not fit a URI netloc (userinfo, port, IPv6
   brackets, the whole 127.0.0.0/8 range), so a small `_targets_loopback_host`
   helper was added next to the rule instead of bending it; it still reuses
   `auth.LOOPBACK_HOSTS` for the by-name allowlist. Non-loopback targets fail
   boot as before, with the same redaction.

3. `MCP_TOOL_CONTRACT.md`'s `scaffold_node` row described only
   `local_nodes/<name>/...`, so a contract-following client omitted the
   `local_nodes/__init__.py` package marker `_scaffold_node` also returns and
   the engine could not import `local_nodes.<name>`. The row now names the
   marker and says to write every returned path; `docs/public/mcp/http/tools.md`
   carried the same omission and is corrected too. The engine-module doc now
   states the target-keyed transport rule.

Test-first for 1 and 2: 46 new tests fail against the previous implementation.
No existing test encoded the old bind-keyed rule (every refusal case already
used a non-loopback target), so none needed updating.

763 passed, 0 skipped (was 717).

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

* fix(mcp): refuse a cleartext engine URI on every bind, not just a public one (#2315)

Closes the gap the previous commit left in `_resolve_engine_uri`. That commit
moved the cleartext refusal onto the engine's TARGET host
(`_targets_loopback_host`), but the explicit branch still guarded the call with
the BIND:

    if explicit:
        if not loopback:
            _refuse_cleartext_engine_uri(...)

so a loopback-bound MCP server with `ROCKETRIDE_URI=ws://engine.remote:5565`
skipped the check entirely and sent the CALLER's credential over the network in
the clear -- `handle_mcp` binds it to the per-request engine client and
`WsEngineClient` puts it in the first DAP `auth` frame. The bind says nothing
about whether the wire is exposed; only the target does.

`_refuse_cleartext_engine_uri` now runs for every explicit URI, on any bind,
with the same `rocketride_uri` / `ROCKETRIDE_URI` source naming and the same
redaction. Loopback TARGETS stay allowed inside the helper, so the shipped
`dist/server/.env` (`ROCKETRIDE_URI=http://localhost:5565`) still boots. The
local `loopback` variable is gone: `auth.is_loopback_bind` is now read inline
where it genuinely selects the DEFAULT URI, so no bind-keyed condition is left
anywhere near the encryption rule. The `loopback default` branch resolves
`ws://127.0.0.1:<port>`, a loopback target, and the `public default` branch
already called the helper.

Docs and docstrings that stated the old bind-keyed rule now say the check keys
on the target host: `_resolve_engine_uri`, `initModule`,
`_refuse_cleartext_engine_uri`, and `docs/development/engine/mcp-module.md`.
`docs/public/mcp/http/self-hosting.md` never stated the rule at all even though
it fails an operator's boot, so its guard list now carries it.

Test-first: 8 new cases fail against the previous implementation (explicit
`ws://` and `http://` remote targets on `127.0.0.1` / `localhost` / `::1`
binds, from env and from config, redaction included), plus explicit `wss://`
accepted on any bind and the loopback-target cases extended over loopback
binds. Two existing tests used an explicit remote `ws://` URI incidentally --
one for dropper-link origins, one for `engine_origin` threading -- and now use
`wss://`; neither was asserting the transport rule.

794 passed, 0 skipped (was 763).

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…l sends (#2346)

* fix(ai): accept the single-component validation payload the node panel sends

The node config panel validates one component on save and sends
`{version, component}` — the `IComponentValidatePayload` shape the shell
contract declares this endpoint accepts (shell/src/types/project.ts:308). The
engine validates pipelines and requires `components`, so every save answered
"'pipeline.components' must be an array", whatever the node.

The component now travels as a one-item `components` list, built before the
source is inferred so `resolve_implied_source` sees it. A config that already
carries `components` is untouched.

Reachable since #1573: until then `rrext_validate` failed on every call with
"'pipeline' is missing or invalid", so this payload never reached the
validator. The panel has sent this shape since #1843 (7 Aug).

Removing the four lines fails the two new tests, and the engine itself answers
the reported message:

    validatePipeline({'pipeline': {'version': 1, 'component': c}})    -> "'pipeline.components' must be an array"
    validatePipeline({'pipeline': {'version': 1, 'components': [c]}}) -> 0 errors

* docs(clients,ai): document the single-component validate payload

The endpoint now accepts {version, component} as well as a full pipeline, which
is a public-contract change: both SDK references say so, and on_rrext_validate's
Args: block does too — the inline comment explained it, but the docstring is
what a reader hits first.

Also states that a payload carrying both keys keeps 'components' and drops the
stray 'component', so the next reader does not have to derive that it is
deliberate.
Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>
Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>
Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>
* fix(search-hybrid): isolate fallback identities

Closes #2322

* test(search-hybrid): cover empty-ID fallback

Addresses CodeRabbit review on #2329

---------

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>
* fix(notion): accept search pagination cursors

* test(notion): cover empty search cursors

---------

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>
…sh_schema tool (#2283)

* fix(ai,nodes): surface database error text from execute and add refresh_schema tool

Two gaps in the shared database node base, both scoped to stay out of the
way of the pull requests already open against this file.

_executeRawQuery caught every SQLAlchemyError, logged it, and returned
None, so execute() answered with the same 'check server logs for details'
string whether the statement had a typo, named a missing table, or was
refused for permissions. Its except clause now raises with the driver's own
message through IGlobal._format_db_error. Only that clause changes:
execute()'s `if result is None` branch becomes unreachable but is left in
place, with a comment saying so, because an open PR is rewriting that
region. Session-bound execute already re-raised the real exception, so the
two paths finally agree, and the max_execute_rows rollback is untouched.

IGlobal.db_schema is assigned exactly once, in beginGlobal, so get_schema
serves a task-start snapshot for the life of the task and a table created
through execute stays invisible until the pipeline restarts. The new
refresh_schema tool re-reflects and replaces it, returning the get_schema
shape plus a refreshed_at UTC timestamp so a caller can show how current
the schema it holds is. Reflection and publication run under a module-level
lock: concurrent callers would otherwise repeat the same full table walk
and race to publish the result. The tool is additive and lives entirely in
db_instance_base.py, since another open PR is adding to db_global_base.py.
It also fixes the natural-language path, which describes that same dict to
the LLM. get_schema's nested table formatter is lifted to a shared private
method so both tools render one shape.

Tests are a new module rather than an addition to test_db_base.py, which
two open PRs are both appending to. Eleven cases over an in-memory SQLite
engine cover the driver text reaching the caller, the gate still refusing
first, re-reflection seeing created and dropped tables, the timestamp
format, and four concurrent refreshes all returning.

Tool tables in the four node READMEs that inherit this surface list
refresh_schema and say in one sentence what get_schema's snapshot does not
show. services.json declares no tool list, so nothing to mirror there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd

* test(ai): give the execute-session IGlobal stub a real _format_db_error

execute()'s stateless failure path now formats the driver error through
IGlobal, but this module builds IGlobal as a SimpleNamespace carrying only
the four fields the transaction tests needed. Nothing failed, because no
test here drove a bad statement through that path — the next one to try
would have died with AttributeError instead of the RuntimeError it was
asserting.

The stub borrows the real DatabaseGlobalBase implementation rather than a
lambda, so the message a test sees is the message a caller gets. A test for
the stateless failure path comes with it, so the fixture stays honest:
removing the one line makes it fail with AttributeError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd

* refactor(ai): shrink the refresh_schema diff in db_instance_base

No behaviour change; this only narrows what the previous commit touched in
a file three open pull requests are editing.

get_schema's body is back to exactly what develop has, docstring aside: the
nested _format_table closure returns, and the _format_table static method
and _schemaPayload helper it had been lifted into are gone. Sharing them
was tidier, but it rewrote a method none of the open PRs needs to change,
and a few duplicated formatting lines cost less than that. That hunk is now
five added docstring lines instead of a 31-line rewrite.

refresh_schema moves to the end of the tool section, below dialect, so the
whole tool arrives as one appended block past the execute body PR #1908
rewrites rather than as an insertion ahead of it. It formats its own tables
inline, and the lock is now _REFLECT_LOCK.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd

* docs(nodes): complete the agent tool contract in the db READMEs

Two review findings on the READMEs this PR touched, both in the section
the node README schema calls the agent-facing contract and holds to the
highest accuracy bar.

db_postgres listed four tool functions in the summary and in "As a tool",
then a separate table of three transaction tools — seven of the nine
@tool_function methods an agent actually sees on DatabaseInstanceBase.
`execute` appeared only as prose inside the transaction paragraph and
`dialect` only as the QuestionType.DIALECT lane behaviour, so an agent
author reading the inventory would not know either exists as a tool. The
count is now nine in both places, `dialect` joins the open table, and
`execute` joins what was the Transactions table, renamed "Raw SQL and
transactions" because a statement without a session_id is neither. That
table's gate sentence also said requests are "silently dropped" when
allow_execute is off; the four tools raise (db_instance_base.py:301,
339, 371), and it is the questions lane that logs and drops — the two
behaviours are now stated apart. The `execute` row records the driver
message and the rollback-on-overflow that this PR's own change produced,
rather than implying a truncated result set.

rocketride_sql named refresh_schema in its tool table but left its
contract out of the prose beneath, where that README already documents
every other non-obvious argument and return shape. It takes no arguments
(input_schema declares no properties) and returns {database, tables}
exactly as get_schema does, plus a refreshed_at UTC ISO-8601 timestamp
from datetime.now(timezone.utc).isoformat().

Docs only; no behaviour change. Both nodes still pass
scripts/validate-node-readme.py, and so does the whole corpus under
./builder docs:validate.

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

* fix(ai): keep SQL statements and bind parameters out of execute error text

`_format_db_error` only produced a clean `Error <code>: <msg>` when the
driver put an integer first in `orig.args` — the pymysql and
clickhouse-driver shape. psycopg2 and sqlite3 put a string there, so both
fell through to `str(exc)`, which for a SQLAlchemy StatementError is the
full repr: the executed statement in `[SQL: ...]` and the values bound
into it in `[parameters: ...]`.

That string is no longer log-only. `_executeRawQuery` re-raises it as a
RuntimeError that the `execute` tool returns to its caller, and #2282
wires a user-facing Refresh/Run UI onto exactly that path, so a failed
INSERT would hand the caller back the row it tried to write.

Unwrap each driver shape explicitly and keep only the primary message:
the numeric-code branch as before; psycopg2's `diag.message_primary`
(its `str()` also carries a `LINE n:` echo of the statement, which
psycopg2 has already interpolated the bind values into); and `args[0]`
for sqlite3 and the generic DBAPI shape. Every branch then runs through
`_strip_statement_detail`, which truncates at the first `[SQL:`,
`[parameters:`, `LINE n:`, `DETAIL:`/`HINT:`/`CONTEXT:` or sqlalche.me
marker, so a driver shape not enumerated here still cannot leak the
tail. DETAIL is dropped along with the rest because PostgreSQL uses it
to restate the offending key values.

The helper is module-level rather than a method so `_format_db_error`
keeps working when tests bind it onto a stub IGlobal.

Tests cover the real sqlite3 driver end-to-end through `execute`, plus
the psycopg2 (with and without `diag`), sqlite3 and pymysql shapes and a
bare StatementError repr.
`test_format_db_error_falls_back_to_str_when_args_not_int_first` pinned
the old leaking fallback, so it is replaced by an assertion that the
driver message — not `str(exc)` — is what comes back.

`_strip_statement_detail`'s degenerate case gets the same treatment. A
message that is nothing but detail markers left no primary sentence, and
the old fallback returned the first line — which on that input is the
`[SQL: ...]` echo the function exists to remove. It now returns a
neutral constant, so there is no input on which the helper hands back the
statement.

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

* fix(ai): refresh the answers-lane column map in refresh_schema

`refresh_schema` replaced `IGlobal.db_schema`, the database-wide
reflection the natural-language path describes to the LLM, and stopped
there. `IGlobal.schema` — the configured table's column map that
`_insertData` iterates to build every answers-lane INSERT — is a second
start-up snapshot, and it was left untouched, rebuilt only lazily when
falsy.

So the node was current on one path and stale on the other: after
running `ALTER TABLE ... ADD COLUMN` and calling the refresh tool, the
LLM path saw the new column while the insert lane kept writing the
start-up column set and silently dropped it. The four db node READMEs
document both the insert lane and "re-reads the schema from the
database", so the documented contract overstated the code.

Invalidate the map inside the same reflection lock. Emptying it re-arms
the lazy rebuild already at the top of `_insertData`, which reflects
through the same `_getTableSchema` call `beginGlobal` uses, so the next
insert sees what a freshly started node would. Emptying rather than
re-reflecting here keeps the call cheap for a node with no answers lane
wired, and makes the write a single atomic rebind: a concurrent
`_insertData` either reads the old map or finds it falsy and rebuilds,
never a half-built dict.

Invalidating the map is only safe once `_insertData` stops binding a
generated primary key, so that is fixed in the same commit.
`_createTableFromData` curates `IGlobal.schema` down to the data columns
for an auto-created table, because the `id` it prepends is generated by
the database; a map built by reflection carries `id` instead. Since
`_insertData` binds NULL for any schema column the rows do not supply,
swapping the curated map for a reflected one would have started binding
`id=None` on every later insert — tolerated by SQLite's rowid alias, a
not-null violation against the `id SERIAL NOT NULL` Postgres renders for
the same column. `_insertData` now drops primary-key columns that no row
in the batch supplies, decided once per batch so every mapping handed to
executemany keeps an identical key set. The two maps therefore produce
the same INSERT and refreshing cannot change answers-lane behaviour
mid-task. It also fixes the same NULL bind on the path that always
reflected: a table that already existed when `beginGlobal` ran.

Tests cover the refreshed insert picking up a new column, the
pre-existing stale behaviour it fixes, a dropped configured table leaving
a falsy map, and — by asserting on the compiled INSERT rather than on
what SQLite tolerates — that an auto-created table's insert still carries
the data columns only after a refresh, that a reflected primary key the
rows do not supply is omitted, that one they do supply is still bound,
and that a primary-key-only table falls back to binding it.

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

* docs(nodes): describe the raw-SQL gate the db nodes actually have

The `Raw SQL and transactions` section said `allow_execute` is "the same
gate as `QuestionType.EXECUTE`" and contrasted the tools with "the
`questions` lane, which logs and drops the request". No SQL database
node implements that branch: `DatabaseInstanceBase.writeQuestions` does
not look at `question.type` at all, and `QuestionType` appears in
db_instance_base.py only at the import and in one unrelated
`Question(type=QuestionType.QUESTION, ...)` construction. The
dispatch-on-type behaviour belongs to the graph node
(`graph_instance_base.py` handles `DIALECT` and `EXECUTE`), and the
wording was carried across without being checked against this file.

Correct it wherever it appears in prose:

- db_postgres: the "Two special question types are handled on the
  questions lane" bullets are removed — the lane has one behaviour,
  translate-then-execute, and `dialect` / `execute` are the tool
  functions that reach those behaviours. The gate sentence now says
  what `allow_execute` gates (the four raw-SQL tools) and that it does
  not change the questions lane, which never runs raw SQL.
- rocketride_sql: same two claims, in the config and Notes sections.

Also make the refresh_schema wording match the code now that the tool
invalidates the answers-lane column map as well as the database-wide
schema, so "re-reads the schema from the database" is true end to end
rather than only for the LLM path.

The same claim lived in the generated config tables, whose source of
truth is the `allow_execute` field description in each node's
services*.json. Those five descriptions now name the four tool functions
the flag actually gates, and the generated regions are regenerated from
them, so db_postgres/README.md no longer contradicts itself between its
prose and its parameter table. Only the SQL nodes are touched: the graph
nodes do dispatch on `QuestionType.EXECUTE`, so their copy is correct as
it stands.

Two smaller instances of the same transposition: the new db_postgres
sentence named `db_neo4j`, a retired path that redirects to
`graph_neo4j`, and db_clickhouse/IInstance.py said its dialect string is
"surfaced via QuestionType.DIALECT" when only the `dialect` tool reaches
it.

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

* fix(ai): apply the same execute error contract to the session path

A raw statement run with a session_id went through
TransactionRegistry.execute, which has no try/except, and the existing
`except Exception` arm in execute() re-raised the SQLAlchemy exception
unchanged. str() of that exception ends in `[SQL: ...]` and
`[parameters: ...]`, so the same tool behind the same allow_execute gate
returned the statement and its bound values to the caller purely because
a session id was passed, while the sessionless half stripped them.

Add a `SQLAlchemyError` arm above the existing `except Exception` (the
max-rows RuntimeError is not a SQLAlchemyError, so its wording and its
rollback are untouched): log the full exception, release the session, and
raise `RuntimeError('SQL execution failed: ' + _format_db_error(e))`.
The fix lives in execute() rather than tx_registry because the registry
has no IGlobal and is shared with other callers.

Session release moves into _releaseFailedSession, shared by both arms.
A rollback that itself fails is logged and swallowed: previously an
exception raised inside the cleanup replaced the error the caller was
being told about (a monkeypatched rollback surfaced as 'boom').

Also drop the dead `result is None` branch in execute() and correct
_executeRawQuery's annotation to `-> dict`: the method has returned the
shaped dict or raised since it started carrying the driver's message, so
the branch could only ever have produced the opaque 'check server logs
for details' string this work exists to replace, and the annotation
contradicted the comment above the branch. execute() is its only caller.

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

* refactor(ai): share one table formatter between get_schema and refresh_schema

refresh_schema rebuilt get_schema's nested _format_table line for line,
with `result` renamed to `entry`. Both tools promise callers the same
per-table shape -- their tool descriptions say so, and an LLM picks
between them on that sentence -- but the equality test compared the two
copies to each other, so it would have stayed green while a later change
added a key (an index list, a column comment, a nullable flag) to one
copy only.

Lift the formatter to a module-level _format_table and call it from
both; get_schema's body loses the nested definition, refresh_schema
loses the loop. The wire format is byte-identical, so the existing shape
tests pass untouched. The docstring paragraph calling the duplication
deliberate goes with it.

test_refresh_schema_matches_get_schema_shape now reflects a table with
both a primary key and a foreign key, so the two optional entries are
actually exercised on both sides, and _format_table gains a direct unit
test for the optional-key rules an equality check cannot see.

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

* fix(ai): make the lazy schema rebuild safe for concurrent inserts

refresh_schema empties IGlobal.schema to re-arm the rebuild at the top of
_insertData, which is what makes this reachable at runtime: a second
insert can now arrive while the first is reflecting.

_getTableSchema published `self.schema = {}` and then grew it column by
column, and _insertData checked, rebuilt and then took a reference to
that same dict without a lock. A concurrent insert could therefore see
the empty map (falling into the "no schema cached" branch, which binds
the item's raw keys and skips the generated-key handling), or a truthy
but half-built one (silently dropping columns the caller had supplied),
or a dict changing size while the per-row loop iterated it. A reflection
that failed midway left the half-built map published for the next insert
to believe.

_getTableSchema now builds into a local dict and publishes it with one
assignment, so a failure leaves the previous map untouched. _insertData
holds _REFLECT_LOCK across the falsy check, the rebuild and a snapshot
copy, and builds the batch from the snapshot; the lock is released before
the Table reflection and before the INSERT, neither of which reads the
map. The comment above refresh_schema's invalidation no longer claims
the single rebind makes the rebuild atomic.

Tests hold a rebuild open inside the real column walk rather than
simulating the window: without the lock the second insert stored NULL in
a column it had supplied, an overlapping refresh left a one-column map,
and a failed reflection published a partial one.

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

* fix(ai): decide generated primary keys per row, not per batch

The answers lane worked out which primary-key columns to leave unbound
once per batch, from the union of every row's keys. One row carrying
`id` therefore put `id` into every mapping, and the rows that omitted it
bound NULL into a key the database was supposed to generate. SQLite
tolerates that for a rowid alias; Postgres rejects the whole batch for an
`id SERIAL NOT NULL` column. The sole-primary-key table had the same
problem by construction: the `omit_columns = set()` fallback bound NULL
because omitting everything would have left an empty mapping.

Which primary keys the database generates is now read from the reflected
Table -- `autoincrement_column` (SQLAlchemy's own resolution, covering a
reflected `autoincrement=True` and the `'auto'` rule that recognises
SQLite's rowid alias), plus any primary-key column with a server_default,
an Identity, or an explicit autoincrement -- rather than assumed of every
primary key. Per row: a generated key the row omits is left out of that
row's mapping, a supplied value is bound, and an explicitly supplied None
is bound as NULL, so the three cases stay distinguishable. A primary key
the database does NOT generate and the row does not carry is a ValueError
naming the table, the columns and the row position, raised before the
transaction opens.

Rows are then executed as contiguous runs sharing a key set, all inside
one engine.begin(): contiguous rather than gathered, because generated
ids follow insertion order and regrouping would hand back ids in an order
the caller's rows never had, and one transaction because a batch must
stay all-or-nothing as a single executemany was. A run whose mapping is
empty gets a values-less `insert(table)` per row, which SQLAlchemy
renders as `DEFAULT VALUES` on PostgreSQL and SQLite and `() VALUES ()`
on MySQL.

test_insert_binds_the_primary_key_when_it_is_the_only_column pinned the
old fallback and is replaced by the default-values test, plus a
compile-only check of all three dialect renderings (no live PostgreSQL or
MySQL in this suite).

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

* test(ai): pin the caller-facing error policy for Postgres and MySQL

CodeRabbit's security finding asks for a generic message or a stable
category instead of the driver's own words. That is not adopted: the
maintainer's review endorses the primary-message contract precisely
because "no such column: foo" is what lets a caller fix their statement.
What was missing was the policy written down and held in place by tests.

_format_db_error's docstring now states the residual it accepts: a
driver's primary sentence may quote a value from the statement being
reported (Postgres `invalid input syntax for type integer: "abc"`, MySQL
`Duplicate entry 'x' for key 'users.email'`). That value was submitted by
the caller now reading the message, who also holds allow_execute; values
belonging to other rows appear only in DETAIL / HINT / CONTEXT, which are
stripped. Blanking quoted tokens generically is rejected because MySQL
quotes identifiers with single quotes too, so the redaction would delete
"which column" from the message that most needs it.

The tests build real DBAPIError wrappers via DBAPIError.instance, so
str(exc) genuinely carries the `[SQL: ...]` / `[parameters: ...]` tail and
the assertions bite: a Postgres duplicate-key whose DETAIL restates the
email, the same without .diag so the LINE echo is exercised, the MySQL
duplicate entry that documents the accepted residual, and the MySQL
unknown-column that documents why redaction is refused. One more drives
both shapes through BOTH execute paths and asserts the two messages are
identical -- against the pre-fix source the session-bound half raised the
raw DBAPIError with the bound 'ada@example.com' attached.

Mock-based by necessity: no live PostgreSQL or MySQL in this suite, so
the driver exception shapes are reproduced rather than provoked. No
behaviour change.

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

* docs(nodes): correct the lane, cache and error prose in the four SQL READMEs

All four checked against the node code and services.json rather than
against each other.

db_clickhouse declares only a `questions` lane (services.json
`lanes: {questions: [...]}`), so the sentence about schema refresh
reaching the `answers` insert lane described a lane the node does not
have; it is removed. db_mysql, db_postgres and rocketride_sql do declare
`answers`, so theirs stays — reworded, because refresh_schema
invalidates that column map rather than rebuilding it, and the rebuild
happens on the next insert.

"Return the schema reflected when the node started" was wrong in all
four after refresh_schema shipped: get_schema serves whatever snapshot
the node currently holds, which is the start-up reflection until a
refresh replaces it.

db_postgres said every question on the `questions` lane is translated to
SQL and executed. When the LLM reports the question is not a database
query (`isValid: false`), writeQuestions emits its prose answer and
nothing runs against the database, so the lane is described as the
natural-language path with only valid generated SQL executed.

Each README now states the execute error contract the code enforces: a
failure raises `SQL execution failed:` plus the driver's primary
message, identically with and without a session_id, with the statement
and bound parameters kept to the server log — and with the residual
named, since a driver's primary sentence can quote a value the caller
itself submitted.

scripts/validate-node-readme.py passes for all four nodes; no GENERATED
block touched.

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

* fix(ai): publish the auto-created table's column map in one assignment

_createTableFromData is the second writer of IGlobal.schema and still had
the publish-then-fill shape B4 removed from _getTableSchema: it created
the table, assigned `self.schema = {}`, then added one key per column,
all outside _REFLECT_LOCK. By the time that loop runs the table exists,
so a second first-batch can already be inside _insertData; it takes the
lock, finds a truthy one-column map, snapshots it and silently drops
every other column its row carried. Reproduced by pausing the real fill
loop: the second row landed as {'label': 'b', 'size': None,
'colour': None} for input that supplied all three.

The map is now built in a local dict and published in a single
assignment. The lock is NOT widened over table creation, so the
pre-existing double-create race is untouched and still out of scope.

Also seeds a stale map into a direct _getTableSchema failure test: the
existing rebuild-failure test starts from {}, which cannot tell "previous
map left untouched" from "map reset to {}". Against the pre-B4 source
that new test fails with the stale entry replaced by a partial one.

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

* fix(ai): cut the clickhouse stack trace and position echo from execute errors

The formatter's docstring claimed "pymysql / clickhouse-driver put
(errno, message) in .orig.args". Only pymysql does. clickhouse-driver's
DBAPI layer raises OperationalError(ServerException), so .orig.args holds
one exception object, every branch falls through to str(orig), and what
reached the caller was the driver's full string form: "Code: N.\n
DB::Exception: <message>. Stack trace:\n\n0. DB::Exception::Exception(...)
@ 0x..." — server internals the caller cannot act on — plus, for a syntax
error, the failing statement fragment quoted after "failed at position N
('...')", which is the same class of statement echo as psycopg2's
"LINE n:".

_DB_ERROR_DETAIL gains "Stack trace:" and "failed at position" as
markers, so both are cut by the stripper that already handles the other
drivers rather than by a new branch. Neither phrase can misfire on the
shapes the other drivers produce.

The docstring now states the real clickhouse-driver shape instead of
claiming the pymysql one. Two constructed tests cover it, labelled in
their docstrings as reproduced from the driver's documented behaviour:
clickhouse-driver is not installed in this engine, so the shape cannot be
provoked here.

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

* docs(ai): state the real execute error contract and schema-cache wording

Three corrections to text, no behaviour change.

The error-contract paragraph overclaimed. It said values belonging to
other rows appear only in DETAIL / HINT / CONTEXT, which are stripped.
Both halves of that are wrong: a real SQLite run returns
`SQL execution failed: near "'hunter2'": syntax error` — the statement
fragment the parser stopped on is in the primary sentence — and
PostgreSQL's `invalid input syntax for type integer: "<value>"` names a
row value an INSERT ... SELECT or a CAST merely touched, which can come
from another table. pymysql and psycopg2 interpolate binds client-side,
so such a fragment can carry a bound value too. The docstring now lists
what is removed (the SQLAlchemy tail, LINE n:, DETAIL/HINT/CONTEXT/
QUERY/STATEMENT, the ClickHouse stack trace and position echo) and what
is passed through, and rests the policy on the argument that actually
holds: the recipient has allow_execute and can already read the same data
with a SELECT, so the error channel does not widen read access. Two tests
(one per execute path) pin the syntax-error shape on a real SQLite run.

get_schema's docstring said the schema is "reflected once in beginGlobal"
and refresh_schema's tool description — the sentence an LLM reads when
choosing between the two tools — said get_schema serves the snapshot
reflected when the node started. Both became false the moment
refresh_schema shipped, and both contradicted the READMEs corrected for
exactly that in c258211d.

_generated_primary_keys gains a paragraph recording the SQLite-only
residuals of SQLAlchemy's 'auto' rule: INT/BIGINT/INTEGER-DESC primary
keys are treated as generated although SQLite does not alias them to the
rowid, and an INTEGER PRIMARY KEY that is also a foreign key is treated
as required although it is an alias. PostgreSQL and MySQL reflection set
autoincrement explicitly, so production nodes resolve exactly.

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

* docs(nodes): correct the error-contract and remaining schema wording

Two corrections, both to sentences this PR itself introduced or left
behind.

The error sentence in all four READMEs promised that "the statement text
and the bound parameter values are never part of it". That is not what
the code does: a real run returns `SQL execution failed: near
"'hunter2'": syntax error`, and PostgreSQL's `invalid input syntax for
type integer: "<value>"` names a value an INSERT ... SELECT or a CAST
read from another table. Each README now says what is actually removed
(SQLAlchemy's `[SQL:]`/`[parameters:]` echo, and per engine: PostgreSQL's
LINE n:/DETAIL/HINT/CONTEXT, ClickHouse's server stack trace and its
`failed at position` echo) and that the primary sentence is passed
through as the database wrote it, so it can name a value the statement
carried or touched — resting the policy on the argument that holds, that
reaching this tool requires Allow direct query execution and such a
caller can read the same data with a SELECT. The full text stays in the
server log. db_postgres moves that contract out of the already-long
`execute` table cell into prose below the table, as the other three do.

The intro sentences in db_clickhouse and db_mysql still said
"startup-reflected schema", the wording corrected everywhere else in
c258211d; both now describe the cached snapshot and name refresh_schema.

scripts/validate-node-readme.py passes for all four; no GENERATED block
touched.

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

* fix(ai): leave every database-owned column to the database on the insert lane

Two ways an explicit NULL still reached a column the database was meant
to fill, both silent because writeAnswers only logs, and one comment that
claimed neither could happen.

_generated_primary_keys skipped primary-key columns only, so any other
newly reflected column was bound NULL — and an explicit NULL overrides a
server default. The sequence the refresh tool invites: answers is
auto-created as (id, q, a) with the curated map {q, a}; an agent runs
ALTER TABLE answers ADD COLUMN created_at ... NOT NULL DEFAULT now(); the
tool description tells it to call refresh_schema, so the next batch
rebuilds the map by reflection, binds created_at = NULL, and the batch
that worked a moment earlier is dropped with one log line. Reproduced on
SQLite: NOT NULL constraint failed: answers.created_at, parameters
('how', 'like this', None). _insertData now also reads
generated_defaults — every column with a server_default or an identity —
off the reflected table and omits those the row does not supply.

An explicit null on a generated key was bound as NULL. On the execute
tool that is right: a person wrote the statement. On the insert lane the
caller is an upstream node, and an LLM node or a JSON mapper emits every
schema key, writing null for the ones it has no value for — the same
intent as omitting it. Postgres rejects that NULL for a SERIAL column;
MySQL and SQLite generate a value, so the failure is dialect-dependent as
well as silent. A supplied null on a generated primary key is now treated
as omitted; everywhere else it is still bound as given.

The comments at refresh_schema's invalidation and above the binding loop
said the invalidation cannot change answers-lane behaviour. This PR's own
test shows it can, and that half is the point of the tool. They now state
the guarantee that actually holds: the columns present only in the
reflected map are omitted rather than bound NULL, so the database still
fills in what it owns.

Also drops the unreachable "no schema cached" branch: after the locked
block IGlobal.schema is populated or the method has raised, so the
snapshot is always truthy. The empty-map half of the lock rationale went
with it; the half-built-map half, which the concurrency test proves,
stays.

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

* fix(ai): format driver errors the dialect did not wrap in a SQLAlchemy exception

The error contract this PR documents was not delivered on the ClickHouse
node at all. db_clickhouse connects with `clickhouse+native://`, which is
clickhouse-sqlalchemy's own connector; its Cursor.execute raises
`clickhouse_sqlalchemy.exceptions.DatabaseException`, a plain Exception
subclass carrying the driver's ServerException in `.orig`. SQLAlchemy
does not wrap a non-DBAPI exception, so `except SQLAlchemyError` never
fired on EITHER execute path, `_format_db_error` never ran, and the
caller got `Orig exception: Code: 62.\nSyntax error: failed at position
21 ('hunter2') ... Stack trace:\n\n0. DB::Exception::Exception(...)` —
raw, on both paths. The markers added in 27a04f68 were verified against
clickhouse-driver's own DBAPI layer, which this node does not use, so
they were unreachable for the node they were added for.

Both paths now handle a non-SQLAlchemy exception that carries a driver
`.orig`: log it and raise the same `SQL execution failed: <formatted>`
RuntimeError, releasing the session on the session path as before. Duck
-typed on `.orig` rather than importing clickhouse modules into the
shared base — a driver error carrying its original is precisely the shape
being handled, and the base must not depend on one node's driver.
Anything without `.orig` is re-raised untouched, so the max_execute_rows
RuntimeError keeps its wording and rollback and a programming error still
surfaces as itself.

_format_db_error also reads `.code` and `.message` off the original when
they are an int and a string: clickhouse-driver's ServerException puts
the code there rather than in args, so the numeric branch missed it and
the caller lost the code. ClickHouse now reads like pymysql —
`Error 60: Table default.widgets doesn't exist` — and the message still
runs through the stripper, so the stack trace and the `failed at
position` fragment inside it are cut.

The two existing clickhouse formatter tests are relabelled: they cover
clickhouse_driver.dbapi's shape, which this node never produces. New
tests cover the connector's real shape at the formatter and drive it
through both execute paths, asserting no stack trace, no statement
fragment, and a released session; two more assert that an exception
without `.orig` is not swallowed. All labelled as constructed shapes —
clickhouse-sqlalchemy is not installed in this engine.

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

* fix(ai): reject a malformed params argument instead of misreporting it

`to_sqlalchemy_text` turns `$n` into a bind by indexing `params[n - 1]`,
so a JSON object for `params` raised `KeyError: 0` and a short list an
`IndexError`, both from inside the rewriter. The sessionless path leaked
those bare; worse, on the session path the `KeyError` was caught by the
arm that maps a missing session id, so `execute` reported "unknown or
expired transaction session: <sid>" for a session that was still open and
still usable — the caller was told to abandon a live transaction because
they passed the wrong JSON type.

`execute` now validates before dispatching anything: `params` must be
absent or an array, and every `$n` the statement references must have a
value, with the offending placeholder named. tx_registry.py is untouched
— the check lives in `execute` and imports the placeholder pattern from
the module that owns it so it cannot drift from the rewriter it guards.

An empty list keeps its current meaning of "no binds", the same as
omitting `params`, because a statement carrying a literal `$n` inside a
string body relies on the rewriter leaving it alone.

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

* docs(nodes): document the insert-lane null rule and the clickhouse error layer

The three nodes that declare an `answers` lane now state, beside the
existing insert prose, what the code does with a value the row does not
carry: a column with a generated key or a DEFAULT is left out of the
statement so the database supplies it, rather than bound as an explicit
NULL that would override the default; a generated primary key supplied as
`null` counts as not carried, because the sender on this lane is an
upstream node that may emit every schema key, while a `null` anywhere
else is inserted as given. db_postgres also records the rejection of a
primary key it cannot generate. db_clickhouse declares no `answers` lane,
so none of this applies there.

db_clickhouse's error sentence now describes the layer that actually
raises: `clickhouse+native://` does not produce a DBAPI error, so the
node unwraps the driver error it carries, and the message is prefixed
with the server's own `Error <code>:`.

The comment above refresh_schema's invalidation also drops the last
sentence that was still too strong. It said the database fills in what it
owns either way, which holds only for a column the row omits. For a
supplied primary key the two maps genuinely differ: on an auto-created
table the curated map has no `id`, so a row carrying one has it silently
dropped and the database generates a different value, and after a refresh
the reflected map binds it and the row's own value is kept. The comment
now says that, and notes that curating the key into the auto-created map
would be a separate decision.

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

* docs(ai,nodes): tighten the refresh comment and two README sentences

Comments and prose only; no behaviour change.

The refresh_schema comment said _insertData "leaves out any primary-key
or server-default column the rows do NOT supply, so a column present only
in the reflected map is omitted rather than bound NULL". Two clauses
overreached: a primary key the database cannot generate is rejected with
a ValueError when the rows omit it, not left out; and a plain nullable
column with no default that appears only in the reflected map IS bound
NULL. The guarantee named in the sentence before it still holds -- the
stored value is the NULL the database would have used anyway -- so the
sentence now says that instead of describing a mechanism the code does
not have.

rocketride_sql's insert-lane rule had been spliced onto the end of the
"Table name and database description" paragraph, running the two subjects
together; it now ends a paragraph of its own. db_clickhouse's "unwraps
its exception by the driver error it carries" reads as intended:
"unwraps it to the driver error it carries".

Two directories: 194 passed. Validator passes for both edited nodes.

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

* docs(nodes): regenerate the DB node parameter tables

dylan-savage (review 5215826786, nit 5) flagged that the
ROCKETRIDE:GENERATED:PARAMS regions of the four DB node READMEs had been
hand-edited, which AGENTS.md:87-88 forbids, and that
rocketride_sql/README.md carried an EMPTY region even though this PR
changed its services.json.

Develop has since filled that region: e035c26c3 (#2285) regenerated it
from the pre-PR wording, so merging develop reinstated
"Permit QuestionType.EXECUTE callers to run raw SQL..." — the exact claim
this PR corrected at the source (services.json:125 now says "Permit the
execute, begin, commit, and rollback tool functions...").

Regenerated with the repo generator rather than by hand: a scratch
`git archive HEAD | tar -x` copy with a fresh `git init` (so the
branch-name gate at nodes/scripts/gen-node-tables.mjs:211 passes and the
source links resolve to the upstream fallbacks
rocketride-org/rocketride-server @ develop, not this fork's origin), then
`node nodes/scripts/gen-node-tables.mjs db_mysql db_postgres
db_clickhouse rocketride_sql`. Only rocketride_sql/README.md changed;
the generator reproduced the other three regions byte-for-byte, which
also settles dylan's hand-edit concern for them — they already match
generator output.

Verified: python3 scripts/validate-node-readme.py PASS on all four nodes.

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

* fix(ai): anchor the PostgreSQL block markers and stop stripping diag.message_primary

dylan-savage (review 5215826786, item 4) showed that _DB_ERROR_DETAIL
matched DETAIL:/HINT:/CONTEXT:/QUERY:/STATEMENT: as bare substrings with
.search(), so the stripper truncated messages that merely contain one of
those words, and that every branch — psycopg2's already-clean
diag.message_primary included — ran through it. All three of his shapes
reproduced on the PR head:

  RAISE EXCEPTION 'Order rejected. HINT: contact billing'
      -> 'Order rejected.'
  pymysql (1644, 'Invalid QUERY: missing tenant filter')
      -> 'Error 1644: Invalid'
  a message whose first token is 'HINT:'
      -> 'Database error'

The five PostgreSQL blocks are separate LINES of the server message, so
they are now matched only after a newline (\n[ \t]*), while the tails a
library appends — [SQL:, [parameters:, [cached since, [generated in,
(Background on this error, LINE n:, Stack trace:, failed at position —
stay unanchored, because those genuinely appear mid-string and anchoring
them would reopen the leak this PR exists to close.

The anchor is \n rather than (?m)^ on purpose: ^ also matches at position
0, which is what turned a message beginning with 'HINT:' into
all-detail and degraded it to the fallback constant.

_format_db_error now returns diag.message_primary without the stripper.
psycopg2 splits DETAIL/HINT/CONTEXT/QUERY/STATEMENT and the LINE n: echo
onto their own diag fields, so that string carries no tail to cut and a
marker firing in it could only be truncating the application's own
wording.

Tests (tests/ai/common/database/test_db_base.py): the three shapes above
plus a message whose inline HINT: is followed by a real DETAIL: line
(only the line goes), and test_format_db_error_still_cuts_a_real_postgres_detail_block,
which pins that the anchoring does not let a genuine DETAIL block
through on the no-diag path. Against the pre-fix code the four new
assertions failed: "4 failed, 1 passed, 65 deselected in 0.52s".

No live PostgreSQL or MySQL is available here, so each case is a
constructed exception shape reproducing what the driver hands SQLAlchemy;
every new test's docstring says so.

Verified: 243 passed (tests/database + tests/ai/common/database), ruff
check + format clean.

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

* fix(ai): cut ClickHouse's in-message statement echoes

dylan-savage (review 5215826786, item 3) pointed out that _DB_ERROR_DETAIL
knew only ClickHouse's `Stack trace:` and `failed at position` tails, and
that ClickHouse does not always append its statement echo — code 47
(UNKNOWN_IDENTIFIER) splices the whole query into the MIDDLE of the
sentence. Reproduced on the PR head by running the module's own regex:

  Code: 47. DB::Exception: Missing columns: 'secret' while processing
  query: 'SELECT secret FROM users WHERE token = ''hunter2''', required
  columns: 'secret'. Stack trace:…

was cut only at `Stack trace:`, so the statement — with the bound value
already inlined — reached the caller. The analyzer's `in scope <sql>`
form leaked the same way.

Three markers added: `while processing query:`, `, required columns:`,
and `in scope `. The first two are distinctive enough as bare
substrings; `in scope ` carries a lookahead for an uppercase statement
keyword, because ClickHouse formats the echoed query back out of its AST
(so the phrase is always followed by SELECT/INSERT/… there) while in
prose — "not in scope for this trigger" — it is not. That is the same
lesson as the DETAIL/HINT anchoring in the previous commit: a marker that
can fire inside an application's own sentence will.

This dialect is the one where the echo genuinely carries user data:
clickhouse-driver sends no server-side parameters by default, so it
interpolates binds client-side and the repeated statement contains the
caller's literal rather than a placeholder. The docstring is reconciled
accordingly — its opening line no longer claims "never the statement or
its parameters" flatly but points at the REMOVED / PASSED THROUGH
section, the ClickHouse bullet now names the two in-message echoes and
the client-side interpolation, and the "every branch runs through the
stripper" sentence now excepts the diag.message_primary branch the
previous commit took off it.

Tests (tests/ai/common/database/test_db_base.py): the code-47 shape on
both connectors — clickhouse_driver.dbapi's OperationalError(
ServerException) and clickhouse-sqlalchemy's DatabaseException, which is
what the db_clickhouse node's `clickhouse+native://` URL actually raises
— plus a lone `, required columns:` tail and a guard that prose
containing "in scope" is not truncated. Against the pre-fix code:
"3 failed, 1 passed, 70 deselected in 0.57s" (the fourth is a guard and
passed before too).

No live ClickHouse server is available here and neither clickhouse-driver
nor clickhouse-sqlalchemy is installed, so both connector shapes are
constructed from the message forms the server documents; each test
docstring says so.

Verified: 247 passed (tests/database + tests/ai/common/database), ruff
check + format clean.

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

* fix(ai): format _validateQuery's EXPLAIN error before returning it

dylan-savage (review 5215826786, nit 1) noted that _validateQuery's
`except Exception as e: return False, str(e)` made the PR description's
security claim false. The EXPLAIN failure does not stop at the retry
loop: _buildSQLQuery carries it into `result['error']`, get_sql returns
that as its `error` field, and get_data returns get_sql's dict
unchanged. Neither tool consults IGlobal.allow_execute — the gate exists
only in execute() — so `str()` of a SQLAlchemy StatementError, tail and
all, reached callers who were never granted raw SQL. On the pre-fix code
the string was:

  (sqlite3.OperationalError) no such table: no_such_table
  [SQL: EXPLAIN SELECT secret FROM no_such_table WHERE token = 'hunter2']
  (Background on this error at: https://sqlalche.me/e/20/e3q8)

Now `self._format_db_error(e)`, the same formatter execute() uses, and
the full exception is logged with warning() first so the statement stays
in the server log where the retry loop's own log line used to keep it.

Tests (tests/ai/common/database/test_db_execute_errors.py): the helper
directly, then both ungated lanes — get_sql and get_data with
allow_execute switched OFF and a stubbed _buildSQLQueryOnce that keeps
proposing a query against a missing table — asserting the driver's
sentence survives while '[SQL:', '[parameters:' and the bound literal do
not. Plus the success half, `(True, '')`, which is unchanged. Against
the pre-fix code: "3 failed, 1 passed, 40 deselected in 0.55s".

Verified: 251 passed (tests/database + tests/ai/common/database), ruff
check + format clean.

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

* fix(ai): read an explicit null on any database-owned column as omitted

dylan-savage (review 5215826786, item 7) pointed out the asymmetry the
previous round left behind: the OMITTED branch skips a generated key AND
anything carrying a server default or identity, while the EXPLICIT-null
branch only skipped generated keys. So `{'created_at': None}` against
`created_at timestamptz NOT NULL DEFAULT now()` fell through to
`prepare_value(None)` and bound NULL. Reproduced on SQLite, which raises
the same class of error:

  sqlite3.IntegrityError: NOT NULL constraint failed: notes.created_at
  [SQL: INSERT INTO notes (label, created_at) VALUES (?, ?)]
  [parameters: ('a', None)]

and on the answers lane writeAnswers only logs, so the batch vanished
with one line — the exact failure mode the generated-key rule was added
to prevent.

The condition now reads `lowered in generated_pk_columns or lowered in
generated_defaults`, i.e. the same column set as the omitted branch.
Splitting the rule by nullability or by key-ness would make one emitted
row land differently depending on a column attribute its sender — an LLM
node or a JSON mapper that emits every schema key — cannot see.

The three insert-lane README paragraphs said "a null on any other column
is inserted as NULL as given"; they now say a null on any column the
database fills in counts as not carried, and a null on a column with
nothing behind it is inserted as given. Hand-written prose only; the
GENERATED:PARAMS regions are untouched (validate-node-readme passes on
all four DB nodes).

This is a behaviour change for a pipeline that deliberately writes NULL
into a column that happens to carry a DEFAULT — it now gets the default
instead. The `execute` tool is a different contract and is unaffected.

Tests (tests/ai/common/database/test_db_execute_errors.py): the NOT NULL
DEFAULT case dylan named, the nullable-default case, a column with
nothing behind it still binding NULL, and one showing the run-splitting
this collapses — a batch alternating an explicit null and an omission is
now a single executemany instead of one run per row. The narrow-rule pin
test_insert_still_binds_an_explicit_null_on_a_non_key_column is removed:
it asserted the behaviour this item reverses, and the two new cases cover
both halves of what replaced it. Against the pre-fix code:
"3 failed, 3 passed, 42 deselected in 0.78s".

Verified: 254 passed (tests/database + tests/ai/common/database), ruff
check + format clean, validate-node-readme PASS on db_postgres, db_mysql,
db_clickhouse and rocketride_sql.

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

* fix(ai): guard refresh_schema's reflection and keep both caches on failure

dylan-savage (review 5215826786, item 5) pointed out that
`self.IGlobal.db_schema = self.IGlobal._getDatabaseSchema()` ran
unguarded inside the locked block. _getDatabaseSchema has no per-table
try/except — one `inspector.get_columns(table)` the database refuses (a
revoked grant, a lock timeout, a table dropped mid-walk) propagates out
of the whole walk — so refresh_schema was the one tool in this file
whose failure still reached the caller as the SQLAlchemy repr, tail
included, and this tool has no allow_execute gate in front of it.

The reflection now runs in a try/except: the full exception goes to the
log with error(), and the caller gets
`RuntimeError('Schema refresh failed: ' + _format_db_error(e))` with
`from None`, matching execute()'s shape.

Publication is all-or-nothing, and the docstring now says so and why.
The result is assigned only after the walk returns, so a failure leaves
db_schema AND IGlobal.schema exactly as they were — the node keeps
serving the schema it has instead of publishing a half-walked database,
and IGlobal.schema is not emptied, which would otherwise re-arm the
insert lane's lazy rebuild against a database that just refused to be
reflected. Skipping the offending table and continuing (what
_getTableSchema does for a single table) is rejected explicitly: the LLM
path cannot tell a table that vanished from one that could not be read,
and would write queries as if it were gone.

Tests (tests/ai/common/database/test_db_execute_errors.py): a patched
inspector whose get_columns raises a wrapped driver error for one table
— injected, since no PostgreSQL is available here to revoke a grant on,
and injected after get_table_names so the walk fails part-way as a real
one would. Three cases: the message carries the driver's sentence and
neither '[SQL:' nor the reflection statement; both caches are byte-equal
to their pre-refresh values and a table created in between does not
appear; and _REFLECT_LOCK — process-wide, so a leak would wedge every
node in the engine — is free afterwards and the next refresh succeeds.
Against the pre-fix code: "3 failed, 1 passed, 46 deselected in 0.65s".

Verified: 257 passed (tests/database + tests/ai/common/database), ruff
check + format clean.

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

* fix(ai): format an ODBC (sqlstate, message) pair instead of returning the code

dylan-savage (review 5215826786, nit 6) noticed that the generic
`args[0]` branch returns the SQLSTATE alone for a driver that reports
`('42S02', 'Base table or view not found')` — pyodbc and the other
ODBC-backed drivers put a five-character SQLSTATE where pymysql puts an
integer errno, so the integer branch misses them and the caller gets the
half of the pair they cannot act on. The str(exc) fallback test this PR
removed used to cover the shape incidentally.

A branch before the generic one formats it as `Error <sqlstate>:
<message>`, matched strictly against `\A[0-9A-Z]{5}\Z` and requiring a
non-empty string second entry, so a driver that merely happens to put
two strings in `args` keeps the old behaviour. The result still runs
through the stripper.

dylan notes correctly that no node in this repo uses such a driver today
(there is no pyodbc dialect under nodes/src/nodes), so this is hardening
for a shape we do not ship rather than a live bug; the docstring bullet
says as much.

Tests (tests/ai/common/database/test_db_base.py): the pair formatted, the
stripper still cutting a STATEMENT block inside it, a two-string `args`
that is not a SQLSTATE left alone, and the single-argument sqlite3 shape
unchanged. Constructed shapes — no ODBC driver is installed here. Against
the pre-fix code: "2 failed, 2 passed, 74 deselected in 0.46s" (the two
guard cases passed before, which is the point of them).

Verified: 261 passed (tests/database + tests/ai/common/database), ruff
check + format clean.

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

* fix(ai): give each database node its own reflection lock

dylan-savage (review 5215826786, item 8): `_REFLECT_LOCK` was module-level
and held across `refresh_schema`'s full-database inspector walk, which runs
on an engine with no statement timeout. `_insertData` took the same lock
unconditionally on every batch, just to evaluate `if not IGlobal.schema`. So
one node refreshing a wide database stalled the answers lane of every other
DB node in the same engine process -- cross-node blocking the pre-PR code
never had, since two nodes share no cache and have nothing to race on.

The lock is now per-IGlobal, created in `beginGlobal` (`reflect_lock`,
declared `None` on the class so a global that never ran `beginGlobal` fails
loudly instead of silently sharing class state). Both writers rebind
`schema` / `db_schema` wholesale and neither reads the other node's, so the
state the lock guards is exactly one node's.

`_insertData` still takes the lock on every batch, and the comment now says
why that is the deliberate choice: an uncontended acquire is nanoseconds
against an insert about to do network I/O, while a check-then-lock fast path
needs the map read once into a local and snapshotted from THAT -- read twice,
a `refresh_schema` landing between the two hands the batch an empty map --
which is a second path no test pins. What made the unconditional acquire
expensive was its process-wide scope, and that is what changed.

Also item nit 4 (DS-N4), first half: `refresh_schema` rendered the tool
payload inside the lock. It is pure formatting, so it now runs after the
release, over the local `refreshed` map rather than `IGlobal.db_schema` --
a refresh that overtook this one has already published its own map, and this
call still owes its caller the schema it reflected. The second half of that
nit (rebuilding `IGlobal.schema` from `db_schema[table]['columns']` instead
of emptying it) is left alone: it changes the map's value shape and the
table-is-gone case that `test_refresh_schema_empties_the_column_map_when_the_table_is_gone`
pins, so it is not a nit-sized change.

Test: `test_a_refresh_does_not_block_another_nodes_insert` builds two
globals over two separate SQLite files, pauses one node's refresh inside the
column walk, and asserts the other node's insert completes while it is held.
Against the pre-fix code it fails with "the second node's insert waited for
the first node's refresh" (1 failed, 50 deselected in 5.63s). The five
existing deterministic concurrency tests are unchanged in substance; the two
that assert the lock is not stranded now acquire the node's own
`reflect_lock`, and the fixture builds one per global the way `beginGlobal`
does.

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

* test(ai): pin the post-refresh supplied-id flip on an auto-created table

dylan-savage (review 5215826786, item 9): the comment in `refresh_schema`
concedes that on an auto-created table the curated column map has no `id`, so
a row carrying one has it dropped, while after a refresh the reflected map
binds it -- and asks for that flip to be either removed or pinned by a test.
Nothing exercised it: `test_insert_after_refresh_does_not_bind_the_generated_primary_key`
only inserts rows that OMIT `id`.

`test_a_supplied_id_is_dropped_before_a_refresh_and_bound_after` auto-creates
through the real path, asserts the compiled column list of an insert that
supplies `id` before the refresh ({q, a}) and after it ({id, q, a}), and
checks the stored rows. Its docstring names the consequence the SQLite
fixtures cannot show: PostgreSQL advances a SERIAL sequence only for rows
that take the DEFAULT, so a bound `id` of 501 leaves the sequence at 3 and
the next generated row collides on the primary key, which `writeAnswers`
logs and drops.

No behaviour change -- this is characterisation only. Curating `id` into
`_createTableFromData`'s map so both maps agree is the alternative dylan
offers; it is a product decision about what an auto-created table promises
and is not taken here.

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

* docs(nodes): name the trigger-populated primary key as a known limitation

dylan-savage (review 5215826786, item 6): `_insertData` rejects a row that
omits a primary key with no reflected `server_default` / identity /
autoincrement. A `CHAR(36)` key filled by a `BEFORE INSERT` trigger -- the
standard MySQL UUID idiom before 8.0.13, same shape on PostgreSQL -- reflects
exactly that way, so the batch is refused before the trigger can run, and
`writeAnswers` turns that `ValueError` into one log line and drops every row.
He asks for this to be documented at minimum.

This is the documentation half. The three insert-lane sections (db_postgres,
db_mysql, rocketride_sql; db_clickhouse has no answers lane) now name the
trigger case, say that the answers lane logs and drops the batch rather than
reporting it, and give the workaround: a real column default
(`DEFAULT (uuid())` on MySQL 8.0.13+, `DEFAULT gen_random_uuid()` on
PostgreSQL) reflects and is honoured. db_mysql's paragraph also gained the
rejection sentence itself, which only the other two READMEs carried. All
edits are hand-written prose; no GENERATED:PARAMS region is touched.

`_generated_primary_keys`'s docstring gains the same paragraph, stated as the
false positive it is: reflection describes columns, not triggers.

The stronger option dylan offers -- drop the pre-flight rejection, bind
nothing, let the driver's error come back through `_format_db_error` -- is
not taken here. It costs the guarantee that a refused batch leaves nothing
behind, which is what makes the current rejection safe, so it is put to him
as a question on the thread rather than decided here.

Verified: validate-node-readme.py PASS for all four DB nodes; 263 passed in
packages/ai/tests/database + packages/ai/tests/ai/common/database.

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

* refactor(ai): make _getTableSchema the only publisher of IGlobal.schema

dylan-savage (review 5215826786, nit 3), two parts.

`_getTableSchema` already assigns `self.schema` and then returns the same
columns as pairs; `beginGlobal` and `_insertData` rebuilt the map from those
pairs and assigned it again, comment-less. The rebuild was not a no-op only
in that it replaced each reflected column comment with '' -- and this map is
read exclusively by `_insertData`, by key. Both rebuilds are gone; the two
call sites now test the return value and say why there is nothing to assign.
`beginGlobal` keeps its one real assignment, `self.schema = {}` for a table
that does not exist yet, which is the case `_getTableSchema` leaves untouched.
Behaviour for every reader is unchanged: same keys, same order, in the same
single atomic rebind.

The comment above the `dict()` snapshot claimed it guarded an in-place
mutation. No publisher mutates in place any more -- both build a map and
rebind it, which is what they were fixed for earlier in this PR -- so the
comment now says what the snapshot actually buys: binding the map to a local
is what stops a `refresh_schema` mid-batch swapping the map the per-row loop
is walking, and copying it is what keeps that true if a publisher ever fills
one in place again.

No behaviour change. 263 passed in packages/ai/tests/database +
packages/ai/tests/ai/common/database.

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

* fix(ai): cut a parenthesised ClickHouse in-scope echo as well

The `in scope ` marker's lookahead required the uppercase statement
keyword to follow the phrase immediately, so it fired on the analyzer's
`in scope SELECT ...` echo but not on `in scope (SELECT ...)`.
ClickHouse formats a scope that is itself a subquery or a CTE body
through ASTSubquery, which parenthesises it, and clickhouse-driver
interpolates bind values client-side -- so that form carries the
caller's literal straight through the stripper.

The lookahead now allows one optional `(` before the keyword. Added
test_format_db_error_clickhouse_cuts_a_parenthesised_subquery_scope,
which fails against the previous regex with the whole subquery (and its
bound literal) still in the message. No live ClickHouse server or driver
is available here, so the shape is constructed from the documented
message form, as the neighbouring cases are; the test docstring says so.

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

* fix(ai): name the table a refresh lost instead of echoing a bare noun

`refresh_schema`'s guard formats every reflection failure through
`_format_db_error`, which reads the driver's own sentence. The one case
dylan-savage's item 5 names that has no such sentence is a table dropped
between `get_table_names` and its `get_columns`: SQLAlchemy raises
`NoSuchTableError`, built from the table name alone and carrying no
`.orig`, so the generic branch returned `args[0]` and the caller saw
"Schema refresh failed: ghost" -- a bare noun with no verb.

A `NoSuchTableError` arm before the generic one now says what happened
("table \"ghost\" disappeared while it was being reflected") and logs the
same sentence server-side. The all-or-nothing publication is unchanged:
the arm raises before either cache is assigned.

Added test_refresh_schema_names_a_table_dropped_mid_walk, which fails
against the previous code with the bare table name. The drop is injected
through the inspector rather than raced against a live server, and the
docstring says so.

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

* fix(ai): name beginGlobal when a global reaches the lock without one

`reflect_lock` defaults to None on DatabaseGlobalBase so that a global
built without `beginGlobal` cannot silently share one lock across every
node in the process. The failure it produced was loud but anonymous:
`with None:` raises "'NoneType' object does not support the context
manager protocol", naming neither the attribute nor the call that
creates it, and on the answers lane `writeAnswers` reduces any exception
to a single log line -- the quiet failure this PR removes elsewhere.

`_insertData` and `refresh_schema` now take the lock through
`_reflectLock()`, which raises RuntimeError('reflect_lock is created in
beginGlobal; this global never ran it'). No production path reaches it:
no DB node overrides `beginGlobal`. Added
test_a_global_that_never_ran_begin_global_names_the_missing_lock, which
fails against the previous code with the bare TypeError.

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

* docs(ai): correct the explicit-null sentence in _generated_primary_keys

The docstring still described the pre-item-7 rule -- "anywhere else a
supplied null is bound as given" -- which caf5bc6e4 made false when it
widened the explicit-null branch to every server-default and identity
column, and the trigger-limitation paragraph added in fd5e29eb7 had been
stitched onto that sentence, leaving a 130-character line.

The paragraph is now its own, and states the live rule: the generated
keys this returns plus the server-default / identity columns _insertData
reads off the reflected table are one set, and an explicit null on any of
them means "no value". Documentation only; no behaviour change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic…
* fix(account): close DB broker HTTP errors

Closes #2324

* fix(microsoft-365): close HTTP error responses

---------

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>
* fix(db): avoid coroutine leak in sync DSN resolver

Closes #2318

* test(db): clarify running-loop regression guard

---------

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>
* test(db): repair SQL integration fixture imports

Closes #2319

* test(db): isolate config utils fixture

* test(db): verify config alias teardown

* test(db): restore loaded module aliases

* docs(test): document database fixtures

* test(db): restore temporary module aliases

* test(db): restore all temporary node modules

---------

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>
* fix(authoritative-overlay): enforce SEC period scope

Closes #2320

Closes #2323

* fix(authoritative-overlay): reject conflicting restatements

* fix(authoritative-overlay): bound submission lookups

* fix(authoritative-overlay): reject invalid matched values

---------

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>
* fix(crustdata): expose search filters and field projections

* docs(crustdata): align person operator wording

---------

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>
* fix(chunker): preserve Unicode token boundaries

Closes #2321

* test(chunker): install real tokenizer in harness

* test(chunker): pin overlap and tokenizer constraints

---------

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>
…ree) + PR-comment Action (#1607)

* feat(sdk): rocketride.pipediff — semantic diff engine for .pipe files (#1606)

A raw `git diff` of a `.pipe` file is dominated by canvas coordinate
churn: every node carries a `ui` block whose `position.x`/`position.y`
move whenever the node is nudged, plus a top-level `viewport` that
shifts on pan or zoom. Those lines say nothing about behavior, yet they
bury the changes that do — a swapped LLM provider, a re-tuned chunk
size, a rewired retrieval step.

`rocketride.pipediff` compares two parsed `.pipe` documents instead of
their text. Components are matched by `id`; the result is grouped into
nodes (added / removed / re-provisioned), edges (reconstructed from each
component's `input[]` data lanes and `control[]` orchestration lanes),
and config (deep-diffed into dotted paths such as
`config.default.strlen`). Layout is collapsed to a single "changed" flag
unless the caller asks for it, and JSON values are compared with JSON
typing so `1` and `true` are never conflated.

The package is import-only and does no I/O beyond reading the files it
is given: `gitref` shells out to `git show <ref>:<file>` for the
ref-vs-worktree case and maps every failure mode onto an explicit error,
and `reporters` renders the same diff as human text, stable sorted JSON,
or Markdown with `|` and backticks escaped so untrusted values cannot
break a PR comment.

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

* feat(cli): add `rocketride diff` subcommand (#1606)

Wires `rocketride.pipediff` onto the CLI as `rocketride diff`, the
review half of the "pipelines as code" loop: `validate` says a `.pipe`
is well-formed, `diff` says what changed in it.

The subcommand is deliberately the odd one out in `setup_parser`: it is
local only — it never contacts an engine or the network — so it does not
take the shared `--uri`/`--apikey`/`--token` connection arguments every
other command gets from `_add_connection_args`, and its `--json` is a
plain format flag rather than the shared `--json [FILE]`. It compares
two files positionally, or a single working-tree file against a git ref
via `--git REF`. `--markdown` renders a PR-comment-friendly report,
`--include-layout` opts the canvas churn back in, and `--exit-zero`
turns a gating run into an informational one.

Exit codes are the CI contract: 0 for no semantic changes (or any
successful run under `--exit-zero`), 1 when semantic changes are found,
and 2 for a usage error, an unreadable or unparseable file, or a bad git
ref.

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

* feat(ci): add pipe-diff composite action — semantic `.pipe` diff as a sticky PR comment (#1606)

Because `rocketride diff` is local and exits non-zero on change, it
drops straight into a pull-request check. This composite action does the
assembly: it installs the CLI, resolves the PR base commit (fetching it
itself, so the default `fetch-depth: 1` checkout is fine), finds every
changed `.pipe` file, diffs each against the base, and maintains one
sticky comment so reviewers see "OpenAI → Anthropic, chunk size
512 → 1024, retrieval rewired through a reranker" instead of a wall of
coordinate noise.

Every run also writes the full report to the job summary, which is what
keeps the action useful on a fork pull request: GitHub hands those jobs
a read-only `GITHUB_TOKEN` regardless of `permissions:`, so the comment
API returns 403 — the action warns instead of failing and the diff stays
visible in the summary. The summary is written on the base-fetch failure
path too, rather than leaving a run with no output at all.

`install-from` exists because `rocketride diff` ships in a release after
1.3.0 and PyPI does not serve it yet; it points pip at a checkout or a
pinned commit instead. Markdown assembled from file names and diff
output is escaped, and the action distinguishes "no `.pipe` files
changed" from "could not determine what changed" rather than reporting
the reassuring answer blind.

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

* docs: document `rocketride diff` in the CLI reference and examples (#1606)

Documents the new subcommand where the restructured docs tree puts CLI
content — `docs/public/product/connect/cli.mdx` — as a `### diff`
section alongside `### validate`, the other file-oriented, CI-friendly
command. The section covers the flags, the exit-code contract, what does
and does not count as a semantic change, all three output modes, and the
`pipe-diff` action recipe including the fork-PR token caveat and the
pre-release `install-from` workaround.

Two surrounding statements needed amending rather than just appending:
the Python-vs-TypeScript note now records that `diff` is the one
Python-only command, and the "Connecting" preamble now excludes `diff`
from the commands that take connection options, because it takes none.

`examples/pipe-diff-example.md` puts a raw `git diff` next to
`rocketride diff` on the same edit to `rag-pipeline.pipe`; the examples
index gains a Guides entry pointing at it.

Note for reviewers of the earlier revision: this content previously
lived in `docs/README-python-client.md`,
`packages/client-python/docs/index.md`, and
`packages/docs/content-static/cli.mdx`. All three paths were removed by
the documentation revamp, so the content is re-homed here instead of
resurrecting them, and the example's cross-link now points at the
published `/connect/cli#diff` anchor.

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

* fix(client-python): ship rocketride_common when installing from the source tree (#1607)

`pip install ./packages/client-python` from a clean checkout produced a
`rocketride` binary that could not start:

    File ".../rocketride/cli/utils/env.py", line 29, in <module>
        from rocketride_common.env import (
    ModuleNotFoundError: No module named 'rocketride_common'

`cli/__init__.py` eagerly imports `.main`, `main.py` imports `.utils.env`
at module scope, and that imports `rocketride_common` — so every
subcommand, `diff` included, dies at import time.

`rocketride_common` is a source library shared with the TypeScript client
rather than a published distribution, so it cannot be added to
`dependencies`. It lives at `packages/client-common/python/src` and the
builder copies it into the staging tree (`client-python:wheel-source`)
before the released wheel is built — which is why the published wheel has
always been fine and only the source tree is broken. The source tree is
also exactly what the new pipe-diff action's `install-from` input points
at, in both documented forms (`./packages/client-python` and
`git+https://…#subdirectory=packages/client-python`, where pip clones the
whole repository), so the documented pre-release recipe could never work.

Adding the sibling source root to `packages.find.where` makes setuptools
find the shared package in the source-tree layout. setuptools skips a
`where` entry whose directory does not exist, so the builder's staged
layout — where `rocketride_common` is already inside `src/` and there is
no sibling directory — is untouched. Verified end to end: from a clean
`git archive` tree `rocketride diff --help` now exits 0, and rebuilding
the staged layout through `python -m build` still yields an sdist and a
wheel carrying both `rocketride` and `rocketride_common`, byte-identical
in top-level contents to before.

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

* fix(ci): diff .pipe files against the merge base, not the base branch tip (#1607)

The action took `github.event.pull_request.base.sha` -- the tip of the
base branch at event time -- and used it directly for both
`git diff "${BASE_SHA}" HEAD` and `rocketride diff --git "${BASE_SHA}"`.
That tip keeps moving as other pull requests merge, so every commit
merged into the base since this branch forked was reported as a change
made by THIS pull request, and reported backwards: those commits sit on
the left-hand side of the diff, so a node someone else added shows up as
a node this PR deleted.

Reproduced in a scratch repository -- fork at C0, this branch adds C1
touching `a.pipe`, the base then gains C2 adding `b.pipe`:

    git diff --name-status $C2 HEAD     ->  M a.pipe / D b.pipe   (wrong)
    git diff --name-status $(git merge-base $C2 HEAD) HEAD
                                        ->  M a.pipe             (right)

The action now resolves `git merge-base "${BASE_SHA}" HEAD` once and
feeds that to the changed-file enumeration, the deletion enumeration and
every per-file `rocketride diff --git`. This is the comparison GitHub's
own "Files changed" tab shows, and the same one the action's README
already recommended in its manual recipe.

Fetch depth had to change with it. The base commit was fetched with
`--depth=1`, which leaves it with no ancestry for `merge-base` to walk,
so that flag is gone. A default `fetch-depth: 1` checkout also leaves
HEAD with no ancestry, so when the first `merge-base` attempt comes back
empty the action deepens (`--unshallow`, falling back to `--deepen`) and
retries; a workflow that already checks out with `fetch-depth: 0` never
pays for that. Verified against a real depth-1 clone: the first attempt
returns nothing, the deepen recovers, and the merge base resolves to the
fork point.

If the merge base still cannot be computed the step now fails with a
report and an annotation pointing at `fetch-depth: 0`, rather than
falling back to the base tip -- silently diffing the wrong thing is the
defect being fixed.

The action README and the CLI reference are updated to describe the
merge-base comparison and the optional `fetch-depth: 0`.

Verified: action.yml parses as YAML, `bash -n` and shellcheck pass on all
four `run:` blocks.

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

* test(client-python): assert the Markdown table escaping without cmarkgfm (#1607)

`test_gfm_renders_escaped_cells_back_to_the_original_value` was guarded by
`@unittest.skipIf(cmarkgfm is None)` and `cmarkgfm` was in no dependency
group, so the one test that decides whether `_md_cell`'s backslash
handling is right never ran — not locally, not in CI.

Declaring the dependency is not enough on its own: CI does not `pip
install` this package's extras at all. `builder test` runs
`client-python:run-pytest`, which invokes pytest through the bundled
engine binary in its own isolated environment (packages/client-python/
scripts/tasks.js -> scripts/lib/pytest.js), so an entry under
`[project.optional-dependencies] test` would still leave the test
skipped on every CI run.

So the coverage now comes from an assertion that needs no C extension.
`_split_gfm_row` applies cmark-gfm's own table-row rule directly: a `|`
splits the row only when no backslash precedes it, and a `|` preceded by
a run of n backslashes is escaped and loses exactly one of them. The new
`test_escaped_cells_survive_gfm_row_splitting` runs that over the same
value corpus — which both tests now share — and asserts three columns and
an exact round-trip to the original value.

That rule was derived from cmark-gfm rather than from the spec prose, and
then checked against it: comparing `_split_gfm_row` with `cmarkgfm` over
several thousand random values built from the punctuation that shows up
in pipeline config produced zero disagreements. Mutation-checked too —
making `_md_cell` double the backslash instead of adding one fails six of
the eight corpus values.

The cmarkgfm test is kept as an oracle rather than deleted: it is the
stronger check when the library is present, it now shares the corpus, and
`cmarkgfm` is added to the `test` extra so `pip install .[test]` enables
it instead of leaving the guard permanently dead. Its skip message says
which check still covers the rules.

Verified: 131 passed with cmarkgfm installed; 130 passed and 1 skipped
(only the oracle) without it. Ruff check and format clean.

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

* ci: run the pipe-diff action on this repo and pin-check composite actions (#1607)

Two gaps, both of which let the new action ship unexercised.

Nothing referenced the action. No workflow under `.github/workflows/`
mentioned pipe-diff, so the install step, the base resolution, the
`rocketride diff` invocation and the comment step had never run as a
GitHub Action — the first consumer to adopt it would have been the one to
find out. `.github/workflows/pipe-diff.yml` runs it on `pull_request`
against the repository's own 45 `.pipe` files, and the paths filter also
fires on changes to the action, the diff engine and the CLI subcommand,
so a change to any of them is exercised before it merges even when no
pipeline changed.

It is `pull_request`, not `pull_request_target`, for the reason the
action's own README gives: a `pull_request_target` job checks out the
base branch (nothing to diff) and holds a writable token, and this
workflow installs the CLI from the checkout. On the `pull_request` event
a fork's code runs with a read-only token and no secrets, and the report
lands in the job summary — the documented fork behaviour. `install-from`
points at the checkout because PyPI does not serve `rocketride diff`
yet; the comment says to switch to `cli-version` once it does.

Second, `scripts/verify-action-pins.mjs` only enumerated
`.github/workflows/*.y(a)ml`, so the SHAs pinned inside composite actions
were outside the pin guard entirely — both this PR's
`.github/actions/pipe-diff/action.yml` and the pre-existing
`.github/actions/validate-pipes/action.yml`. A composite action could
carry a mismatched or unpinned `uses:` and CI would pass. The verifier
now also enumerates `.github/actions/<name>/action.{yml,yaml}`, treating
a missing directory as an empty list.

Verified: the existing verifier unit tests still pass (7/7); the live
check goes from 26 files to 29 and reports "Verified 87 action pins
across 29 workflow and composite-action files" with no errors; and
drifting the pipe-diff action's github-script comment from v7 to v6 is
now caught with the correct file and line annotation, which it was not
before.

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

* test(client-python): run the cmarkgfm escaping oracle in CI

`test_gfm_renders_escaped_cells_back_to_the_original_value` is the test that
decides how `_md_cell` escapes a pipe, but `cmarkgfm` was only in the `test`
extra and CI never installs that extra — it runs pytest on the bundled engine
interpreter against the source checkout. So on every CI run the decisive test
skipped, and the gate was `test_escaped_cells_survive_gfm_row_splitting`,
which resolves the same corpus with our own model of cmark-gfm's cell
scanner. Code and test then shared one assumption: if the model were wrong,
both would be wrong together and nothing would go red. The values are
untrusted `.pipe` content rendered into a PR comment, so a cell that splits
its row is exactly the failure the escaping exists to prevent.

Add `cmarkgfm` to `packages/client-python/tests/requirements.txt`, which
`client-python:setup-test-deps` installs into the engine interpreter through
`depends()` — the mechanism #1612 added for the otel-bridge tests, so the
wheel resolves under the same merged constraints as everything else and both
packages stay on one route. The floor stays `>=2024.1.14`: 2024.11.20
publishes no macOS wheels, and a tighter pin would make the macOS leg build
cmark-gfm from source. It resolves to 2025.10.22, which has wheels for every
CI leg.

The `pyproject.toml` comment that explained why the extra was not enough now
names the route CI actually takes. The library-free check keeps its own
docstring but no longer claims CI never installs the renderer.

Verified with the engine interpreter, the same way CI will:
  depends(tests/requirements.txt)  ->  cmarkgfm==2025.10.22 (wheel)
  before: 1 passed, 1 skipped ("cmarkgfm is not installed")
  after:  2 passed, 16 subtests passed
The oracle agrees with the scanner model on the whole corpus, so `_md_cell`'s
rule is now checked against the renderer rather than against our model of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(client-python): document rocketride diff and the pipediff API in the python docs tree

The Python-SDK half of this PR's documentation lived in
docs/README-python-client.md. The documentation revamp (#2139, 92a1b7e)
deleted that file wholesale as part of moving the client docs into
docs/public/{python,typescript}/, and this branch's merge of develop took the
deletion without relocating the pipe-diff content. The result was a regression
against the state this PR was reviewed in: `rocketride diff` and the importable
`rocketride.pipediff` package had no page anywhere under the new docs tree, and
nothing in the Python client's own docs folder mentioned them.

Add docs/public/python/pipe-diff.md as a Python-only site page (sidebar_position
14, the slot after otel-bridge at 13) carrying the material that used to live in
the deleted README: install/quickstart, the flag table and exit codes, what
counts as a change, the human/JSON/Markdown output modes, the CI recipe that
points at the bundled composite action, and a reference of every name exported
from `rocketride.pipediff` (engine, data model, reporters) plus troubleshooting
and limitations. The three sample outputs are the real renderers' output,
reproduced byte for byte.

Wire it into the docs spine the same way otel-bridge is wired:

- scripts/validate-client-docs.py: declare `pipe-diff` in PY_ONLY_PAGES. This is
  required — an undeclared page in one client folder and not the other fails the
  site-page-set parity check.
- docs/development/clients/readme-schema.md: the schema doc enumerates the
  declared single-language extras that the validator constant mirrors, so it
  would otherwise name otel-bridge as Python's only extra.
- docs/public/product/clients/index.md: the spine paragraph that lists what the
  Python package additionally ships would otherwise claim the OTel bridge is the
  only such surface.
- docs/public/python/index.mdx: a side card next to the OTel bridge card.
- docs/public/product/connect/cli.mdx: a pointer from the `diff` section to the
  in-depth guide. The CLI reference keeps its own flag table; the guide is the
  one that also documents the importable API.

No behaviour change.

Verification: `python3 scripts/validate-client-docs.py` ok (6 README sections,
13 shared site pages, symbol parity); `node scripts/build.js docs:test
docs:build docs:check` exit 0 (250 pages staged, docs exports in sync);
`ruff check` / `ruff format --check` clean on the touched .py file.

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

* docs(client-python): say the cmark-gfm oracle runs in CI, not that it is optional

asclearuc asked for the cmark-gfm oracle to actually execute in CI rather than
skip, so the backslash-escaping question is settled by the renderer instead of
by our model of it. That was done by adding `cmarkgfm` to
packages/client-python/tests/requirements.txt, which the
`client-python:setup-test-deps` step installs into the engine interpreter the
suite runs on. The prose around the test was never updated and still described
the renderer as an optional local nicety:

- `_md_cell`'s docstring said the round-trip was "cross-checked against
  ``cmarkgfm`` when that optional renderer is installed";
- the `skipIf` reason pointed only at `pip install .[test]`, so a reader of a
  skip line had no way to tell whether CI had skipped it too;
- the library-free sibling test read as the primary check rather than the
  fallback.

Rewrite all four strings (the guarded import comment, both test docstrings, and
the skip reason) to state where the renderer comes from in CI and what a skip
therefore means: a local interpreter without it, never the CI leg. The
library-free `_split_gfm_row` check is named as the fallback over the same
corpus; the renderer test is named as the one that decides the rule.

Comments only -- no behaviour change, and the skip condition itself is
unchanged.

Verification: `./dist/server/engine -m pytest
packages/client-python/tests/test_pipediff_reporters.py -q` -> "32 passed in
0.08s" (exit 0), with
test_gfm_renders_escaped_cells_back_to_the_original_value running rather than
skipping; `ruff check` / `ruff format --check` clean on both files.

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

* test(client-python): pin the top-level keys diff_pipes reads and ignores

Author-side finding, not a reviewer ask. `diff_pipes` reads exactly three
top-level keys of a `.pipe`: `components`, `version` and `viewport`. Every other
top-level key the schema defines -- `project_id`, `isLocked`, and anything a
later schema adds -- is ignored on purpose, because it is editor/session
metadata rather than pipeline behaviour. That rule is load-bearing for the exit
code (it is why opening a pipeline in the editor and saving it does not fail a
CI gate) and it was implicit: nothing asserted it, and nothing stated it. A
later contributor widening the read set by one key would turn every
`project_id` rewrite into a gate failure, with no test to catch it.

Pin it:

- tests/test_pipediff_engine.py: `test_unknown_top_level_keys_are_ignored`
  changes `project_id`, `isLocked`, a `name`, and an unknown future key, and
  asserts an empty diff under both `include_layout=False` and `True`;
  `test_version_is_compared_but_viewport_only_under_include_layout` pins the
  other half -- `version` always counts, `viewport` is enumerated and counts
  only under the flag.
- engine.py: one paragraph in the `diff_pipes` docstring stating the read set
  and naming the test.
- docs/public/product/connect/cli.mdx and docs/public/python/pipe-diff.md: one
  bullet each in "What counts as a change", so the contract is stated where a
  user decides whether `diff` is safe to gate on.

No behaviour change -- the tests characterize what the code already does, so
they pass on the pre-change engine. Proof that they bite: with a temporary
mutation adding `project_id` to the compared set,
test_unknown_top_level_keys_are_ignored fails with "assert [NodeChange(i...
d_changes=[])] == [] / Left contains one more item: NodeChange(id='__project__',
kind='config', ...)" -- "1 failed, 61 deselected in 0.04s" (log
impl-1607-toplevel-pin-mutation.log). The mutation was reverted before this
commit.

Verification: the four pipediff/CLI modules through the bundled engine ->
"133 passed in 0.35s" (exit 0, was 131 before the two new tests); `ruff check` /
`ruff format --check` clean on both .py files; `node scripts/build.js docs:test
docs:build docs:check` exit 0; `python3 scripts/validate-client-docs.py` ok.

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

* docs(pipediff): pin note and two accuracy clauses on the Python guide

The CI snippet on the Python guide carried no pin caveat, unlike its two
siblings in cli.mdx and examples/pipe-diff-example.md, so a reader landing
here was not told that `@develop` is a placeholder. Add the one sentence.

Two accuracy clauses in the same page: the `--git` row now says a file that
is absent in the ref also reports a version change from `null` (the CLI
substitutes an empty pipeline), and the `resolve_git_ref` row names the
remaining `PipeDiffError` case — contents at the ref that are not a valid
pipeline.



---------

Co-authored-by: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com>
…ly falling back (#2292)

A read-only node whose "Default Graph" doesn't exist on the server was
silently resolving to whatever preconfig.default declares (agent), rather
than the graph the user actually intended. FalkorDB accepts a query against
a missing graph and only fails on the first result, with "ERR Invalid graph
operation on empty key" -- an error that gives no hint the graph itself was
the problem (#2155).

- _open_driver now raises a clear error naming the missing graph and the
  ones that do exist, at pipeline start rather than on first query.
- _probe_connection warns the same way at save time.
- Both skip the check when allow_writes or allow_execute is on, since
  FalkorDB creates a graph lazily on its first write -- a write-capable
  node may legitimately target one that doesn't exist yet.
- The canvas tile was empty, so the active graph -- or that it had silently
  fallen back to one -- was invisible without opening the inspector. It now
  shows "Graph: <name>".

The original report described the fields as unexposed/unpersisted; they
already were (confirmed by a prior investigation on the issue). The real
defect was the silent, unvalidated fallback, which is what this fixes.

Co-authored-by: madhumithachandrasekaran31 <madhumitha2110549@ssn.edu.in>
…quests Graph scopes (#2373)

LoginWithMicrosoftButton looked up SERVICE_TIER_SCOPES[formContext.provider],
but formContext.provider is the node protocol name (tool_excel, tool_word,
tool_onedrive, tool_outlook_mail, tool_outlook_calendar) while the map was
keyed excel/word/onedrive/outlook_mail/outlook_calendar. Every lookup missed,
so no scope param was sent, the broker requested identity only, and the node
rejected the token (e.g. "Missing: Files.ReadWrite").

Rekey the map by protocol name (scope values unchanged; they mirror
core/microsoft_access.py) and hoist it to an exported module constant. Add a
test that reads every tool_microsoft_365 services.*.json and asserts the map
has non-empty scopes for each protocol and each access tier.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… changes (#2378)

* fix(ci): cancel

* fix(ci): labeler
…ion, history, relationships, plain EXPLAIN, honest designer (#2282)

* test(sql-ui): add node:test harness and pure-logic suites

SQL Explorer had no test setup. Add the explorer-ui harness (tsx +
node:test, a `sql-ui:test` builder action over tests/) and six pure-logic
suites covering the modules where the audit found bugs.

Unlike explorer-ui, docs.ts imports Documents / NOOP_VFS from 'shell' as
values, so the runner preloads a local stub-shell.cjs (apps/shared's
pattern, copied rather than shared: extending apps/shared's stub would
change a file its own suite depends on). Every other tested module reaches
the platform through `import type` alone, which tsx erases.

15 of the 71 tests fail on purpose. They encode the fixed behaviour for two
audited correctness bugs, ahead of the fix in the next commit:

- sql/paging.ts inlines filter and search values, passing numeric-looking
  strings through unquoted, so `code >= '0100'` on a VARCHAR column becomes
  a number comparison (MySQL coerces the column, Postgres raises).
- docs.ts leaves the table name unencoded in `table:` / `design:` URIs, so
  a table whose name contains a colon splits one segment too late and
  yields an endpoint key that does not exist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 8f788cf75fd14235ed07d3bb5ba0acac24f8041b)

* feat(sql-ui): parse plain EXPLAIN output into a plan tree

Adds the pure half of the query-plan feature: buildExplain() prefixes the
dialect's plain EXPLAIN form (mysql FORMAT=JSON, postgres (FORMAT JSON),
clickhouse text) to the statement exactly as it would run, and returns null
for dialects with no support here. parseExplain() normalises the three
documented result shapes into one IPlanNode tree, flagging planner numbers so
the UI can suffix them with `est.`.

Plain EXPLAIN only — nothing here emits ANALYZE, so the explained statement is
never executed. Postgres arrives either already parsed or as a JSON string
depending on the driver; both are handled.

A shape the parser does not recognise reports `ok: false` with a reason rather
than throwing, so the caller keeps showing the database's raw output.

Every fixture is hand-built from vendor documentation, not captured from a
live server: no MySQL, PostgreSQL or ClickHouse instance was reachable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit a1c0678b2531f0dfe049c7c41b07841b4c6c30a9)

* fix(sql-ui): bind filter values, remote sort, page errors, URI table encoding

Five audited correctness bugs in the data browser and its URI scheme, plus
the two session capabilities the rest of the upgrade needs.

paging: filter, range-bound, and search values were rendered into the
statement by quoteValue, which passed numeric-looking strings through
unquoted — `code >= '0100'` on a VARCHAR column became a number
comparison, matching the wrong rows on MySQL and raising a type error on
Postgres. buildPageStatements now returns `params` alongside the two
statements and binds every user value as `$1..$n`; quoteValue is gone and
introspect's always-quoting quoteLiteral moves to sql/quote.ts as the app's
single literal quoter. LIKE patterns are assembled as bind VALUES, because
the node rewrites `$n` textually before binding.

TableDataView: SELECT and COUNT now run in parallel; header sorts reach
`req.sort` (remoteSort); a failed page shows an error Banner instead of
failing silently behind the grid's transient toast; and a monotonic request
sequence keeps a slow, superseded page from overwriting a newer one. A
table with no primary key and no user sort pages without any ORDER BY, so
the view says the order is not guaranteed.

docs: `table:` / `design:` URIs percent-encode the table segment. The
endpoint key contains colons, so these schemes split on the last one — a
table named `odd:name` split one segment too late and produced an endpoint
key that does not exist.

connect: `execute` takes optional bind params, and `ISqlSession` gains
`refreshSchema()` over the node's `refresh_schema` tool, falling back to
`get_schema` with `stale: true` when the node does not have it. The
engine's exact wording for an unowned tool is not readable from this repo
(it comes from the compiled engine), so `isUnsupportedToolError` classifies
conservatively: false only for errors that clearly came from a tool that
ran, true otherwise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 98f7303c6b8cef104a80ddce0c43d327ffa200ad)

* feat(sql-ui): map declared foreign keys into a relation graph and join paths

The record drawer and the join-path finder need to answer three questions
from a schema snapshot alone: what points at this table, how do I get from
here to there, and what does that join look like as SQL.

Everything here comes from keys the engine DECLARED. Nothing is inferred
from a column or table name, anywhere — a name that looks like a key is a
guess, and a guess presented as a relationship is worse than no answer. A
schema that models its relationships only by convention (and ClickHouse,
which declares none at all) therefore produces an empty graph, which is the
honest result rather than a bug.

findJoinPaths returns EVERY shortest path instead of picking one. Two
tables are frequently joinable in several equally short ways — through
parallel keys, or through different intermediate tables — and which one is
meant depends on the question being asked, which this module cannot know.
Self-referential keys are shown in the drawer but never walked, so a path
cannot loop; composite keys carry all their column pairs into a multi-column
ON clause; and keys pointing outside the snapshot are kept as edges for the
quality rules but are never walkable.

The generated SELECT says what it is on its first line: generated from
declared foreign keys, review before running.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit e2c893e669d798fc6d60e9125283ecbfeac04189)

* feat(sql-ui): record finished statements and bound them before they are kept

Query history rides the app's workspace preferences file, which the shell
reads and writes on every app switch. An unbounded list would make that
switch slower for everyone, so the bounds are enforced in pure functions
before every write rather than left as good intentions: 100 entries per
connection, 8 KB per statement, 1 KB per error, 256 KB across the whole bag.

Pins buy priority, not immunity. They count against the same 100 and are
dropped last, because an unbounded pinned list is the same problem wearing a
nicer name. When a repeat of the same statement collapses onto the entry
before it, the OLDER entry survives with its id, pin and note intact — a
person who annotated a statement meant that statement, and re-running it
must not quietly discard what they wrote.

runEvents is a one-way bus so the runner never imports the store: a run is
never blocked by, or aware of, whether anything is recording it, and a
listener that throws cannot fail the query that just succeeded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit e1fbc2e8a48ee9b60953dd9d074992f7e933f7f4)

* fix(sql-ui): tell the truth about the schema after a designer Apply

The node reflects its schema once, at task start. The designer used to re-read
that same snapshot after Apply and repaint it, so a freshly added column
appeared to vanish. Apply now asks for a fresh reflection and says which of the
two things actually happened:

- re-read succeeded: `Applied HH:MM · schema re-read from the database.`
- the node has no refresh_schema tool, or the re-read failed: a warning saying
  the tree and diagram will not show the change until the pipeline restarts.

Because the answer depends on the snapshot that comes back, the banner is
composed in an effect against the post-refresh snapshot rather than inside the
Apply closure, which cannot see it.

A batch that stops part-way now reports what committed, which statement failed
with the database's own first line, what never ran, and how many statements the
plan still holds — instead of a bare `Apply failed:`.

The confirmation no longer says schema changes "cannot be undone from here",
which reads as if a rollback existed elsewhere. It states the statement count
and the engine's actual behaviour for a half-finished plan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 167ea69d4c081f8e85746c6f82000c95f403e3b5)

* feat(sql-ui): add dialect-aware statement splitter and classifier

The database node executes exactly ONE statement per `execute` call, so a
buffer holding several statements has to be split in the app before anything
is sent. `sql/split.ts` is a hand-written scanner rather than a regex because
a `;` separates statements only in code: inside a string literal, a quoted
identifier, a line or block comment, or a PostgreSQL dollar-quoted body it is
ordinary text. Every dialect difference the scanner claims is asserted in the
tests instead of assumed — MySQL's `#` line comments (which ClickHouse does
not have), PostgreSQL's nested block comments, dollar-quoted bodies and
`E'...'` backslash escapes, and ClickHouse's plain backslash escapes.

MySQL's `DELIMITER` directive is NOT supported and says so in the JSDoc and in
a test that pins the wrong-but-documented split, because a half-working
implementation of a client-side directive is worse than an honest gap.

`sql/classify.ts` decides whether the runner may pass `idempotent: true` (which
permits one silent retry with a fresh token), so anything not clearly read-only
is not a read: `EXPLAIN ANALYZE` is classified by the statement it runs, and a
`WITH` chain containing a data-modifying keyword is a write. Its `patternCheck`
is the text heuristic behind the confirmation dialog; the false positives it is
allowed to have (a WHERE that exists only inside a subquery) are pinned by test
so they are not "fixed" into false negatives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 1942a0b4f4487f06bc8972e4f99cb1c9e512f7d3)

* feat(sql-ui): add pattern notes and raw-text rendering to the plan parser

planNotes() turns a handful of documented planner markers into plain-language
observations, each citing the exact field it read: MySQL access_type = ALL,
filesort and temporary tables (in either the tabular Extra column or the
FORMAT=JSON booleans), and PostgreSQL sequential scans. Row estimates carried
into a note keep the `est.` suffix.

These are text rules over planner output, not a cost model. A node no rule
matches gets no note — silence is not a verdict — and the UI labels the column
`detected by pattern` so nobody mistakes them for measurements.

formatRawPlan() renders the result as the text a reader would have seen at a
SQL prompt, which is what the panel shows first and by default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 3de518dd4d52aec8ab06d5b5214632192f4dd2fd)

* feat(sql-ui): keep query history in the workspace, never in the browser

History lives in the shell's workspace preferences — server-side and per
user — so it follows the person to another browser or machine, which is the
whole reason it is worth keeping at all.

That store has one sharp edge, and it decides the design of this file.
`setPref` patches whichever app is active AT WRITE TIME, and the shell
re-reads each app's preferences from disk on every app switch. A write
issued while SQL Explorer sits in the background would be filed under the
app in the foreground and then lost. So the store writes only while a
HistoryPrefsBridge is mounted, and the shell unmounts the app's whole
subtree when another app takes over, which makes "mounted" mean "SQL
Explorer is on screen". On unmount the pending write is dropped rather than
flushed: up to 300 ms of history can be lost if someone switches apps
immediately after a run, and that is the honest cost of never writing into
another app's preferences.

Everything read back from the workspace file is narrowed field by field.
The file outlives this version of the app and a person can edit it by hand,
so it is treated as untrusted input rather than as our own types.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit f2a265442a71369598e73a94d232422de4c8fd20)

* feat(sql-ui): add a plain EXPLAIN plan panel

ExplainPanel is self-contained: given an endpoint, a dialect and the statement
exactly as it would run, it performs the EXPLAIN itself through the session and
shows what came back. Integration into the query runner is a separate change.

The database's RAW OUTPUT is the default view. The interpreted tree sits behind
a second toggle, and a plan shape the parser does not recognise is an info note
beside the raw text — not an error, because nothing failed on the database's
side. A node that refuses execute gets the allow_execute sentence instead of
driver text, since the statement never reached the database.

PlanTree renders the plan as an ARIA tree: arrow keys walk and expand rows,
every planner number carries `est.`, and the notes column is labelled
`detected by pattern` with each note citing the field it read. No cost bars and
no colour as meaning — every state is text.

Plain EXPLAIN only. Nothing here runs the statement being explained.

The parsers are fixture-tested against documented output shapes; no live MySQL,
PostgreSQL or ClickHouse server was reachable to validate them against.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 98c005937c34c7b67caa5db6a244e4813cbbad2b)

* feat(sql-ui): add the query history drawer

A modeless right drawer, so the editor behind it stays usable — the point of
history is to work alongside the statement being written, not instead of it.
Search, filter by reads/writes/errors/pinned, expand a row for the full text,
then load it into the editor, rerun it, pin it, annotate it, delete it, or
open it in a new query document.

The privacy notice is permanent, not a first-run tip. These statements are
written verbatim to the user's workspace file on the RocketRide server,
literal values and all. Someone who assumed browser-local storage would be
wrong in a way that matters, so the drawer says where the text goes for as
long as it is open.

Rerunning anything that changed data asks first, and the warning quotes what
the statement did LAST time rather than pretending to know what it will do
now — nothing is counted or checked against the database beforehand. The
kind badge comes from a text pattern, which the wording reflects.

"Load into editor" does not ask before replacing the editor's text: the
drawer cannot tell an untouched editor from an edited one, so the query view
owns that confirmation. "Open in new query" deliberately does not mark the
document as generated — the statement is the user's own text and must not
claim to be something this app wrote.

announce() lands here because the shell has no live region: useAnnouncements
fetches GitHub product announcements and only Banner sets aria-live, so
pinning or clearing an entry would otherwise be silent to a screen reader.
The visible live region that reads this channel is mounted by the app shell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 7c88324f53c787838395a86a2829213a10dbe82f)

* fix(sql-ui): retry a failed tool call only when re-running it is safe

A session caches the owning task's token, and that token goes stale
whenever the task restarts. The transport reports a stale token exactly
like a statement the database refused, so the blanket one-retry meant a
write could be sent twice: the first INSERT may well have committed before
the failure surfaced.

Retrying is now a per-call decision. `execute` takes `opts.idempotent` and
retries only when the caller sets it; reflection and dialect reads keep the
retry, since repeating them is free. TableDataView's SELECT and COUNT pass
it; the query runner will pass it only for statements its classifier marks
as reads. An unmarked statement is assumed to write and its first failure
is the answer.

refreshSchema no longer inspects error text to decide whether the node has
the refresh tool. The engine offers no dependable signal for "no such
tool", and matching its wording would rot the first time it changed, so ANY
failure falls back to get_schema with `stale: true` — and schemaStore
remembers that per endpoint, so later refreshes on a node without the tool
read the snapshot directly instead of paying for a doomed call each time.
isUnsupportedToolError and its marker lists are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 9c2f7826d52c0d2ab4ce7eaca010b2de82d6647f)

* feat(sql-ui): show inbound keys, preview queries and join paths in the record drawer

The drawer already said what a table declares. It could not say what points
AT the table, which is the half of a relationship a person usually arrives
looking for, and it left the obvious next step — actually querying the thing
— as an exercise.

"Referenced by" lists the inbound foreign keys and opens the referencing
table in a STACKED drawer, so following a relationship keeps the way back
under the shell's own back arrow instead of quietly replacing what was on
screen. The join-path finder walks declared keys undirectedly to four joins
and lists EVERY shortest path rather than picking one, because which join is
meant depends on a question the schema cannot answer. Parallel keys and
composite keys each come out as their own path, with all their column pairs
in the ON clause.

Both sections carry "from schema snapshot HH:MM". A snapshot read at task
start can be older than the database it describes, and a relationship
presented without its age invites a decision based on a stale reading.

Where there are no declared keys there is no guessing: the section says the
schema declares none, and ClickHouse says so in its own terms. Nothing here
infers a relationship from a column name.

Select top 100 and Count rows open a NEW query document whose first line
marks it as a generated preview. Neither runs anything.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 27c89777443c87ca3b4e217b812e27546a5f5d18)

* feat(sql-ui): show inbound foreign keys before applying a designer plan

The Apply confirmation now has an Impact section naming every table that
declares a foreign key on a column the plan drops, renames or retypes, and
stamps where those facts came from: `from schema snapshot HH:MM`. The wording
says the database MAY reject or cascade the change, because the tool asks the
snapshot, not the database.

Renames are traced back to the name the snapshot knows. A plan that renames a
column and then retypes it under its new name still finds the keys pointing at
the original, which a match on the operation's own name would miss. Composite
keys pair their columns positionally.

Dropping a column adds a line saying its data is not recoverable by this tool.

Column rows already acted as buttons on click; they now carry role, a tab stop,
an accessible name and Enter/Space, so the designer is reachable without a
mouse. Dropped rows and create-mode drafts stay inert, as they were.

ExplainPanel announces a finished plan through the app's live region: unlike a
Banner, a tree and a `pre` do not announce themselves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit c7dc728d9fbab75f4bbe3f0dc701bed40eec2732)

* feat(sql-ui): type result columns and suggest from the schema snapshot

`sql/resultTypes.ts` replaces the blanket `rrType: 'string'` on query results,
which disabled numeric sorting and the grid's number and date range filters.
Types come from the schema snapshot only when the source table is CERTAIN —
one FROM, no join, and every returned column belongs to that table — and from
the returned values otherwise; which of the two was used is reported so the
column tooltip can say `BIGINT (schema)` or `number (inferred from 200 rows)`
rather than presenting a guess as a fact.

Type-name matching is whole-token, fixing the substring test that claimed
POINT as a number and missed MONEY, REAL and SERIAL. Value inference accounts
for the node's sanitisation (Decimal arrives as a float, datetime as an ISO
string) and keeps `0123` a string, since a leading zero is the difference
between a product code and a number.

`sql/completion.ts` builds the suggestion model: alias-scoped columns for an
`alias.` prefix, table names after FROM/JOIN/UPDATE/INTO, columns of the
tables the buffer mentions, and keyword snippets — ordered entirely through
`sortText` because Monaco merges these with its own keyword list. JOIN clauses
are prefilled from DECLARED foreign keys, in both directions, with composite
keys producing a multi-column ON; nothing is inferred from a column's name.
Identifiers that need quoting get it in the inserted text only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit c4af7d8cad2d922b71a6413be6d7ee67486f5ee4)

* fix(sql-ui): mark the EXPLAIN round trip idempotent

Plain EXPLAIN plans the statement without running it, so the session may retry
it once with a re-resolved token after a task restart. Without the flag the
EXPLAIN failed outright whenever the cached token had gone stale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 24ad6e1e361a7a1d6e0ebc01bb4ffe5b74e557ef)

* fix(sql-ui): keep history recorded before preferences were reachable

Runs can finish before any prefs bridge is mounted — a query document open
while its connection workbench is closed records straight into memory. The
store then replaced that bag wholesale on hydration, throwing those entries
away at the exact moment they first became saveable.

Hydration now merges instead: the in-memory entry wins on a shared id, the
two lists are combined by age and re-trimmed, and a write is scheduled for
whatever was waiting. A recording switch flipped before the bridge mounted
outranks the stored one for the same reason.

The connection workbench also mounts a bridge, so an open workbench keeps
the write channel alive for every query document on that connection rather
than only while the History drawer happens to be open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 0a70a2429089b4087e6d63b91aa65c5cb2ed0340)

* feat(sql-ui): add the Insights page — schema shape and snapshot-only review

A second page on the connection workbench that says how the schema is shaped
and what is demonstrably wrong with it, without touching the database: hubs,
leaves and isolated tables from declared keys, then three rules over the same
snapshot. It works with execute disabled, because it never executes anything.

Each rule stops where the evidence stops. R1, a table with no primary key, is
certain. R3, a key naming a table or column the snapshot does not have, is
certain within the snapshot — and a snapshot read before a migration can
raise it against a perfectly consistent database, which is why the page
states when it was read. R2, the two sides of a key spelling their type
differently, is a TEXT comparison after normalisation rather than a
compatibility check, so it is information and never a verdict.

Normalisation drops parenthesised arguments (INT(11) is INT) and resolves a
short, exact alias list — every entry is a spelling the engines themselves
treat as identical, not a judgement that two types are close enough.

There is no naming rule. A column called customer_id that happens to match
another table's primary key is a guess, and a guess sitting in a list of
findings reads as a fact.

The limitations banner is not boilerplate. It is the scope of every claim
the page makes, and it stays on screen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit ef3359f00dd523ca249c4bfcf848e6f54d565baa)

* feat(sql-ui): give the editor a run handle, schema completion and decorations

`SqlEditor` grows the surface the runner needs: a ref handle for the live
selection, caret offset and statement decorations, `Ctrl/Cmd+Shift+Enter` for
run-all, and `Ctrl/Cmd+Shift+E` behind an OPTIONAL `onExplain` prop so the
keybinding exists before the panel that answers it does.

Two things are module-level on purpose. The completion provider is registered
once per language id, not per editor: `registerCompletionItemProvider` is
global and every query document stays mounted, so per-instance registration
would stack providers and repeat every suggestion once per open tab. What
varies per editor is looked up by model URI in a map each instance maintains,
and the providers are deliberately never disposed — they belong to the module,
and tearing them down when one tab closes would break the others. The
decoration stylesheet is injected once behind an id guard, the pattern
`InputField` uses for its placeholder colour, because inline styles cannot
reach a Monaco decoration class.

`sql/batch.ts` holds the per-statement run model and its wording, including
the batch summary that has to say a failed batch left earlier statements
committed, and `applyRowLimit`, which appends the header limit only to
statements that return rows and leaves a user's own LIMIT alone.

`sql/failure.ts` reads a failed execute against the four messages the node
actually produces, matching the machine-stable fragment (`allow_execute`,
`max_execute_rows`) rather than a whole sentence. It recognises the generic
placeholder that node versions predating the error-text fix return, so the app
can say the driver's message was not returned instead of presenting the
placeholder as what the database said.

`LiveRegion` renders the existing `a11y/announce` store into one polite live
region, keyed by the store's revision so announcing the same sentence twice
still speaks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 6a429e2790cd938a54b277bb06be6e5f58a367b7)

* feat(sql-ui): run the selection or statement at the cursor, honestly

The query runner previously sent the WHOLE editor buffer as one `execute`,
which the driver rejects for anything multi-statement, and reported failures
through a bare error string. It now splits the buffer and runs one statement
per call, because that is what the node supports.

Everything the runner says is tied to how the node actually executes:

- Each statement runs in its OWN transaction, so a batch that fails partway
  leaves the earlier statements committed. The status strip shows every
  statement's outcome and the summary line says which ones committed, which
  failed and which never ran. The error banner repeats it for the failure.
- There is no cancel anywhere in the tool protocol. "Stop waiting" appears
  after a second, bumps a run sequence so the late answer is discarded, and
  says the database may still be running the statement. It never claims to
  have stopped anything.
- Transaction-control statements would each land in a separate autocommit
  call, so they are refused before anything is sent rather than run into a
  false sense of safety.
- `idempotent` is passed only for statements the classifier reads as READ, so
  the session's retry-with-a-fresh-token can never re-send a write.
- The confirmation dialog for an unbounded UPDATE/DELETE, TRUNCATE, DROP or
  ALTER calls itself a text check and says it is not a database safeguard. It
  is not called a safe mode, because it prevents nothing.
- When the node returns its generic placeholder instead of the driver's
  message, the banner says the message was not returned rather than showing
  the placeholder as the database's words. When the driver's text IS there, it
  is shown verbatim in a collapsible block.

What a run would send is named in TEXT under the editor before the key is
pressed, not only by a decoration, and the same applies to the result meta:
the applied limit is stated, and a result that exactly fills the limit is
badged as possibly incomplete rather than presented as the whole answer.

Export is a text hint pointing at the grid's gear menu. `IDataGridHandle`
exposes the Tabulator instance but the shell registers 13 Tabulator modules
and DownloadModule is not among them, so `table.download()` does not exist;
adding a second CSV writer to the app was explicitly out of scope.

Result columns are typed from the schema when the source table is certain and
from the values otherwise, which restores numeric sorting and the range
filters, and the column tooltip says which of the two it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 863dc06a7f6ae2a9f2b9d994e9e560db21803fed)

* fix(sql-ui): read the live selection on the run path, settle a pending confirm

Two narrow correctness fixes in the runner.

The run path took the selected TEXT from the editor handle but its OFFSETS
from the debounced cursor state, so a run fired within the debounce window of
a drag attributed the run to the previous selection's lines — the statement
sent was right, the lines reported for it were not. The handle now exposes
`getSelection()`, returning text and offsets together from the live editor.

A pattern-check confirmation open when the document unmounts left the run loop
awaiting a promise nothing could settle. The unmount now answers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit f753c373e15dbad037b4e43e6729864385214cec)

* docs(apps): add the SQL Explorer guide

SQL Explorer had no product page: the only mention of the app anywhere in
docs/ was a builder task reference. This adds the spine page and its Apps
sidebar entry, describing the app as it actually behaves today.

The page leads with the constraints rather than hiding them, because they
are what surprises people: every statement travels through a pipeline
database node, so `allow_execute` gates execution, each statement commits
on its own, transaction control is refused, nothing can be cancelled, and
the schema is a snapshot taken when the task started. The verbatim UI
microcopy is quoted so the docs and the app say the same words.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 1601764e6db8eabbf2de7e20b7dc77c0cd605271)

* feat(sql-ui): finish the runner's drawers — focus return, explain gating, prefs bridge

Completes the integration points QueryView owns, now that the history and
explain panels are on the branch.

Focus return. Closing either drawer puts focus back on the button that opened
it. The shell's Button forwards no ref, so each opener sits in a
`display: contents` wrapper the view can query — no layout change, and the
alternative (leaving focus on the document body) strands keyboard users at the
top of the page after every close.

Explain gating. The button is disabled with a reason rather than silently
doing nothing: `buildExplain` returns null for engines with no supported
EXPLAIN form and for an empty statement, and the title says which it was. The
panel is now mounted unconditionally and driven by `open`, as its own contract
asks, and is handed the statement exactly as Run would send it — applied LIMIT
included — so the plan describes the real statement.

History prefs bridge. The history store only writes while a bridge is mounted.
One at app level covers a query document whose connection tab has been closed;
the bridge is reference-counted, so the copies in ConnectionView and in the
drawer stay harmless.

Announcements. `Banner` is itself a live region for every variant — assertive
for error, polite otherwise — so announcing an event that is also rendered in
a Banner reads it out twice. The transaction refusal, the stop-waiting notice
and the failure announcement are dropped for that reason; what remains
announced is only what has no live region of its own: the batch summary, the
row and affected counts, the pattern-check switch, and loading a statement
into the editor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 902f2cd76c9b0d0361225a091ba6e1560919726f)

* fix(sql-ui): ask for a fresh read when the user presses Refresh

Every user-facing Refresh called `refreshSchema` without `{ fresh: true }`,
so it re-read the node's task-start snapshot and returned the same answer
it already had — even on a node that has the `refresh_schema` tool and could
have gone to the database. The one place that asked for a fresh read was the
designer's post-Apply path. Pressing Refresh after a CREATE TABLE therefore
did nothing visible, which is the worst possible result: the control looks
broken when the capability was there all along.

The workbench header, the Overview retry and the sidebar tree's refresh now
all ask for a fresh read. Automatic first loads still do not, because a page
opening does not need two round trips to say what it already serves.

The header stamp stops blurring the two things a "read" can mean. A node
that re-read the database says `snapshot re-read HH:MM`; a node serving the
reflection taken when its task started says `task-start snapshot, read
HH:MM`, however recently we asked. The old wording let the second pass for
the first, which is what made the missing table look like our bug rather
than a node capability that is not there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 17353eb86de6cabda7ec205f70b834c65b67afce)

* fix(sql-ui): stop quoting the node's placeholder as the database's words

A node without the execute error-text fix returns `SQL execution failed (check
server logs for details)`. The Explain panel and the designer both printed it
after `Database reported: `, presenting the node's placeholder as if the
database had said it. Both now route through describeFailure and show the
fallback sentence when the message is generic, with no "Database said" block
to open — there is nothing in it.

Where the driver's text IS real, both surfaces now match the query runner: the
first line as the headline, the full text behind a collapsible block. The
designer had no such block before, so multi-line driver text was lost.

The Explain subtitle said "Validated by EXPLAIN". Nothing is validated — the
planner returned an estimate, which the same line goes on to say. It now reads
`EXPLAIN ran HH:MM`.

The ClickHouse note claimed ALTER runs as an asynchronous mutation. That holds
for ALTER ... UPDATE/DELETE, not for metadata operations like ADD COLUMN, so it
now says some ALTER forms do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 724d70696fcaa8dbde790ce66bd9d7f620738685)

* fix(sql-ui): let the review rules know the dialect, and say what they mean

R1 had no dialect input, so "table has no primary key" fired on every
ClickHouse table — the engine reflects no primary-key constraint for any of
them, a MergeTree ORDER BY not being one. The Insights tab then showed a
number equal to the table count and the grid called each row "definitely
wrong in this snapshot". Every part of that was false, and the loudest part
was the verdict.

R1 now takes the dialect and drops to information on ClickHouse with wording
that names the engine rather than accusing the schema. Severities read
`warning` and `info` throughout: a missing primary key is worth fixing on
MySQL or PostgreSQL, but a log table without one is a choice, not an error,
and "definitely wrong" was never ours to say.

The tab badge counts warnings only. Information is something to know, not
something to do, and a badge lit on every healthy schema stops being read.

The Rule column shows the rule's name instead of R1/R2/R3. The ids were for
cross-referencing a document the reader does not have, and there was no
legend anywhere on the page. Hub chips say what their number counts —
`orders · 5 referencing tables` rather than `orders 5`, which could have
been rows, columns or anything else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 8698af8c30b79c04b840d69f79447f027fb79472)

* fix(sql-ui): give history rows their limit, and stop announcing a load that may not happen

A history row said `1000 rows`. That number cannot tell you whether it is
the whole answer or the ceiling cutting it off, and those mean opposite
things — every other count in this app carries its suffix for exactly that
reason. The entry gains an optional `limit`, the badge reads `12 rows (limit
1000)` when one was applied and `12 rows returned` when none was, and the
runner fills the field in.

"Load into editor" no longer announces. The query view may raise a "Replace
editor text?" confirm that the user cancels, in which case the announcement
was a lie, and on the path where the load does happen the query view already
announces it — so the drawer was either wrong or repeating.

The recording toggle now says `Recording: on` / `Recording: off`. It was a
pressed ghost button whose text never changed, which left the state carried
by colour alone for anyone reading it by eye.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 3aadbae8ebc612b1a5a6bcec68a672b9a7c8808b)

* fix(sql-ui): say when the join-path list hit its cap

The finder stops enumerating at 25 equally short paths, and the drawer
reported that as "25 paths found — choose one", which claims there are
exactly 25 and that one of them is the one you want. At the cap the list is
a sample, so it now reads "25 paths shown (cap reached — more may exist)".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit dfef8bab31035f140aa045a598c56f8c1e71f89b)

* refactor(sql-ui): type the query-document payload and hide module internals

`IQueryDocPayload` existed but every producer built the payload as an
untyped object literal, so a misspelled field would have shipped silently
and shown up as a query document that opened blank. The type moves to
docs.ts, beside the URI builders every producer already imports — nobody
should have to import a view to describe what they are handing it — and the
two call sites that build a payload now declare it.

Opus B: SqlApp's local copy can become a re-export from docs.ts; the shapes
are identical, so nothing breaks in the meantime.

`DEFAULT_MAX_DEPTH`, `JOIN_SQL_HEADER` and the history store's preference
keys, `attachPrefs` and `recordRun` were exported with no caller outside
their own module. An export is a promise to keep something stable, and these
were promises nobody had asked for. `MAX_JOIN_PATHS` stays exported with a
note saying why: the drawer compares against it to admit the path list is a
sample rather than the answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit d6a927e390f93aa18892d5a94af2009d264c73ce)

* fix(sql-ui): split a selection instead of sending it as one statement

A dragged selection was sent as a single `execute`, classified by whatever
keyword happened to come first. Selecting `SELECT …; DELETE FROM t` therefore
read as a SELECT: the statement was marked read-only and so became eligible
for the session's retry-with-a-fresh-token, which could run the DELETE twice;
the pattern check only ever saw the first verb; the transaction refusal only
fired when the FIRST statement was transaction control, so selecting
`UPDATE …; ROLLBACK;` sent both and the ROLLBACK silently did nothing. psycopg2
executes multi-statement strings, so this was reachable, not theoretical.

`splitStatementsIn` splits a slice of the buffer and rebases every offset and
line number onto the whole buffer, so each statement in a selection gets its
own classification, pattern check, limit, history entry and strip position
while decorations still point at the real text. A selection holding part of
one statement still yields that one fragment, which is what a partial
selection wants.

The pre-run line now says how many statements a selection holds, so a
multi-statement selection cannot be mistaken for one before it runs, and a
buffer holding only comments reads `no statement found` rather than claiming
the editor is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit e7c2adbd935f159e1cc207132c1c0f9fe5f115f2)

* fix(sql-ui): publish the completion model once the editor model exists

Schema autocomplete was silently dead on the ordinary path. The effect that
publishes this editor's completion model into the shared provider's map read
the model URI from a ref and returned early when it was null, keyed only on
`[completion, dialect]`. @monaco-editor/react mounts asynchronously — loader
promise, then editor, then onMount — so the URI is null on the first pass, and
nothing re-ran the effect unless the snapshot happened to arrive afterwards.

Browsing a connection and then opening a query document, which is the normal
order, therefore produced a completion model that was never published: the
provider found no entry for the model and returned nothing, leaving only
Monaco's built-in keyword list. It appeared to work only when the schema
finished loading after the editor had mounted.

The URI is now state as well as a ref, so the effect runs again once it is
known, and `handleMount` also publishes immediately from prop mirrors rather
than waiting a render. The JSDoc records why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 09f046243fcc62ae6b84836d12fd5119d10b6c41)

* fix(sql-ui): say "ran" for reads, and stop claiming no limit was applied

Three wording and modelling corrections. The QueryView side of each landed in
e7c2adbd; this is the model and the tests.

A read that finished now says `ran`; only a write or DDL says `committed`. The
strip could previously report `1–3 committed` for three SELECTs, which dilutes
the one word the app uses to mean the change is durable and cannot be undone
from here. Anything not clearly read-only is still called committed, which is
the safe direction. `formatPriorStatements` builds the failure banner's second
sentence the same way, so a batch that failed after two reads says they ran
rather than implying data changed.

`applyRowLimit` now reports which of three states applies. A statement
carrying its own LIMIT used to render `100 rows returned (no limit applied)` —
true of the app and badly misleading about the result, since the generated
"Select top 100" reads exactly that way and a reader concludes the table holds
100 rows. It now reads `(limit in statement)`, and the check runs before the
header selection so `All` reports it too.

A statement rerun from the history drawer has no position in the editor
buffer, so `startLine`/`endLine` are optional rather than faked as line 1 — the
status strip omits the line reference instead of pointing at unrelated text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 91fb9fe13ed64c2aaf3f415b1fa72720af8e0153)

* fix(sql-ui): stop telling users Refresh will find a missing table

The suggestion hint read "Missing a table? Refresh schema on the connection
page." On a node without the `refresh_schema` tool that advice does nothing at
all: the node reflects once at task start and serves that reflection however
often Refresh is pressed, so a table created since will not appear no matter
what the user does from here.

It now reads "Missing a table? The snapshot dates from HH:MM; Refresh re-reads
it only on nodes with refresh_schema." — which dates the data and says what
the button can and cannot do, rather than sending the user round a loop that
cannot succeed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit a1c7ca002cf2a1f6c20bf55bc9762989235b3d35)

* chore(sql-ui): drop the unused table-name decoder

`tableFromTableDataUri` was added as the symmetric counterpart to the URI
encoder and has never had a caller — the views read their table from the
document's content payload. An export with no caller is a maintenance claim
nobody is checking, so it and its tests go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit 497540e230285405ac222b1104419400a7e9343e)

* fix(sql-ui): make Explain give the same answer however it is invoked

The button's disabled state was computed from the debounced caret while the
click handler read the live editor handle, so the two could briefly disagree:
the button could look available and then do nothing. Worse, Ctrl/Cmd+Shift+E
returned silently whenever the statement could not be explained — a keybinding
that does nothing at all reads as a broken app, and unlike the button it has
no tooltip to explain itself.

Both paths now go through one `explainRefusal` function, so they can never
offer different reasons. The live handle still decides WHAT is explained, so a
caret move that has not settled cannot send the previous statement, and when
the answer is that nothing can be explained the reason is announced — the same
sentence the button's title carries.

Also drops SqlApp's own copy of the query-document payload type in favour of
the one in docs.ts, which is where the panels that build the payload already
read it from. The shapes were identical; two declarations of the same contract
is one too many.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit d83e48224c0ab1d840ba86411f8a6861af2f724d)

* fix(sql-ui): ask for a fresh schema read from the diagram's Retry and Reverse Engineer

The two user-initiated refreshes in DiagramView still served the task-start
snapshot even on nodes that provide refresh_schema. Also drops the unused
IQueryDocPayload re-export from SqlApp (producers import it from docs.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit c9331827e435ab0f1bf3a8baf5be92b36c39d3ef)

* docs(apps): sync the SQL Explorer guide with the reviewed UI wording

Reads say "ran" and writes "committed" in batch outcomes; the results line
has three limit states; the ClickHouse ALTER note covers only mutation forms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4fLnswQF6yKQ6x7eWzSdd
(cherry picked from commit f9c7664ab4a6fbd27752db8fd59449874511e7d8)

* fix(sql-ui): make the appended row limit reach the statement it bounds

`applyRowLimit` ran its checks on comment-stripped text but built the
statement from the raw one and joined the clause with a space, so
`SELECT * FROM orders -- daily` was sent as `... -- daily LIMIT 200`:
the database ran an unbounded SELECT while the meta line reported
"limit 200". The clause is now joined with a newline, which terminates
a trailing line comment before LIMIT.

The `in-statement` check had the mirror-image problem: `/\blimit\b/i`
matched anywhere in the statement, so a LIMIT inside a subquery (or
inside a string literal) made the app skip its own limit and label the
result "limit in statement" while the outer query streamed every row.
It now uses the splitter's `hasTopLevelKeyword`, which already masks
strings and comments and only accepts a match at parenthesis depth 0.

The existing comment test asserted `.limit` alone and so never saw the
broken SQL; the regression tests assert `.sql` and re-strip comments
from the emitted statement to prove LIMIT survives as code.

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

* fix(sql-ui): enforce each dialect's delimiter preconditions in the splitter

Two delimiters were recognised more eagerly than the engines recognise
them, and in both cases the scanner swallowed a statement separator.

MySQL only starts a line comment when whitespace (or a control
character) follows `--`: it parses `SELECT 1--2; SELECT 3` as
arithmetic across two statements, while the scanner hid the semicolon
and returned one. `opensDashComment` applies that precondition for
MySQL alone — Postgres and ClickHouse keep taking a bare `--`.

PostgreSQL allows `$` inside an unquoted identifier, so `foo$tag$` is
one identifier and opens no dollar-quoted body. `continuesIdentifier`
refuses a dollar quote whose `$` follows an identifier character, so
`SELECT foo$tag$; SELECT 2` splits again.

Near-miss cases for both are in the splitter's table, including the
contrasting Postgres reading of the same `--` text.

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

* fix(sql-ui): stop one bad refresh from disabling fresh reflection

Two ways a refresh could quietly stop happening, both leaving the app
describing the database against a snapshot it knows is out of date.

A single fallback latched `refreshTool: 'unavailable'` for the rest of
the app session, and nothing ever cleared it. But the session cannot
tell a missing tool from a restarted task or a timed-out call — it says
so itself in `connect/session.ts` — so one fallback during a `fresh`
refresh made every later Refresh Schema, Reverse Engineer and post-DDL
read serve the task-start snapshot. The state now carries a `suspect`
step: one fallback suspects, a SECOND CONSECUTIVE one latches, and a
fresh read that answers clears the suspicion. A node that really has no
`refresh_schema` still stops paying two round trips after two tries.

`refreshSchema` also returned immediately whenever a refresh was already
loading, so Apply in TableDesignView could neither start nor await its
post-DDL reflection — and the outcome effect composes its banner once,
from the snapshot present when Apply resolves, so the change was
reported against pre-DDL structure. Refreshes are now serialised per
connection: an ordinary read joins whatever is running, while a `fresh`
request queues behind it and runs its own, because a plain re-read
cannot answer a caller that just ran DDL.

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

* fix(sql-ui): do not record history before the recording switch is read

A persisted `sql.historyRecording[key] === false` is unreadable until a
bridge mounts and `hydrate` runs. In that window `recording[key]` is
merely absent, which the guard read as the default — on — so a run that
finished first stored the user's SQL, and `hydrate` then merged that
entry and scheduled it to the workspace preferences file. History the
user had explicitly turned off was persisted, statement text and
literal values included.

Runs that arrive before hydration now wait in a bounded queue and are
decided once the stored flags are in hand: replayed oldest-first when
recording is on for that connection, dropped when it is off. The queue
is capped at 100 entries for the same reason the history itself is
bounded — it holds the user's SQL.

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

* fix(sql-ui): keep a history entry's row limit and its full statement

`IHistoryEntry.limit` exists because a row count without its limit is
ambiguous — 1,000 rows is either the whole answer or the ceiling cutting
it off. Two transformations lost it anyway: `collapse` kept the OLDER
run's limit beside the NEWER run's row count, and `readEntry` dropped
the field entirely on hydration, so every limit vanished the first time
the workspace file was re-read. The limit is now replaced with the rest
of the run facts on collapse, and validated as a number on hydration.

`trimHistory` also deduplicated AFTER truncating, so two different
statements sharing their first 8 KiB collapsed into one entry and one of
them was lost. Repeats are now collapsed on the untruncated text and the
storage bound is applied to what survives.

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

* fix(sql-ui): make "Run and stop asking" hold for the rest of the batch

`setPatternChecks` only updated state, and the ref the run loop reads
was mirrored by a passive effect. The loop resumes from its awaited
confirmation in a microtask and reaches the next statement's check
before React runs that effect, so iteration i + 1 still saw `true` and
opened the dialog again — for every further matching statement in the
same batch, however many times the user chose "Run and stop asking".

The ref is now written next to the state update, which is the only
write ordering the run loop can rely on.

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

* fix(sql-ui): restore the mounted flag when StrictMode replays the effect

The cleanup set `mountedRef.current = false` and only `useRef(true)`
ever set it back, which a re-run of the effect on the SAME instance does
not re-execute. Under the shell's `React.StrictMode` root that leaves
the flag false after the replayed setup, so `record` returns early and
no completed statement reaches history for as long as that instance
lives. The setup now asserts the flag it owns.

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

* fix(sql-ui): bind the table name in both foreign-key catalog queries

`fetchForeignKeyNames` interpolated the table name through
`quoteLiteral`, which doubles `'` and leaves `\` alone. On a MySQL
connection that honours backslash escapes — the default outside
NO_BACKSLASH_ESCAPES — a name containing `\'` ends the literal early
and the remainder of the name becomes part of the WHERE predicate. The
name is a VALUE compared against INFORMATION_SCHEMA columns, so both
dialects now take it as a `$1` bind, the way every value in
`sql/paging.ts` already travels; the node rewrites `$n` per driver.

That left `sql/quote.ts` with no callers. Its own header says it exists
for the statements that cannot bind, and there are none left, so the
module and its suite go with the last use rather than standing by as a
ready-made way to reintroduce the same class of defect.

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

* fix(sql-ui): do not render an error headline with nothing after it

`describeFailure('')` returned `generic: false`, and both the query view
and the Explain drawer show the headline whenever `generic` is false —
so an Error carrying an empty message produced the banner "Database
reported:" followed by nothing. An empty (or whitespace-only) message is
the same situation as the node's own placeholder: there is nothing real
to quote, which is exactly what the generic wording says.

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

* fix(sql-ui): make the drawers report what actually happened

Four places where the UI said nothing, or said the wrong thing.

Copy in the row inspector took an optional chain through
`navigator.clipboard`, so an absent Clipboard API skipped both callbacks
and the button neither copied nor announced anything. A missing API now
announces the same failure a rejection does.

The Explain drawer showed "Explaining…" for as long as the shell
connection was down, because `run` returns before sending anything when
there is no client — so `running` never became true and no request was
ever in flight. It now says it is not connected.

The history row's statement and note were inline spans, where
`overflow`, `text-overflow` and vertical margins have no effect: a long
one-line statement overflowed the drawer instead of ending in an
ellipsis, and the note ran on beside the statement instead of under it.
Both are block boxes now.

The live region replaced its child by key, which performs the removal
and the insertion in ONE commit; assistive technology can coalesce that
into no change and miss a repeated sentence. It now empties and restores
the text in separate tasks, cancelling a pending restore when a newer
announcement arrives.

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

* fix(sql-ui): keep a control's outcome to the control the user pressed

The data grid's error banner survived until the NEXT request succeeded,
so after one failed page a fresh search, sort or page left the previous
request's failure on screen while the grid loaded different parameters —
the banner described a question the user had already moved on from. The
error is now cleared where the new sequence is taken.

In the designer, the column row carries button semantics and contains
the Drop button, so a click (or Enter/Space) on Drop bubbled into
`selectColumn`: the column was dropped and its editor opened at the same
time, leaving the user staging an edit for a column that is on its way
out. The cell around the button stops propagation — the shell's Button
passes no event to its handler, so it cannot be stopped at the source.

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

* fix(sql-ui): open the table record drawer where it can be seen

`requestTableRecord` is raised by the Insights panel and by the sidebar
tree, but the drawer it opens is rendered by OverviewPanel — and
TabPanel hides the inactive panel with `display: none`, so a table
opened while Insights was in front raised a drawer inside a hidden
subtree. The connection view now brings Overview forward when a request
for its own connection arrives, which covers the sidebar route as well.
Requests raised before the document mounted are ignored, so opening a
connection never overrides the page the user left it on.

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

* docs(apps): state where a statement's own LIMIT has to be

Two sentences in the SQL Explorer guide now match what the code does:
`limit in statement` reports a LIMIT on the outermost query only, and
the splitter paragraph names the two dialect preconditions a reader
would otherwise have to discover — MySQL's whitespace after `--`, and a
`$tag$` that continues an identifier opening no dollar quote.

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

* refactor(sql-ui): test MySQL's comment precondition without a control regex

The dash-comment check spelled its "whitespace or control character"
rule as a regex class covering U+0000-U+001F, which the repo's
`no-control-regex` lint rejects. A `\s` test plus a code-point
comparison says the same thing and keeps `eslint apps/sql-ui` clean.

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

* fix(sql-ui): never collapse a history entry that is already truncated

trimHistory compares statements on their text, and the previous fix moved
the collapse ahead of the per-entry bound so a FRESH pair is compared on
full text. That protects an entry only up to its first truncation.

historyStore.hydrate reads bounded entries back out of the workspace file
and passes them through trimHistory again on every reload. By then `sql`
is a prefix, so two different statements that agree for their first
maxSqlChars characters satisfy sameStatement and one real entry is
collapsed away — silently, and once per app open.

A prefix cannot prove two statements are the same, so a truncated entry is
no longer merged at all. The alternative, persisting a full-text
fingerprint, was rejected: it widens the persisted schema with another
field read back from an untrusted, hand-editable file, and every entry
already on disk lacks it, so the conservative rule would still be needed
as the fallback. What it buys is collapsing consecutive re-runs of an
8 KB+ statement, which costs one extra row when it does not happen.

Tests: a reload-shaped regression (trim, then trim the output again) and
the fresh-repeat-of-a-truncated-entry case. Both fail without the guard.

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

* fix(sql-ui): write the history recording switch through, not on the batch

setRecording only called schedulePersist, a 300 ms debounce, and the last
bridge detach CANCELS a pending write. Switching apps within that window
dropped the write: the workspace file kept saying recording was on, and
the next page load resumed recording SQL on a connection the user had
switched off. The queue added for pre-hydration runs narrowed the exposure
but never closed it, because it only governs runs that arrive before the
flag is readable, not a flag that never reached disk.

The switch is now written through immediately. Runs still batch — they
arrive in bursts and one lost run costs a list row — but a switch is
flipped once, deliberately, and losing it costs the user a decision about
their own data that they will never be told was discarded.

A second defect sat behind the same line. hydrate merged the whole
in-memory `recording` map over the stored flags, so any flag from an
earlier attach outranked the file for the rest of the session — a stale
`true` surviving a detach turned recording back on for a connection the
file says is off. Only overrides that have NOT yet reached disk may
outrank the file, so they are tracked separately in `unsavedRecording`,
cleared on a successful write, and hydrate now schedules a write when one
is still outstanding (a switch flipped while no bridge was mounted).

attachPrefs is exported: it is the store's real seam onto the shell, and
the rules worth testing live there rather than in the null-rendering
bridge component.

The module had no test coverage at all. It now has seven tests driving it
the way the app does — attach, act, detach — asserting on what reached a
fake workspace file, because that file is the privacy boundary. Three of
them fail against the previous code.

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

* docs(apps): state what "no limit applied" means with All and an inner LIMIT

applyRowLimit recognises only a LIMIT on the outermost query. With the
header set to All, a statement whose only LIMIT sits in a subquery
therefore reports state `none`: QueryView prints `no limit applied` and
nothing is appended. The guide described `no limit applied` as the case
for All "with no LIMIT in the text", which reads as a contradiction the
first time someone sees the line on a statement that plainly contains one.

The guide now names that combination and shows a join against a limited
subquery, where the inner bound genuinely does not bound the result, so
the line is read as a statement about the result rather than the text.

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

* test(sql-ui): fail the test target when discovery finds no tests

`builder test` runs sql-ui:test in CI, and the action returned cleanly —
task.output set, exit 0 — both when tests/ was absent and when the
*.test.ts(x) filter matched nothing. Either state reported the whole
target green while not a single assertion had run, over a suite of 521
tests that is the only cover this app has.

Both cases are now errors. tests/ is tracked source: a directory that has
gone missing, or a…
…2376)

* fix(ai): pass single-component validate payloads through unwrapped

* style(ai): drop extra blank line so ruff format passes
* fix(db_hotdata): match the live Hotdata API result contract

Three defects, all traced to the node disagreeing with what the API
actually returns. Verified against api.hotdata.dev.

Result rows never got their column names. /v1/query and /v1/results both
answer with `columns` (the names) and `rows` (lists of *values*), never
with objects:

    {"columns": ["city","units"], "rows": [["Reno",200],["Austin",120]]}

_run_sql passed `rows` straight through, so everything downstream saw
positional lists. _rows_to_markdown fell through its isinstance(dict)
check to str(row) and emitted Python list reprs - "['Reno', 200]" - on
the answers, table and text lanes instead of a Markdown table. The same
cause silently emptied _schema_via_sql, whose `if not isinstance(row,
dict): continue` dropped every row, so natural-language SQL was
generated against no schema at all. Names are now attached once, at the
_run_sql boundary, with repeated and empty column names disambiguated so
`SELECT city, city` cannot collapse to one value.

get_result and get_query_run never sent X-Database-Id. Both endpoints
are database-scoped and answer 400 "this endpoint is scoped to a
database" without it, so the whole deferred-query path - every truncated
result and every async run poll - failed 100% of the time.

get_data retried failures no rewrite could fix. A bare `except
Exception` fed anything back to the generator, so a bad workspace (HTTP
404 workspace_not_found) spent three LLM calls and ~100s before
reporting "could not answer after 3 attempts", with the real cause
buried in the tail. Retries are now limited to what a different
statement could plausibly fix: HTTP 400, plus a deferred run that failed
server-side (SqlStatementError, which carries no HTTP status). Auth,
workspace, database and transport failures raise as-is, keeping the
server's own wording. Same case now fails in ~8s naming the 404.

Also floors async_after_ms at 1000; the API rejects anything lower, so a
pipeline configured below it failed every query.

Five existing tests changed: their mocks returned `rows` as dicts and
raised SQL errors as bare RuntimeError, neither of which the live API
does. That unfaithful mocking is why the row-naming bug shipped green.
Mocks now fail the way the client actually fails, and 13 new tests pin
the behaviour (162 collected, up from 149).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(db_hotdata): stop a generated column key overwriting a real one

Review catch. Both cases drop a value silently, which is the exact
failure the naming helper exists to prevent.

_name_columns checked a generated name against the base it was derived
from, not against the names already taken. `SELECT city, city, city_1`
renamed the second `city` to `city_1` and collided with the real third
column, so the dict kept two values out of three.

The same hole sat in the wider-than-header tail: `column_3` is a legal
column name, so for columns ['a', 'column_3'] a three-value row parked
its third value on the key already holding the second.

Both now allocate through _free_name, which suffixes until the key is
unclaimed and reserves it. Two regression tests, which fail on the
previous code with 'city_1' twice and [1, 3] respectively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(db_hotdata): retry on the statement-error contract, not a bare 400

Review catch. _run_sql drives four endpoints - create_database, query,
get_query_run, get_result - and `status_code == 400` treated a failure
from any of them as SQL the model could rewrite. A bad ttl, a malformed
limit or a missing scoping header would each burn every LLM attempt and
then be replaced by "could not answer after N attempts", which is the
exact masking this branch set out to remove.

The server distinguishes the two itself: it answers with a query_run_id
only once it has created a run and executed the statement. Verified
against the live API - missing table, parse error and unknown function
all return 400 with a run id; bad ttl and out-of-range limit return 400
without one.

HotdataError now carries query_run_id off the error body, and
_is_sql_fixable requires it. SqlStatementError still covers the deferred
path, where a terminal run failure is reported in the poll body with no
HTTP status to read.

Four tests: the client lifts the run id when present and leaves it empty
when absent, and get_data retries the first while raising the second
as-is without spending a second LLM call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(db_hotdata): close out the review - signature, schema, docs, rendering

Review catch, plus a full pass over the branch for the same class of
problem so they land together rather than one per round.

get_result kept `offset` and `limit` in their original positions.
Database scoping had been inserted ahead of them, so a positional
get_result(result_id, offset, limit) written against the old signature
would have sent its offset as the X-Database-Id header. `database_id` is
now keyword-only and last.

services.json advertised `minimum: 0` for async_after_ms while the node
floors it at 1000, so the form accepted a value the node then silently
overrode. Both now read 1000 - the smallest the API accepts - and a test
ties the schema to the constant so they cannot drift apart.

A cancelled run is no longer classified as a statement failure. A
rewritten query cannot un-cancel a run, so it must not earn an LLM turn.

Two rendering bugs that only became reachable once rows were objects:
header cells were not escaped, so an alias such as "a|b" split its
column in two, and SQL NULL rendered as the Python word "None". Headers
go through _cell like body cells, and NULL is an empty cell.

README: get_data retries a rejected statement, not any failure, and the
troubleshooting table says why a 401/404 surfaces on the first attempt.

Corrects a claim made earlier on this branch. The docstrings cited
`SELECT city, city` as the source of repeated column names; the planner
rejects that outright. Verified against the live API, the real source is
a join - `SELECT a.city, b.city` answers ['city', 'city'] and `SELECT *`
over a self-join answers ['city', 'units', 'city', 'units']. The naming
helper is needed for exactly that, and a test now pins the live shape.

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

* fix(db_hotdata): feed the real failure reason back for a deferred run

Found by driving the deferred path against the live API, which no test
had done: every earlier live query was small enough to return inline.

A run that fails after deferring reports its cause as `error_message`:

    {"status": "failed",
     "error_message": "query execution failed: Arrow error: Divide by zero error"}

_await_run read `error` or `message`, found neither, and fell back to the
status, so the exception read "query failed: failed". That text is what
the model is handed for its corrective attempt, so every deferred
failure was being retried blind. The jobs poller in the same file
already reads `error_message`; this was the one place with guessed keys,
covered by a mock that encoded the guess.

_run_failure_text reads `error_message` first, then `error` as text or
as the {"message": ...} object the query endpoint uses, then `message`.

Verified live with a 20M-row window query that divides by zero on its
last row: the run deferred, polled four times, failed, and the caller
now receives the Arrow error. The same session confirmed the deferred
success path (query -> 4x get_query_run -> get_result) and the truncated
path (query truncated=true -> get_result) end to end for the first time.

Also adds docstrings to the functions this branch touches; coverage on
touched functions goes from 66% to 90%.

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

* fix(db_hotdata): wait for the run before reading a truncated result

Addresses the review on #2372, read against the published contract at
https://www.hotdata.dev/openapi.yaml.

A result that was still being saved came back as an empty answer. A
truncated /v1/query response always carries a result_id, so the old
`result_id is None` test never waited for it - and per QueryResponse
that id "is issued while the save is still in flight, so on its own it
does not mean the result can be retrieved". Reading it early answers 202
{status, result_id} with no rows; _request only raises from 400 up, so
_run_sql returned zero rows for a result large enough to be truncated.
Reproduced live with an 8M-row query: 200 truncated, then 202
"processing", then 200 once the run had succeeded. _run_sql now waits on
the run whenever the server deferred or truncated, and get_result raises
on a pending/processing body instead of letting it read as empty.

`interrupted` is a documented terminal run status the poll loop did not
know, so it was polled until job_timeout_secs ran out and reported as a
timeout that never happened. It is now terminal and raised as a plain
RuntimeError, not SqlStatementError: a rewritten statement cannot
un-interrupt a run. `cancelled`/`canceled` were never seen live; the
comment on the set says so.

_query_run_id reads inside the `error` object as well as beside it, the
way _error_code does, since the documented error body is
{"error": {code, message}}.

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

* Update nodes/src/nodes/db_hotdata/IInstance.py

Co-authored-by: Alexandru Sclearuc <asclearuc@gmail.com>

* fix(db_hotdata): run an interrupted query once more, as the contract says

Addresses the three notes left with the approval on #2372.

`interrupted` was classified correctly - terminal, not the statement's
fault, never SqlStatementError - but nothing acted on the other half of
the contract, which calls it "terminal, and safe to retry". The caller
lost a whole tool call because Hotdata replaced a server, and so did
`execute` and `get_schema`, which share the path.

_run_sql now runs the same statement once more when the run comes back
interrupted. It is bounded at one re-run, so a run interrupted every
time still ends; it sends the same SQL, so it costs no LLM turn; and it
is safe to send twice because only read-only statements reach it.
_await_run signals the case with its own RunInterrupted type, a
RuntimeError, so `cancelled` and failed statements are never re-run and
everything that already treats it as "not the SQL's fault" still does.

Pins the suggestion applied in e5fa0d8: a run that reaches a
terminal-OK status without a result_id still reads the id the query
answered with, instead of falling through to an empty result. The test
fails on c628275 and passes from e5fa0d8.

Settles the result_id handed out on the inline fast path. It is issued
while the save is still in flight, and it is safe to hand out anyway:
the loads endpoint documents 409 "the result is still being computed"
rather than loading part of a result, and three loads issued right
behind a 9,500-row query each returned all 9,500 rows. The code says so
where the id is returned.

The re-run is covered by mocks only - a server replacement cannot be
provoked from outside - so it rests on the spec's wording.

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

* fix(db_hotdata): a run is finished when its status says so

Addresses the review on #2372 at 1c67cd9.

Blocking: _await_run returned as soon as a run named a result_id, even
while it was still `running`. The spec's `running` covers "still
executing, or still being saved", so that id names a result that is not
ready; a read against it answers 202, which raises and fails the whole
question. Truncated results have come through this path since c628275,
which is what made it reachable. It now returns on the status alone.

A run that ends succeeded with no result_id "saved nothing", so the id on
the query body names a result that was never saved. e5fa0d8 fell back
to reading that id, which answers 404 or 202. The statement did succeed,
and a truncated response carries a preview of its rows, so that preview
is returned instead, cut to the limit, and no result_id is offered. A
deferred query has nothing inline, so it raises with the run's
warning_message rather than report an empty result.

Not changed, deliberately: a `failed` run behind a truncated preview is
still handed to the model as a statement failure. The preview is
streamed before the statement finishes, so it can fail on a later row.
Checked live - an 8M-row query whose last row divides by zero answered
200 truncated with 10,000 rows in 0.3s, and its run then ended failed
with "query execution failed: Arrow error: Divide by zero error". A
rewrite fixes that. `failed` also covers a failed save, but only
error_message tells the two apart, and that is prose, not contract. The
code and a test now record this.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexandru Sclearuc <asclearuc@gmail.com>
… binding, drop the scaffolded launch.json (#2386)

F5 app debugging had three defects:

- Breakpoints never bound: the app bundles' sourcemaps carry sources
  relative to the build root's parent (the workspace root), so after the
  browser clamps the leading "../"s js-debug sees
  "<origin>/apps/<name>/src/*.tsx". With webRoot set to the APP folder the
  mapped path doubled ("…/apps/home-ui/apps/home-ui/…") and never matched a
  real file. webRoot is now the workspace root and the stale
  sourceMapPathOverrides (written for webpack:/// URLs the bundles do not
  emit) are gone.

- The debug session was hardwired to Edge. New browser.ts resolves the
  developer's OS default browser onto a js-debug CDP target: Chrome/Edge map
  to their native types, other Chromiums (Brave, Vivaldi, Opera, Arc) launch
  through the 'chrome' type with the registry-resolved runtimeExecutable,
  and a non-Chromium default falls back to Edge with a log line explaining
  why. Resolved once per session.

- The scaffolder wrote a static launch.json entry that could not ensure the
  watch session, so F5 from it opened a preview with no dev bundle served.
  The entry (and ensureLaunchConfig) are removed: rocketride.app.debug is
  the single debug entry point and builds its config in-memory.

Supporting fixes:

- appLoader.isDevPreviewPage() now returns false for TOP-LEVEL pages: only
  framed pages (App Builder panel) receive the rrdev:registerRemote
  postMessage injection. An F5 external-browser tab with rrdev=1 has no
  embedder, and its dev entry is already in the server manifest (resolved
  via the ?rrsession= nonce) — holding for a message that can never come
  left the tab blank.

- DesignView: the DEV watch-target badge is now a link that launches the
  same debug session as the gear menu's Debug (F5) row.

- AGENTS.md: correct the './builder build' description (global command,
  builds every module, not server-only). README touch-ups: pin tiktoken
  <1.0.0 in context_optimizer docs; regenerate core README wording.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…hon (#2369)

* fix(engine): keep a Python thread's own name in the engine and in Python

A thread Python starts, such as the asyncio workers the data path hands
every pipe open, write and close to, reached the engine without a thread
context. The engine made one named "External", and the first call back
into Python (setupDebug) copied that over the thread's Python name, so
the profiler listed every such thread as "External" and engine logs
could not be matched to Python ones.

syncThreadName() now settles one name per thread on both sides: a
thread Python started keeps its name and the engine takes it; a thread
Python knows only as a _DummyThread takes the engine's name, as before.
It runs where Python enters the engine (UnlockPython, which now holds
gil_scoped_release as a member initialized after the name sync, so the
GIL is still held) and where the engine enters Python (setupDebug). The
placeholder becomes ThreadApi::ExternalName.

New engtest cases cover a Python-started thread, the same with a context
made before UnlockPython, an engine thread and a foreign thread; the
first two failed before this change ("External" == "rr-py-thread").

* test(engine): keep the thread-name workers from outliving their state

The Python worker was not a daemon, so a hang in a GIL path would have
left it alive and Py_FinalizeEx() at the end of the run waits for such a
thread: the process would hang after reporting the failure. The engine
and foreign workers kept their Names and Done on the Catch2 thread's
stack, which a worker abandoned on timeout writes into after Catch2 has
unwound the case. Both now share one State through a shared_ptr the
worker holds a copy of.
stepmikhaylov and others added 5 commits September 22, 2026 20:50
* chore(test): remove legacy test

* feat(server): shared engine library
* feat(nodes): add streamed media inspect node

* fix(nodes): address media inspect review and portability findings

* test(nodes): initialize media inspect dependencies before parallel tests

* fix(media-inspect): reject non-finite settings and protect native imports

* test(media-inspect): retain real loader across stock test collection
…2400)

Nothing has reached npm, PyPI, Open VSX or the VS Code Marketplace since
2026-07-07. All five artefacts sit at 1.3.0 because `main` has not moved since
that date, while `develop` carries 758 commits of work that no release train
has ever picked up.

The versions on develop could not be released as they stood:

  server              3.3.0  < published 3.3.1, tag server-v3.3.0 taken
  vscode              1.2.0  < published 1.3.0, tag vscode-v1.2.0 taken
  client-typescript   1.3.0  = published, tag taken
  client-python       1.3.0  = published, tag taken
  client-mcp          1.5.0  unpublished, tag free

Every one of them would have been skipped by the tag and version-exists checks,
so a release run would have been green and shipped nothing.

This bumps the four that needed it and leaves client-mcp at 1.5.0, which is
already a valid unused candidate. Verified free before choosing them: the tags
server-v3.4.0, vscode-v1.4.0, client-typescript-v1.4.0, client-python-v1.4.0 and
client-mcp-v1.5.0 are all absent, and npm 1.4.0, PyPI 1.4.0, PyPI mcp 1.5.0 and
Open VSX 1.4.0 all return not-found.

The changelog cut rides this PR as RELEASE.md requires, so the release body is a
scoped section rather than an ever-growing [Unreleased] blob.


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

Co-authored-by: Dmitrii Kataraev <dmitrii.kataraev@rocketride.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four files conflicted when promoting develop to stage. All four resolve
to develop's side:

  packages/ai/src/ai/modules/task/commands/cmd_misc.py
  packages/ai/tests/ai/modules/task/commands/test_cmd_misc.py
    stage carries 562ec48, a cherry-pick of develop's 1180074 (#2346),
    byte-identical by `git patch-id --stable` (f5afd116...). develop then
    superseded that approach in 9e902c1 (#2376, 21 Sep): a root-level
    `component` is passed through unwrapped rather than rewrapped as a
    one-item `components` list. stage simply stopped at #2346.

  docs/agents/context/ROCKETRIDE_python_API.md
  docs/agents/context/ROCKETRIDE_typescript_API.md
    generated; develop's copies are the current output.

After resolving, the merged tree is identical to develop's: zero files
differ. That confirms stage carries nothing develop lacks, consistent with
all 19 stage-only commits matching develop by patch-id.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nTVr6jfSFYm1GppxbjghP
@coderabbitai

coderabbitai Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Ignore keyword(s) in the title.

⛔ Ignored keywords (5)
  • [bot]
  • renovate
  • dependabot
  • release
  • chore(release)

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository: rocketride-org/rocketride-server/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: b5124d68-74a0-41ef-90df-b8d43122102a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor
🤖 Internal: Discord sync marker

Auto-managed by the Discord notification workflow. Stores the linked Discord message ID and forum thread ID. Do not edit or delete.

@github-actions github-actions Bot added docs Documentation module:server C++ engine and server components module:nodes Python pipeline nodes module:client-python Python SDK and MCP client module:vscode VS Code extension labels Sep 23, 2026
@kwit75 kwit75 mentioned this pull request Sep 23, 2026
7 tasks
@github-actions github-actions Bot added module:ai AI/ML modules ci/cd CI/CD and build system builder Node builder tooling and ./builder workflows module:client-typescript TypeScript client SDK labels Sep 23, 2026
@dk-rocketride
dk-rocketride merged commit e9c7d8c into stage Sep 23, 2026
41 checks passed
@dk-rocketride
dk-rocketride deleted the release/v3.4.0-develop-to-stage branch September 23, 2026 16:20
dk-rocketride added a commit that referenced this pull request Sep 23, 2026
…on bump only (#2409)

#2405 merged all of develop onto stage, including 34 commits that were
never deployed to staging or tested. The release is meant to ship the stage
that was captured and tested (d3b6eea), not develop.

This reverts e9c7d8c (#2405), which restores stage's tree exactly to
d3b6eea, and applies only what publishing mechanically requires. The
published versions and tags already exist at the old numbers:

  rocketride-server   3.3.0 -> 3.4.0
  vscode              1.2.0 -> 1.4.0
  client-typescript   1.3.0 -> 1.4.0
  client-python       1.3.0 -> 1.4.0
  client-mcp          1.5.0 (unchanged, unpublished)
  CHANGELOG           [Unreleased] cut to [3.4.0]

Diff against d3b6eea: those five files and nothing else. No code.


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

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

builder Node builder tooling and ./builder workflows ci/cd CI/CD and build system docs Documentation module:ai AI/ML modules module:client-python Python SDK and MCP client module:client-typescript TypeScript client SDK module:nodes Python pipeline nodes module:server C++ engine and server components module:vscode VS Code extension

Projects

None yet

Development

Successfully merging this pull request may close these issues.