21. Project manifest
A Hanki project is a directory containing a manifest file hanki.config.hk plus one or more .hk source modules. The manifest is itself a Hanki file, its top-level meta-const declarations holding the configuration values:
# hanki.config.hk
name = "todo"
version = "0.1.0"
programs = ["src/main.hk"]
description = "A simple todo CLI"
In a .config.hk file the bare IDENT = EXPR form is sugar for IDENT: <inferred-type> = EXPR, the type inferred from the right-hand side. The typed form IDENT: TYPE = EXPR parses here too, for cases where inference would land on the wrong type: port: u16 = 8080 against the int an unpinned integer literal falls back to (§3). In either form the meta keyword on the right-hand side is rejected as redundant, the same rule as §16.
A .config.hk file may end with a single bare expression that is the value of the file, in the Dhall style. Embedder hosts, Rust, Go or Python applications using Hanki as their config language, consume this value as the thing the file evaluates to:
# foo.config.hk (host imports a schema and instantiates it)
open schema
default_port: i32 = 8080
MyConfig(name="todo", port=default_port)
The trailing expression sees every binding declared above it, can call any pure def or meta def in scope, across imported .config.hk files included, and is itself evaluated under the meta-eval budget. It must be the last thing in the file, and any further declaration after it is a parse error. Bindings above it still populate the file's meta-const map, and a host can therefore read both the trailing value and individual named fields from the same file.
Regular .hk files reject a bare top-level expression: top-level expressions are gated on the .config.hk suffix. The project manifest, hanki.config.hk, is the canonical embedder caller and uses named bindings alone, and needs no trailing expression. The trailing-expression form exists for hosts that prefer the file-is-a-typed-value model over the file-is-a-bag-of-fields one.
Recognised fields:
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | ✓ | project name |
version | string | ✓ | semver string |
programs | List<string> | * | paths (relative to the project root) to the modules this package runs, each defining main! |
exports | List<string> | * | paths, relative to the project root, to the modules a dependent may import: the package's public surface |
description | string | one-line description | |
hanki | string | the single toolchain/language-version constraint (covers core + extra, which share one version); absent ⇒ the compiler's own version | |
deps | List<Dep> | contrib + universe dependencies (absent/[] ⇒ a leaf package); see Dependencies below | |
exclude | List<string> | paths, relative to the project root, that no declared root reaches, by intent (a directory or a single file); the orphan-module report and hanki doc --check skip them, and hanki fmt does not | |
rifts | List<Rift> | the effects this application answers with native Rust crates of its own, one Rift(effect_name, path, deterministic) per effect; root manifest only, a dependency carrying it being refused at resolution; see Rifts below | |
<name> | string | a top-level binding whose name matches a Dep.source is that universe dependency's git-URL binding, and its use alias (§17) |
exports is a boundary and no label. A binding is a handle on the whole package, and exports is the list of its modules a dependent may reach: use <alias> reaches <alias>.hk, and use <alias>.<module> any other exported one (§14). A module the package does not export is refused at the importing line, naming the module and what the package does export; the fix is usually to export it, and not to stop importing it. <alias>.hk is not special and not required: a package may export client.hk and model.hk and no file named for the binding at all, and only a bare use <alias> is then refused, pointing at the dotted spelling that works. An exported module is named by its file stem wherever it sits under the package, and exports = ["src/model.hk"] is reached as use <alias>.model, module names being flat and the directory an export sits in being the package's own arrangement. The rule applies to path dependencies too, a sibling project in the same repo being edited alongside its dependent and the easiest place to reach through the boundary by accident. A dependency with no manifest at all declares no boundary, and nothing is enforced against it.
* marks that at least one of programs and exports is required. A package that is run declares programs, a library declares exports, and a package that is both declares both. entry was the single field these two replaced and is retired, and not accepted as an alias: it was already doing double duty, a library like contrib/sql having to declare entry = "sql.hk" for a module defining no main! at all. A manifest naming neither key is rejected with both named, the current form of a missing entry.
programs and exports are the root set. Both are parsed, confined under the project root, and checked to name files that exist, and a typo is therefore reported and not ignored. Their union is what a directory command builds and what reachability is rooted at.
build builds every program, and run picks one and never guesses. hanki build <dir> emits one binary per declared program in the manifest's order, building being closed under multiplicity and picking nothing. hanki run <dir> has to pick, and a package declaring several is refused with them named. --program <name>, the file stem, where src/main.hk is main, says which, and narrows build to one as well. First-in-the-list would make reordering the list a silent behaviour change, and a command that guesses between two binaries runs the wrong one. Only programs are offered: an export is a library a dependent imports and nothing to run. The linked binary takes the manifest's name only for a package with a single program; once several are declared each takes its own file stem, two of them being unable to both be out/<name>. A stem may carry dashes, and entsoe-fetch.hk builds out/entsoe-fetch: a program is named by its path and not by the module-name rules, and a deployed binary name therefore needs no separate manifest field.
Reachability is rooted at every declared root and never at one file. programs and exports together are the root set, and hanki check and hanki test work outward from all of them: a package's second program is checked, and its tests run, without being named in exclude. Each root is checked in its own workspace, and a module two programs share is visited once per program. --coverage still measures one program and refuses a package declaring several, one lcov file being unable to describe two.
exclude names what is unreachable by intent. hanki test and hanki check report every .hk file under the project that the entry cannot reach, a module with no caller yet being a suite that does not run, the one wrong answer a test runner must not give. A project holding a fixtures or testdata tree, a vendored subpackage, or a path dependency checked out under its own root has many such files by intent, and would otherwise hear about every one of them on every run. Each entry is a path relative to the project root, a directory covering everything beneath it or a single file, matched whole-component, and exclude = ["testdata"] therefore covers testdata/nested/a.hk and does not cover testdatax/. An excluded file is never opened, and the report costs nothing for what it skips. They are plain paths and no glob patterns: a pattern syntax here would be a second matcher beside glob for the manifest to keep in step.
The single-file form is for a module another project consumes. A Hanki import resolves within its own project, and a library shared with another project in the tree has to sit in one of them, where that project's own entry will never reach it. Naming it is how the project says the exclusion is a fact about its layout and no oversight. Where the module has tests, they must be run explicitly, an excluded module's suite no longer being covered by the package's own hanki test.
hanki check . reads hanki.config.hk from the current directory, runs it through the standard parse, type-check and meta-evaluate pipeline, and confirms that the entry file together with every module it imports, transitively, lowers. hanki build . does the same and then AOT-compiles the entry to a native binary, and hanki run . does the same and then invokes the entry file's main on the bytecode interpreter. Manifest errors use the same file:line:col: error: msg form as any other Hanki diagnostic, and a typo in the manifest reads like a typo in any other source file. A top-level binding that is neither a recognised field nor a declared dep's source is an error naming the recognised set: a typo'd optional field, descriptoin or log_level, must not vanish unreported, and the required trio only ever surfaces a typo as itself missing. This forecloses stashing host-specific extra fields in the manifest; an embedder wanting its own configuration reads its own file.
Dependencies are a frozen 1.0 contract. The manifest declares them through the baked pkg module (§17, tier 1: core), a data module like path providing two constructible structs, and a manifest builds them as ordinary struct literals. Hanki has no map or tuple literals, and dependencies are therefore a List<Dep> and no {name: constraint} map:
open pkg
name = "todo"
version = "0.1.0"
entry = "src/main.hk"
hanki = "^1.4" # the ONE toolchain/language-version
# constraint (covers core + extra)
http = "git.sr.ht/~user/http" # a universe dep's git-URL binding;
# doubles as its `use http` alias (§17)
lexer = "path:../front" # a local path dep: a sibling project
# directory, live normally, pinned frozen
deps = [
Dep(source = "money", version = "^2.0"), # contrib: curated flat name
Dep(source = "http", version = "^1.2"), # universe: `source` names the
# top-level URL binding above
Dep(source = "lexer", version = "^0.1"), # path: `source` names the
# `path:` binding above
]
struct Dep { source: string, version: string }.sourceis a contrib flat name or a short name with a matching top-level binding, and binding presence and form select the tier: apath:-prefixed binding (lexer = "path:../front") makes it a local path dep, any other binding is its universe git URL, and no binding means contrib, with no cross-source fallthrough.Dephas no alias field, the alias being the top-level binding (§17).versionis a constraint, below.- Path dependencies compose sibling project directories,
tools/sitereachingtools/front, without collapsing them into one namespace. Thepath:suffix resolves relative to the project root and must name a project directory, one with its ownhanki.config.hk. A target may sit beside or inside the graph root, but may not equal or enclose it: otherwise its source-tree pin would include the consumer's generated dependency lock and no baseline could converge. The declared constraint is checked against that target's version,use <alias>binds the target's<alias>.hkpublic module, anduse <alias>.<module>reaches its other exported ones. Several aliases may point into one target directory,lexerandhighlightboth at../front, and one binding per dependency is enough. A path dep is workspace-local live source with a frozen-build baseline: ordinary resolution reads the sibling's current bytes and writes or refreshes apath:<dir>lock entry with their deterministicsha256:package-tree hash, without applying the third-party integrity or effect-growth refusal to local edits. A project whose deps are all path deps therefore has a lockfile.--frozenstill reads the live directory, but it requires that committed baseline and refuses a version or source-tree change, naming the moved path dependency and the normal resolve that refreshes it. The tree recipe is the same as contrib and excludes the package root's generatedout/directory plus.git,.hgand.svncontrol entries at any depth; build and checkout history therefore cannot move a source pin. This never makes path source fetchable or immutable outside frozen mode. Dependency resolution runs underhanki build,run,checkandtest, and under the standalonehanki resolve. A single-file entry is governed by ahanki.config.hkin its own directory where one exists, same directory only with no upward walk, and a secondary tool file beside a project manifest therefore composes too. - Version constraints are caret-default SemVer strings:
^1.2.0for compatible,>=1.2.0,<2.0.0;~1.2for patch-level,>=1.2.0,<1.3.0;>=1.2,<2for an explicit range, a comma being intersection;1.2.0for exact; and*for any version.hanki addwrites^major.minorof whatever version the target declares, and*only where it cannot read one, on a--gitbinding, whose version needs a fetch. Core and extra move together under the singlehankifield, a core break bumping major and an extra break minor, and contrib and universe take independent SemVer perDep. - The lockfile,
hanki.lock.config.hk. A generated, committed, human-diffable text.config.hkfile and no binary one: itopen pkgs and bindslocked = [Locked(...)], wherestruct Locked { source: string, version: string, hash: string, api: string, effects: List<string> }records, per resolved dependency, its resolved identity, a git URL ortier:name@versionorpath:<dir>, its exact version, its content pin inhash, a digest of the exported names and signatures inapi, and its approved transitive world and user-effect surface. Re-resolving recomputes each effect surface, and a third-party surface that grew past the lockfile'seffectsfails resolution by default; approval is editingeffectsin the diff, and shrinkage is always free. Thehashcontent pin is checked the same fail-by-default way for third-party source: re-resolving a dependency's same version to a different commit hash, a moved upstream tag, fails resolution and never re-locks to the new content unannounced, and a build cannot be re-pointed at tampered content behind a stable version. A path dependency is the workspace exception: its pin and effect surface refresh freely during ordinary resolution because it is live source. Frozen resolution verifies both its version and bytes against the committed baseline. A genuine SemVer bump, the constraint resolving to a new version, re-locks freely, and an intentional same-version third-party re-lock means first deleting that entry. A third-party integrity failure names what moved besides the bytes, the content it moved from being gone by then: it compares the recomputedapiandeffectsagainst the committed ones and says whether the exported interface and the effect surface moved with the pin. "The effect surface is unchanged ([io]) and the exported API is unchanged (same symbols, same signatures), and whatever changed is behind the interface and not in it" is what tells a consumer where the change is not, without leaving the repository. It is no claim that the change is benign, an unchanged signature and an already-declared effect row still admitting a rewritten body, and a lock recording noapiclaims nothing in place of guessing. Thehashpins the package's source, and generated build artifacts and VCS control metadata are excluded from it, or the pin would move with local history. The lockfile is text so that approval is reviewable. The separate release-time vcs-value cache, cachingvcs.sha()anddescribe()so that a git-less tarball still builds, is a distinct sibling file sharing the.lock.config.hkfamily, and is not folded into this dependency lock. - Publish descriptor.
hanki publishderives a canonical, deterministic postcard descriptor from the source manifest, and a registry can therefore index a package without meta-evaluating its source. It reports the resolved deps, the effect surface, and an extensible region of derivedhanki scoreboardGuarantees, a reader ignoring Guarantee tags it does not know, and the catalog therefore grows without a format break. The human-authoredhanki.config.hkremains the source of truth, and the descriptor is derived at publish and client-verifiable against the content-addressed source.
These wire shapes are frozen for 1.0 (docs/design/package-manifest-schema.md). The resolver covers all three dep tiers, transitively across the whole graph. hanki build, run, check and test resolve each declared universe dep's version constraint against the package's git tags, fetch the selected version into the content-addressed cache, then resolve that package's own manifest the same way, recursing over the closure; each contrib dep against the toolchain's seeded contrib/<name>/ source, one shipped version per package; and each path dep against its live local project directory, a workspace-local tier where a fetched or seeded package may not declare path: bindings. Each dep's resolved source is then imported (§17). One version per source, graph-wide. Where several packages constrain the same source, the selected version is the highest satisfying all of their constraints, SemVer-max over the intersection. An empty intersection is a hard error naming every requirer and its constraint: there is no backtracking solver, no override table and no version duplication. A dependency cycle is likewise rejected. A universe dep's source for that rule is its host/user/repo identity and not the string a manifest happens to bind, and git@host:u/r, https://host/u/r, a scheme-less host/u/r and a trailing .git are one source: two packages spelling one repository differently constrain the same node in place of duplicating it in the lockfile at independently selected versions. The first binding the resolution reaches supplies the URL that is fetched and recorded, and an SSH binding is never downgraded to https by a later spelling, the transports differing even where the identity does not. Each resolved package's own use binds the aliases its manifest declares, and never the consuming project's. The whole closure is recorded flat in the lockfile, one Locked entry per resolved package. Each re-resolved third-party pin is verified against any committed one, and a moved-tag hash change on universe, or a same-version seeded-tree change on contrib, therefore fails by default, above; a path entry instead refreshes freely under ordinary resolution because it records live workspace source for frozen verification and is no third-party immutability claim. A contrib pin is the reserved contrib:<name>@<version> identity with a deterministic sha256: tree hash, the seeded source having no git; a path pin is path:<dir> with the same tree hash. Locked.hash is a prefixed string and the prefix names the scheme, git: for a fetched commit and sha256: for a tree hash over source bytes, which is what leaves the frozen field additive. Locked.api beside it is fnv1a:, and it remains there because it answers whether the exported surface moved and not whether the bytes are the ones that were locked. hanki build --frozen resolves the same closure entirely offline, from the committed lockfile plus the warm cache for universe deps, a transitive dep's manifest being read from its warm slot, the hash-verified seeded source for contrib deps, and the hash-verified live local directory for path deps, fetching nothing and rewriting nothing. It fails on a cache miss, a missing lockfile where any dependency is declared, a contrib tree, path tree or warm universe cache slot that no longer matches its pin, or a pin the graph's constraints no longer satisfy, a stale lock. It is for CI and for hermetic, reproducible builds. All three tiers re-read their bytes in place of treating a directory's existence as proof: a cache slot or path source is an ordinary local directory that can be edited after it is written, and frozen mode is where that check is affordable, fetching nothing. The package-manager CLI verbs sit on top of this contract. hanki resolve locks the graph standalone, and --frozen verifies the committed lock offline. hanki add <source> edits the manifest, contrib by default, with a --git URL or --path DIR binding selecting the universe or path tier, refusing a source it cannot find, a contrib package that is not shipped or a path that is not a directory, defaulting the constraint to ^major.minor of the version that source declares, and re-parsing the result so that a malformed edit never lands. hanki list [TIER] enumerates a tier's packages; only contrib ships with the toolchain, and it is therefore a local directory read of each seeded package's manifest, name, version and description, one line each, with no network and no index format, and it is how a reader learns the curated tier exists at all. hanki publish derives the postcard descriptor from the exported API and validates it locally, printing the readable form or --outputing the encoded bytes. The effect-growth gate works as follows: each re-resolve recomputes every dependency's exported effect surface, the union of its public API's effect rows, the package effect manifest, into the lockfile's effects, and a third-party surface that grew past the committed set, the leftpad-style effects [] → [net] on a patch bump, fails resolution by default, naming the package and the new effects. A path dependency's local surface instead refreshes freely with its content pin during ordinary resolution. Shrinkage is always free, and approval is adding the new effect to that dependency's effects in the lockfile diff, the lock being text so that approval is reviewable, or, equivalently, hanki resolve --accept-effects '<source>=<eff>[,<eff>]', which writes that same widened surface into the lock in one command. The growth error names both forms, and --accept-effects cannot combine with --frozen, which never re-locks. Only the hosted registry upload remains planned tooling built on top of this contract; the manifest-reading side and the lockfile and descriptor formats are what these fields fix.
An application may answer an effect declaration's ops with a Rust crate in its own repository, a rift into native code; docs/design/native-rifts.md is the design record, and the §6 prose covers the value boundary and the runtime. The manifest binds it through the baked pkg module's third record, struct Rift { effect_name: string, path: string, deterministic: bool }, its field order frozen like Dep's, and effect itself being a keyword, hence effect_name:
open pkg
name = "mygame"
version = "0.1.0"
programs = ["src/main.hk"]
rifts = [Rift(effect_name = "audio.Audio", path = "native/audio", deterministic = false)]
effect_name is the module-qualified effect, spelled as a provide head spells it, audio.Audio for effect Audio in audio.hk. path is the crate directory, relative to the manifest. deterministic is the author's claim that the crate is reproducible, which --deterministic requires and cannot verify; every field is spelled at every binding, and the claim the toolchain cannot check is therefore visible in the diff. A built binary asks the same question of HANKI_DETERMINISTIC, its counterpart to the flag, and refuses at startup in place of replaying against native code that claims nothing. A bound effect counts as provided: the program needs no provide block for it, and a provide in the application's own files still wins for the root whose workspace contains it, a test root named in exports importing a fake (§6), while one in a dependency is refused, a package being unable to shadow the application's native code with a Hanki body of its own (H0642). The binding is checked before any Rust is built or run, each rule at the binding in the manifest. The effect exists and is an effect, by that name, reachable from some program root (H0636). There is one binding per effect (H0637). The path is confined under the project, is a directory, and contains a Cargo.toml, and no other property is inspected, hanki check never running cargo (H0638). Every op signature uses only the representable subset, transitively: (), bool, the fixed-width integers, f32 and f64, string, bytes, Option, Result, List and Pair of those, and non-generic user structs and sums built from them. int, decimal, rational, resources, futures, closures, actor references, generic user types and Map are refused, naming the op, the parameter or return, and the type (H0639); a map's trie is a private stdlib representation the runtime cannot enumerate at the boundary, and a rift therefore takes List<Pair<K, V>>, the author crossing with to_list and Map.from_list. Every op, parameter, field, variant and type name must also have a Rust spelling of its own, a predicate's ? being dropped and a Rust keyword raw-escaped, and a user type may not wear a name the generated crate already spells, Vec, Result, its own Wire or DecodeError, or the trait the effect becomes; the effect's own name may not either, becoming that trait (H0643). Two bindings may not generate one name, neither the mangled symbol prefix nor the api crate's lowercased one (H0644). One binding may not name a different effect in two program roots, which a bare effect_name does: the entry file's own top-level names are keyed bare, "Audio" therefore matches every root that declares one, and there is no single interface to generate from (H0645). And some program references the effect, a binding being a claim that native code is part of this program; a package declaring no programs cannot open a rift at all (H0640). Only the root manifest's rifts is honoured. A resolved dependency whose manifest declares the key, contrib, universe or a local path dependency, fails resolution naming the dependency (H0641), and the field is never lost without a word. A dependency may still declare an effect and leave it unprovided, which is the capability-interface form the application answers for, with a provide or a rift of its own.
An op a rift answers is called and never stored as a value. A stored op value binds its provider where it is written (§6), and a rift-answered op has no Hanki function to bind to, the call crossing into native code the runtime resolves at the call itself. f = audio.Audio.play is refused, naming the two ways round it: call the op, or wrap it in a def of your own and take a handle to that (H0646). A root that answers the effect with a provide of its own is not this case: that provider wins over the binding, the op is an ordinary Hanki function there, and the value is legal.
From each checked declaration the toolchain writes two crates under .hanki/rifts/<module>__<Effect>/, gitignored by hanki new: an api crate holding the Rust twin of every type the ops mention, the wire codec for them, the trait the author implements, and the interface text as a constant; and a shim crate exporting the four extern "C" symbols a runtime binds. The author's own crate depends on the api crate and exports pub fn open() -> impl <Effect>, and it writes no unsafe, sees no Hanki types, and cannot call back into Hanki. hanki rift generate rewrites both from the current declarations without building anything, which is what an editor wants after an effect edit, and run and build do it for themselves. The trait is generated from the declaration, and an effect block that changes therefore cannot be answered by yesterday's implementation: the crate stops compiling.
Running one, under hanki run or hanki test on the bytecode tier, builds the shim with cargo, cargo being the freshness oracle for the Rust side, loads it, and compares the artifact's interface text with the declaration the program was checked against before any op runs, refusing a stale one with the line that differs. Three things then hold, and each is what the boundary is for. State is per actor: an actor opens its own instance on its first call into a rift and drops it when it dies, and a rift therefore cannot become a shared back channel between actors the type system believes are isolated; a device that is per-program by nature gets one owning actor that serves the rest by message. Nothing between those two points closes or reopens the instance. A close() the author declares is an ordinary op call on a live instance, and whether a later open(...) works is the rift author's Rust to define. A panic is an actor death, never an abort and never an unwind through Hanki frames: it crosses as a status code and becomes DeathCause::HostPanic naming the rift, the op and the author's message, a supervisor hears it like any other death, and the program continues. And a declared failure is no panic: a rift that can fail returns Result, which crosses as an ordinary value. A rift op charges the allocation of its decoded result and no more, and --max-steps and --max-bytes do not meter time spent inside native code.
A module imports another module of its project with use NAME, qualified, or open NAME, unqualified, which drops the module's items into the importing file's scope bare. Both resolve to the project's one NAME.hk, wherever it sits under the project root (§14: module names are flat, and directories organise). The project is the tree of the nearest manifest enclosing the importer, or, where no manifest encloses it, the importer's own directory alone, a manifest being what makes a tree one project, and a loose file among others sees only the files beside it. Two files of one stem are refused naming both, and a nested package's files and the out/ artifact directory are not the project's modules. Every top-level item kind, def, def name!, actor, struct, type including each variant constructor, trait, impl, effect and meta const, sits at its module-qualified name: option.Option, option.Some, parse.parse_command!, todos.Todos. Cross-file references either spell the qualified form, parse.parse_command!(line), spawn todos.Todos, option.Some(v), or rely on open NAME to drop the bare form into scope. Within the defining file an item's own bare name still works, the workspace Rewriter splicing bare to qualified at merge time, and parse.hk's tests therefore call parse_command! without the prefix. Two use imports each exporting a sum-type variant named Same do not collide, living at a.Same and b.Same. Two opens introducing the same bare name make it ambiguous, a bare use through the pair being an error (H0306, §14), and either export remains reachable through its qualified form. The resolver walks the import graph rooted at entry, detects cycles, and type-checks each reachable file with its dependencies' exports pre-populated in the env. A two-segment path is a dependency's other exported module, use <dependency>.<module> (§14), and it is therefore spent and not reserved, and a project's own subdirectories need no scheme at all, their modules being reached by bare name. Three or more segments are rejected. Stdlib modules, io, list, option, display, eq? and str, bypass the project's file resolution: the embedded stdlib owns them, and use io and open option work with no on-disk file.
The basename hanki.config.hk is reserved by the compiler for the manifest; it is not importable as a user module.
The manifest is the one place the build tool acts as a capability host. The commands that read it, hanki check, build, run and test over a project directory, inject a vcs capability, and the manifest can therefore read version-control facts from git at build time. The vcs effect, stdlib extra/vcs.hk, exposes sha() -> string, the commit sha, and describe() -> string, a git describe --tags --always --dirty string, and the manifest writes open vcs and then version = describe(), or reaches them through the effect's name as vcs.Vcs.describe() under use vcs (§6). hanki fmt never evaluates the manifest, and it therefore remains pure and needs no git. There is a reproducibility cache. In a git checkout the values resolve fresh from git each build, and a repo build always reflects current state. A released source tarball has no .git, and the values would be unresolvable. For that case hanki build --refresh-manifest re-resolves every vcs op the manifest uses and writes them to a git-tracked hanki.vcs.lock.config.hk, a <op> = "value" .config.hk file, and a later build in a git-less tree reads the cached values from it in place of failing. The cache is a separate sibling file in the .lock.config.hk family, distinct from the dependency lock, hanki.lock.config.hk under Dependencies above, which leaves the security-reviewed dependency lock single-purpose and the vcs cache free to evolve. Where neither git nor a cached value is available, the op is an unsatisfied capability naming the missing value and the --refresh-manifest recipe, and never a default applied without a word.
hanki.config.hk is the canonical example of a broader convention: any Hanki file whose role is to declare data in place of defining runtime behaviour uses the .config.hk suffix. Such files share the grammar, parser, type checker and effect system with regular .hk modules, and a strict-mode checker pass rejects items that do not belong in a declarative file: actor declarations, def name! actions, inside impl blocks included, and test blocks. The convention is both a tooling signal, globs like **/*.config.hk and editor recognition coming at no cost, and a compile-time guarantee. Renaming a file from .hk to .config.hk is a meaningful refactor: it changes which items are accepted. Rejecting imports of non-.config.hk modules from a .config.hk file is a planned extension to the strict-mode pass; the import resolver knows the imported file path, and the suffix-based veto is not yet wired up.
The worked example is examples/v0_1/config_dsl/, which shows the convention end to end. app.config.hk opens the host entry module, computes its fields, a derived port and a mapped list, in place of restating literals, and ends in the AppConfig value. main.hk binds it with CONF: AppConfig = config.load("app.config.hk") and reads it like any other const. Nothing reads the file at runtime, and editing it changes the binary.
The bare name = expr form is .config.hk-only. Regular .hk files require the type annotation, a top-level identifier with no type being ambiguous in a module that mixes declarations and code, and the bare binding form is therefore gated on the .config.hk suffix. The meta keyword is rejected on the right-hand side of every top-level binding, canonical or sugared: there is one way to spell each form.
Injectable effects are the config capability interface. A .config.hk value, a binding's right-hand side or the trailing expression, may invoke user-declared effect ops (§6) even though it is otherwise a pure position: the embedding host supplies each op's behavior as an injected capability, and a config can therefore say token = secrets_lookup("api") and let the host decide what that means. Three rules keep this safe:
- Only user effects are injectable. Anything with a built-in ambient effect,
io,fs,net,db,process,time,envorrandom, athrows, orCrash, is rejected in a config value (H0614): OS authority is denied at the API boundary and never opted into by the file. Declaring actions, actors, tests orprovideblocks in a config is already a strict-mode error. - A config needs no
provideblock for the effects it uses, the host being the provider, and the §6 one-provider completeness rule is therefore waived for.config.hkfiles. The set of user-effect atoms a config references with no in-module provider is its capability interface, what a host must inject to evaluate it, exposed to tooling asrequired_capabilities. - Evaluating a config that calls an op with no installed capability fails with
UnsatisfiedCapability, and never proceeds unannounced.