Evaluation order#
Program entry#
- All top-level nodes are scanned. Pipeline declarations are registered by name. Import declarations are processed (loaded and evaluated).
- If the file declares a pipeline, the pipeline named
"default"is selected. If there is no"default"pipeline, the first pipeline is selected. - The selected pipeline's body is executed. A file with any pipeline does not
auto-call a top-level function named
main. - If the file declares no pipeline, its top-level statements execute in source order as an implicit entry point (script mode).
- After those statements, a top-level
fn main(harness: Harness)is called automatically with the runtime's rootHarnessvalue 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
overridedeclarations, 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
overridedeclarations, 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:
- If path starts with
std/, loads embedded stdlib module (e.g.std/text) - Relative to current file's directory; auto-adds
.harnextension <current-generation>/packages/<path>in the leased package snapshot published by the nearest ancestor package root (the search walks upward and stops at a.gitboundary).- Package manifest
[exports]mappings under that snapshot'spackages/<package>/harn.toml - Package directories with
lib.harnentry 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 public export surface — the set of names any module 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.
An @sibling non-public function is a separate visibility level: only modules
whose resolved source files have the same parent directory may import it.
Both selective and wildcard imports apply that rule. The function is absent
from the public export surface, package catalogs, and public-return-type
ratchets. A pub import cannot promote a sibling-only name; an explicit public
wrapper is required to publish it. Import spellings and symlinks do not widen
the resolved directory scope. Moving a function between files in the same
directory leaves the public surface unchanged.
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 unless annotated @sibling: ordinary private names
are usable by the module's own functions but cannot be imported. A module that
marks nothing pub has no public exports.
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 visibility rule applies to both import forms: a selective import
cannot reach a function that a wildcard import would not see. Importing an
ordinary private name, or a sibling-only name from another directory, is an
error (HARN-IMP-002) at harn check time and at load time.
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, non-@sibling 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 importederror (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-functionrule 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_oflookup, 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.