Skip to content

feat: attach a registered source into a database's scope, and read back when it expires - #97

Merged
rohan-hotdata merged 3 commits into
mainfrom
feat/90-attach-catalog
Sep 3, 2026
Merged

feat: attach a registered source into a database's scope, and read back when it expires#97
rohan-hotdata merged 3 commits into
mainfrom
feat/90-attach-catalog

Conversation

@rohan-hotdata

@rohan-hotdata rohan-hotdata commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Refs #90.

Every tool set in this package is scoped to exactly one instant database — resolved once at
build time, one X-Database-Id per query. There was no way to bring a second source into that
scope without leaving the library for the raw SDK. This adds hl.attach_catalog,
hl.detach_catalog and hl.database_attachments.

Where the capability was

Not missing — one layer down:

Layer Package Has attach_database_catalog?
Generated SDK hotdata yes
Ergonomic client hotdata-framework no, on no version
This package hotdata-langchain no, and it wraps the layer above

HotdataClient exposes its ApiClient as a public .api property and builds its own
DatabasesApi(self._api) from it internally, so going through
DatabasesApi(client.api).attach_database_catalog(...) is the same construction the framework
makes rather than a way around it — and it is the route resolve_database_by_id already takes.

That keeps this shippable now. Waiting for the method to land on the client would mean a
framework release plus a floor bump to >=0.14.0, and the floor only moved to 0.13.0 last
release. It is also the second place this package reaches past the client, which is a gap in the
layer below rather than good use of an escape hatch — filed separately on
sdk-python-framework, and when it lands these become thin delegations.

Two decisions that came out of the API, not preference

