Skip to content

fix!: address managed databases by id only, never by name - #44

Open
rohan-hotdata wants to merge 1 commit into
mainfrom
fix/38-managed-databases-by-id
Open

fix!: address managed databases by id only, never by name#44
rohan-hotdata wants to merge 1 commit into
mainfrom
fix/38-managed-databases-by-id

Conversation

@rohan-hotdata

Copy link
Copy Markdown
Contributor

Closes #38.

Why

Hotdata database names are display labels and are not unique. hotdata-framework's resolve_managed_database(name_or_id) tries get_database(id) first and then falls back to scanning list_databases() matching on db.name, so a label collision resolves to the wrong database — and every query, load and drop then follows it there.

Every database-addressing path in this package went through that resolver, and hotdata_load_managed_table made it reachable from an LLM. That tool replaces the table it loads into, so a wrong target means overwriting another database's data. This repo is a worse case than hotdata-dlt-destination was, because the value can be model-generated rather than configured.

Latent rather than live: the test workspace has 8 managed databases with no duplicate labels.

What

Mirrors hotdata-dlt-destination#59 — bind by id once, then address the resolved record.

  • resolve_database_by_id goes straight to GET /databases/{id} via DatabasesApi(client.api), the same move as dlt-destination's _bind_by_id. No by-name fallback exists on this path at all, rather than one guarded after the fact. An already-resolved ManagedDatabase passes through untouched.
  • The tool factories resolve once at construction and carry the resolved record. Two consequences: a bad id fails while the tools are being wired up rather than on the agent's first query, and queries no longer pay a resolution round-trip each. execute_sql(database="<id>") was re-resolving on every call.
  • query_scope rejects a string scope reaching the query helpers with a TypeError naming the fix. Worth having as a runtime guard because mypy is not in CI yet (CI: test the supported Python range, and run ruff + mypy #42), so annotations alone would not stop it.
  • Names are rejected loudly: KeyError naming hotdata_list_managed_databases as where ids come from.

Breaking changes

Before After
make_hotdata_tools(database=...) database_id= (id, or a ManagedDatabase)
make_hotdata_search_tool(database=...) database_id=
make_hotdata_describe_tables_tool(database=...) database_id=
load_managed_table(database=...) database_id=
hotdata_load_managed_table tool arg database database_id
execute_sql_json/bm25_search_json/describe_tables_json database= took a name or id takes a resolved ManagedDatabase

To keep scoping to the same database, pass its id — client.list_managed_databases() reports one per database.

Suggest releasing this as 0.3.0, matching how dlt-destination shipped its id-only change as a single minor bump. #23 is a stale release/v0.3.0 whose changelog text never merged; it should be closed and the branch deleted before re-preparing.

Verification

Against a live workspace:

  • langchain_bm25_demo and f1_db — labels the framework does resolve — are now rejected, while their ids resolve and correctly scope queries.
  • The demo runs end to end both self-bootstrapping and with the new --database-id, and examples/langchain_basic.py runs clean.
  • The agent-facing question: can a model actually obtain an id? An unscoped agent told to load into "the database labelled langchain_bm25_demo" — no id in the prompt — called hotdata_list_managed_databases and then passed the correct database_id in 5 of 5 runs. The pointer lives in the load tool's own description, which names both tools that hand out ids.

18 new tests (139 total), ruff and format clean, changelog check passes. mypy is unchanged at the 18 pre-existing errors in test files tracked by #42; package, demo and examples are clean.

Two things worth a reviewer's attention

A description change I made and then reverted. I first added "Call this first whenever you need a database id" to hotdata_list_managed_databases. It fired on 3/5 demo runs where the tool set was already scoped and no id was needed, burning a turn on an irrelevant call. Removed — the pointer belongs in the tool that needs an id, not broadcast to every plan. Spurious calls went to 0/5.

A correction to demo/README.md. Chasing that, I found the checked-in "aggregate correct 5 of 5" does not replicate; I measured 3/5. Before attributing it to this change I ran five runs on merged main in a worktree, scored against a ground-truth SQL query — main is also 3/5. So this PR did not regress it and the previous figure was a lucky sample. The README now reports the measured figure and the two failure modes. Search and describe are called every run on both, which is the part these tools are responsible for.

One deliberate exception to id-only addressing: demo/bm25_search_demo.py keeps a single by-label lookup so re-runs work without pinning an id, clearly marked as demo bootstrap. dlt-destination dropped its equivalent, but that is a destination; this is a script whose job is idempotent re-runnability. Pass --database-id to skip it.

Hotdata database names are display labels and are not unique. The framework's
resolve_managed_database tries the id first and then scans list_databases()
matching on name, so a label collision resolves to the wrong database and every
query, load and drop follows it there. Every database-addressing path in this
package went through that resolver, and hotdata_load_managed_table made it
reachable from an LLM — where a wrong target means a replacing load overwrites
another database's table.

resolve_database_by_id goes straight to GET /databases/{id} with no by-name
fallback, and returns an already-resolved ManagedDatabase untouched. The tool
factories resolve once at construction and carry the resolved record, so a bad
id fails while wiring the tools up rather than on the agent's first query, and
queries no longer pay a per-call resolution round-trip. query_scope rejects a
string scope reaching the query helpers, which would otherwise reach the
by-name fallback despite the annotations.

Mirrors hotdata-dlt-destination #59.

Breaking: database= becomes database_id= on make_hotdata_tools,
make_hotdata_search_tool and make_hotdata_describe_tables_tool; the
hotdata_load_managed_table tool's database argument becomes database_id;
load_managed_table takes database_id=; execute_sql_json, bm25_search_json and
describe_tables_json take a resolved ManagedDatabase.

Verified against a live workspace: a display label the framework does resolve
is now rejected, ids resolve and scope queries, and the demo runs end to end
both self-bootstrapping and with --database-id. An unscoped agent asked to load
into a database named only by its label called hotdata_list_managed_databases
and then passed the correct id in 5 of 5 runs.
@rohan-hotdata
rohan-hotdata requested a review from a team as a code owner July 27, 2026 15:02
@rohan-hotdata
rohan-hotdata requested review from shefeek-jinnah and removed request for a team July 27, 2026 15:02
@rohan-hotdata rohan-hotdata added breaking Breaking change to the public API bug Something isn't working ai-native-layer Part of the AI-native query layer effort for LangChain labels Jul 27, 2026
LoadManagedTableResult,
ManagedDatabase,
)
from hotdata_framework.databases import api_error_message, managed_database_from_detail

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: this is the only place the package reaches into a hotdata_framework submodule — every other import goes through the top-level namespace (from hotdata_framework import ...). api_error_message and managed_database_from_detail look like framework internals rather than public API, and pyproject.toml pins hotdata-framework>=0.9.0 with no upper bound, so a rename in either breaks import hotdata_langchain at module load rather than at call time.

