@telorun/analyzer 0.58.0 → 0.60.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.
Files changed (89) hide show
  1. package/dist/analyzer.d.ts +9 -2
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +38 -10
  4. package/dist/builtins.d.ts.map +1 -1
  5. package/dist/builtins.js +16 -42
  6. package/dist/cel-environment.d.ts.map +1 -1
  7. package/dist/cel-environment.js +35 -0
  8. package/dist/flatten-for-analyzer.d.ts +4 -0
  9. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  10. package/dist/flatten-for-analyzer.js +8 -0
  11. package/dist/index.d.ts +6 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +8 -2
  14. package/dist/invocation-contract.d.ts +37 -0
  15. package/dist/invocation-contract.d.ts.map +1 -1
  16. package/dist/invocation-contract.js +81 -1
  17. package/dist/kernel-globals.d.ts +1 -1
  18. package/dist/kernel-globals.d.ts.map +1 -1
  19. package/dist/kernel-globals.js +15 -1
  20. package/dist/manifest-schemas.d.ts +407 -0
  21. package/dist/manifest-schemas.d.ts.map +1 -0
  22. package/dist/manifest-schemas.js +396 -0
  23. package/dist/module-file-claims.d.ts +6 -0
  24. package/dist/module-file-claims.d.ts.map +1 -1
  25. package/dist/module-file-claims.js +4 -0
  26. package/dist/module-metadata-scope.d.ts +44 -0
  27. package/dist/module-metadata-scope.d.ts.map +1 -0
  28. package/dist/module-metadata-scope.js +82 -0
  29. package/dist/parse-loaded-file.d.ts.map +1 -1
  30. package/dist/parse-loaded-file.js +16 -0
  31. package/dist/release/bump-level.d.ts +46 -0
  32. package/dist/release/bump-level.d.ts.map +1 -0
  33. package/dist/release/bump-level.js +80 -0
  34. package/dist/release/changelog.d.ts +36 -0
  35. package/dist/release/changelog.d.ts.map +1 -0
  36. package/dist/release/changelog.js +53 -0
  37. package/dist/release/fragment.d.ts +48 -0
  38. package/dist/release/fragment.d.ts.map +1 -0
  39. package/dist/release/fragment.js +84 -0
  40. package/dist/release/index.d.ts +26 -0
  41. package/dist/release/index.d.ts.map +1 -0
  42. package/dist/release/index.js +18 -0
  43. package/dist/release/ledger.d.ts +53 -0
  44. package/dist/release/ledger.d.ts.map +1 -0
  45. package/dist/release/ledger.js +109 -0
  46. package/dist/release/payload-digest.d.ts +63 -0
  47. package/dist/release/payload-digest.d.ts.map +1 -0
  48. package/dist/release/payload-digest.js +65 -0
  49. package/dist/release/release-plan.d.ts +128 -0
  50. package/dist/release/release-plan.d.ts.map +1 -0
  51. package/dist/release/release-plan.js +277 -0
  52. package/dist/release/version-stamp.d.ts +50 -0
  53. package/dist/release/version-stamp.d.ts.map +1 -0
  54. package/dist/release/version-stamp.js +119 -0
  55. package/dist/release/workspace-config.d.ts +41 -0
  56. package/dist/release/workspace-config.d.ts.map +1 -0
  57. package/dist/release/workspace-config.js +60 -0
  58. package/dist/schema-compat.d.ts +18 -0
  59. package/dist/schema-compat.d.ts.map +1 -1
  60. package/dist/schema-compat.js +71 -7
  61. package/dist/validate-cel-context.d.ts.map +1 -1
  62. package/dist/validate-cel-context.js +69 -13
  63. package/dist/validate-step-inputs.d.ts +1 -1
  64. package/dist/validate-step-inputs.d.ts.map +1 -1
  65. package/dist/validate-step-inputs.js +99 -8
  66. package/package.json +3 -3
  67. package/src/analyzer.ts +41 -10
  68. package/src/builtins.ts +16 -42
  69. package/src/cel-environment.ts +37 -0
  70. package/src/flatten-for-analyzer.ts +12 -0
  71. package/src/index.ts +18 -3
  72. package/src/invocation-contract.ts +92 -1
  73. package/src/kernel-globals.ts +23 -1
  74. package/src/manifest-schemas.ts +408 -0
  75. package/src/module-file-claims.ts +10 -0
  76. package/src/module-metadata-scope.ts +88 -0
  77. package/src/parse-loaded-file.ts +16 -0
  78. package/src/release/bump-level.ts +95 -0
  79. package/src/release/changelog.ts +62 -0
  80. package/src/release/fragment.ts +130 -0
  81. package/src/release/index.ts +66 -0
  82. package/src/release/ledger.ts +142 -0
  83. package/src/release/payload-digest.ts +83 -0
  84. package/src/release/release-plan.ts +392 -0
  85. package/src/release/version-stamp.ts +142 -0
  86. package/src/release/workspace-config.ts +81 -0
  87. package/src/schema-compat.ts +69 -5
  88. package/src/validate-cel-context.ts +82 -13
  89. package/src/validate-step-inputs.ts +109 -9