Confirmation is on by default. Both endpoints answer 204 with no body, mapping
400/404 (and 409 on attach) to ApiErrorResponse — so a refusal raises and there is
nothing ambiguous to inspect. None is the success signal. What a status code cannot cover is
a 204 that did not do the work, and that shape is measured on this platform:
delete_managed_table reports success while leaving a connection registration behind (#36). It
has not been observed for an attach, so this is a cheap guard against a known failure mode
rather than a fix for something seen. Each helper re-reads and raises; confirm=False skips the
extra request, and the returned alias is then the one requested rather than the one that
landed, since there is nothing to read it from.

A 409 is resolved by reading, not assumed. Rather than guessing that it means "already
attached", the attach re-reads: if the connection is attached, that existing attachment is
returned and the call is a no-op; if it is not, the original message is raised. Safe whichever
way the platform uses 409, and it makes re-running a provisioning step idempotent.

The confirmation matches on the specific connection_id rather than on the attachment list
being empty, so detaching one of two attachments succeeds. Both cases are tested.

Why database_attachments is part of this and not a follow-up

It is what makes an attach verifiable. ManagedDatabase carries id, description and
default_connection_id; the detail response it is built from also carries attachments,
default_catalog, default_schema and expires_at, and managed_database_from_detail
discards all four.

That function lives in hotdata_framework, so this package cannot fix the discarding in place —
it re-reads the detail response instead and maps attachments into a frozen CatalogAttachment,
so callers do not depend on an SDK model. This satisfies #90's second ask without fixing its
cause
, which is why I have not closed that issue: the upstream half is in the framework ticket.

Also collapsed the duplicated GET /databases/{id} error handling into _database_detail, which
resolve_database_by_id now shares — it drops from twelve lines to one, and the three call sites
cannot drift apart on 404 translation.

Docs

  • README — new section on attaching, and two corrections. The claim that "a query cannot
    reach across databases … Each set queries its own" now names the platform's refusal explicitly
    rather than reading as a blanket impossibility. And "Nothing on the database record
    distinguishes the two" was made imprecise by this very change: the detail response does carry
    attachments, it is ManagedDatabase that drops them. Reworded to say which record carries
    what, and why information_schema is still the right source — attachments say which sources
    are attached, not which catalogs hold tables.
  • docs/engine-contract.md — replaces its guess that attachment was "presumably the
    supported route — not verified here" with the measured table. The guess was half right:
    attachment is the route, and it does not work for another instant database. Two instant
    databases cannot see each other by any route, including via result_id. Marked per that
    doc's convention: the endpoints are verified, and the helpers added here are not yet
    exercised against a live workspace.
  • CHANGELOG under [Unreleased].

Also here: reading back when a database expires

hl.database_expiry and hl.database_expiries. Same shape of gap as the attachments one, and
found the same way.

A lifetime is written as a string, an RFC 3339 timestamp or a relative window such as "24h",
and the server resolves it to an instant: the request field is str, the response field is
datetime. So the resolved time is only knowable by reading it back, and nothing here could —
ManagedDatabase carries no expires_at. A caller could set a TTL and never learn which second
it landed on, or whether a database still had one.

database_expiries returns the workspace keyed by database id, at one request per page of the
listing
rather than one per database, because DatabaseSummary already carries the field. A
database with no TTL maps to None, so "lives forever" stays distinguishable from "not in this
workspace", which a missing key would not separate.

It reads DatabasesApi.list_databases directly rather than going through
client.list_managed_databases(), which has three problems for this purpose: it returns
list[ManagedDatabase] and so drops expires_at, it calls get_database once per database, and
it wraps each of those in except ApiException: pass, so a database whose detail read fails is
silently omitted. That method is unchanged here and its defects are noted on #36 rather than
fixed in this PR.

The listing is paginated (limit, cursor, search), so the cursor is followed. A single-page
read would report a subset as though it were the whole workspace. The loop stops on an empty page
and caps at 10,000 records, so a server that keeps returning a cursor cannot spin it.

Neither is on a tool. Reaping runs from the TTL or from an explicit cleanup step, so a model
has no decision to make with the value, and an unexplained timestamp in a tool result is surface
it can only misuse — the same line #91 drew for partition_by. Adding it to the tool later stays
additive; removing it later would change what every model sees.

Verification

Gate Result
Suite 634 passed (598 before, +36)
mypy clean, 26 files
ruff check + format clean
--resolution lowest-direct 634 passed
Documented examples all executed, including the idempotent re-attach

The floor job earned its place here. This imports a new SDK model, and hotdata's declared floor
is >=0.8.0, so I checked the surface at the floor directly rather than trusting the tests —
they mock DatabasesApi and would pass whether or not the real method existed. At hotdata
0.9.0 / hotdata-framework 0.13.0 / langchain-core 1.0.0, attach_database_catalog,
detach_database_catalog and attachments on the detail response all exist.

Not in scope

  • No agent-callable tool. These are Python helpers. Whether provisioning of this kind should
    be reachable by a model is Provisioning boundary: agent-driven database, table, and index lifecycle #61's open question and nothing here answers it.
  • Attaching one instant database into another, which the platform refuses. That is a product
    question, not a wrapping one.
  • A live end-to-end run. The endpoints are measured working; this code path is not. Worth a
    demo or an integration check against a real workspace before anyone relies on it.

Follow-up

docs/ai-native-layer-roadmap.md says "attach_database_catalog may already be the supported
route for the cross-database case; unverified". That is now wrong in a specific way — it is not
the route for the cross-database case — but the file has unrelated uncommitted changes in my
working tree, so I left it out rather than sweep them in. Wants a one-line fix.

Every tool set here is scoped to exactly one instant database, and there was no
way to bring a second source into that scope without leaving the library for the
raw SDK. Adds attach_catalog, detach_catalog and database_attachments.

attach_database_catalog and detach_database_catalog are on the generated SDK and
on no version of HotdataClient, so these go through DatabasesApi(client.api) --
the same construction the framework uses internally, and the route
resolve_database_by_id already takes.

Both endpoints answer 204 with no body, so a refusal raises and there is nothing
ambiguous to inspect. What a status cannot cover is a 204 that did not do the
work, so each helper re-reads the database and raises if the attachment did not
land or a detached connection is still listed, behind confirm=True. A 409 on
attach is resolved by reading rather than assumed to mean "already attached", so
re-running a provisioning step is a no-op.

ManagedDatabase carries only id, description and default_connection_id, while
the detail response also carries attachments -- so database_attachments reads
what the resolved record drops, and is what makes an attach verifiable at all.

Also records the measured cross-database boundary in docs/engine-contract.md,
replacing its guess that attachment was "presumably the supported route": it is
the route, and it does not work for another instant database.
@rohan-hotdata
rohan-hotdata requested a review from a team as a code owner September 3, 2026 12:22
@rohan-hotdata
rohan-hotdata requested review from eddietejeda and removed request for a team September 3, 2026 12:22
identifier,
AttachDatabaseCatalogRequest(connection_id=connection_id, alias=alias),
)
except ApiException as e:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Translate a 404 from attach_database_catalog into KeyError here, the way detach_catalog does at line 303.