Worth either an upper bound on the pin or a one-line note here that these two are borrowed from the framework's internals, so the coupling is visible when the pin is next bumped. (not blocking)

Comment thread demo/bm25_search_demo.py
Comment on lines +284 to +293
target = (
hl.resolve_database_by_id(client, args.database_id)
if args.database_id
else find_database_by_label(client, DATABASE_LABEL)
)
if target is None:
print(f"No managed database labelled {DATABASE_LABEL!r} to delete")
else:
client.delete_managed_database(existing.id)
print(f"Deleted managed database {DATABASE!r} (id={existing.id})")
client.delete_managed_database(target)
print(f"Deleted managed database {target.id} (label={target.description!r})")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: the --cleanup path picked up two changes that no test and — going by the verification notes — no live run covers:

  • With --database-id, resolve_database_by_id raises KeyError on an unknown id, so the target is None branch is unreachable on that path: the user gets a traceback instead of the tidy message. And because this block sits above the try: / finally: client.close(), the client is never closed. The old find_database returned None, so cleanup always exited cleanly. Wrapping the resolve in a try/except KeyError that prints and returns would restore that.
  • delete_managed_database(target) now receives the resolved record where it previously got existing.id. Consistent with the rest of the change and presumably fine through the same resolver load_managed_table goes through, but a single --cleanup run would confirm it.

Separately, No managed database labelled {DATABASE_LABEL!r} to delete names the label even when the user pinned an id, which reads oddly on that path. (not blocking)

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

Labels

ai-native-layer Part of the AI-native query layer effort for LangChain breaking Breaking change to the public API bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Address managed databases by id only, never by name (breaking)

1 participant