Declaring runtime requirements
A module declares which versions of Telo it is verified against, in a top-level
requires: block:
kind: Telo.Library
metadata:
name: SQL
version: 0.9.0
requires:
telo: ">=0.80.0"
Why
Telo closes every extension vocabulary and rejects an unknown token. That is right
for a typo and wrong for a version — both are "a string I do not recognise", and the
checker cannot tell them apart. So when a module adopts new syntax, an older runtime
rejects it and blames the module's author: an unknown use token, an object where a
zone annotation expects a pointer, an unexpected key on a built-in schema. Every one of
those messages is true and none of them names the actual cause.
Declaring the range fixes both halves. telo upgrade stops selecting versions your
runtime cannot read, and when you reach one anyway — a hand-written pin, a ref copied
from the hub — you are told exactly that, instead of meeting a schema error inside a
module you do not own.
The same rule drives every upgrade affordance in an IDE: the Telo editor's Imports view
and the VS Code import lenses read each candidate version's own telo.yaml, newest
first, and offer the newest one your Telo can host. A newer version that was held back
is named along with the reason, so a pin never becomes a silent ceiling; a version list
offered for a deliberate pick still shows every version, marking the ones that need a
newer Telo rather than hiding them. A candidate that cannot be read or reached is never
treated as incompatible.
The grammar
Each axis is a semver range, and every bound must be testable.
^and~are rejected. Pre-1.0 both allow only a single minor (^0.40.0is>=0.40.0 <0.41.0), and Telo ships breaking changes as minor bumps deliberately — so the caret reading is correct and useless: it pins your module to one release generation. Write>=0.40.0.- A bare version is rejected. Semver reads
0.80.0as an exact pin, which is the same trap in a different costume. ||, hyphen ranges and wildcards are rejected. Verification runs the CLI at the range's lowest and highest bound, and a disjunction has neither.- An upper bound must name a version that already exists, checked when you publish. Predicting a future break is a claim nobody can make honestly.
Open above is the normal shape. A closed bound earns its place in one case: a module
known broken on a newer Telo that will not be fixed. Publishing a patch bounded below
that release makes telo upgrade report no compatible version rather than handing a
consumer a confusing failure.
Host axes
Requirements on the machine rather than on Telo nest under host::
requires:
telo: ">=0.85.0"
host:
node: ">=20.0.0"
node is the only host axis today, and that is deliberate: an axis exists only
once something compares it. A declared requirement nothing checks is worse than none
— it validates, it reads as protection, and it protects nobody. rustc arrives with
the slice that builds controller crates and can compare it; until then, writing it is
reported as an unknown axis rather than silently accepted.
They are nested because nodejs and rust are already kernel labels elsewhere in
Telo (an imports: entry's runtime:), so a top-level node: would read as the Node
kernel rather than the Node.js runtime. Position disambiguates where no word can.
The tiers also differ in how they are established: telo is verified by running the
CLI, while a host axis is asserted by you and compared against the version the running
host reports. An IDE checks neither host axis, because it is not the host — it speaks
for the Telo surface and nothing else, and a host requirement it cannot judge still
surfaces at the load gate when the manifest actually runs.
telo is always checked first. That is what keeps the block extensible: a module using
an axis introduced in Telo 0.85 also requires telo: ">=0.85.0", so an older runtime
fails on the version and never has to interpret an axis it has never heard of.
One scale, every kernel
There is no separate range per kernel. telo names the manifest surface generation a
runtime implements — one scale reported by the Node kernel, the Rust kernel and any
future Go kernel alike. Writing bounds per kernel would restate one fact several times,
and would ask you to assert things about kernels you have never run.
Telo's own packages and crates carry the generation as their version. The runtime
packages — @telorun/sdk, @telorun/cel, @telorun/templating, @telorun/analyzer, @telorun/kernel,
@telorun/cli, @telorun/ide-support and @telorun/language-server — are released
together at one version, and their Rust twins (telo-kernel, telo-cli,
telo-analyzer, telo-templating, the CEL engine's telorun-cel and telorun-cel-value, and the
SDK's telorun-sdk, telorun-sdk-core and telorun-sdk-macros) carry the same number. So >=0.100.0 means telo 0.100.0 whichever
of them you look at: the CLI you verify with, the kernel that loads the module, and the
editor engine checking it as you type.
A kernel that implements only a subset of a generation claims none and skips the check. A version expresses older, not smaller.
In the editor
Editors read the same ranges to decide which telo a module is edited against
(the telo.version setting's auto): a module declaring requires: telo: is
edited against the lowest available version its own range and its imports'
ranges accept; a module declaring none stays on the editor's bundled version
when its imports accept that, else moves to the lowest version they all accept;
and when nothing satisfies them, the bundled version runs and its load gate
names the module that refuses it. Declaring a floor therefore also decides what
your editor checks you against. See
Editing against a telo version.
Verifying it
The claim is verified by running the CLI at each edge of your declared range:
npx @telorun/cli@0.80.0 check modules/sql/telo.yaml
Green means the declaration is true; red at the low edge means you adopted syntax that version cannot read. Two edges bound the whole range rather than sampling it, because syntax support is monotonic — a construct added in 0.43 works in 0.44 and later. For a range open above the high edge is HEAD, which your ordinary check already covers.
This runs automatically in two places:
telo publish— as a preflight, so a published range is a verified one. A refuted range is fatal; a CLI that could not be installed warns, since an unverified claim is not a disproven one and blocking a publish on registry reachability trades one failure for a worse one.telo release check— over every module in the workspace, batched by edge, so a standard library declaring one or two distinct lower bounds costs one or two runs.
In a workspace where modules import siblings by relative path, propagation is automatic: when a sibling adopts new syntax, every dependent fails its own edge check until its range moves too.
Declaring a release that has not happened yet
Adopting new syntax means declaring the version that will carry it — which, on the commit that does so, is not published. That is the expected state, not a mistake, and it is reported as its own outcome rather than as a failed install:
· telo 0.79.0 1 module pending — not published yet (latest: 0.78.0)
No CLI is fetched: an unpublished version has one possible outcome, and npm's ETARGET
arrives wrapped in install noise indistinguishable from being offline. The latest
published version is printed beside the bound because that is what makes a typo
visible — >=0.790.0 next to a latest of 0.78.0 reads wrong at a glance, where "could
not run" would read the same for a typo and for tomorrow's release.
pending is informational in telo release check and fatal in telo publish. The
asymmetry is the release order: npm publishes before modules do, so by the time a module
is pushed its declared floor exists. If it does not, the release is out of order, and the
module would land at the registry declaring a minimum no runtime can satisfy — which
every consumer's telo upgrade would then refuse.
Two things this does not cover. A version below the latest that is nevertheless absent (yanked, never published) still reports as "could not run": that is honest, and guessing at why it is missing would be a second rule with nothing behind it. And when npm cannot be reached at all, nothing is classified — a guess about what exists is worse than the run's own verdict.
Who declares
Libraries declare always, and open above. The lower bound moves only when verification forces it, so the standing value is whatever release the module was last verified against — read it as "the oldest runtime we support and test", not "the oldest that would technically work".
Applications may declare, and should when distributed — a starter, an example, a deployed app. An application has no importer, so its block buys a version-attributed message for its own operators plus the CI check, rather than a contract anyone resolves against. It is not derivable from its imports: an app's own syntax is visible nowhere else.
A module that declares nothing carries no requirement. That is permanent for everything published before this mechanism existed, and correct — none of it uses syntax that did not yet exist.
One caveat worth knowing: the block itself needs a Telo new enough to recognise it. Module
documents are closed schemas, so a manifest declaring requires: is a schema error on any
Telo released before the block existed. That is the one break this mechanism cannot explain
away — and from here on it is what explains every other one.
What this is not
requires: is what must be true before Telo can run the manifest at all. A tool the
work depends on — ffmpeg, a headless browser — is a different thing: unverifiable,
unbounded, and carrying a per-platform install action. That belongs in a resource kind
provisioned through telo install, not here.
It also does not make anything work. An old runtime cannot execute semantics it lacks; this makes the failure early, singular, and correctly attributed.