skills/cache-expert/references/session_resources.md
This document describes the current session-resource model used for secrets and sockets, and how it integrates with the dagql cache.
The source of truth is the code, mainly:
dagql/cache.gocore/secret.gocore/socket.gocore/ssh_auth_socket.gocore/client_resource.gocore/schema/secret.gocore/schema/host.gocore/schema/git.gocore/git_remote.gocore/container_exec.goThis doc focuses on:
Some values are not ordinary content-addressed data.
Examples:
These values have two conflicting requirements:
The current session-resource system is the compromise that gives us both.
This is the key rule to remember:
If a cache hit has a direct or transitive dependency on a session resource, the hit is only valid if the caller's session has already loaded that session resource handle.
In practice that means:
So session resources make some cache hits conditional rather than unconditional.
Without this rule, cache reuse would be wrong in both directions:
The handle model lets us say:
That is the whole point of the system.
Each session resource has two forms:
This is the real thing:
This concrete value is stored in the cache's per-session resource tables via
BindSessionResource.
This is what flows through the dagql graph and cache:
Secret or Socket object carrying a SessionResourceHandlerequiredSessionResources including that handleThe handle-form object is safe to cache and compare. The concrete value stays session-bound.
The dagql cache owns the session-resource binding tables:
sessionResourcesBySessionsessionHandlesBySessionThe important APIs are:
BindSessionResourceResolveSessionResourceBindSessionResource stores:
and records that the session currently has that handle available.
ResolveSessionResource later looks up the concrete value from:
with a fallback to the latest binding for that handle in the session if the exact client did not bind it.
When a handle-form resource object is created, the result is stamped with:
WithContentDigest(handle)WithSessionResourceHandle(handle)WithSessionResourceHandle is what makes the result resource-conditional:
sessionResourceHandlerequiredSessionResourcesLater, as results depend on other results, the cache recomputes transitive
requiredSessionResources by unioning dependency requirements.
So if a container depends on a secret, and an exec depends on that container, the required resource handle propagates transitively.
When the cache finds candidate results, it does not immediately return the first equivalent one.
Instead, selectLookupCandidateForSessionLocked filters candidates by checking:
requiredSessionResourcessessionHandlesBySessionOnly a candidate whose required handle set is a subset of the session's loaded handles is eligible.
This is the actual enforcement point of the core rule.
There are four handle-backed categories worth thinking about today.
secret(...)This is the preferred secret path.
The GraphQL entry point is Query.secret.
The user supplies:
cacheKeyThe concrete value is a Secret with:
URIValSourceClientIDThe handle is derived in one of two ways:
If no custom cache key is supplied, the secret plaintext is loaded up front and
the handle is derived from the plaintext using SecretHandleFromPlaintext.
That means two provider secrets with the same plaintext get the same handle by default, even if their URIs differ.
This is what enables the important equivalence behavior:
env://FOOenv://BARIf cacheKey is provided, the handle is derived from that string instead of the
plaintext.
That is an explicit override:
This is intentionally powerful and intentionally user-directed.
If the provider secret cannot be resolved up front for plaintext-based handle derivation, the implementation falls back to a random cache key.
That disables cross-call equivalence instead of accidentally making unrelated provider failures collide.
setSecret(name, plaintext)This is the in-session plaintext secret path.
The GraphQL entry point is Query.setSecret.
Unlike provider secrets, the handle here is not derived from plaintext.
It is derived by SetSecretHandle(name, accessor), where accessor comes from
GetClientResourceAccessor.
So setSecret equivalence is based on:
not directly on plaintext.
This is the backwards-compatible behavior the current system preserves.
That means setSecret does not have the same equivalence semantics as the
provider-secret path.
The plaintext itself stays only in the concrete bound value. The handle-form secret object carries:
HandleHandleHandleOne other important detail: setSecret sanitizes the call frame so the handle
result's ResultCall records "***" for plaintext rather than the actual
secret value.
_sshAuthSocket(...)This is the SSH-specific socket path.
It is currently mostly used by:
--mount=type=ssh paths through dockerBuildThe GraphQL entry point is the internal host field Host._sshAuthSocket.
The concrete socket is a real socket source, but the handle is derived from SSH agent fingerprints, not from the local socket path.
That is crucial.
The handle is produced by:
ScopedSSHAuthSocketHandle(secretSalt, fingerprints) for the main clientScopedNestedSSHAuthSocketHandle(secretSalt, fingerprints, clientID) for
nested clientsImportant consequences:
So SSH socket equivalence is semantic "same agent identity set", not "same file path on the host".
host.unixSocket(path: ...)This is the generic opaque unix socket path.
The GraphQL entry point is Host.unixSocket.
The concrete socket is stored as:
Kind = unix_opaqueURLVal = unix://...SourceClientIDThe handle is derived by HostUnixSocketHandle, which uses
GetClientResourceAccessor(...) for the given path.
So host unix socket equivalence is accessor-based, not content-based.
Unlike SSH sockets, we do not derive equivalence from the socket's behavior or a remote identity. We just treat the scoped accessor as the handle.
This is intentionally weaker and more opaque than the SSH case.
There is also SocketKindHostIP.
That exists in the type system, but it is not part of the handle-based session resource model discussed here in the same way the four categories above are.
The interesting handle-backed categories are:
setSecretGetClientResourceAccessorGetClientResourceAccessor is an important helper for setSecret and host
unix sockets.
It computes an HMAC-based accessor over:
That gives two important properties:
This is why setSecret("FOO", ...) and host.unixSocket("/tmp/x") are not
just globally keyed by those raw strings.
The handle-form objects are what flow through the graph.
Concrete resolution happens only when somebody actually needs the underlying resource.
For secrets:
resolveSessionSecretSecret.NameSecret.URISecret.PlaintextFor sockets:
ResolveSessionSocketSocket.URLSocket.PortForwardSocket.ForwardAgentClientSocket.MountSSHAgentSocket.AgentFingerprintsSo the cached graph carries the handle, but actual execution-time operations resolve back to concrete bound resources through the session tables.
The two most important execution-time consumers are:
Container exec paths eventually call Secret.Plaintext(ctx) to materialize
secret mount data or secret env data.
That means exec only succeeds if the current session can resolve the secret handle to a concrete secret.
Container exec SSH mounts and git SSH operations eventually call
Socket.MountSSHAgent(ctx) / ForwardAgentClient.
That again depends on resolving the handle to a concrete session-bound socket.
So the handle is not just an identity trick. It is also the gate that connects cacheable graph state back to the right live resource.
The provider-secret example is the clearest one.
Suppose two sessions do the same operation except:
env://FOOenv://BARWith the current system:
So we get the desired behavior:
The same general pattern applies to SSH sockets by fingerprint identity.
Git paths show a few important subtleties.
For Git SSH URLs, the schema tries hard to route SSH auth through
Host._sshAuthSocket, not raw host.unixSocket, so the cache key can be scoped
by SSH key fingerprints instead of host path.
If the caller provides an unscoped socket, the schema reinvokes itself with a
scoped _sshAuthSocket result so that the scoped handle appears explicitly in
the DAG.
For HTTP auth:
The code comment is explicit about the reason: it is safer to scope by auth configuration than risk cache poisoning across different auth methods.
Git also mixes these resources into some content digest calculations or cache scope strings.
That is related to cache identity, but it is not the whole story. The session-resource gating is still separately important because equivalence alone does not authorize a hit.
Containers do not need bespoke session-resource logic to participate.
They just depend on secrets and sockets as ordinary dagql results:
withSecretVariablewithUnixSocketBecause dependency attachment is exact and
recomputeRequiredSessionResourcesLocked unions requirements transitively,
container results automatically inherit the required handles of the secrets and
sockets they depend on.
That is what makes later cache hits on container-derived results conditional on resource availability.
Concrete bindings live under sessionResourcesBySession and
sessionHandlesBySession.
When the session is released:
This is why the cache has to be involved in session resource lifecycle at all. It is the place that:
Secrets and sockets do implement persisted object encoding, but what is persisted is only the handle-form metadata, not the concrete session-bound value.
That is a very important part of the design.
We cannot safely persist the concrete resource itself:
What is safe to persist is the handle form. Persisting the handle form means
persisting the fact that some result depends on "any secret or socket that
matches handle H".
That is exactly the right logical model:
HHThis is what makes it coherent for other persistent objects to depend on session resources. For example, a persistent container can still be persisted normally even if it depends on a secret or socket, because what persistence keeps is the handle-form dependency graph, not the concrete secret plaintext or live socket instance.
So persistence preserves the conditional dependency, not the concrete backing resource.
setSecretIt is intentionally indirect:
That is more moving parts than a direct "just store the secret/socket" model, but it is what enables safe conditional reuse.
Provider secrets and setSecret do not have identical equivalence semantics.
SSH sockets and host unix sockets do not either.
That is real and intentional, but it is worth keeping in mind when reasoning about cache hits.
A matching content digest or structural lookup is not enough by itself. The session still must have the required handles loaded.
If you want to load this model into your head quickly, this order works well:
dagql/cache.go
BindSessionResourceResolveSessionResourceWithSessionResourceHandlerecomputeRequiredSessionResourcesLockeddagql/cache_egraph.go
selectLookupCandidateForSessionLockedcore/secret.go
SecretHandleFromCacheKeySetSecretHandleSecretHandleFromPlaintextresolveSessionSecretcore/socket.go
HostUnixSocketHandleResolveSessionSocketMountSSHAgentAgentFingerprintscore/ssh_auth_socket.go
ScopedSSHAuthSocketHandleScopedNestedSSHAuthSocketHandlecore/schema/secret.go
secretsetSecretcore/schema/host.go
unixSocket_sshAuthSocketcore/schema/git.go and core/git_remote.go
Session resources let the cache reuse work across equivalent secrets and sockets without letting one session silently consume another session's backing resource: the graph carries stable handles, the cache tracks which sessions have loaded which handles, and a cache hit is only valid if the caller's session satisfies the required handle set.