package/src/analyzer.ts CHANGED
@@ -51,6 +51,7 @@ import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
51
51
  import { resolveRefSentinels } from "./resolve-ref-sentinels.js";
52
52
  import { resolveSchemaRefKinds, type RefConstraintIssue } from "./resolve-schema-ref-kinds.js";
53
53
  import { runZoneAnalysis, type ZoneExportCache } from "./resolve-zone-requirements.js";
54
+ import { MANIFEST_SCHEMA_URI, ManifestRootSchema } from "./manifest-schemas.js";
54
55
  import { validateZoneSlotDeclarations, type ZoneSlotIssue } from "./validate-zone-slots.js";
55
56
  import {
56
57
  validateDynamicSelectors,
@@ -316,23 +317,50 @@ export function resolveLocalRef(
316
317
  const resolved = root.$defs?.[defName];
317
318
  if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
318
319
  }
320
+ // A kernel-owned structural fragment (`telo://manifest#/$defs/InvokeStep`).
321
+ // Resolved HERE rather than by each walker: this is the one chokepoint every
322
+ // structural walk already goes through — the step-array walks, the call graph,
323
+ // the zone projection, the eval-path collector — so a composer that points at a
324
+ // shared shape stays legible to all of them at once. Nothing is inlined into
325
+ // the stored schema, which keeps validator-cache identity stable and matches
326
+ // what `resolveSchemaTypeRefs` does for a named user type.
327
+ if (typeof ref === "string" && ref.startsWith(BUILTIN_FRAGMENT_PREFIX)) {
328
+ const defName = ref.slice(BUILTIN_FRAGMENT_PREFIX.length);
329
+ const resolved = (ManifestRootSchema.$defs as Record<string, unknown>)[defName];
330
+ if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
331
+ }
319
332
  return schema;
320
333
  }
321
334
 
