@telorun/analyzer 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,304 @@
1
+ import { OBSERVED_STATE_KEY } from "@telorun/sdk";
2
+ import { effectiveStatusSchema } from "./extends-resolution.js";
3
+ import { parseExportEntry } from "./flatten-for-analyzer.js";
4
+ import { moduleScopedDefResolver } from "./alias-resolver.js";
5
+ import { buildReferenceFieldMap, isRefEntry, isScopeEntry, resolveFieldValues, } from "./reference-field-map.js";
6
+ /** The kernel capabilities whose `run()` the kernel dispatches. A ref slot that
7
+ * accepts one of them is a slot that can start a resource — `targets:` on an
8
+ * Application or a `Run.Sequence`, and a step's `invoke:` (whose schema accepts
9
+ * `Telo.Runnable` alongside `Telo.Invocable`, and which the kernel dispatches
10
+ * through `run()` when the target has no `invoke()`). Keyed on the declared
11
+ * capability, never on a field name or a kind, so any composer that accepts a
12
+ * runnable participates without the analyzer knowing about it. */
13
+ const RUN_DISPATCH_CONTRACTS = new Set(["Telo.Runnable", "Telo.Service"]);
14
+ const SYSTEM_KINDS = new Set([
15
+ "Telo.Definition",
16
+ "Telo.Abstract",
17
+ "Telo.Import",
18
+ "Telo.Application",
19
+ "Telo.Library",
20
+ ]);
21
+ /**
22
+ * The `status:` block's own schema — a plain JSON Schema, structurally. The one
23
+ * normative restriction (`required:` is rejected) is enforced by
24
+ * {@link validateObservedStateDeclarations} rather than here, so the author gets
25
+ * a message naming the rule and the fix instead of AJV's "must NOT be valid".
26
+ *
27
+ * Exported from the analyzer and re-used by the kernel's manifest schemas, so
28
+ * the rule has one definition rather than two kept in sync by hand.
29
+ */
30
+ export const OBSERVED_STATE_SCHEMA = {
31
+ type: "object",
32
+ additionalProperties: true,
33
+ };
34
+ /**
35
+ * `required:` inside a `status:` block. Every declared field is mandatory once
36
+ * the resource has run, so the list would be either redundant or a lie; a
37
+ * genuinely sometimes-absent value is declared with a nullable type, which
38
+ * `CEL_NULLABLE_ACCESS` already guards.
39
+ */
40
+ export function validateObservedStateDeclarations(manifests) {
41
+ const out = [];
42
+ for (const m of manifests) {
43
+ if (m.kind !== "Telo.Definition" && m.kind !== "Telo.Abstract")
44
+ continue;
45
+ const status = m.status;
46
+ if (!status || typeof status !== "object" || !Array.isArray(status.required))
47
+ continue;
48
+ const name = m.metadata?.name ?? "<unnamed>";
49
+ out.push({
50
+ kind: m.kind,
51
+ name,
52
+ filePath: m.metadata?.source,
53
+ message: `${m.kind}/${name}: 'status:' must not declare 'required:' — every field a kind declares ` +
54
+ `it reports is mandatory once the resource has run, so the list is either redundant or a ` +
55
+ `lie. Declare a sometimes-absent field with a nullable type instead ` +
56
+ `(e.g. type: [string, "null"]); CEL_NULLABLE_ACCESS then forces the reader to guard it.`,
57
+ });
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Recognise an observed-state read in a member-access chain. Purely syntactic —
63
+ * it inspects the chain, not the topology — so the availability rule it feeds
64
+ * applies to every kind, declared or not.
65
+ *
66
+ * `resources.<name>.status.<field>` and the two-level cross-module form
67
+ * `resources.<Alias>.<name>.status.<field>` are both observed-state reads.
68
+ */
69
+ export function observedStateRead(chain) {
70
+ if (chain[0] !== "resources")
71
+ return undefined;
72
+ if (chain[2] === OBSERVED_STATE_KEY)
73
+ return { name: chain[1], field: chain[3] };
74
+ if (chain[3] === OBSERVED_STATE_KEY) {
75
+ return { alias: chain[1], name: chain[2], field: chain[4] };
76
+ }
77
+ return undefined;
78
+ }
79
+ /**
80
+ * The names of every resource some slot can start: referenced from a ref slot
81
+ * that accepts a `Telo.Runnable` / `Telo.Service`, or named as a step's
82
+ * `invoke:` target. A resource in none of them can never `run()`, so it can
83
+ * never report observed state.
84
+ *
85
+ * Deliberately an over-approximation — a name reachable through any of these
86
+ * routes counts as runnable — because the cost of a false "can never run" is a
87
+ * valid manifest rejected, while the cost of a miss is only that the reader
88
+ * finds out at runtime instead, with a message that names the same fix.
89
+ */
90
+ export function collectRunReachableNames(manifests, defs, aliases) {
91
+ const names = new Set();
92
+ const resolve = (kind) => defs.resolve(aliases?.resolveKind(kind) ?? kind) ?? defs.resolve(kind);
93
+ for (const manifest of manifests) {
94
+ const def = resolve(manifest.kind);
95
+ const schema = def?.schema;
96
+ if (!schema)
97
+ continue;
98
+ for (const [path, entry] of buildReferenceFieldMap(schema)) {
99
+ if (!isRefEntry(entry))
100
+ continue;
101
+ if (!entry.refs.some((ref) => RUN_DISPATCH_CONTRACTS.has(ref)))
102
+ continue;
103
+ for (const value of resolveFieldValues(manifest, path))
104
+ collectRefName(value, names);
105
+ }
106
+ // Step arrays nest through `if` / `while` / `switch` / `try`, and the step
107
+ // `invoke:` slot sits behind a local `$ref` the field map does not follow.
108
+ // Match the declared invoke key at any depth instead of re-deriving the
109
+ // nesting rules — over-approximating in the safe direction.
110
+ const invokeKey = stepInvokeKey(schema);
111
+ if (invokeKey)
112
+ collectKeyedRefs(manifest, invokeKey, names);
113
+ }
114
+ return names;
115
+ }
116
+ /** The property name a kind's `x-telo-step-context` declares as its dispatch
117
+ * slot (`invoke`), or undefined when the kind has no step array. */
118
+ function stepInvokeKey(schema) {
119
+ for (const fieldSchema of Object.values((schema.properties ?? {}))) {
120
+ const stepCtx = fieldSchema?.["x-telo-step-context"];
121
+ if (stepCtx?.invoke)
122
+ return stepCtx.invoke;
123
+ }
124
+ return undefined;
125
+ }
126
+ /** Collect ref names at every `key` property anywhere in `node`. */
127
+ function collectKeyedRefs(node, key, out) {
128
+ if (Array.isArray(node)) {
129
+ for (const item of node)
130
+ collectKeyedRefs(item, key, out);
131
+ return;
132
+ }
133
+ if (node === null || typeof node !== "object")
134
+ return;
135
+ for (const [k, value] of Object.entries(node)) {
136
+ if (k === key)
137
+ collectRefName(value, out);
138
+ collectKeyedRefs(value, key, out);
139
+ }
140
+ }
141
+ /** Record the resource name a slot value points at — a resolved `{kind, name}`
142
+ * ref, an unresolved `!ref` sentinel, or a `{ ref }` / `{ invoke }` wrapper. */
143
+ function collectRefName(value, out) {
144
+ if (value === null || typeof value !== "object")
145
+ return;
146
+ if (Array.isArray(value)) {
147
+ for (const item of value)
148
+ collectRefName(item, out);
149
+ return;
150
+ }
151
+ const v = value;
152
+ if (typeof v.name === "string")
153
+ out.add(v.name);
154
+ if (typeof v.source === "string") {
155
+ const dot = v.source.lastIndexOf(".");
156
+ out.add(dot >= 0 ? v.source.slice(dot + 1) : v.source);
157
+ }
158
+ for (const wrapper of ["ref", "invoke"]) {
159
+ if (v[wrapper] !== undefined)
160
+ collectRefName(v[wrapper], out);
161
+ }
162
+ }
163
+ /**
164
+ * Index every resource a CEL `resources.…` read can name: the module's own
165
+ * top-level resources, the ones declared inside `x-telo-scope` slots (a
166
+ * `Run.Sequence`'s `with:`), and each import's exported instances — keyed
167
+ * `<Alias>.<name>`, the two-level shape those publish under.
168
+ *
169
+ * Scope slots are found through the declaring kind's schema annotation, not by
170
+ * field name, so any composer with a scope participates.
171
+ */
172
+ export function buildObservedStateIndex(manifests, defs, aliases, scopes) {
173
+ const out = new Map();
174
+ const resolve = moduleScopedDefResolver(defs, aliases, scopes);
175
+ /** `module` is the resource's DECLARING module: an exported instance is
176
+ * written with that library's aliases (`kind: Self.Listener`), which the
177
+ * consumer's table cannot resolve. */
178
+ const record = (kind, key, scoped, module) => {
179
+ const status = effectiveStatusSchema(resolve.in(kind, module), resolve);
180
+ out.set(key, { kind, ...(status ? { status } : {}), ...(scoped ? { scoped } : {}) });
181
+ };
182
+ for (const manifest of manifests) {
183
+ const kind = manifest.kind;
184
+ const name = manifest.metadata?.name;
185
+ if (!kind || SYSTEM_KINDS.has(kind))
186
+ continue;
187
+ if (name)
188
+ record(kind, name, false, manifest.metadata?.module);
189
+ const schema = resolve(kind)?.schema;
190
+ if (!schema)
191
+ continue;
192
+ for (const [path, entry] of buildReferenceFieldMap(schema)) {
193
+ if (!isScopeEntry(entry))
194
+ continue;
195
+ for (const value of resolveFieldValues(manifest, path)) {
196
+ for (const scopedEntry of Array.isArray(value) ? value : [value]) {
197
+ const scopedKind = scopedEntry?.kind;
198
+ const scopedName = scopedEntry?.metadata?.name;
199
+ if (typeof scopedKind === "string" && typeof scopedName === "string") {
200
+ record(scopedKind, scopedName, true);
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ for (const [alias, name, kind, module] of importedExports(manifests, aliases)) {
207
+ record(kind, `${alias}.${name}`, false, module);
208
+ }
209
+ return out;
210
+ }
211
+ /**
212
+ * Every `<alias, exported name, kind>` an import makes readable as
213
+ * `resources.<Alias>.<name>`. The importer's `Telo.Import` docs give the
214
+ * aliases; the exported instances are the ones already stamped
215
+ * `metadata.forwardedExport` by `selectModuleManifestsForAnalysis` — the module
216
+ * doc that declared `exports.resources` is dropped for non-root modules, so the
217
+ * stamp, not the declaration, is what survives into the consumer's manifest
218
+ * list. A module doc is still consulted when one IS present (a single-library
219
+ * analysis, the editor's projection).
220
+ */
221
+ function* importedExports(manifests, aliases) {
222
+ if (!aliases?.moduleForAlias)
223
+ return;
224
+ const declaredByModule = new Map();
225
+ for (const m of manifests) {
226
+ if (m.kind !== "Telo.Library")
227
+ continue;
228
+ const libName = (m.metadata?.name ?? m.metadata?.module);
229
+ const declared = m.exports?.resources;
230
+ if (!libName || !Array.isArray(declared))
231
+ continue;
232
+ declaredByModule.set(libName, new Set(declared
233
+ .filter((e) => typeof e === "string")
234
+ .map((e) => parseExportEntry(e).name)));
235
+ }
236
+ const exportsByModule = new Map();
237
+ for (const m of manifests) {
238
+ const module = m.metadata?.module;
239
+ const name = m.metadata?.name;
240
+ if (!module || !name || SYSTEM_KINDS.has(m.kind))
241
+ continue;
242
+ const forwarded = m.metadata?.forwardedExport;
243
+ if (!forwarded && !declaredByModule.get(module)?.has(name))
244
+ continue;
245
+ let byName = exportsByModule.get(module);
246
+ if (!byName)
247
+ exportsByModule.set(module, (byName = new Map()));
248
+ byName.set(name, m.kind);
249
+ }
250
+ for (const m of manifests) {
251
+ if (m.kind !== "Telo.Import")
252
+ continue;
253
+ const alias = m.metadata?.name;
254
+ if (!alias)
255
+ continue;
256
+ const targetModule = aliases.moduleForAlias(alias);
257
+ const exported = targetModule && exportsByModule.get(targetModule);
258
+ if (!exported)
259
+ continue;
260
+ for (const [name, kind] of exported)
261
+ yield [alias, name, kind, targetModule];
262
+ }
263
+ }
264
+ /** The `resources` node of a CEL context schema: one entry per resource, each
265
+ * open except for a typed, closed `status` node on kinds that declare one.
266
+ * `open` keeps the map itself permissive, so unknown resource names and every
267
+ * flat field pass exactly as they do today. */
268
+ export function buildObservedStateResourcesSchema(index, open) {
269
+ const properties = {};
270
+ for (const [key, { status }] of index) {
271
+ if (!status)
272
+ continue;
273
+ applyObservedStateNode(properties, key, status);
274
+ }
275
+ return open
276
+ ? { type: "object", additionalProperties: true, properties }
277
+ : { type: "object", properties };
278
+ }
279
+ /**
280
+ * Write the typed `status` node for one index key into a `resources` property
281
+ * map. A dotted key (`Alias.name`) is an import's exported instance, which
282
+ * publishes two levels deep — the alias node stays open so every other name
283
+ * under it keeps resolving as it does today.
284
+ */
285
+ export function applyObservedStateNode(properties, key, status) {
286
+ const dot = key.indexOf(".");
287
+ const leaf = {
288
+ type: "object",
289
+ additionalProperties: true,
290
+ properties: { [OBSERVED_STATE_KEY]: { ...status, additionalProperties: false } },
291
+ };
292
+ if (dot < 0) {
293
+ properties[key] = leaf;
294
+ return;
295
+ }
296
+ const alias = key.slice(0, dot);
297
+ const name = key.slice(dot + 1);
298
+ const aliasNode = (properties[alias] ??= {
299
+ type: "object",
300
+ additionalProperties: true,
301
+ properties: {},
302
+ });
303
+ (aliasNode.properties ??= {})[name] = leaf;
304
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -48,7 +48,7 @@
48
48
  "@types/node": "^20.0.0",
49
49
  "typescript": "^5.0.0",
50
50
  "vitest": "^2.1.8",
51
- "@telorun/sdk": "0.56.0"
51
+ "@telorun/sdk": "0.58.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@telorun/sdk": "*"
@@ -121,3 +121,61 @@ export function scopeResolverForModule(
121
121
  ? aliasesByModule.get(ownModule)
122
122
  : undefined;
123
123
  }
124
+
125
+ /** Per-declaring-module alias tables plus the set of root (consumer-owned)
126
+ * modules. The shape `StaticAnalyzer.analyze` already threads through its
127
+ * passes, and what {@link moduleScopedDefResolver} needs to re-scope. */
128
+ export interface ModuleScopes {
129
+ aliasesByModule: ReadonlyMap<string, { resolveKind(kind: string): string | undefined }>;
130
+ rootModules: ReadonlySet<string>;
131
+ }
132
+
133
+ /** Minimal view of the definition registry a kind lookup needs. */
134
+ export interface DefinitionLookup {
135
+ resolve(kind: string): unknown;
136
+ }
137
+
138
+ /**
139
+ * Resolve a kind to its definition **in the scope that declared it**.
140
+ *
141
+ * An `extends` alias belongs to the file it was written in, not to whoever is
142
+ * reading it: a consumer that imports only a backend (the sanctioned "one import
143
+ * instead of two") has no alias for the abstract's library, so folding an
144
+ * inheritance chain with the consumer's table silently stops at the first hop
145
+ * and yields an un-merged schema. `from` — the definition the kind was read off —
146
+ * carries `metadata.module`, which is the scope to resolve in; a chain crossing
147
+ * several modules re-scopes at every hop.
148
+ *
149
+ * `resolveIn` takes the module explicitly, for the top-level lookup where there
150
+ * is no `from` yet (an exported instance's `kind: Self.X` is written in the
151
+ * exporting library's scope, which the consumer's table cannot resolve either).
152
+ *
153
+ * The runtime counterpart is `resource-definition-controller`, which resolves
154
+ * against the defining module context and stamps the result; keeping both on the
155
+ * same rule is what stops `telo check` and the kernel from disagreeing about
156
+ * which inherited fields a child kind may set.
157
+ */
158
+ export function moduleScopedDefResolver<T>(
159
+ defs: { resolve(kind: string): T | undefined },
160
+ aliases?: { resolveKind(kind: string): string | undefined },
161
+ scopes?: ModuleScopes,
162
+ ): {
163
+ (kind: string, from?: { metadata?: { module?: string } }): T | undefined;
164
+ in(kind: string, module?: string): T | undefined;
165
+ } {
166
+ const resolveIn = (kind: string, module?: string): T | undefined => {
167
+ const scoped =
168
+ module && scopes && !scopes.rootModules.has(module)
169
+ ? scopes.aliasesByModule.get(module)
170
+ : undefined;
171
+ return (
172
+ (scoped ? defs.resolve(scoped.resolveKind(kind) ?? kind) : undefined) ??
173
+ defs.resolve(aliases?.resolveKind(kind) ?? kind) ??
174
+ defs.resolve(kind)
175
+ );
176
+ };
177
+ const resolver = ((kind: string, from?: { metadata?: { module?: string } }) =>
178
+ resolveIn(kind, from?.metadata?.module)) as ReturnType<typeof moduleScopedDefResolver<T>>;
179
+ resolver.in = resolveIn;
180
+ return resolver;
181
+ }
package/src/analyzer.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
- import { canonicalTypeSchemaId } from "@telorun/sdk";
2
+ import { canonicalTypeSchemaId, OBSERVED_STATE_KEY } from "@telorun/sdk";
3
3
  import type { Environment } from "@marcbachmann/cel-js";
4
4
  import { defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
5
5
  import { AliasResolver, scopeResolverForModule } from "./alias-resolver.js";
@@ -14,6 +14,13 @@ import { DefinitionRegistry } from "./definition-registry.js";
14
14
  import { effectiveAuthorSchema } from "./extends-resolution.js";
15
15
  import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
16
16
  import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
17
+ import {
18
+ buildObservedStateIndex,
19
+ buildObservedStateResourcesSchema,
20
+ collectRunReachableNames,
21
+ observedStateRead,
22
+ validateObservedStateDeclarations,
23
+ } from "./validate-observed-state.js";
17
24
  import { computeSuggestKind } from "./kind-suggest.js";
18
25
  import { visitManifest } from "./manifest-visitor.js";
19
26
  import { isModuleKind } from "./module-kinds.js";
@@ -33,6 +40,7 @@ import {
33
40
  import { collectValueSchemaIssues } from "./validate-value-schema.js";
34
41
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
35
42
  import {
43
+ extractAccessChains,
36
44
  extractCelRegionScopes,
37
45
  extractContextsFromSchema,
38
46
  getManifestItem,
@@ -717,6 +725,16 @@ function errorContextForPath(
717
725
  return best?.schema;
718
726
  }
719
727
 
728
+ /** Member-access chains in a CEL expression, or none when it doesn't parse.
729
+ * Best-effort: a syntax error is reported by the engine pass, not here. */
730
+ function celAccessChains(env: Environment, expr: string): string[][] {
731
+ try {
732
+ return extractAccessChains(env.parse(expr).ast);
733
+ } catch {
734
+ return [];
735
+ }
736
+ }
737
+
720
738
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
721
739
  const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
722
740
 
@@ -1280,9 +1298,38 @@ export class StaticAnalyzer {
1280
1298
  }
1281
1299
  }
1282
1300
 
1301
+ // What each resource reports while running (`status:`), and which resources
1302
+ // some slot can actually start. Both feed the observed-state checks below.
1303
+ // A RESOURCE's kind is written in the module that declares it and is never
1304
+ // canonicalized (unlike a definition's `extends`, normalized at registration
1305
+ // above), so an exported instance's `kind: Self.X` only resolves in its own
1306
+ // library's scope.
1307
+ const moduleScopes = { aliasesByModule, rootModules };
1308
+
1309
+ const observedState = buildObservedStateIndex(allManifests, defs, aliases, moduleScopes);
1310
+ const reportsObservedState = [...observedState.values()].some((r) => r.status);
1311
+ const runReachable = reportsObservedState
1312
+ ? collectRunReachableNames(allManifests, defs, aliases)
1313
+ : new Set<string>();
1314
+
1283
1315
  // Build typed kernel globals schema so x-telo-context chain validation
1284
1316
  // recognises variables, secrets, resources, env automatically
1285
- const kernelGlobals = buildKernelGlobalsSchema(allManifests);
1317
+ const kernelGlobals = buildKernelGlobalsSchema(allManifests, observedState);
1318
+
1319
+ // Fallback context for CEL in a slot with no `x-telo-context` annotation:
1320
+ // everything stays open except the typed `.status` nodes, so unknown-field
1321
+ // checking reaches observed state everywhere without newly rejecting any
1322
+ // read that passes today.
1323
+ const observedStateContext: Record<string, any> | null =
1324
+ reportsObservedState
1325
+ ? {
1326
+ type: "object",
1327
+ additionalProperties: true,
1328
+ properties: {
1329
+ resources: buildObservedStateResourcesSchema(observedState, true),
1330
+ },
1331
+ }
1332
+ : null;
1286
1333
 
1287
1334
  // The module doc (Application/Library) carries the Application-only `ports`
1288
1335
  // namespace; threaded into per-resource CEL typing so `${{ ports.X }}`
@@ -1600,6 +1647,9 @@ export class StaticAnalyzer {
1600
1647
  // `x-telo-step-context` / `x-telo-error-context` scopes. A `!cel` outside
1601
1648
  // every region is read as a literal — the runtime never evaluates it.
1602
1649
  let celEvalPaths: string[] = [];
1650
+ // The compile half alone: a field that resolves at startup, where observed
1651
+ // state cannot exist yet.
1652
+ let celCompilePaths: string[] = [];
1603
1653
  let celRegionScopes: string[] = [];
1604
1654
  let celRuleApplies = false;
1605
1655
 
@@ -1639,9 +1689,14 @@ export class StaticAnalyzer {
1639
1689
  ? buildEvalPaths(capabilityDef.schema as Record<string, any>)
1640
1690
  : { compile: [], runtime: [] };
1641
1691
  celEvalPaths = [...own.compile, ...own.runtime, ...parent.compile, ...parent.runtime];
1692
+ // A `Telo.Provider`'s fields are implicitly compile-eval — the
1693
+ // capability abstract carries the root annotation — so its reads are
1694
+ // covered here without the provider restating anything.
1695
+ celCompilePaths = [...own.compile, ...parent.compile];
1642
1696
  celRegionScopes = extractCelRegionScopes(ownSchema);
1643
1697
  } else {
1644
1698
  celEvalPaths = [];
1699
+ celCompilePaths = [];
1645
1700
  celRegionScopes = [];
1646
1701
  }
1647
1702
  },
@@ -1674,6 +1729,44 @@ export class StaticAnalyzer {
1674
1729
  return;
1675
1730
  }
1676
1731
 
1732
+ // Observed state exists only while the application runs, so a path
1733
+ // through `.status` is illegal in a field that resolves at startup —
1734
+ // and a resource nothing can start reports nothing, ever. Both are
1735
+ // decided from the expression and the manifest alone.
1736
+ if (reportsObservedState && engineName === "cel" && expr.includes(OBSERVED_STATE_KEY)) {
1737
+ for (const chain of celAccessChains(this.celEnv, expr)) {
1738
+ const read = observedStateRead(chain);
1739
+ if (!read) continue;
1740
+ // An import's exported instance is indexed under `<Alias>.<name>`,
1741
+ // the two-level shape it publishes under, so a cross-module read
1742
+ // is checked exactly like a local one.
1743
+ const reported = observedState.get(
1744
+ read.alias ? `${read.alias}.${read.name}` : read.name,
1745
+ );
1746
+
1747
+ if (celRuleApplies && evalPathsCover(celCompilePaths, path)) {
1748
+ diagnostics.push({
1749
+ severity: DiagnosticSeverity.Error,
1750
+ code: "OBSERVED_STATE_IN_STARTUP_FIELD",
1751
+ source: SOURCE,
1752
+ message: `${m.kind}/${resource.name}: '${path}' is resolved once at startup, so '${chain.join(".")}' does not exist yet — '${read.name}' reports it only while the application is running. Read reported values where the call happens: a step's inputs:, a request's url, a route handler, or a returns: expression.`,
1753
+ data: { resource, filePath, path },
1754
+ });
1755
+ continue;
1756
+ }
1757
+
1758
+ if (reported && !runReachable.has(read.name)) {
1759
+ diagnostics.push({
1760
+ severity: DiagnosticSeverity.Error,
1761
+ code: "OBSERVED_STATE_NEVER_RUN",
1762
+ source: SOURCE,
1763
+ message: `${m.kind}/${resource.name}: '${read.name}' reports '${read.field ?? OBSERVED_STATE_KEY}' only while it is running, and nothing starts it. Add '!ref ${read.name}' to a targets: list, or invoke it from a step.`,
1764
+ data: { resource, filePath, path },
1765
+ });
1766
+ }
1767
+ }
1768
+ }
1769
+
1677
1770
  let matchedContext: Record<string, any> | undefined =
1678
1771
  e.contextSchema ?? celInvocationContext;
1679
1772
 
@@ -1723,6 +1816,12 @@ export class StaticAnalyzer {
1723
1816
  allManifests: allManifests as Record<string, any>[],
1724
1817
  });
1725
1818
  effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
1819
+ } else if (observedStateContext) {
1820
+ // No `x-telo-context` matched, so nothing was chain-validated here
1821
+ // before. Validate the observed-state segment alone rather than
1822
+ // merging the kernel globals, whose closed `variables` / `ports`
1823
+ // nodes would newly reject reads that pass today.
1824
+ effectiveContext = observedStateContext;
1726
1825
  }
1727
1826
 
1728
1827
  const engine = defaultRegistry().get(engineName);
@@ -1792,6 +1891,23 @@ export class StaticAnalyzer {
1792
1891
  // kind-instead-of-instance ref there is caught statically, not at runtime.
1793
1892
  diagnostics.push(...validateStepInvokeReferences(allManifests, defs, aliases));
1794
1893
 
1894
+ // `required:` inside a `status:` block — reported here rather than by the
1895
+ // AJV shape, which could only say "must NOT be valid" without naming the
1896
+ // rule or the fix.
1897
+ for (const issue of validateObservedStateDeclarations(allManifests)) {
1898
+ diagnostics.push({
1899
+ severity: DiagnosticSeverity.Error,
1900
+ code: "OBSERVED_STATE_REQUIRED_FORBIDDEN",
1901
+ source: SOURCE,
1902
+ message: issue.message,
1903
+ data: {
1904
+ resource: { kind: issue.kind, name: issue.name },
1905
+ filePath: issue.filePath,
1906
+ path: "status.required",
1907
+ },
1908
+ });
1909
+ }
1910
+
1795
1911
  // Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
1796
1912
  diagnostics.push(...validateExtends(allManifests, defs, aliases));
1797
1913
 
package/src/builtins.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ResourceDefinition } from "@telorun/sdk";
2
+ import { OBSERVED_STATE_SCHEMA } from "./validate-observed-state.js";
2
3
 
3
4
  /** Descriptive provenance a module declares about itself, shared by
4
5
  * `Telo.Application` and `Telo.Library`.
@@ -186,6 +187,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
186
187
  },
187
188
  capability: { type: "string" },
188
189
  schema: { type: "object", additionalProperties: true },
190
+ status: OBSERVED_STATE_SCHEMA,
189
191
  },
190
192
  required: ["metadata"],
191
193
  // Telo.Abstract is an extension point by design — it must accept forward-compatible
@@ -214,6 +216,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
214
216
  type: "object",
215
217
  additionalProperties: true,
216
218
  properties: {
219
+ status: OBSERVED_STATE_SCHEMA,
217
220
  resources: {
218
221
  type: "array",
219
222
  items: {
@@ -2,8 +2,17 @@ import type { ResourceDefinition } from "@telorun/sdk";
2
2
  import { mergeTypeSchemas } from "@telorun/sdk";
3
3
 
4
4
  /** Resolves a kind string (canonical or alias form, depending on the caller's
5
- * registry) to its `Telo.Definition` / `Telo.Abstract`, or undefined. */
6
- export type DefResolver = (kind: string) => ResourceDefinition | undefined;
5
+ * registry) to its `Telo.Definition` / `Telo.Abstract`, or undefined.
6
+ *
7
+ * `from` is the definition the kind was read off. An `extends` alias belongs to
8
+ * the file that DECLARES the definition, not to whoever is reading it, so a
9
+ * resolver that walks an inheritance chain across module boundaries must
10
+ * re-scope at each hop — `from.metadata.module` is what it scopes to. Resolvers
11
+ * that operate in a single scope ignore the parameter. */
12
+ export type DefResolver = (
13
+ kind: string,
14
+ from?: ResourceDefinition,
15
+ ) => ResourceDefinition | undefined;
7
16
 
8
17
  /** The template-body / controller fields a definition may carry. Kept local
9
18
  * because `ResourceDefinition` intentionally types only the stable surface;
@@ -19,6 +28,7 @@ interface DefinitionBody {
19
28
  resources?: unknown[];
20
29
  base?: Record<string, unknown>;
21
30
  schema?: Record<string, any>;
31
+ status?: Record<string, any>;
22
32
  }
23
33
 
24
34
  const body = (def: ResourceDefinition | undefined): DefinitionBody =>
@@ -32,7 +42,7 @@ export function resolveParent(
32
42
  ): ResourceDefinition | undefined {
33
43
  const ext = body(def).extends;
34
44
  if (typeof ext !== "string" || ext.length === 0) return undefined;
35
- return resolve(ext);
45
+ return resolve(ext, def);
36
46
  }
37
47
 
38
48
  /** The `extends` ancestor chain, nearest-first, excluding `def` itself.
@@ -122,3 +132,27 @@ export function effectiveAuthorSchema(
122
132
  const parentSchema = effectiveAuthorSchema(parent, resolve);
123
133
  return mergeTypeSchemas([parentSchema, own]) as Record<string, any>;
124
134
  }
135
+
136
+ /** The observed state a kind reports (`status:`), folded through `extends`:
137
+ * - with `base:` present → the **parent's** effective status unchanged; the
138
+ * child delegates to the parent's controller and *is* a parent instance, so
139
+ * it publishes exactly what the parent publishes.
140
+ * - without `base:` but with `extends` → `merge(parent-effective, own)`, so a
141
+ * contract can mandate what its implementations report and an implementation
142
+ * can add to it.
143
+ * - no `extends` → the own block unchanged.
144
+ * Undefined when nothing in the chain declares one — the signal that the kind
145
+ * has not opted into typed `.status` reads. */
146
+ export function effectiveStatusSchema(
147
+ def: ResourceDefinition | undefined,
148
+ resolve: DefResolver,
149
+ ): Record<string, any> | undefined {
150
+ const own = body(def).status;
151
+ const parent = resolveParent(def, resolve);
152
+ if (!parent) return own;
153
+ const parentStatus = effectiveStatusSchema(parent, resolve);
154
+ if (body(def).base) return parentStatus;
155
+ if (!parentStatus) return own;
156
+ if (!own) return parentStatus;
157
+ return mergeTypeSchemas([parentStatus, own]) as Record<string, any>;
158
+ }
package/src/index.ts CHANGED
@@ -24,10 +24,23 @@ export {
24
24
  type ReExportSpec,
25
25
  } from "./flatten-for-analyzer.js";
26
26
  export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
27
+ export {
28
+ applyObservedStateNode,
29
+ buildObservedStateIndex,
30
+ buildObservedStateResourcesSchema,
31
+ collectRunReachableNames,
32
+ observedStateRead,
33
+ validateObservedStateDeclarations,
34
+ OBSERVED_STATE_SCHEMA,
35
+ } from "./validate-observed-state.js";
36
+ export type { AnalyzedResource, ObservedStateRead } from "./validate-observed-state.js";
37
+ export { moduleScopedDefResolver, scopeResolverForModule } from "./alias-resolver.js";
38
+ export type { ModuleScopes } from "./alias-resolver.js";
27
39
  export {
28
40
  ancestorChain,
29
41
  controllerBearingAncestor,
30
42
  effectiveAuthorSchema,
43
+ effectiveStatusSchema,
31
44
  hasOwnControllerOrTemplate,
32
45
  inheritedCapability,
33
46
  isInheritedDelegation,