Skip to main content

Observed state (status:)

A resource has two kinds of value worth publishing, and they are not the same thing:

  • What its author configured — the port you asked for, the base URL you wrote. Known before anything runs.
  • What it observed — the port the socket actually got, the endpoint a negotiation produced. Known only once the resource is running — or, for a Telo.Provider, once it has initialized.

The first is published flat, at resources.<name>.<field>. The second is declared with a status: block on the kind and published under its own segment, resources.<name>.status.<field>. Keeping them apart means neither has to be renamed to avoid the other.

Declaring what a kind reports​

status: is a JSON Schema on a Telo.Definition (or a Telo.Abstract, so a contract can mandate what its implementations report). Declaring it — even empty — opts the kind into typed .status reads. A kind that declares nothing behaves exactly as before.

kind: Telo.Definition
metadata:
name: RedirectListener
capability: Telo.Service
schema:
type: object
properties:
port:
type: integer
description: Pin a port; omit to let the OS choose one.
status:
type: object
properties:
port: { type: integer }
redirectUri: { type: string }

port is what the author asked for; status.port is what the socket got.

required: is rejected inside status:. Every declared field is mandatory once the resource has run, so the list would be either redundant or a lie. A field that is genuinely sometimes-absent is declared with a nullable type instead, which CEL_NULLABLE_ACCESS already guards.

Through extends, a child without base: merges its parent's status:; a child with base: publishes the parent's unchanged, because it is a parent instance. The chain is folded in the scope that declared each definition, never the consumer's: an extends alias belongs to the file it was written in, so an abstract's status: reaches a consumer that imports only the implementation (the sanctioned "one import instead of two"). The kernel stamps the folded block onto the definition at registration; the analyzer re-scopes per declaring module and reaches the same answer.

Reporting it from a controller​

The two halves travel on different channels, because only one of them is derivable on demand. Configured state is pulled — snapshot() returns it and the kernel calls that whenever it needs the value. Observed state is pushed — ctx.setStatus(...) reports it at the moment it is learned, because nothing but the controller knows when that is.

async run() {
this.server.listen(this.config.port ?? 0);
await ctx.setStatus({
port: this.address.port,
redirectUri: `http://127.0.0.1:${this.address.port}/callback`,
});
}

snapshot() {
return { port: this.config.port ?? 0 };
}

That split is what keeps each shape described in exactly one place: the controller never rebuilds observed state inside snapshot() from a field it stashed only for that purpose, and the two payloads can never collide.

Rules the kernel enforces:

  • Reporting replaces, never merges. This is the resource's observed state now. A declared field the call omits reads as missing, which is the truth — declare a sometimes-absent field with a nullable type and report it as null.
  • Reporting before the resource has started is an error (ERR_OBSERVED_STATE_BEFORE_START) — for every capability but Telo.Provider. Only a provider learns anything in init() (see Providers).
  • Every report is validated against the kind's status: at the call that made it (ERR_OBSERVED_STATE_INVALID, or ERR_OBSERVED_STATE_UNDECLARED when the kind declares no block), so an invalid claim is refused where it was made rather than surfacing at some later publication.
  • A reading is sticky. The last value reported stays published until the resource is torn down: a dispatch that reports nothing leaves it in place, because a listener's bound address does not stop being true between calls.
  • A publication is a reading, not a live window. The published value is detached from what the controller handed over — plain objects and arrays are rebuilt, while class instances and functions pass through — so mutating a reported structure cannot rewrite what was already published.
  • status is only reserved for kinds that declare it. A kind with a status: block may not also return a flat status field (ERR_OBSERVED_STATE_KEY_COLLISION); a kind with no block may use the name freely.

Providers report from init()​

A Telo.Provider has no run(): its init() is where it becomes what it provides, so it may acquire external resources there — a connection, a container — as long as each acquisition is an effect whose inverse releases it completely, and it may report what it learned there with ctx.setStatus(...):

