Skip to content

fix(nodes): stop four nodes passing raw input downstream - #2304

Merged
asclearuc merged 1 commit into
developfrom
fix/RR-2303-lane-forward-remaining-nodes
Sep 17, 2026
Merged

asclearuc merged 1 commit into
developfrom
fix/RR-2303-lane-forward-remaining-nodes

Conversation

@asclearuc

@asclearuc asclearuc commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • context_optimizer, summarization, tool_guild and tool_n8n returned normally from lane handlers whose input lane is also an output lane. So the engine also forwarded the raw input: the untrimmed question next to the trimmed one, the full text next to the summary, and the raw input next to the Guild / n8n result.
  • Each affected handler now ends in return self.preventDefault(), after its own work. The false "overriding suppresses raw passthrough" docstrings in tool_guild and tool_n8n are corrected.
  • Added delivery-count tests for every changed handler, and a first unit-test file for summarization. Existing tests that call these handlers directly now require the PreventDefault exception.

Type

fix

Testing

  • Tests added or updated
  • Tested locally: 791 tests pass under the engine interpreter (test_lane_forward_once_nodes.py, test_tool_guild.py, test_tool_n8n.py, context_optimizer/, summarization/, test_sys_modules_guard.py, test_contracts.py), and 104 pass under plain Python. With the node changes reverted, 36 of those tests fail. ruff format and ruff check are clean.
  • ./builder test passes: not run (it needs the full build and a server). The nodes were not run on a live engine.

Checklist

  • Commit messages follow conventional commits
  • No secrets or credentials included
  • Wiki updated (if applicable): n/a, the node READMEs already describe only the node's own output
  • Breaking changes documented (if applicable): n/a, the raw passthrough was never documented. None of the shipped pipelines that use these nodes relies on it (examples/guild-agent.pipe, examples/n8n-roundtrip.pipe, examples/n8n/*.pipe, packages/server/test/pipelines/advrag-ingest.json)

Linked Issue

Fixes #2303

Related: #1638 (memory_persistent, PR #1682), #1985 (prompt, PR #1992), #2041

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Prevented unprocessed text, questions, documents, and media inputs from being forwarded alongside generated results.
    • Ensured context optimization forwards only the trimmed question.
    • Ensured summarization, tool, and media workflows emit results once through their final processing step.
  • Tests

    • Added coverage for buffering, output formatting, reset behavior, media handling, and prevention of duplicate or raw deliveries.
    • Added regression checks for disabled sections, chunk limits, and missing listeners.

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.
@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.

@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 430ce3a3-194b-434b-a9b9-8067222f3507

📥 Commits

Reviewing files that changed from the base of the PR and between 3f6e347 and 7a53fff.

📒 Files selected for processing (9)
  • nodes/src/nodes/context_optimizer/IInstance.py
  • nodes/src/nodes/summarization/IInstance.py
  • nodes/src/nodes/tool_guild/IInstance.py
  • nodes/src/nodes/tool_n8n/IInstance.py
  • nodes/test/context_optimizer/test_all.py
  • nodes/test/summarization/__init__.py
  • nodes/test/summarization/test_summarization.py
  • nodes/test/test_lane_forward_once_nodes.py
  • nodes/test/test_tool_n8n.py

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Changes

The four affected nodes now call preventDefault() after processing lane input. Tests verify that raw input is not forwarded and that node-specific output remains buffered or emitted as expected.

Raw passthrough prevention

Layer / File(s) Summary
Handler prevention behavior
nodes/src/nodes/context_optimizer/IInstance.py, nodes/src/nodes/summarization/IInstance.py, nodes/src/nodes/tool_guild/IInstance.py, nodes/src/nodes/tool_n8n/IInstance.py
Handlers now call preventDefault() after forwarding or buffering input. Docstrings describe the affected methods and buffers.
Node-specific prevention tests
nodes/test/context_optimizer/test_all.py, nodes/test/summarization/test_summarization.py
Context optimizer tests require Ec.PreventDefault. Summarization tests cover buffering, closing output, limits, disabled sections, and listener behavior.
Cross-node delivery regression coverage
nodes/test/test_lane_forward_once_nodes.py, nodes/test/test_tool_n8n.py
Regression tests verify that text, questions, documents, and media chunks are buffered without direct delivery.

Priority: ⬆️ High

Estimated code review effort: 3 (Moderate) | ~30 minutes

Change: Bug fix · Severity of issue fixed: High

Sequence Diagram(s)

sequenceDiagram
  participant Engine
  participant NodeHandler
  participant Downstream
  Engine->>NodeHandler: Send lane input
  NodeHandler->>NodeHandler: Buffer or optimize input
  NodeHandler->>Engine: Raise PreventDefault
  Engine->>Downstream: Send node-generated output only
Loading

Merge Risk: ⚪ Minimal · up to 7a53f

The updated handlers suppress raw-input forwarding while retaining their intended outputs, with no remaining concrete merge-blocking risk.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing four nodes from forwarding raw input downstream.
Linked Issues check ✅ Passed Issue #2303 requires the four affected nodes to suppress automatic raw-input forwarding with preventDefault(), and requires corrected tool_guild and tool_n8n docstrings. The changed handlers now…
Out of Scope Changes check ✅ Passed The reported changes stay within issue #2303. Handler updates prevent the described raw-input leak. Docstring corrections address the inaccurate behavior described by the issue. Delivery-count tests a…
Docstring Coverage ✅ Passed Docstring coverage is 89.69% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 97 functions across 8 files.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/RR-2303-lane-forward-remaining-nodes

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.

@asclearuc
asclearuc enabled auto-merge (squash) September 17, 2026 12:38
@asclearuc
asclearuc merged commit 97bb71c into develop Sep 17, 2026
25 checks passed
@asclearuc
asclearuc deleted the fix/RR-2303-lane-forward-remaining-nodes branch September 17, 2026 12:49
asclearuc added a commit that referenced this pull request Sep 18, 2026
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.
dk-rocketride added a commit that referenced this pull request Sep 23, 2026
* docs(agents): Drizzle in the agent bundle + restore the task battery (#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>

* fix(nodes): stop four nodes passing raw input downstream (#2304)

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.

* fix(memory_persistent): prevent double-forward of questions and answers (#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>

* fix(audio_player): accept stop()'s leftover sentinel in the drain test (#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()`.

* fix(ci): open the model-sync PR with the release-bot App token (#2312)

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.

* feat(profiler): per-thread breakdown and a call tree that keeps real 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

* fix(mcp): make the HTTP MCP server testable end to end (auth, routing, 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, 0dfdc8b44) 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 (76708650a): 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 @ eb67ddea` 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>

* fix(ai): accept the single-component validation payload the node panel 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.

* fix(crustdata): patch HTTP transport by object in tests (#2332)

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>

* fix(notion): normalize malformed success responses (#2340)

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>

* fix(notion): preserve query request status (#2339)

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>

* fix(search-hybrid): isolate fallback identities (#2329)

* 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 (#2338)

* fix(notion): accept search pagination cursors

* test(notion): cover empty search cursors

---------

Co-authored-by: Krish Garg <kg@g14-node1.tailb99b1b.ts.net>

* fix(ai,nodes): surface database error text from execute and add refresh_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 wou…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

module:nodes Python pipeline nodes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(nodes): four nodes pass their raw input downstream next to their own output

2 participants