The docstring at line 245 promises KeyError when the workspace has no database with that id. This except block handles 409 only, so every other status becomes RuntimeError. attach_catalog never reaches _database_detail on that path, because the attach call fails first.

Failure scenario: a caller passes a deleted database id. The attach endpoint answers 404. attach_catalog raises RuntimeError. A caller following the docstring wraps the call in except KeyError. The RuntimeError escapes and crashes the caller.

No test covers attach_catalog against a 404, so the mismatch is unguarded. Correct the docstring instead if RuntimeError is the intended contract.

Comment thread hotdata_langchain/databases.py Outdated
detail = _database_detail(client, database_id)
return [
CatalogAttachment(connection_id=str(one.connection_id), alias=one.alias)
for one in getattr(detail, "attachments", None) or ()

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: read detail.attachments directly rather than through getattr (not blocking).

The PR description records that attachments exists on the detail response at the declared floor, so the default never applies today. If the SDK renames or drops the field, the default turns that into an empty list: database_attachments reports "nothing attached" for a database that has attachments, and attach_catalog raises "reports it is not attached" after an attach that landed. Direct access fails loudly instead. or () still covers a None value.

Comment on lines +271 to +274
raise RuntimeError(
f"attaching connection {connection_id!r} to database {identifier!r} reported "
"no error, but the database reports it is not attached."
)

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: name confirm=False in this message (not blocking).

The read-back can also come back empty because the detail response lags behind the attach. A caller cannot tell that case from an attach that did not land, and the message points at no escape hatch. Error text in this module carries its remedy already: _database_detail names hotdata_list_managed_databases at line 175.

Comment thread hotdata_langchain/databases.py Outdated
return database_id.id if isinstance(database_id, ManagedDatabase) else database_id


def _database_detail(client: HotdataClient, database_id: str | ManagedDatabase) -> Any:

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: annotate the return with the SDK detail model rather than Any (not blocking). Fix this only if hotdata ships type information for DatabasesApi.get_database, since disallow_any_unimported is set.

resolve_database_by_id passes this value straight into managed_database_from_detail. Under Any, mypy no longer checks that argument, and mypy runs with strict = true in pyproject.toml. The inline call this replaced was checked.

Comment thread hotdata_langchain/databases.py Outdated
Comment on lines +37 to +40
``alias`` is the catalog name the attached tables answer to in SQL, so a query reads
``<alias>.<schema>.<table>``. It is ``None`` when the attachment was made without one
and the server chose the name, which is why :func:`attach_catalog` reports back what
landed rather than echoing what was asked for.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

super nit: this explanation of alias contradicts attach_catalog (not blocking). The text says alias is None when the server chose the name. attach_catalog returns the attachment read back from the server, and test_attach_reports_the_alias_that_landed_not_the_one_requested has a server-chosen alias arriving as pg_main. Say instead that the API declares alias optional.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review

Blocking Issues

  • hotdata_langchain/databases.py:254attach_catalog does not translate a 404 from attach_database_catalog into KeyError. The docstring at line 245 promises KeyError for an unknown database id. The code raises RuntimeError instead, because the except block handles 409 only. detach_catalog translates 404 at line 303, so the two helpers disagree. No test covers attach_catalog against a 404.

Action Required

Handle 404 in the attach_catalog except block the way detach_catalog does, and add a test for it. Correct the docstring instead if RuntimeError is the intended contract.

Four non-blocking comments are inline.

CI note: Lint and types, Test (Python 3.13) and Test (Python 3.14) were still pending when this review started. Their results are unknown here.

attach_catalog's docstring promised KeyError for an unknown database id, but its
except block handled 409 only, so every other status became RuntimeError. The
read-back that would have raised KeyError is never reached on that path: the
attach call fails first. detach_catalog already translated 404, so the two
helpers disagreed on the same failure.

A 404 here does not say whether the database or the connection is missing, so
the message names both, as detach's does.

Also from review:

- Read detail.attachments directly rather than through getattr. The field is
  required on DatabaseDetailResponse, so the default never applies -- and if the
  SDK dropped it, the default would turn that into "nothing attached" for a
  database that has attachments, and an unlanded-attach error after an attach
  that landed.
- Annotate _database_detail as DatabaseDetailResponse rather than Any. hotdata
  ships py.typed and mypy runs strict with disallow_any_unimported, so Any was
  silently dropping the check on managed_database_from_detail's argument that
  the inline call had.
- Name confirm=False in the unlanded-attach message, matching this module's
  habit of carrying the remedy in the error text.
- Fix CatalogAttachment's alias docstring, which contradicted attach_catalog by
  saying alias is None when the server names the catalog.
@rohan-hotdata

Copy link
Copy Markdown
Contributor Author

All five findings addressed in e6fb5be.

Blocking — 404 on attach. Correct, and it was a real contract break: the docstring promised
KeyError for an unknown database id, the except block handled 409 only, and the read-back that
would have raised KeyError is never reached on that path because the attach call fails first.
attach_catalog now translates 404 the way detach_catalog does. A 404 does not say whether the
database or the connection is missing, so the message names both. Two tests added: one for the
404 itself, one asserting attach and detach agree, since a caller wrapping both should not need
two except clauses.

getattr on attachments. Taken. Checked first: the field is required on
DatabaseDetailResponse, so the default never fires today — but the failure it would mask is the
bad kind. If the SDK dropped the field, database_attachments would report "nothing attached"
for a database that has attachments, and attach_catalog would raise "reports it is not attached"
after an attach that landed. Now detail.attachments or (), which still covers a None value.

Any on _database_detail. Taken, and the caveat was the right one to raise — hotdata does
ship py.typed, so it was actionable. Now annotated DatabaseDetailResponse. Worth noting the
concrete cost: under Any, mypy had stopped checking the argument passed to
managed_database_from_detail that the inline call this replaced was checking.

confirm=False in the unlanded-attach message. Taken — it matches how the rest of this module
carries a remedy in the error text. Left out the read-back-lag framing, since a stale detail
response after an attach has not been observed here and I did not want to assert it.

CatalogAttachment's alias docstring. Taken; it did contradict the code, and its own test
proves it — test_attach_reports_the_alias_that_landed_not_the_one_requested has a server-chosen
alias arriving as pg_main, not None. Reworded to say the API declares alias optional.

Re-run locally: 622 passed (up 3), mypy clean with the real annotation, ruff clean, and the
--resolution lowest-direct floor job green.

One note on the CI caveat in the review: Lint and types, Test (Python 3.13) and
Test (Python 3.14) all passed on the previous head, along with the rest of the matrix and the
floor job.

Comment thread hotdata_langchain/databases.py Outdated
if e.status == 404:
raise KeyError(
f"no instant database with id {identifier!r} in this workspace, or no "
f"connection {connection_id!r} registered in it."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

super nit: say "registered in this workspace" rather than "registered in it" (not blocking).

A connection is registered in the workspace, not in the database. detach_catalog's docstring at line 302 states that: "The connection itself stays registered in the workspace". The attach message points a reader looking for the connection at the wrong place.

claude[bot]
claude Bot previously approved these changes Sep 3, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Prior blocking finding is fixed: attach_catalog now translates a 404 into KeyError, and two tests cover it. The four nits are addressed as well. One super nit inline, not blocking.

CI checks were still queued or in progress at review time, so this approval does not rest on their results.

…base

The attach 404 message said "no connection <id> registered in it", where "it" is
the database. A connection is registered in the workspace and then attached into
a database, which detach_catalog's own docstring already states. The message
pointed a reader looking for the connection at the wrong place.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Prior blocking finding is resolved: attach_catalog translates a 404 into KeyError at hotdata_langchain/databases.py:264, matching detach_catalog. The four nits are addressed too.

No new findings.

@rohan-hotdata
rohan-hotdata merged commit 544a8e7 into main Sep 3, 2026
10 checks passed
@rohan-hotdata rohan-hotdata changed the title feat: attach a registered source into an instant database's scope feat: attach a registered source into a database's scope, and read back when it expires Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant