Kind inheritance
A Telo.Definition can extends any kind — not only a Telo.Abstract, but a
concrete kind with a real controller. This is ordinary single inheritance:
the child reuses the parent's controller and behavior while presenting a
friendlier schema and preset config.
The motivating case: an API client that is, for every runtime and type purpose,
a preconfigured http-client Client — so it drops into anything that
expects a Client, and any operation that expects one can point at it.
What a child inherits
A child that extends a concrete kind and declares no own controllers: /
template body inherits, by delegation:
- The controller. The kernel resolves the controller-bearing ancestor,
evaluates the child's
base:mapping, calls the ancestor controller'screate()with the mapped config, and returns that instance verbatim. The child instance therefore is a parent instance — it duck-types identically (e.g. it has theClient's.snapshot()), so consumers and reference injection need no awareness of the subclass. - The capability — immutably. Omit
capabilityto inherit the ancestor's, or restate it identically. Declaring a different capability than an ancestor is a hard error (EXTENDS_CAPABILITY_MISMATCH). Inheritance never changes a kind's lifecycle role. - The schema. With
base:present, the child's author-facing schema is its own schema — the parent's config fields become internal, set solely throughbase:, so the child genuinely narrows. Withoutbase:, the child's schema ismerge(parent, own)— a pure additive extension exposing the parent's config fields plus its own. A merge-form child forwards its whole config as the parent's, so a parent that closes its schema (additionalProperties: false) cannot accept an added field; declaring one isEXTENDS_CLOSED_PARENT_ADDS_FIELD, and the fix is abase:mapping. - The published reading.
resources.<child>is the parent instance'ssnapshot(). Withoutbase:, the fields the child declares that the parent did not are published over it, so a field the parent has never heard of reads back asresources.<child>.<field>. A redeclared inherited field is not: narrowing a field's schema (a description, a pattern, a widget hint) says nothing about publication, and the parent'ssnapshot()stays the sole authority on what a parent instance publishes — including its normalizations, its deliberate omissions and its redactions. Two values are withheld even for an own field, because neither is a reading: one whose slot isx-telo-eval: runtime(still an unevaluated expression when the reading is taken) and one holding a live resource instance. Withbase:the reading is the parent's alone — there the child's fields are construction inputs consumed by the mapping and do not exist on the instance. - The invocation contract — by replacement, not merge.
inputType/outputTyperesolve to the nearest declaration along the chain: declare neither and the child presents its ancestor's signature unchanged; declare one and it fully replaces the ancestor's. Config merges because construction is additive; a call signature is not — merging a child's required inputs into its parent's would produce a union no caller could satisfy.
A child is Liskov-substitutable for every ancestor at any !ref slot,
transitively — a GithubClient satisfies any slot typed
x-telo-ref: Http.Client. Replacing the contract does not change that: extends
decides which slots accept a resource, and never carried the dispatch contract.
What a replaced contract does affect is wiring, per slot — see
Presenting your own signature.
Presenting your own signature
A child that inherits a controller may present a different call signature by declaring its own contract plus the mapping that bridges it back:
| declared | required mapping | what it does |
|---|---|---|
inputType | inputs: | turns the child's signature into the parent's call |
outputType | result: | turns the parent's result into the child's |
The mapping is not optional. The inherited controller understands only the shape
it was written for, so a declaration without one would never be applied —
telo check reports CONTRACT_MISSING_MAPPING.
kind: Telo.Definition
metadata: { name: Doubler }
extends: JS.Script
schema: { type: object, additionalProperties: false, properties: {} }
base:
code: |
function main({ n }) { return { out: n * 2 }; }
inputType:
kind: Telo.JsonSchema
schema:
type: object
required: [value]
properties: { value: { type: integer } }
outputType:
kind: Telo.JsonSchema
schema:
type: object
required: [doubled]
properties: { doubled: { type: integer } }
inputs:
n: !cel "inputs.value"
result:
doubled: !cel "result.out"
One dispatch now checks four boundaries where none were checked before: the
child's inputs, the mapped values against the parent's contract, the parent's
result, and the mapped result against the child's outputType.
Doubler stays wired wherever JS.Script is. Where the wiring rule bites is a
slot whose consumer builds the arguments itself from the slot's declared kind
alone — it would send the parent's shape to something that accepts the child's,
so telo check rejects it (CONTRACT_SLOT_INPUTS_UNSATISFIABLE). At a slot that
takes a paired author inputs: — a Run.Sequence step, an inline target step —
the author supplies the arguments and the call site is checked instead.
base: — the super mapping
base: is the "super(...)" of Telo. It is a top-level sibling on the
definition: an object of CEL expressions over self (typed from the child's own
schema), evaluated once to build the config passed to the inherited controller.
It mirrors the existing inputs: / result: top-level-sibling factoring.
kind: Telo.Definition
metadata: { name: GithubClient }
extends: Http.Client # concrete parent; capability (Provider) inherited, immutable
schema:
type: object
required: [token]
properties:
token: { type: string } # with base:, this IS the author surface
base: # the base kind's config, derived from self
baseUrl: https://api.github.com
headers:
Authorization: !cel "'Bearer ' + self.token"
Accept: application/vnd.github+json
base: constructs the parent's config once. How that config then behaves is
the parent controller's contract: fixed for kinds that consume config in
create() (like Http.Client), or baked defaults the controller re-layers per
call (like Http.Request, whose inputs a caller can still override).
An absent field stays absent through every level. A base: entry that forwards
a field its author left out (accessToken: !cel "self.accessToken") contributes
no key to the parent's config, so a parent whose own base: asks
has(self.accessToken) sees false however many levels of base: lie between.
When an expression in a base: mapping fails, the error names the definition and
field that wrote it — in base.headers of GoogleAuth.GoogleClient (<source>) —
not only the resource being created.
A base-form child's own fields are compile-eval, without annotation. They are
construction inputs and nothing else — no controller ever sees them, base:
reads them once at creation — so an expression written at one is evaluated there,
against the startup scope (variables, secrets, ports, module, and the
resources already published). That is the same posture Telo.Provider takes for
the same reason, and it is why such a field needs no x-telo-eval: compile. The
consequences are the ones compile-eval always has: the expression is
type-checked at telo check, and a read of observed state (resources.x.status.…)
is OBSERVED_STATE_IN_STARTUP_FIELD, because nothing has run yet. A field the
child annotates x-telo-eval: runtime still wins.
A definition may not declare base: and a body. base: is read only on the
inherited-controller path, and resources: / controllers: / a dispatch slot
selects the template path instead — where base: is never evaluated. Declaring
both is BASE_WITH_TEMPLATE_BODY at telo check and ERR_BASE_WITH_TEMPLATE_BODY
at the kernel. A kind that stands up children of its own is a
template; one that reshapes an existing
controller's config is this.
Forwarding a reference field. When
base:forwards a reference the child holds (e.g.client: !cel "self.client"), write it as a bareself.<field>access — nothing more. By the timebase:runs, that field is a live resource instance, and a live instance cannot flow through a CEL expression (CEL's type checker rejects it). So!cel "self.enabled ? self.client : null",!cel "[self.client]", or any wrapping of the reference will fail. Keep reference forwarding and value-building CEL in separatebase:fields — the same rule that governsself-derived references in a templatedresources:body.
Worked example — a GitHub client library
A small library that exposes a GithubClient plus one wrapped operation:
kind: Telo.Library
metadata:
name: github
namespace: acme
version: 0.1.0
imports:
Http: oci://ghcr.io/telorun/http-client@0.23.0
exports:
kinds:
- GithubClient
- SearchRepos
---
kind: Telo.Definition
metadata: { name: GithubClient }
extends: Http.Client
schema:
type: object
required: [token]
properties:
token: { type: string }
base:
baseUrl: https://api.github.com
headers:
Authorization: !cel "'Bearer ' + self.token"
Accept: application/vnd.github+json
---
# An operation that inherits Http.Request's controller. base: builds the parent
# request from THIS instance's config; the request fields are computed from `q`.
kind: Telo.Definition
metadata: { name: SearchRepos }
extends: Http.Request
schema:
type: object
required: [client, q]
properties:
client: { x-telo-ref: Http.Client } # re-declared to expose it; forwarded below
q: { type: string }
base:
client: !cel "self.client"
inputs:
url: /search/repositories
method: GET
query:
q: !cel "self.q"
Consuming it — the client is constructed from { token }, and because it is a
Client, both the wrapped SearchRepos and a raw Http.Request can point at it:
kind: Telo.Application
metadata:
name: github-demo
version: 1.0.0
imports:
Gh: oci://ghcr.io/acme/github@0.1.0
Http: oci://ghcr.io/telorun/http-client@0.23.0
secrets:
ghToken: { env: GITHUB_TOKEN, type: string }
targets:
- invoke: !ref TopRepos
- invoke: !ref RateLimit
---
kind: Gh.GithubClient
metadata: { name: Api }
token: !cel "secrets.ghToken"
---
kind: Gh.SearchRepos # a wrapped operation
metadata: { name: TopRepos }
client: !ref Api # GithubClient into the inherited Http.Client slot
q: "stars:>10000"
---
kind: Http.Request # the escape hatch: a raw request for an unwrapped endpoint
metadata: { name: RateLimit }
client: !ref Api # same GithubClient, straight into Http.Request's client slot
inputs:
url: /rate_limit
method: GET
At runtime both instances are native instances of their parents: Api runs
http-client's Client controller (from its base:-mapped config) and TopRepos
runs the Request controller. Because Api is a Client, it drops into every
client slot with no special handling.
Inheritance vs. templated composition
Both let you define a kind without writing a controller — pick by intent:
Kind inheritance (extends + base:) | Templated composition | |
|---|---|---|
| Identity | The instance is the parent (duck-types as it, .snapshot() etc.) | The instance wraps internal children and forwards a dispatch method |
| Reuse | One parent controller, preset via base: | Several existing kinds wired together |
Substitutable at a parent's !ref slot | Yes, transitively | No — it is its own kind |
| Config | base: maps self onto the parent's config once | Internal resources: bodies are self-only |
Reach for inheritance when your kind is one existing kind, specialized. Reach for templated composition when it is an assembly of several.
Telo.Abstract
Telo.Abstract remains the non-instantiable base: it uniquely means no
default implementation — must be extended. Use it for a contract with no default
implementation (Sql.Connection, Codec.Encoder, Ai.Model, …); use plain
extends of a concrete kind when you want to reuse an existing controller.