refactor(secrets): one binding list with a kind, and split the secret writes

`secret_env_bindings` took the general noun for one delivery mode. A binding is
the concept; how it arrives is a property of one. `secret_bindings` is a list of
`SecretBinding`, tagged by `kind`, so a later mode is a variant rather than a
sibling field -- and the rules that are per-Function, like how many Secrets it
may bind, stay answerable from one place.

The cost of one field is that an unknown kind is a decode error unless it is
caught. It is caught, the way `PythonRuntimeSpec` catches an unknown runtime:
`Unrecognized { kind }`, `#[non_exhaustive]`, and the payload dropped rather
than retained because the client does not proxy catalog values. A test pins it
-- a `file` binding from a newer server decodes, reports its kind, and
round-trips as its discriminator without failing the version around it.

A list has no key order to inherit, and the list is in the version hash, so
`bind_secrets` sorts it: a caller's argument order is not part of what a
Function is.

The Secret is named under `secret_ref`, not `secret`: the service scans Job
payloads for credential-shaped keys and refuses one called `secret` whatever it
holds. That guard is worth more blunt than argued with.

`create_secret` and `alter_secret` also stop sharing a request shape. They are
different operations to the service -- one refuses an existing name, the other
requires it -- and either may grow a field the other has no meaning for. What
they share is posting a body that must not be logged, which is a function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
This commit is contained in:
Jonathan M Hsieh
2026-09-11 20:06:42 +00:00
co-authored by Claude Opus 5
parent d6bcfe52a9
commit 404b91d4d6
11 changed files with 312 additions and 66 deletions
+33 -5
View File
@@ -227,6 +227,20 @@ class FunctionOutput(_OpenRemoteValue):
fields: tuple[FunctionResultField, ...] = ()
class SecretBinding(_RemoteValue):
"""How a Secret reaches the Function that binds it.
One list rather than a field per delivery mode: a binding is the concept,
and how it arrives is a property of one. ``kind`` is open, so a binding a
newer service introduces decodes here instead of failing the whole
FunctionVersion.
"""
kind: str
variable: Optional[str] = None
secret_ref: Optional[str] = None
class FunctionSignature(_RemoteValue):
inputs: tuple[FunctionParameter, ...]
output: FunctionOutput
@@ -310,7 +324,7 @@ class FunctionVersion(_RemoteValue):
runtime: PythonRuntimeSpec
runtime_digest: str
environment_digest: str
secret_env_bindings: Mapping[str, str] = {}
secret_bindings: tuple[SecretBinding, ...] = ()
created_at: str
def __call__(self, **inputs: Any) -> FunctionApplication:
@@ -375,7 +389,7 @@ class FunctionRegistrationRequest(_RemoteValue):
"""Stable remote registration envelope produced by :func:`udf`.
Credential values deliberately have no field here. The only secret-shaped
thing a client sends is ``secret_env_bindings``: the name of a Secret the
thing a client sends is ``secret_bindings``: the name of a Secret the
database already holds, which the remote service resolves at execution.
"""
@@ -383,7 +397,7 @@ class FunctionRegistrationRequest(_RemoteValue):
artifact: FunctionArtifactRequest
signature: FunctionSignature
runtime: PythonRuntimeSpec
secret_env_bindings: Mapping[str, str] = {}
secret_bindings: tuple[SecretBinding, ...] = ()
class FunctionVersionRef(_OpenRemoteValue):
@@ -1322,8 +1336,22 @@ class UdfDefinition:
)
if not bindings:
return self._request
resolved = {binding.env_variable: binding.secret for binding in bindings}
return self._request._copy(update={"secret_env_bindings": resolved})
# Sorted, because the list is carried in the FunctionVersion hash and a
# caller's argument order is not part of what a Function is.
resolved = tuple(
sorted(
(
SecretBinding(
kind="env",
variable=binding.env_variable,
secret_ref=binding.secret,
)
for binding in bindings
),
key=lambda binding: (binding.kind, binding.variable or ""),
)
)
return self._request._copy(update={"secret_bindings": resolved})
def __call__(self, *args, **kwargs):
return self._function(*args, **kwargs)
@@ -13,6 +13,7 @@ from lancedb.functions import (
FunctionBinding,
FunctionVersion,
PythonRuntimeSpec,
SecretBinding,
RefreshColumnResult,
)
from lancedb.table import AsyncTable
@@ -110,7 +111,9 @@ def test_function_version_identity_is_immutable_and_exact():
version = FunctionVersion.from_json(json.dumps(value))
assert version.name == "embed"
assert version.version == "fv_01K3EXACT"
assert dict(version.secret_env_bindings) == {"HF_TOKEN": "hf-prod"}
assert list(version.secret_bindings) == [
SecretBinding(kind="env", variable="HF_TOKEN", secret_ref="hf-prod")
]
with pytest.raises((TypeError, ValueError)):
version.version = "fv_changed"
@@ -299,17 +302,19 @@ def test_canonical_client_values_carry_bindings_and_no_credentials():
json.dumps(job_result("remote_function_job.json"))
)
canonical = json.loads(version.to_canonical_json())
assert canonical["secret_env_bindings"] == {"HF_TOKEN": "hf-prod"}
assert canonical["secret_bindings"] == [
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"}
]
assert_no_secret_values(canonical)
def test_a_version_without_bindings_keeps_the_original_wire_shape():
"""Every Function registered before Secrets existed serializes unchanged."""
value = job_result("remote_function_job.json")
del value["secret_env_bindings"]
del value["secret_bindings"]
version = FunctionVersion.from_json(json.dumps(value))
assert dict(version.secret_env_bindings) == {}
assert "secret_env_bindings" not in json.loads(version.to_canonical_json())
assert list(version.secret_bindings) == []
assert "secret_bindings" not in json.loads(version.to_canonical_json())
class _FunctionDeclarationInner:
@@ -24,6 +24,7 @@ import pytest
import lancedb
from lancedb.functions import (
PythonRuntimeSpec,
SecretBinding,
UdfDefinition,
_canonical_arrow_type,
_GRAMMAR_PRIMITIVES,
@@ -101,7 +102,7 @@ def test_an_unbound_request_carries_no_binding_at_all():
whether or not a credential is later bound to it.
"""
unbound = json.loads(analyze_caption.registration_request.to_canonical_json())
assert "secret_env_bindings" not in unbound
assert "secret_bindings" not in unbound
assert "OPENAI_API_KEY" not in json.dumps(unbound)
@@ -121,7 +122,7 @@ def test_a_function_declaring_no_secret_is_registered_exactly_as_before():
== normalize_score.registration_request.to_canonical_json()
)
assert (
"secret_env_bindings"
"secret_bindings"
not in normalize_score.registration_request.to_canonical_json()
)
@@ -166,8 +167,10 @@ def test_a_binding_envelope_reaches_the_service_for_it_to_judge():
db.create_function(normalize_score, secrets=bindings)
sent = state["requests"][0][1]
assert len(sent["secret_env_bindings"]) == 17
assert sent["secret_env_bindings"]["TOKEN_0"] == "secret-0"
assert len(sent["secret_bindings"]) == 17
assert {"kind": "env", "variable": "TOKEN_0", "secret_ref": "secret-0"} in sent[
"secret_bindings"
]
_SECRET_DEBUG_LOG_SOURCE = """
@@ -1430,7 +1433,7 @@ def _mock_remote_function_catalog():
"runtime": body["runtime"],
"runtime_digest": "sha256:runtime",
"environment_digest": "sha256:environment",
"secret_env_bindings": body.get("secret_env_bindings", {}),
"secret_bindings": body.get("secret_bindings", []),
"created_at": "2026-08-21T00:00:00Z",
}
response = {"job_id": "job-register"}
@@ -1541,10 +1544,14 @@ def test_remote_registration_sends_bindings_and_never_a_credential():
secrets=[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")],
)
assert dict(created.secret_env_bindings) == {"OPENAI_API_KEY": "openai-prod"}
assert list(created.secret_bindings) == [
SecretBinding(kind="env", variable="OPENAI_API_KEY", secret_ref="openai-prod")
]
path, create_request = state["requests"][0]
assert path == "/v1/functions/create"
assert create_request["secret_env_bindings"] == {"OPENAI_API_KEY": "openai-prod"}
assert create_request["secret_bindings"] == [
{"kind": "env", "variable": "OPENAI_API_KEY", "secret_ref": "openai-prod"}
]
# The request names a Secret and carries nothing that could be one.
assert create_request == json.loads(
analyze_caption.bind_secrets(