Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

Evaluation order#

Program entry#

  1. All top-level nodes are scanned. Pipeline declarations are registered by name. Import declarations are processed (loaded and evaluated).
  2. If the file declares a pipeline, the pipeline named "default" is selected. If there is no "default" pipeline, the first pipeline is selected.
  3. The selected pipeline's body is executed. A file with any pipeline does not auto-call a top-level function named main.
  4. If the file declares no pipeline, its top-level statements execute in source order as an implicit entry point (script mode).
  5. After those statements, a top-level fn main(harness: Harness) is called automatically with the runtime's root Harness value when present.

main is an ordinary identifier, not a reserved keyword. At the top level it has this entrypoint role. The type checker requires one Harness parameter named harness or _harness, with no default or rest marker.

Pipeline parameters#

If the pipeline parameter list includes task, it is bound to context.task. If it includes project, it is bound to context.projectRoot. A context dict is always injected with keys task, project_root, and task_type.

Pipeline parameters use the same required name: TypeExpr annotations as function parameters. The type checker uses those types in the pipeline body and at local or imported call sites:

pub pipeline deploy(config: DeployConfig, dry_run: bool) -> bool {
  return !dry_run
}

An unannotated pipeline parameter is HARN-TYP-028 under plain harn check. Pipeline default values and rest parameters are rejected because pipeline invocation does not define those runtime semantics.

Pipeline return type#

Pipelines may declare a return type with the same -> TypeExpr syntax as functions:

pipeline ghost_text(
  harness: Harness,
  task: dict,
) -> {text: string, code: int} {
  return {text: "hello", code: 0}
}

The type checker verifies every return <expr> statement against the declared type. Mismatches are reported as return type doesn't match errors.

A declared return type is the typed contract that a host or bridge (ACP, A2A) can rely on when consuming the pipeline's output.

Public pipelines (pub pipeline) without an explicit return type emit the pipeline-return-type lint warning by default. Return types remain inferable, so this is a public-contract lint rather than a checker error.

Pipeline inheritance#

pipeline child(x) extends parent { ... }:

  • If the child body contains override declarations, the resolved body is the parent's body plus any non-override statements from the child. Override declarations are available for lookup by name.
  • If the child body contains no override declarations, the child body entirely replaces the parent body.

Statement execution#

Statements execute sequentially. The last expression value in a block is the block's result, though this is mostly relevant for closures and parallel bodies.

Import resolution#

import "path" resolves in this order:

  1. If path starts with std/, loads embedded stdlib module (e.g. std/text)
  2. Relative to current file's directory; auto-adds .harn extension
  3. <current-generation>/packages/<path> in the leased package snapshot published by the nearest ancestor package root (the search walks upward and stops at a .git boundary).
  4. Package manifest [exports] mappings under that snapshot's packages/<package>/harn.toml
  5. Package directories with lib.harn entry point

Package manifests can publish stable module entry points without forcing consumers to import the on-disk file layout directly:

[exports]
capabilities = "runtime/capabilities.harn"
providers = "runtime/providers.harn"

With the example above, import "acme/capabilities" resolves to the declared file inside the installed acme package.

Export visibility#

A module's export surface — the set of names other modules can import, whether by wildcard (import "m") or selectively (import { x } from "m") — is exactly the declarations it marks pub, plus any pub import re-exports. pub may prefix any top-level declaration: fn, tool, skill, eval_pack, struct, enum, type, pipeline, and — for shared configuration and prompt constants — top-level const and let value bindings. Non-pub declarations are private to the module: usable by the module's own functions, but not importable by name or by wildcard. A module that marks nothing pub exports nothing.

A pub const / pub let is exported by value: the binding's value is computed once when the module is instantiated, then bound into each importer. Later mutation of a pub let in its defining module is not observed by importers (cross-module values are by-value, like every other imported value).

This is the explicit-visibility model of Rust, Go, and TypeScript. Harn does not have a "public-by-default until the first pub" rule: such a rule makes adding the first pub a silent breaking change, because it would flip every other function from importable to private. Requiring pub up front keeps a module's export surface stable as it grows.

The same rule applies to both import forms — a selective import cannot reach a private function that a wildcard import would not see. Importing a non-pub name is an error (HARN-IMP-002) at harn check time and at load time; the message points at the import and suggests marking the symbol pub.

Public struct and enum declarations use this same export contract at runtime. Importing a public struct binds its constructor; importing a public enum binds its variant namespace, whose members construct zero-field or field-bearing variants. A private type used as an enum payload does not need to be exported. Type-only declarations (interfaces and type aliases) remain valid static imports, with schema-capable public aliases also available in schema expression positions. The module graph and VM must derive these projections from the same declaration-kind table so a checker-approved import cannot fail only when the module executes.

Testing private functions. A non-pub function is visible to any pipeline or fn declared in the same file, so co-locate unit tests with the code under test (the Rust/Go white-box pattern) rather than importing the private name into a separate test module.

Selective imports: import { name1, name2 } from "module" imports only the specified functions, each of which must be part of the module's export surface (see above).

Scoped selective imports are shorthand for slash-delimited module paths: import std::personas::prelude::{verify_then_act} is equivalent to import { verify_then_act } from "std/personas/prelude".

Public re-exports: prefixing any import with pub re-exports the imported symbols as part of the importing module's public surface, so downstream importers see them as if they were declared there directly:

  • pub import "module" — re-export every name the target module exports. Equivalent to wildcard re-export.
  • pub import { name } from "module" — re-export only the listed names. Other names from the source module remain private to the importing module.

Re-exports compose: a facade module that pub imports from another facade transitively forwards every reachable name. Two re-exports of the same name from different sources — or a re-export that shadows a local pub declaration — are reported by harn check as a re-export conflict naming every contributing module.

A re-exported callable keeps its complete parameter and return-type contract. Callers do not need separate imports for aliases used only by that signature. Those aliases remain type-checking dependencies; they do not become public names unless the module also exports them.

Imported pipelines are registered for later invocation. Non-pipeline top-level statements (fn declarations, let bindings) are executed immediately.

Import cycles: modules may import each other (directly or transitively). A plain import "m" or selective import { name } from "m" that resolves to a module still mid-load is bound late — once every module in the cycle finishes loading, the name resolves for both bare references and calls, regardless of the order the modules happened to load in. A pub import re-export across a cycle is not supported: re-exporting must publish the name into the importing module's public surface immediately, but that surface does not exist yet while the cycle is loading, so it is a load error that names the cycle. Use a plain import inside the cycle and re-export from a module outside it.

Static cross-module resolution#

harn check, harn run, harn bench, and the LSP build a module graph from the entry file that transitively loads every import-reachable .harn module. The graph drives:

  • Typechecker: when every import in a file resolves, call targets that are not builtins, not local declarations, not struct constructors, not callable variables, and not introduced by an import produce a call target ... is not defined or imported error (not a lint warning). This catches typos and stale imports before the VM loads.
  • Linter: wildcard imports are resolved via the same graph; the undefined-function rule can now check against the actual exported name set of imported modules rather than silently disabling itself.
  • LSP go-to-definition: cross-file navigation walks the graph's definition_of lookup, so any reachable symbol (through any number of transitive imports) can be jumped to.

Resolution conservatively degrades to the pre-v0.7.12 behavior when any import in the file is unresolved (missing file, parse error, non-existent package directory), so a single broken import does not avalanche into a sea of false-positive undefined-name errors. The unresolved import itself still surfaces via the runtime loader.