335
+ const BUILTIN_FRAGMENT_PREFIX = `${MANIFEST_SCHEMA_URI}#/$defs/`;
336
+
322
337
  /** Gather property schemas from a (possibly variant-bearing) object schema:
323
- * top-level `properties` plus every `oneOf` / `anyOf` / `allOf` branch. */
324
- export function gatherPropertySchemas(schema: Record<string, any>): Array<[string, Record<string, any>]> {
338
+ * top-level `properties` plus every `oneOf` / `anyOf` / `allOf` branch.
339
+ *
340
+ * Each branch is resolved through {@link resolveLocalRef} first, so a branch
341
+ * that points at a shared shape — a `oneOf` arm that IS the kernel's dispatch
342
+ * site — contributes its properties like an inline one. Without that, pointing a
343
+ * composer at a shared shape would silently empty every role-driven lookup that
344
+ * reads this (the inputs slot, the retry policy, the eval paths), which is a
345
+ * failure with no diagnostic attached to it. */
346
+ export function gatherPropertySchemas(
347
+ schema: Record<string, any>,
348
+ root?: Record<string, any>,
349
+ ): Array<[string, Record<string, any>]> {
325
350
  const out: Array<[string, Record<string, any>]> = [];
326
- if (schema.properties && typeof schema.properties === "object") {
327
- for (const [k, v] of Object.entries(schema.properties as Record<string, any>)) {
351
+ const base = resolveLocalRef(schema, root ?? schema) ?? schema;
352
+ if (base.properties && typeof base.properties === "object") {
353
+ for (const [k, v] of Object.entries(base.properties as Record<string, any>)) {
328
354
  out.push([k, v as Record<string, any>]);
329
355
  }
330
356
  }
331
357
  for (const variantKey of ["oneOf", "anyOf", "allOf"] as const) {
332
- const arr = schema[variantKey];
358
+ const arr = base[variantKey];
333
359
  if (!Array.isArray(arr)) continue;
334
- for (const variant of arr) {
335
- if (variant && typeof variant === "object" && variant.properties) {
360
+ for (const raw of arr) {
361
+ if (!raw || typeof raw !== "object") continue;
362
+ const variant = resolveLocalRef(raw as Record<string, any>, root ?? schema) ?? raw;
363
+ if (variant.properties) {
336
364
  for (const [k, v] of Object.entries(variant.properties as Record<string, any>)) {
337
365
  out.push([k, v as Record<string, any>]);
338
366
  }
@@ -2012,9 +2040,12 @@ export class StaticAnalyzer {
2012
2040
  severity: DiagnosticSeverity.Error,
2013
2041
  code: issue.code ?? "CONTRACT_INPUTS_MISMATCH",
2014
2042
  source: SOURCE,
2015
- message: issue.code
2016
- ? `${m.kind}/${stepName}: inputs at '${issue.path}' flow into ${issue.targetLabel} with disagreeing type arguments: ${issue.message}`
2017
- : `${m.kind}/${stepName}: inputs at '${issue.path}' do not satisfy ${issue.targetLabel}'s declared inputType: ${issue.message}`,
2043
+ message:
2044
+ issue.code === "LIVE_VALUE_RETRIED"
2045
+ ? `${m.kind}/${stepName}: at '${issue.path}', ${issue.message}`
2046
+ : issue.code
2047
+ ? `${m.kind}/${stepName}: inputs at '${issue.path}' flow into ${issue.targetLabel} with disagreeing type arguments: ${issue.message}`
2048
+ : `${m.kind}/${stepName}: inputs at '${issue.path}' do not satisfy ${issue.targetLabel}'s declared inputType: ${issue.message}`,
2018
2049
  data: {
2019
2050
  resource: { kind: m.kind, name: stepName ?? "" },
2020
2051
  filePath: stepFile,
package/src/builtins.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { manifestFragment } from "./manifest-schemas.js";
1
2
  import type { ResourceDefinition } from "@telorun/sdk";
2
3
  import { OBSERVED_STATE_SCHEMA } from "./validate-observed-state.js";
3
4
 
@@ -620,48 +621,21 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
620
621
  },
621
622
  additionalProperties: false,
622
623
  },
623
- // Inline flat invoke step: invoke an Invocable / Runnable on boot
624
- // with an optional `name` (for steps.<name>.result plumbing),
625
- // `when` guard, and `inputs`. Discriminated by the `invoke` key.
626
- // Control flow (if/while/switch/try) is not available here
627
- // reach for Run.Sequence. `invoke` is ref-only: a `!ref` that
628
- // resolves to the `{ kind, name }` shape below. Requiring `name`
629
- // rejects an inline `{ kind }` definition (no name) at analysis
630
- // instead of failing at boot with an undefined resource name. The
631
- // Invocable/Runnable kind set mirrors Run.Sequence invoke steps.
632
- {
633
- type: "object",
634
- required: ["invoke"],
635
- properties: {
636
- name: { type: "string" },
637
- invoke: {
638
- "x-telo-topology-role": "invoke",
639
- "x-telo-ref": {
640
- kind: "Telo.Executable",
641
- use: "call",
642
- inputs: "/inputs",
643
- },
644
- type: "object",
645
- required: ["kind", "name"],
646
- properties: {
647
- kind: { type: "string" },
648
- name: { type: "string" },
649
- },
650
- additionalProperties: true,
651
- },
652
- inputs: {
653
- // Same annotation Run.Sequence steps carry: it is what makes
654
- // a boot target's inputs visible to the call-site contract
655
- // check and to the wiring rule. Without it the kernel would
656
- // validate these at dispatch and nothing before it.
657
- "x-telo-topology-role": "inputs",
658
- type: "object",
659
- additionalProperties: true,
660
- },
661
- when: { type: "string" },
662
- },
663
- additionalProperties: false,
664
- },
624
+ // Inline flat invoke step, discriminated by the `invoke` key
625
+ // THE dispatch site, shared with every `Run` step array rather
626
+ // than restated here. Restating it is what made `retry:` a schema
627
+ // error at boot while working one line away in a sequence: not a
628
+ // decision about boot, just a copy that never grew the field.
629
+ // Control flow (if/while/switch/try) is still not available here
630
+ // reach for Run.Sequence.
631
+ // An expanded, stamped COPY. `builtins.ts` is not a manifest, so it
632
+ // never passes through the loader's expansion — embedding the
633
+ // fragment object itself would leave the nested `retry` as an
634
+ // unresolved `$ref` with no `x-telo-fragment` stamp, which is
635
+ // exactly what made LIVE_VALUE_RETRIED silently skip every boot
636
+ // target. A copy, because this is a module-level singleton and
637
+ // `resolveSchemaRefKinds` rewrites the `x-telo-ref` inside it.
638
+ manifestFragment("InvokeStep"),
665
639
  ],
666
640
  },
667
641
  },
@@ -1,5 +1,6 @@
1
1
  import { Environment } from "@marcbachmann/cel-js";
2
2
  import type { ResourceManifest } from "@telorun/sdk";
3
+ import { authoredModuleMetadata, moduleMetadataSchema } from "./module-metadata-scope.js";
3
4
  import { jsonSchemaToCelType, VALUE_BRAND_BASE } from "./schema-compat.js";
4
5
 
5
6
  /** Transport protocol on a `ports` entry → the nominal CEL brand its resolved
@@ -89,6 +90,32 @@ export function buildTypedCelEnvironment(
89
90
  env.registerVariable("secrets", "map");
90
91
  env.registerVariable("resources", "map");
91
92
 
93
+ // `module` — the declaring module's own `metadata`, so a manifest reads its
94
+ // version instead of restating it. A resource forwarded from an imported
95
+ // library reads THAT library's metadata, stamped as
96
+ // `metadata.moduleGlobals.module`.
97
+ //
98
+ // Falls back to an OPEN map, never to `manifest.metadata`: for a resource
99
+ // doc that is the RESOURCE's metadata (`{name: <resource name>}`), and
100
+ // closing `module` over it would turn a `module.version` that resolves
101
+ // perfectly well at runtime into a hard error the author cannot act on. A
102
+ // static check that is wrong in the rejecting direction is the worse
103
+ // polarity.
104
+ const moduleSchema = moduleMetadataSchema(
105
+ ((manifest.metadata as Record<string, any> | undefined)?.moduleGlobals?.module as
106
+ | Record<string, unknown>
107
+ | undefined) ?? (rootModuleManifest?.metadata as Record<string, unknown> | undefined),
108
+ );
109
+ if (moduleSchema) {
110
+ const schema: Record<string, string> = {};
111
+ for (const [key, property] of Object.entries(moduleSchema.properties as Record<string, any>)) {
112
+ schema[key] = jsonSchemaToCelType(property);
113
+ }
114
+ (env as any).registerVariable({ name: "module", schema });
115
+ } else {
116
+ env.registerVariable("module", "map");
117
+ }
118
+
92
119
  if (extraContextSchema?.properties) {
93
120
  for (const [name, propSchema] of Object.entries(
94
121
  extraContextSchema.properties as Record<string, any>,
@@ -159,5 +186,15 @@ export function buildImportInputCelEnvironment(
159
186
  for (const name of ["resources", "ports"]) {
160
187
  (env as any).registerVariable({ name, schema: {} });
161
188
  }
189
+ // `module` IS part of it: the importer's own identity is config, and passing
190
+ // its version down to a child is the case the binding exists for.
191
+ const metadata = authoredModuleMetadata(mod?.metadata as Record<string, unknown> | undefined);
192
+ if (Object.keys(metadata).length > 0) {
193
+ const schema: Record<string, string> = {};
194
+ for (const key of Object.keys(metadata)) schema[key] = "dyn";
195
+ (env as any).registerVariable({ name: "module", schema });
196
+ } else {
197
+ env.registerVariable("module", "map");
198
+ }
162
199
  return env;
163
200
  }
@@ -100,6 +100,10 @@ export interface ModuleGlobals {
100
100
  variables?: Record<string, unknown>;
101
101
  secrets?: Record<string, unknown>;
102
102
  ports?: Record<string, unknown>;
103
+ /** The declaring module's own `metadata` — what `module.<field>` reads. Its
104
+ * values, not a schema, because the fields are literals in the manifest and
105
+ * the module a resource belongs to is fixed at analysis time. */
106
+ module?: Record<string, unknown>;
103
107
  }
104
108
 
105
109
  /** Read the schema-map blocks off a module doc, or `undefined` when it declares
@@ -120,6 +124,14 @@ function readModuleGlobals(libDoc: ResourceManifest | undefined): ModuleGlobals
120
124
  const block = doc[key];
121
125
  if (block && typeof block === "object" && !Array.isArray(block)) globals[key] = block;
122
126
  }
127
+ // `module` is the declaring module's own identity, so it travels with a
128
+ // forwarded manifest for the same reason the config blocks do: a library's
129
+ // `module.version` is the LIBRARY's, and typing it from the consumer's entry
130
+ // doc would report the app's version to the library that asked.
131
+ const metadata = doc.metadata;
132
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
133
+ globals.module = metadata as Record<string, unknown>;
134
+ }
123
135
  return Object.keys(globals).length > 0 ? globals : undefined;
124
136
  }
125
137
 
package/src/index.ts CHANGED
@@ -62,12 +62,19 @@ export {
62
62
  export type { ContractDirection, DefResolver } from "./extends-resolution.js";
63
63
  export {
64
64
  defaultBearingPaths,
65
+ declaredScalarPaths,
65
66
  PERMISSIVE_CONTRACT,
66
67
  resolveContract,
67
68
  resolveContractSchema,
68
69
  withLiveValuesSkipped,
69
70
  } from "./invocation-contract.js";
70
- export type { ContractOrigin, ContractScope, ResolvedContract } from "./invocation-contract.js";
71
+ export type {
72
+ ContractOrigin,
73
+ ContractScope,
74
+ DeclaredScalarForm,
75
+ DeclaredScalarPath,
76
+ ResolvedContract,
77
+ } from "./invocation-contract.js";
71
78
  export {
72
79
  hasIntermediateWildcard,
73
80
  parseRedactionPath,
@@ -136,7 +143,7 @@ export { validateDynamicSelectors, validateRefSlotDeclarations } from "./validat
136
143
  export type { RefSlotIssue } from "./validate-ref-slots.js";
137
144
  export { validateValueTypeSlots } from "./validate-value-type-slots.js";
138
145
  export type { ValueTypeSlotIssue } from "./validate-value-type-slots.js";
139
- export { checkSchemaCompatibility } from "./schema-compat.js";
146
+ export { checkSchemaCompatibility, selectUnionBranch } from "./schema-compat.js";
140
147
  export type { CompatibilityResult } from "./schema-compat.js";
141
148
  export { visitManifest } from "./manifest-visitor.js";
142
149
  export type {
@@ -263,4 +270,12 @@ export type {
263
270
  PositionIndex,
264
271
  Range
265
272
  } from "./types.js";
266
-
273
+ export * from "./manifest-schemas.js";
274
+ // The release model — module identity, fragments, the ledger, the edge graph and
275
+ // version planning. Pure data in, plan out, so the editor and the CLI release
276
+ // from one model.
277
+ export {
278
+ DERIVED_METADATA_FIELDS,
279
+ authoredModuleMetadata,
280
+ } from "./module-metadata-scope.js";
281
+ export * from "./release/index.js";
@@ -1,4 +1,4 @@
1
- import { isLiveSlot, type ResourceDefinition } from "@telorun/sdk";
1
+ import { isLiveSlot, type ResourceDefinition, valueTypeOf } from "@telorun/sdk";
2
2
  import {
3
3
  type ContractDirection,
4
4
  contractDeclarer,
@@ -268,3 +268,94 @@ export function defaultBearingPaths(
268
268
  walk(schema, [], []);
269
269
  return out;
270
270
  }
271
+
272
+ /** The scalar form a declared node's value takes at runtime. `int64` and
273
+ * `double` are the two JSON scalars whose runtime representation is NOT decided
274
+ * by the value that arrives: a CEL integer is a BigInt and a CEL double a JS
275
+ * number, and both are `typeof "number" | "bigint"` away from what a declaration
276
+ * says they are. Everything else — string, boolean, object, array, and every
277
+ * `instance` value type — is already its own representation, so it is not
278
+ * listed and never rewritten. */
279
+ export type DeclaredScalarForm = "int64" | "double";
280
+
281
+ export interface DeclaredScalarPath {
282
+ readonly path: string[];
283
+ readonly form: DeclaredScalarForm;
284
+ }
285
+
286
+ /** Every path in `schema` whose declared type fixes a scalar representation —
287
+ * the leaves a value must carry in that form for the contract to be TRUE, not
288
+ * merely to pass.
289
+ *
290
+ * A declared shape says what the value IS, the same service a JSON Schema gives
291
+ * an HTTP response serializer. Telo's CEL layer takes it literally:
292
+ * `jsonSchemaToCelType` maps `integer` to CEL `int` and `number` to `double`,
293
+ * and a CEL int IS a BigInt — so a controller handing back a plain JS number at
294
+ * an `integer` slot makes the declaration a lie that surfaces nowhere until an
295
+ * expression composes it, as `result.n + 1` type-checking statically and then
296
+ * dying at dispatch with `no such overload: dyn<double> + int`.
297
+ *
298
+ * A VALUE TYPE is read through the same rule rather than beside it: a `json`
299
+ * representation refines a JSON type and contributes its `base` (so
300
+ * `Telo.TcpPort` is an `int64` slot), while an `instance` representation
301
+ * REPLACES the JSON layer — a byte buffer and a stream handle are already their
302
+ * own representation, nothing converts to them, and the walk stops there rather
303
+ * than descending into a value that is not a plain container.
304
+ *
305
+ * Bounded by the schema's declarations rather than by the size of the payload,
306
+ * exactly as {@link defaultBearingPaths} is: a contract declaring no such
307
+ * scalar walks nothing at dispatch. A union counts when it names exactly one of
308
+ * the two — `["integer", "null"]` is still the integer branch, while
309
+ * `["integer", "number"]` fixes nothing and is left alone. */
310
+ export function declaredScalarPaths(
311
+ schema: Record<string, any>,
312
+ resolveRef?: (ref: string) => Record<string, any> | undefined,
313
+ ): DeclaredScalarPath[] {
314
+ const out: DeclaredScalarPath[] = [];
315
+
316
+ const walk = (node: unknown, path: string[], chain: readonly object[]): void => {
317
+ if (!node || typeof node !== "object") return;
318
+ let s = node as Record<string, any>;
319
+ if (chain.includes(s)) return;
320
+ if (resolveRef && typeof s.$ref === "string") {
321
+ const target = resolveRef(s.$ref);
322
+ if (!target || chain.includes(target)) return;
323
+ s = { ...target, ...s, $ref: undefined };
324
+ }
325
+ const here = [...chain, node as object];
326
+
327
+ const entry = valueTypeOf(s);
328
+ // An `instance` representation replaces the JSON layer: it is its own
329
+ // representation, so there is nothing to normalize to and nothing below it
330
+ // that is a plain container to walk.
331
+ if (entry && entry.representation === "instance") return;
332
+
333
+ const form = scalarFormOf(entry && entry.base !== undefined ? entry.base : s.type);
334
+ if (form && path.length > 0) out.push({ path, form });
335
+
336
+ const properties = s.properties as Record<string, any> | undefined;
337
+ if (properties) {
338
+ for (const [key, child] of Object.entries(properties)) walk(child, [...path, key], here);
339
+ }
340
+ for (const branch of ["allOf", "anyOf", "oneOf"] as const) {
341
+ const list = s[branch];
342
+ if (Array.isArray(list)) for (const child of list) walk(child, path, here);
343
+ }
344
+ if (s.items) walk(s.items, [...path, "[]"], here);
345
+ };
346
+
347
+ walk(schema, [], []);
348
+ return out;
349
+ }
350
+
351
+ function scalarFormOf(declared: unknown): DeclaredScalarForm | undefined {
352
+ if (declared === "integer") return "int64";
353
+ if (declared === "number") return "double";
354
+ if (!Array.isArray(declared)) return undefined;
355
+ const integer = declared.includes("integer");
356
+ const number = declared.includes("number");
357
+ // A union naming both fixes nothing — either representation satisfies it, so
358
+ // rewriting one into the other would be a choice the declaration never made.
359
+ if (integer === number) return undefined;
360
+ return integer ? "int64" : "double";
361
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
+ import { moduleMetadataSchema } from "./module-metadata-scope.js";
2
3
  import { residualEntrySchemaMap } from "./residual-schema.js";
3
4
  import { applyObservedStateNode } from "./validate-observed-state.js";
4
5
 
@@ -13,7 +14,13 @@ import { applyObservedStateNode } from "./validate-observed-state.js";
13
14
  * by declaring a typed `variables:`/`secrets:` entry with an `env:` binding
14
15
  * and referencing `variables.X` / `secrets.X`.
15
16
  */
16
- export const KERNEL_GLOBAL_NAMES = ["variables", "secrets", "resources", "ports"] as const;
17
+ export const KERNEL_GLOBAL_NAMES = [
18
+ "variables",
19
+ "secrets",
20
+ "resources",
21
+ "ports",
22
+ "module",
23
+ ] as const;
17
24
 
18
25
  const SYSTEM_KINDS = new Set([
19
26
  "Telo.Definition",
@@ -90,6 +97,7 @@ interface ModuleGlobals {
90
97
  variables?: Record<string, unknown>;
91
98
  secrets?: Record<string, unknown>;
92
99
  ports?: Record<string, unknown>;
100
+ module?: Record<string, unknown>;
93
101
  }
94
102
 
95
103
  function globalsSchema(
@@ -103,10 +111,24 @@ function globalsSchema(
103
111
  secrets: buildSchemaMapSchema(doc?.secrets as Record<string, any> | undefined),
104
112
  resources: resourcesSchema,
105
113
  ports: buildPortsSchema(doc?.ports as Record<string, any> | undefined),
114
+ module: buildModuleSchema(doc),
106
115
  },
107
116
  };
108
117
  }
109
118
 
119
+ /** The `module` namespace. Derived by `moduleMetadataSchema`, the same call the
120
+ * CEL environment types from, so the two cannot disagree about which fields
121
+ * exist or whether the namespace is closed. */
122
+ function buildModuleSchema(doc: ModuleGlobals | Record<string, any> | undefined): Record<string, any> {
123
+ return (
124
+ moduleMetadataSchema(
125
+ ((doc as ModuleGlobals | undefined)?.module ?? (doc as any)?.metadata) as
126
+ | Record<string, unknown>
127
+ | undefined,
128
+ ) ?? { type: "object", additionalProperties: true }
129
+ );
130
+ }
131
+
110
132
  /** Every non-system resource name in the set, plus the scope-declared ones. */
111
133
  function buildResourcesSchema(
112
134
  manifests: ResourceManifest[],