init() {
return ctx.effect("container", async () => {
const container = await engine.start(config);
await ctx.setStatus({ host: container.host, port: container.port });
return { result: container, inverse: () => container.remove() };
});
}
  • The report is published when init() returns. A reading is never visible while init() might still fail.
  • A failed or deferred init() discards its report with the attempt; the retry reports afresh.
  • The reading is readable in a field that resolves at startup — the provider is initialized before anything reading it is created — except from a resource declared in the same with: scope: scoped resources are created when the scope opens, which a startup field cannot see. The read is an init-order edge: telo check reports a loop of such reads as DEPENDENCY_CYCLE, and the kernel defers a reader whose provider has not initialized yet (ERR_LOCAL_REF_PENDING).
  • A provider may keep reporting afterwards; the reading stays sticky.
kind: Postgres.DatabaseAccess
metadata:
name: appDatabase
fields:
url:
scheme: postgres
host: !cel "resources.pgPort.status.host"
port: !cel "resources.pgPort.status.port"
user: app
path: /app

Every other capability keeps the rules above unchanged.

Reading it​

Observed state of anything but a provider flows into calls, never into configuration. Such a resource is created before anything runs, so a field that resolves at startup can never take its reported value. Read it where the call happens: a step's inputs:, an invoke-time field like Http.Request's url, a route handler, or a returns: expression.

steps:
- name: auth
invoke: !ref authorization
inputs:
redirectUri: !cel "resources.loopback.status.redirectUri"

The analyzer rejects the rest before the application starts:

DiagnosticWhen
OBSERVED_STATE_IN_STARTUP_FIELDthe read sits in a field annotated x-telo-eval: compile (or a Telo.Provider field, which is implicitly one), and the producer is not a Telo.Provider or is one declared in a with: scope (the message names the scope)
OBSERVED_STATE_NEVER_RUNnothing can start the producing resource — it is in no targets: list and named by no step's invoke: — and it is not a Telo.Provider
CEL_UNKNOWN_FIELDthe field is not one the kind declares it reports
OBSERVED_STATE_REQUIRED_FORBIDDENthe status: block declares required:

Cross-module reads are checked identically: an import's exported instances are typed under resources.<Alias>.<name>.status, so a typo there fails where it is written rather than at runtime.

The first rule reads the access chain and the producer's capability — through extends, so a kind inheriting Telo.Provider counts — and a read whose target resolves to no declaration is refused, since nothing says it reports at startup.

Genuine ordering is not decidable statically: a handler can be invoked from anywhere, so a runtime-evaluated read may legitimately run before the producing resource has started. What remains is caught at runtime, as three separate errors rather than one hedge, because each needs a different action from the reader and only the kernel — which records both whether the resource started and whether its run() returned — can tell them apart:

  • The resource has not started. Edit the targets: that runs before this value is read.
  • The resource started but is still running. The read raced a service that is still coming up. Move the read later, or have the producer report before it becomes reachable.
  • The resource's run() returned and it never reported. That is a defect in the producing module — it declares observed state and never calls setStatus; no change to the reading manifest fixes it, and the error names the module. Only reachable for a one-shot Runnable — a long-lived Service's run() never returns, so it is never blamed for what is actually ordering. A provider that initialized without reporting is the same defect, and is named the same way.

All three raise ERR_OBSERVED_STATE_UNAVAILABLE. None yields an empty value or a bare key error.

Scoped resources​

A with:-scoped resource publishes like any other. Each ScopeHandle.run() gets its own map of the scope's resources, layered at read time over the enclosing module's — the outer half stays live, so a resource that republishes mid-sequence is visible to later steps — with scope-local names winning — the same order !ref resolution uses, so CEL and references never disagree. Two concurrent runs of the same sequence never observe each other's scoped resources. A scoped name resolves only inside the regions x-telo-scope annotates (/steps, /targets); outside them it does not resolve at all.