The Registry
The registry is where a piece of code stops being a file on your laptop and becomes a name that other people can find, pin and import. DreamLake owns the declaration half of that: the name, the source, the signature, and the environment the code expects. It owns none of the execution half.
This page is the write direction — declaring something and giving it a version history. The read direction is Importing from the Registry.
Two layers, and the split is the whole design
A registered runnable is two rows, not one.
Runnable is the mutable head. It survives every edit to the function body,
and it is what a name resolves to:
RunnableVersion is immutable. One row per distinct source revision,
append-only — note the missing updatedAt and the missing deletedAt:
The head is keyed (namespaceId, name, deletedAt). The version is keyed
(runnableId, version). Everything below follows from those two keys.
kind is a lifetime, not a substance
udf, session and agent answer one question — how long the process lives:
kind | Lifetime |
|---|---|
udf | One-shot. Invoked, runs to completion, returns. |
session | Long-lived, with a bidirectional pipe held open. |
agent | A session plus a policy driving it. |
That is the only axis kind encodes. It does not say whether the body is
sync, async, a generator or an async generator — see
what the registry cannot tell you.
What makes this version control and not version display
Four properties, and all four are load-bearing.
1 · Registration is idempotent by content and never overwrites. Posting a
version that already exists returns 200 with the stored row, unchanged. A
new one returns 201. That is safe rather than merely convenient, because
version is sha256(source) — a row already under that key already holds
those exact bytes, so a second caller cannot silently replace what the first one
stored. There is no PUT anywhere in this collection and no way to rewrite a
version row.
2 · latestVersion is a cache, and only one endpoint may move it. It is
deliberately not settable through PATCH; the version-registration endpoint is
the only writer. Re-registering an older version hits the 200 path and returns
early, so the pointer never moves backwards. RunnableVersion stays the source
of truth — if that denormalised write were ever lost, the newest row is still
findable by createdAt desc.
3 · Deleting the head is soft, and leaves every version intact. DELETE on
a Runnable stamps deletedAt and stops there. The version rows are untouched;
they are hidden because their parent is tombstoned, not because they were
removed.
4 · Nothing deletes a version. Not soft, not hard. There is no endpoint that
deletes a RunnableVersion or a RepoSnapshot anywhere in the collection.
DELETE exists only on the three mutable heads (Runnable, RunConfig,
Repo). Both children are append-only immutable history keyed by content, and
hard-deleting one would orphan every invocation on the control plane that names
it.
The routes
Every route is namespace-scoped. :id resolves as a 24-hex ObjectId or as
the dotted name — the SDK knows the id, a human knows
examples.embed.embed_frames, and both work.
| Method | Route | Notes |
|---|---|---|
POST | /namespaces/:slug/runnables | Create. 409 on a duplicate name. Needs runnable:create. |
GET | /namespaces/:slug/runnables | List, paginated. ?kind= and ?module= filters. |
GET | /namespaces/:slug/runnables/:id | The head, including latestVersion. |
PATCH | /namespaces/:slug/runnables/:id | Partial. Omitted fields are left alone. latestVersion is rejected. |
DELETE | /namespaces/:slug/runnables/:id | Soft. Versions survive. |
GET | /namespaces/:slug/runnables/:id/versions | History, newest first. No source. |
POST | /namespaces/:slug/runnables/:id/versions | Register. 201 new, 200 existing. Needs runnable:update. |
GET | /namespaces/:slug/runnables/:id/versions/:version | One version, with source. |
List responses are enveloped under a collection key — runnables, versions,
runConfigs, repos, snapshots — alongside the pagination fields. Errors are
always { error: string } and nothing else.
Not runnable:create — the version belongs to a runnable that already exists,
so pushing one is an update to that runnable. In practice: an org ADMIN can
push versions, and an org MEMBER (READ) can list and read them but cannot
push. If your SDK can create a runnable but 403s on the first
POST …/versions, this is why.
Declaring, end to end
The dreamlake CLI wraps each of the routes above one for one.
Step 4 is the sentence worth remembering: the version string is the hash of the source, so a second caller pushing the same bytes cannot silently replace what the first one stored, and pushing an old revision does not rewind the head.
The hash is computed from the file bytes and cannot be supplied. This is not
ergonomics — RunnableVersion.version must stay byte-identical to the
control plane's Function.version, since that identity is what lets a row here
and a row there agree with no translation table. A --version flag would let a
caller mint a key that the SDK will never reproduce, and the two registries
would fork with no error anywhere. The CLI prints the hash it computed before
it writes.
RunConfig — the environment, declared separately
A RunConfig is the named, reusable declaration of the environment a runnable
runs inside. It is a sibling of Runnable, not a field on it.
runtime and extras are two audiences, and they do not feed each other
This is the thing readers get wrong.
runtimeis read by the runtime layer:{ backend?, server?, runner?, image?, resources?, env?, timeout_s?, host_setup?, run_setup? }.extrasis handed to the launcher untouched:{ instance_type?, partition?, time_limit?, n_cpu?, n_gpu?, … }.
Setting runtime.resources.gpu does not fill in extras.n_gpu, and setting
extras.instance_type does not tell the runtime anything. From the schema
comment, which is the clearest statement of why there are two columns rather
than one bag:
Keeping them in one bag is exactly what let
_RUNTIME_FIELDSrot into a hand-maintained filter with zero readers; two columns make the boundary a schema fact.
tags is a typed column rather than a key in either bag because it is the one
field forwarded to both halves — an EC2 instance tag and a job tag are the
same string, so it cannot honestly live on one side.
provider is a LakeshoreProvider.name, not a foreign key. The control
plane resolves providers by name, late, and an FK here would break the moment it
does.
hostKey is derived, and its exclusions are claims
hostKey is recomputed on every write and never accepted from a client — the
create body schema simply has no such property, and PATCH recomputes it from
the merged value whenever runtime, extras or provider is touched.
Two configs with the same hostKey can share a worker. What is left out is
where the meaning is:
| Excluded | Because |
|---|---|
env | Applied per invocation. Hashing it would cold-start every job that differs by one variable. |
run_setup | Per invocation as well — it does not change what the machine is. |
timeout_s, tags, backend / server | None of them change the machine. |
mounts | Nothing reads a Mount anywhere in the stack yet. |
And host_setup is in the key, with its order preserved — the array is
never sorted. Running pip install torch before apt-get install ffmpeg is a
different host preparation, and pretending otherwise would let a worker prepared
one way serve a job that declared the other. Adding anything to the subset bumps
the prefix to hk2_.
Four languages implement the host key with no shared code (the Python SDK,
nymph, this server, and the TypeScript CLI), and the Python SDK's copy is the
authoritative one — it runs where the declaration actually exists and stamps
run_config.host_key at submit time. The server's copy exists so the dashboard
can show a key and so a divergence is a diffable value rather than a silent
one. src/lib/host-key.ts says it outright: "Never treat the value this
returns as a scheduling decision."
One sharp edge that follows: a float anywhere in resources makes the key
uncomputable, and the server stores null rather than rounding. That is a
deliberate hard error — IEEE-754 round-tripping through msgpack and three JSON
serializers is not something to bet a cache key on — but it means you can end
up with an accepted row that has silently lost its placement column. Keep
resources values integers or strings.
There is no RunConfigVersion model, no history table, and no snapshot of a
config as it was when a run used it. The GraphQL schema states the intent
plainly: "Mutable by name and NOT versioned: it is a hand-edited operator
record, and references to it (Invocation.runConfigRef) are by name on
purpose."
The consequence is worth saying flatly: editing a RunConfig silently
changes what every future run does, and there is no diff, no history and no
rollback. A pinned runnable version plus a mutable config is a half pin. If
you need the environment pinned too, today the honest move is to create a new
config under a new name rather than editing the existing one.
Agents: markdown and channel
An agent is a Runnable with kind: "agent" and two extra columns:
markdown— the agent's instructions, authored as Markdown. This is behaviour.descriptionis the one-line documentation, and the two are deliberately distinct fields.channel— where the agent's pipe is attached. A stored declaration only; no live connection, socket or session is modelled in DreamLake.
Both are rejected with a 400 on any kind other than agent, so a caller who
typed prose into a UDF finds out rather than watching it disappear.
channel is shape-checked and credential-scanned
channel must be exactly { kind, target?, config? }. Any other top-level
key is a 400. Then every key inside it is walked — to a depth of 8, through
arrays — and rejected if it looks like a credential:
Order matters, and it surprises people: the shape check runs first, so
{ token: "x" } comes back with the shape error, not the credential error —
token is not one of the three allowed top-level keys. { config: { auth: { token: "x" } } }
is the case that gets the credential error.
There is no secretCiphertext column anywhere in Runnable, RunConfig or
Repo. Anything you store in channel is stored in plaintext, and comes
straight back out of every GET and out of the GraphQL Runnable.channel
field to any org member holding only READ. The shape check plus the key walk is
the one and only enforcement point there is.
The fix a caller usually wants is to reference a Source (for data) or a
LakeshoreProvider (for a launcher) by name, and let the thing that
already does AES-256-GCM hold the secret.
Every document implies it is, including the shape above. The server does not
enforce it — { target: "#eng" } with no kind is accepted. Treat kind as
required in your own tooling; just do not expect the API to catch you.
markdown and channel are columns on the mutable head. Editing an
agent's instructions leaves no diff, no history and no rollback — the source
hash covers the code and nothing else. The GraphQL schema calls this the
split tripwire.
The fix, if it turns out to matter, is a version history on the head row —
not folding the prose into the source hash. That second option looks cheaper
and is much worse: RunnableVersion.version is byte-identical to the control
plane's Function.version, so mixing prose into it would fork the identity of
every function in the registry to fix a problem that affects agents only.
One database caveat that will bite you
All three head models declare @@unique([namespaceId, name, deletedAt]), and
RunnableVersion declares @@unique([runnableId, version]). On MongoDB a
Prisma @@unique is not enforced by the query engine — it is a real
database index, and that index only exists once someone has run Prisma's schema
push against that database, by hand and out of band. That has not been run.
What you actually get today is a nameTaken() pre-read before every create,
which is what makes the 409 deterministic in the normal case. The P2002 catch
behind it is a backstop for the race between that read and the write — and it
can only fire once the index exists. So two concurrent creates of the same
name can both succeed on a database without the index, which is exactly the
state a fresh test run and a first deploy are both in.
If you are building anything that creates declarations concurrently, serialise it yourself for now.
Try it
The starter kit's 04_version_history directory walks the six steps above
against a local control plane — declare, push, edit, push, push the old bytes
back, and watch the 200 — and finishes on the versions panel in the dashboard
with a ?v=<sha256> deep link.