Skip to main content

Sharing resources across libraries

A library declares three kinds of input. Two are scalar and long-standing — variables: and secrets:. The third is resources:: the instances the library requires from whoever imports it.

kind: Telo.Library
metadata: { name: HubSearch }
imports:
Sql: oci://ghcr.io/telorun/sql@0.28.1
resources:
connection:
kind: Sql.Connection
description: The database this library reads from.
variables:
minScore: { type: number, default: 0.3 }
exports:
resources: [searchResources]

The importer supplies them at the import's object form:

imports:
Search:
source: ./hub-search.yaml
resources: { connection: !ref db }
variables: { minScore: !cel "variables.vectorMinScore" }

A supplied variable or secret — and the library's own default:, standing in for one the import leaves out — is read against the entry the library declares, for every value type: text in a type's encoding is decoded (an RFC 3339 string at a Telo.Timestamp entry is a timestamp inside the library, a URL's text a Telo.Url), then the value is validated. A literal the entry refuses is SCHEMA_VIOLATION at the import that wrote it (URL_INVALID for a URL, HOST_PATH_RELATIVE for a relative host path), an expression of the wrong type CEL_TYPE_ERROR, and the kernel refuses the same supply when the import initializes, naming the import and the variable and never the value.

What it is for​

Instances used to flow up and never down. exports.resources hands an instance to an importer; nothing handed one the other way. Each import declaration also builds its own child module context with its own instances, so two libraries importing a third get two of everything in it — measured: an application importing two libraries that each import one library owning a SQLite connection fails with no such table, because the writer and the reader are on different connections.

Without this block, any set of libraries that must share a resource has to be linearized into a total order, every layer re-exporting the union of everything beneath it — which gives away the self-containment a split is for.

It is a correctness constraint, not a preference: durable-journal-postgres attests exactly-once only when the journal writes on the same connection as the work it records, and a second pool silently degrades every region to at-least-once.

Inside the library, an input is an ordinary name​

!ref connection at a reference slot and resources.connection.<field> in CEL answer exactly as they do for a locally declared resource. The analyzer stands a kind-only declaration behind each entry in the library's own scope, which is what makes that true: a library's internals are validated in the library's own pass — a consumer's flattened analysis drops the library doc — so with nothing behind connection there would be nothing for !ref connection to resolve against.

Kind-only is enough because it is already what a reference slot gets: a reading types its status: half from the kind, closed so a typo below it is CEL_UNKNOWN_FIELD, and leaves the flat half open, since no manifest declares what snapshot() returned.

An entry is constrained by kind, and by nothing else​

There is no use: here. The boundary is a dependency edge for init order whatever the library does with the instance, so the token would carry nothing the edge model reads — and, since the flattened application analysis drops the library doc, it would be the only app-level statement about how a handed-down instance is reached, and an unchecked one.

Detach and zone-attribute checks run in the library's own pass, where the call site is. A zone requirement reaching an injected input cannot be discharged there and defers to the injection site, which is the existing ZONE_REQUIREMENT_DEFERRED vocabulary rather than a new rule.

Two checks are declaration-derived and cannot answer inside the library, so they move to the injection site, where the real declaration is: x-telo-schema-projection-from (which projects one instance's own entries) and OBSERVED_STATE_NEVER_RUN (which a library cannot answer about a resource it does not declare).

Borrowed, not owned​

An injected resource's effect frame belongs to the scope that declared it. The child context never includes it in its own teardown — otherwise a library's teardown would close the application's connection out from under everything else still using it.

Its published reading is mirrored into the library's resources scope, so resources.connection.status.<field> inside the library reads the same value the owner publishes, at the moment the owner publishes it.

Diagnostics​

CodeWhereMeaning
RESOURCE_INPUT_KIND_UNRESOLVEDthe librarythe entry's kind: resolves to nothing in the declaring scope, so it accepts anything
RESOURCE_INPUT_MISSINGthe importa declared entry was not supplied — the same failure as a missing required variable
RESOURCE_INPUT_UNKNOWNthe importan entry the target library does not declare
RESOURCE_INPUT_UNRESOLVEDthe importthe value is not a !ref to a resource this module declares
RESOURCE_INPUT_KIND_MISMATCHthe importthe supplied resource's kind does not satisfy the declared constraint, transitively
RESOURCE_INPUT_EXPORTEDthe libraryan input name also listed in exports.resources — an input is borrowed, so there is nothing to export
RESOURCE_INPUT_NEED_TYPEthe librarythe entry's kind is a need type — a value any level up the import tree may supply is a need, declared under needs:

Init order falls out on its own: the import gains a dependency edge per injected target — to a local resource, or to the import that exports a cross-module one — so a cycle among those is caught by the graph that already catches cycles. Only that direction is modelled: nothing connects a forwarded export back to the import that owns it, so a cycle closing through an imported instance still surfaces as an init-loop failure rather than as a reported cycle. Either way an import whose target is registered but not yet initialized defers with ERR_LOCAL_REF_PENDING and is retried.

Library singletons​

resources: hands one instance down to several libraries. The other half of the same problem is a library that owns an instance several libraries need. By default each import declaration builds its own child scope with its own instances, so two libraries importing a third get two of everything in it — which for a library owning a connection means two connections.

lifecycle: on a Telo.Library names the choice:

kind: Telo.Library
metadata: { name: SharedStore }
lifecycle: shared # `isolated` is the default
  • isolated (default) — one instantiation per import declaration. Right for a library whose instances are the importer's: a client configured per consumer, a codec, a formatter.
  • shared — one instantiation for the whole application. Every import of it, at any depth, resolves to the same child scope and the same instances.

The default is isolated because it is what every published module was written against; flipping it would silently collapse existing resource graphs and turn per-import variables: into a conflict.

The root owns a singleton; every import borrows it. Its child scope is spawned under the root rather than under whichever import reached it first — otherwise tearing that importer down would close a library two others still hold, and which importer that is depends on init order. It is torn down after every other root child, so a borrower's own inverses still find it alive.

Every import of a singleton must agree. One instantiation has one configuration, so two imports supplying different variables, secrets, resource instances or needs is a conflict, naming both aliases and the key that differs — never the value of a secret or a need. Two values are the same when they are equal as the import builds them, so two spellings of one URL or one instant agree. Reported as SHARED_LIBRARY_CONFLICT at telo check wherever it is decidable there (literal values; references only within one module, since a name means the same instance only in one scope), and as ERR_SHARED_LIBRARY_CONFLICT at boot, which holds the resolved values and the live instances and is authoritative. And a per-import logging: or runtime: override is refused (SHARED_LIBRARY_OVERRIDE, statically): both scope a subtree that is no longer one import's subtree.

A second import of a singleton costs no fetch, no parse and no analysis pass — the registry is keyed on the resolved module URL, and only a shared library is ever in it.

Declaring a floor​

resources: and lifecycle: on a library are syntax that did not exist before telo 0.83. A module using either is unreadable to every older runtime, which would reject it with a SCHEMA_VIOLATION on the library's own line. Declare the floor so the failure names the cause instead:

requires:
telo: ">=0.83.0"