Static verification surface

This page is the canonical catalog of the compile-time checks the toolchain runs beyond ordinary type-checking — the structural and semantic guarantees a…

Reference material, synced from the compiler repo’s spec/. The guide is the gentler path in.

This page is the canonical catalog of the compile-time checks the toolchain runs beyond ordinary type-checking — the structural and semantic guarantees a program earns at hale build / hale check time. It describes shipped behavior; the verification roadmap that drove these checks — now delivered — is recorded in GitHub issue #18 (closed).

Two severity levels exist:

Most checks run in the bundle-level passes of crates/hale-types/src/check.rs (check_bundle); a few resolve-time ones run in crates/hale-types/src/resolve.rs; cell slot-of-origin is a codegen-time check. Each entry names the enforcing pass.

Every bundle-level check applies inside module { … }, at any nesting depth (GH #825, 2026-09-20). A module is a NAMESPACE, not an analysis boundary — the resolver registers a module’s declarations under their bare names, so a fn, locus, topic or bindings entry one brace deeper is an ordinary member of the bundle. Findings carry the same message, the same span and the same severity as the identical declaration written at the top level, and whole-bundle facts (the at-most-one-main count, the pool map seeded from the main locus’s placement block, the transport-bound topic set, the @form sync-discipline index) count module-nested declarations too. The behavior summary the effect, allocation and certificate analyses read collects module-nested bodies as well (F.40 phase 3), so a module-nested fn is never an unanalyzed body: a call into one resolves, its effects are in its callers’ sets, and a law through it can be certified.

Concurrency & placement safety

Section titled “Concurrency & placement safety”

The bus + cooperative-pool model is the substrate; these checks keep a program’s placement coherent with how the runtime dispatches.

Check Catches Severity Enforced by
Single-threaded-method invariant a direct cross-pool method call (self.field.method() where field is placed on a different pool) — it would run the callee’s method on the wrong thread error check_placement_single_thread
Dead bus receiver a non-main cooperative locus that subscribes to the bus and makes a blocking call in run() — the blocking call monopolizes the pool thread so the dispatch never delivers and its handlers never fire error check_cooperative_pool_blocking
Blocking call on a cooperative pool a blocking run() (recv/accept, a stdin or file read, an http request, process::run) on a pool that isn’t where async_io — it holds the pool’s OS thread and stalls co-scheduled loci. Follows the call graph: blocking reached through a helper fn or self.method is flagged too warning check_cooperative_pool_blocking
Cooperative pool starvation two or more loci on one cooperative pool (not where async_io) whose run() bodies statically never return (terminal while with no exit — while true, while !self.draining, or a never-assigned Bool flag) — the pool runs each run() to completion in birth order, so the later run() bodies never start. Covers fields with no placement entry (they default to pool main) and the main locus’s own run(), which begins only after params-init warning check_pool_starvation
Nested long-running child a non-main locus with a run() body of its own holding a params field of a locus type whose run() is long-running (a body of its own, whether or not it returns: spec/runtime.md § Typecheck enforcement) — the canonical fix is hoisting it to a main sibling with its own placement error check_nested_long_running_child
Unowned subscriber locus a bus-subscribing locus instantiated inside another locus’s bus handler with no accepting ancestor in the ownership graph on every construction path of the handler’s locus (the paths the placement table records, fn main’s included; a hole proves no owner) — it dissolves when the handler returns, so its subscription can never fire; judged by declaration identity, and silent where the graph cannot decide (spec/semantics.md type-check rule 20; overridable with --allow-unowned-subscriber) error check_unowned_subscriber_locus
Pinned placement in a loop a locus whose placement { } pins a field, instantiated inside a loop body — the pinned thread’s join record is one slot per instantiation site, so every iteration but the last is orphaned with its arena live. A loop that calls a fn holding the literal is fine (each call joins its own thread) error lowering_laws::pinned_root_in_a_loop
Unconsumed placement entry a placement { } entry whose field is initialised by anything but a locus literal (a factory call, a conditional, a reference to an instance built elsewhere) — the entry rides an override the next literal takes, so nothing consumes it and the stated placement is silently dropped. Checked against the init at the instantiation site when the literal supplies one, and the params default otherwise error lowering_laws::placement_entry_consumed

The dead-receiver error is deliberately direct-call-only (its call-graph surface is not widened), while the blocking warning is interprocedural — the high-stakes diagnostic stays precise. See spec/semantics.md type-check rules 7–8 and docs/src/services/concurrency.md.

The warning’s horizon. The helpers that block are read off the effect rows: a fn blocks when its own block leaf holds the worker (below), or when any fn it calls blocks, transitively over the rows’ resolved call targets — a module’s fns by their bare names, a qualified cross-seed call, a stdlib body behind a handle method and another locus’s method included. The run() walk consults that set at two call shapes only: a bare call pump() (a free fn) and self.pump() (a method of the locus’s own). A handle method or a self.field.method() hop written in run() itself is not looked at; the same call inside a helper that run() calls makes the helper block.

What counts as “blocking” for both is the effects registry’s block classification — the same rows the effect assertions and the .hale.effects manifest read (stdlib_surface::SURFACES) — and not a list kept beside it. So io::stdin::*, io::file::read_line, io::udp::recv*, std::http::*, tcp::connect / accept_one, tls::connect / upgrade and process::read_stdout / read_stderr are blocking leaves here because they are block rows there; classifying a new stdlib leaf block puts it in this lint the same day.

One block row is carved out, because on a cooperative pool it does not hold the worker for the wait: std::time::sleep, which the lowering chunks into ≤100 ms slices and drains the pool’s bus queue between them. That slicing is what makes “handlers plus a time::sleep loop” the prescribed event-driven shape — the shape both diagnostics above name as the fix — so counting it would have the lint flag its own advice.

This is a different subtraction from the async_io park exemption below (ASYNC_IO_PARKING, GH #791). That one is the async_io-pool rule: those leaves swap the coro out, which needs the pool’s event loop. check_cooperative_pool_blocking never looks at an async_io placement — it skips the entry on the where async_io constraint before reading a single call — so on every placement it does look at, a park-capable leaf takes its blocking path and really does hold the thread.

Bus-graph property checks

Section titled “Bus-graph property checks”

The bus topology is a typed directed graph in the source; these walk it. (GitHub issue #18 item 4.)

Check Catches Severity Enforced by
Orphan topic / subject a declared topic or literal subject wired to only one end — published with no subscriber, subscribed with no publisher, or used by neither warning check_bus_graph
Queued bus cycle a publish→subscribe→publish loop spanning ≥2 loci, or an unconditional one within one locus with a send the intra-locus rewrite leaves on the bus — the cell hops via the cooperative queue and can spin / livelock warning check_bus_cycles
Intra-locus re-entrant cycle an unconditional self-republish loop within one locus whose every send the intra-locus rewrite turns into a direct synchronous call — it recurses on one thread without bound (stack overflow) error check_bus_cycles
Bus backpressure a publish inside an unbounded while true loop with no flow-control or exit point (yield / sleep/tick / an input-pacing blocking call / break/return) — floods the bus without bound warning check_bus_backpressure
Subject type-mismatch two sites on the same literal subject string declaring different of type payloads — a subscriber would decode the wrong type error check_bus_subject_types
Routing-key fallback rules an on_unmatched: fallback topic with no where key == _ subscriber, or a where key == _ filter on a non-fallback topic error check_phase3_fallback_subscribers
Topic parent-chain cycle a topic hierarchy that loops (topic A : B; topic B : A) error finalize_topic_chain (resolve)

Orphan detection is closed-world gated (it runs only when a main locus is present), and suppressed by transport bindings, ** wildcard coverage, cross-seed (alias::Topic) references, and self-pub/sub — so library seeds and external peers aren’t falsely flagged. The intra-locus cycle error counts only unconditional sends as edges: a self-republish guarded by if/match/loop is a terminating state machine, not unbounded recursion, and is left alone. The orphan, cycle and dead-receiver checks read one bus graph (build_bus_graph): its subjects compared by wire subject, its edges keyed by the declaration that wrote the handler, and a subject it cannot resolve recorded as a hole that no check fires on. See spec/semantics.md type-check rules 7, 9 and 10.

The backpressure check’s “input-pacing blocking call” is the same leaf set as the pool-blocking lint above — the registry’s block rows minus std::time::sleep, which it counts separately as a throttle along with std::time::tick. So a while true { let line = std::io::stdin::read_line(); … <- … } loop is paced by its input and is not flagged, on the same footing as a loop driven by a blocking recv.

Claims — domain requirements as checked sentences (GH #382, phase 1)

Section titled “Claims — domain requirements as checked sentences (GH #382, phase 1)”

Bundle-level, named sentences over the program graph, declared in the main locus and evaluated in hale check as errors — never advisories (an advisory claim reads as law and doesn’t bind, the #354 fail-open shape). The motivating property is multi-tenant isolation: “no path from domain A to domain B” as one declaration with a name a contract can cite, instead of per-fn @effects contracts scattered across every position with completeness by hope.

group delta_wing = { delta::*, DeltaStore };
group gamma_wing = { gamma::Research };
main locus Org {
params { ... }
claims {
iso_dg: forbid reaches(delta_wing, gamma_wing);
iso_calls: forbid reaches(delta_wing, gamma_wing) via { calls };
no_spend: forbid reaches(gamma_wing, effects(money));
}
}

The remaining verbs (#382 phases 2–5):

Indexed effect families (#382 phase 3): domain wing = { delta, gamma }; declares a closed index domain; effect knowledge(wing); declares a family. Every instantiation knowledge(delta) interns as an ordinary declared class and knowledge(*) as an auto-populated composed class over all of them — the whole feature is a reduction onto shipped machinery (masks, only: complements, cross-seed remap, did-you-mean), so a misspelt index is an undeclared-class error and a domain member added later lands OUTSIDE every existing only: contract (#354’s fail-closed, inherited rather than re-derived). The domain must be declared earlier in the same file as the family. Companion: @budget(<user class> = N) bounds calls to declared carriers of a class along any path, with the same loop/indirect unboundedness rules as every per-call dimension.

The value all of this is derived FROM — the typed, provenance-bearing ApplicationModel, its holes and capabilities, the laws it must satisfy, and the discipline a new judgment family follows — is specified in spec/model.md. This document specifies what is CHECKED and what is SERIALISED; that one specifies the IR sitting between them.

The topology artifact (#382 phase 2; schema 1.17): hale check <t> --dump-topology emits a PROJECTION of the model (lossy by construction; see spec/model.md) — sorts (loci, fns, topics), relations (calls with weights: loop nesting, unbounded-loop membership, interface-dispatch tags; publishes; subscribes), the through-stdlib contracted edges (calls_via_stdlib: user→user paths whose interior is stdlib bodies, collapsed to their endpoints with a conservative loop flag, so reachability over the artifact matches reachability as evaluated — the flag is the MODEL’s, true whenever any contracted path is loop-nested; schema 1.13 adopted that reading over the pre-model walk’s, which never revisited a stdlib body a looped path reached second, and a program where the two differ takes a new shape_hash and one re-record), the declared groups, the effect labels (declared carriers), the phase relation, the seed sort, the compiler-derived per-fn effect sets, the supervision relation (schema 1.10, downstream handoff — one row per on_failure handler: supervising locus, supervised child + error types, the recovery ops the body invokes, and a literal retry bound when one is written; a policy change moves shape_hash, and provenance.supervision carries the spans, so the observer’s live RESTART/SUPERV_TRANS stream finally has declared policy to anchor to), and the unknowns (fns with indirect calls, untyped-receiver calls, dead uninhabited-interface dispatch, or computed publish subjects), all in author spelling — plus every named claim’s normalized form and result, under a schema version and a shape_hash (FNV-1a/64 over the canonical model half; claim RESULTS are excluded, so one topology under different law keeps one shape). Schema 1.11+ (GH #476 Change 6) adds three unhashed, digest-covered typed sections: law — every lowered ClaimIr row with its ordinal, origin (main / constitution:<name> / library / annotation), judgment family (reachability / boundary / endpoint / bound / causes / depends / budget / certificate / unmigrated / fleet; unmigrated is now unreachable and kept only so an older artifact decodes), machine verdict, a TYPED law payload (one tagged object per ClaimIr variant carrying the law’s operands — each reference as {"name": <raw canonical identity>, "display": <author spelling>, "resolved": bool}; the raw half is the machine join key, the same deliberate raw/display duality the topics section carries), per-certificate certs evidence keyed by certificate ordinal for the certificate family, and source provenance, plus law_digest (the law table’s semantic digest) and inputs_digest (the analysis-inputs digest) — the ties a consumer checks before trusting external evidence against this artifact; capabilities — the model’s positive completeness account, typed (exact_cardinality is derived: endpoint counts come from a closed-world enumeration and are exact unless a hole says otherwise; exact_costs, added by Change 5h, vouches the per-call COST account — allocation sites, frame estimates and blocking points, carried site-grained in relations.costs because a per-call budget is a statement about one invocation and the loop flag is what turns a finite count into an unbounded one. The three call-hole kinds REQUIRE the COSTS bit: a call whose target the caller chooses is exactly where a quantitative law must not certify through); and adequacy — per migrated judgment family, exact when capabilities vouch every relation family that judgment consumes, else degraded (the certificate family consumes PUBLISHES — a publish-set contract cannot prove a computed subject in-set). As of schema 1.17 every family is judged over the model: causes: (Change 5f), depends: (5g) and @budget (5h, through the evidence sidecar) import no outside verdict. No non-passing law row can coexist with a clean document verdict. The emitted model half — and shape_hash — come from the ApplicationModel projection (project_model_half), and so do the unhashed sources, provenance, and topics sections (project_unhashed_tail); the legacy gathering is gone: Change 10 deleted the evaluator, and the corpus differentials it was the comparison arm of are now committed snapshots of its final answers. Bus selectors in the law payload serialize their resolved CANDIDATE sets (the topic identities and wire patterns the selector matched) plus the selector’s own source location; capabilities carries exact_publishes and exact_subscribes as INDEPENDENT flags, and per-family adequacy reads the positive account — a family is exact only when capabilities vouch every relation family its projection consumes (holes are the validation cross-check, never a source of positive knowledge). A payload contract carries a structural opaque discriminant — opaque is not a reserved word, so a struct field literally named opaque keeps its structural shape. The law section also carries the CANONICAL catalogs: fn_universe, loci, groups, and topics as {name: <raw canonical identity>, display} pairs (topics add their wire subject), subjects (the wire-subject pattern universe), and effect_classes (each class with its declared / cyclic status). fn_universe covers every function the model knows — wider than the legacy sorts.fns, which holds the summarized ones (a failure handler is not). A resolved reference must match one exact (name, display) pair — the raw half is the machine join key and is anchored, not merely cross-row-consistent — and the catalogs are cross-tied to the topics/groups/sorts sections other consumers join on. Bus selectors keep their candidate sets, and admission RECOMPUTES each set from the catalogs with the compiler’s own matching rule (bus_ref_matches) — a candidate swap under an unchanged selector name is refused. Legacy claims rows carry the law ordinal they project from. One shared admission (validate_law_account) runs for Track A rendering AND fleet composition: it decodes every payload against the closed per-kind shape (unknown fields refused), resolves every resolved: true reference against the artifact’s own catalogs (loci, groups, topics, phases, seeds, effect classes, fn_universe), refuses a holds verdict over any unresolved operand, re-renders each certificate’s expected form from the typed operands and requires the row’s certs to match it (an operand swap cannot keep the old certificates), requires the lowered section to project ONE-TO-ONE from the typed account — every lowered row carries its law ordinal (and certificate cert ordinal where applicable) and must match the form, result, and subject re-rendered from the typed operands, and every generated expectation must be met, so deleting law rows orphans their evidence even in an annotation-only artifact — recomputes per-row and document verdicts from the evidence, checks the one-to-one claims-to-law projection in both directions, requires the law.legacy report — the old engines’ verdicts for rows that are still unmigrated, keyed by ordinal and by a form FINGERPRINT re-rendered from the typed operands, so an operand mutation orphans the entry. As of schema 1.17 NO family is unmigrated, so that report is empty for every program; the section survives only so an artifact written by an older toolchain still decodes. Every row that renders a compatibility form must STATE it, and admission re-renders it from the typed payload — that is the operand binding for a judged row, which imports no outside verdict at all. A causes: row naming an undeclared or cyclic class still cannot hold — refuses fleet-family rows outright (an application artifact does not own a fleet account — that is Change 7’s), and recomputes adequacy from capabilities. Static invalidity DOMINATES (round 7): an unresolved operand or an undeclared/cyclic effect class makes invalid the ONLY admissible verdict — a replayed engine result is never an alternative — and otherwise a certificate row’s verdict is EXACTLY its recomputed evidence severity. A subject outside the legacy analyzable universe carries no certificates and judges uncertified with its residue on the row (round 8): sorts.fns for fn subjects, the analyzable flag on law.loci rows for phase contracts — an unanalyzed body is residue, not invalidity. An implicit lifecycle phase with no hook body gets a SYNTHETIC Holds certificate from the evidence layer (no hook performs no effects), so @phase_effects(birth: {}) on a hook-less locus is holds, not a missing report. Beyond the statically decodable invalidities, the claims evaluator’s other legitimate Invalid outcomes (an operand outside a verb’s domain, projection vacuity, an empty during slice, avoiding overlap, …) admit by RETAINING the judgment’s explanation — an invalid row must carry either a decodable invalidity or its judgment’s evidence, and an unanalyzed uncertified its residue. Machine-invalid rows may still PRESERVE an old engine’s report (law.legacy, keyed budget lowered rows) — bound by fingerprint as optional evidence, never demanded and never overriding the machine verdict, so an older artifact’s cyclic-class rows still admit. Nothing the current compiler emits populates law.legacy any more. The catalogs are CLOSED: unique in both halves and in exact bijection with the topics / groups / sorts.loci sections (fn_universe covers sorts.fns), so selector recomputation cannot be widened underneath a certificate. The artifact carries a typed endpoints section (round 8) — every bus endpoint at wire-subject grain with its verb and via: site | declaration, including a DECLARED publisher with no send site, which the V1 site-grained relations never show — and law.subjects must equal exactly the subjects the endpoint and topic sections carry, in both directions. The endpoint section is NOT a second authority (round 9), and endpoint identity is TYPED (round 10): every row carries its wire subject and, when a declared topic covers the end, the topic identity (declared_topic is the model’s syntactic fact — a literal address whose text collides with a topic display stays a literal, never inferred from strings; a topic-typed row’s subject must equal the topic’s wire subject). Site rows are LOSSLESS (rounds 11–12): each carries its owning fn/handler, authored site ordinal, and AUTHORED SPAN, with exactly one occupant per (verb, owner, site). They project onto the V1 relations at (owner, name) grain under their OWN declared identity — a topic-covered end by the topic display, a literal end by its text — and their per-owner SPAN MULTISETS must equal the span-grained provenance section one-to-one (the V1 rows dedup; the provenance spans do not, so a typed site row cannot disappear behind a collision). Declaration rows come from the typed declares_publish relation section and keep the OWNING LOCUS in the compared identity — a declaration cannot move between loci while a require publishes verdict rides on the original owner. The relation’s canonical identity is (locus, subject, declared_topic) (round 13): a literal declaration whose text equals a topic’s wire subject and the typed topic declaration are distinct endpoint facts, and BOTH survive regardless of declaration order — the model schema represents them separately, exactly as BusSubject::canonical and the endpoint judgment distinguish them. Closures never participate in locus analyzability: they are invisible to the certificate machinery at every scope (a top-level closure-only locus already certifies synthetically), so the builder and admission share one membership rule — only members that produce function entities the engines could walk count. The residual is CLOSED as of schema 1.12 (Change 7’s versioned shape transition): the canonical endpoint identity — verb, owner, source-order site ordinal, byte-exact wire subject, and declared topic — is part of the HASHED model half (endpoint_identity), and the unhashed endpoint sections must agree with it exactly. Substituting a colliding literal’s content now contradicts the hashed half; rewriting the hashed half changes the program’s shape identity, which is what replay admission and recorded baselines compare. Shape hashes changed for every bus-carrying program at this transition — re-record baselines and .halerec recordings once. Analysis coverage is FUNCTION-grained with THREE typed states (rounds 10–11): analyzed (the body was walked), summarized (a behavior-summary row exists — this set IS the legacy sorts.fns, the hashed anchor), and whether a certificate engine emitted a report (the evidence account). Failure handlers carry the typed FunctionKind::FailureHandler — no consumer infers handler-ness from a display prefix. The coverage laws — analyzed ⇔ summarized (both directions: a summarized body was walked and a walked body is summarized, anchoring analyzed to the hashed sorts.fns); failure handlers never analyzed; the legacy fn sort equals the summarized set; an unanalyzed body RETAINS its UnanalyzedBody residue and an analyzed body carries none; and (rounds 14–15) LocusDecl::analyzable DERIVES from the CLOSED ownership account: every function carries its canonical owner (Some for methods, hooks, modes, and failure handlers; None for free functions), member_of must be a total, exclusive partition agreeing with it exactly (a free fn appears in no row; every other kind in exactly one, at its owner), and a locus is analyzable iff every relevant owned member (all kinds except failure handlers) is analyzed, an empty set vacuously so — so a membership row can be neither deleted nor moved to launder coverage or group projection — and ownership is anchored to the ENTITY IDENTITY itself (round 16): owner = Some(l) requires the function’s raw name and display to encode l as their prefix (Hidden::poke cannot canonically be owned by App), with the owner id range-checked directly, so even a fully coordinated repoint (owner + row + both analyzability flags) is refused. Ownership is coverage-bearing and folded into the evidence coverage digest. The artifact’s law.fn_universe rows carry the typed owner (present iff the kind is non-free, display-anchored, resolving to a cataloged locus), and admission’s locus coverage recomputes from IT — never from display-prefix recovery. All of these laws live in ONE shared validator (ApplicationModel::validate_coverage), called by ApplicationModel::validate and by EvidenceTable::validate alike: the same coverage state is lawful (or refused) at the model, the sidecar, and the artifact. Coverage is BINDING in the sidecar API too: EvidenceTable::validate categorically refuses a certificate payload for a subject or phase whose typed coverage says no report exists — fn eligibility requires BOTH analyzed and the hashed summarized anchor, and locus eligibility recomputes the member coverage from the typed member_of relation rather than trusting the flag (round 14) — a matching digest proves the sidecar repeated the model’s bits; this proves its evidence obeys them. law.fn_universe rows carry all three facts, and the SUMMARIZED subset must equal sorts.fns exactly. law.loci’s analyzable flag recomputes from the member coverage: a locus with executable members is analyzable iff all its non-on_failure members are analyzed, and a MEMBERLESS locus is vacuously analyzable (no body to walk — every phase contract holds by absence), so the flag is recomputable in both directions. An implicit lifecycle phase’s certificate must be exactly the synthetic holds with no diagnostics. Coverage also participates in EVIDENCE IDENTITY: the sidecar carries a coverage digest (TopologyShapeV1 deliberately excludes coverage for recording compatibility), and a judgment refuses a sidecar derived beside different coverage as stale. The law.issues account (round 9) serializes every table-level law-selection failure — lowering issues (unknown or cyclic constitutions, illegal adoption, collisions) and the judgment pre-pass (duplicate claim names) — with source locations; it participates in law_digest (the canonical fingerprint covers {issues, rows}) and in the document verdict (a non-empty account is law_failed), admission validates each entry like any diagnostic, and the duplicate-name case is recomputed from the rows themselves — no claim error disappears between checking and artifact projection. The evidence engine’s ANALYSIS_SEMANTICS_VERSION is 8. It moves whenever the producer’s RESULTS move, because validate compares digests and never the implementation: v3 was round 8’s synthetic implicit-phase certificates and report-less subjects judging uncertified; v4–v6 are GH #476 Change 5h, where @budget joined the evidence pipeline and its quantitative results changed (fan-out became a per-scenario execution count, cross-seed calls resolve, an unknown subscriber population stopped reading as an absent one); v7 is GH #533, where a call through an interface-typed field stopped resolving against the field’s default literal and fans to every conformer, so a constructor override can no longer hide a carrier; v8 is GH #540, where that fan-out narrows to the impls the program stores into the slot (see Interface dispatch fans out in the claims chapter) and stays at every conformer when a write is one the pre-pass cannot name. Evidence produced under an earlier version cannot share an inputs_digest with this one. Both digests are RECOMPUTED at admission: law_digest is the canonical-JSON fingerprint over the law rows (serde-canonical rendering, fnv1a64 — a row edit under a stale digest refuses), and inputs_digest must equal the consuming binary’s analysis-inputs digest (evidence produced under a different stdlib/analysis snapshot is refused — re-dump). Evidence is VALIDATED, not presence-checked: every diagnostic carries a non-empty message and paired provenance resolving to a known source, a migrated violated / uncertified row must retain its judgment’s evidence (the countermodel / the residue), and a violated certificate must retain its diagnostics. Evidence locations are source-space-honest: a diagnostic whose span lives in a FOREIGN offset space (stdlib parse space, another seed) is never re-resolved against bundle sources — the projection carries a per-diagnostic discriminator, so numeric overlap with a bundle file cannot misfile stdlib evidence as application code. No row bridges to an old engine: every family is judged over the model, and a subject the judgment cannot start from — a body outside the analyzable universe — is uncertified WITH its reason rather than silently absent. The legacy claims / lowered string rows remain, now PROJECTED from the canonical model path; semantics bumps to 2 because the machine verdicts are stricter in two documented places (a certificate naming a cyclically-defined or undeclared effect class is invalid, never a vacuous holds; require attributed over an unanalyzable body is uncertified, never a fail-open holds), and the document verdict follows the machine.

One authority (GH #476 Change 9). Those verdicts are also what hale check reports. Until Change 9 the checker ran a second evaluator that re-derived reachability, boundary, endpoint and bound from source while the artifact read the judgment engines over the canonical model; a corpus differential could hold the two equal, but only one of them could be right about a program the differential did not contain. The checker now judges over the model, so a document and the compiler that produced it cannot disagree about a law. Two consequences are user-visible:

Changes 5f–5h then migrated the last three families — causes:, depends: and @budget — and Change 10 DELETED the second evaluator (about 1900 lines). Its corpus differentials, which could only ever hold two implementations equal, are now committed snapshots of its final answers: a diff in claim_diags_snapshot.txt or law_rows_snapshot.txt is a user-visible change to what hale check says or what the artifact records. The lowered-certificate comparison stays a live differential, because the certificate ENGINES were never what was being migrated.

A law that cannot be certified is now LOUD in every family. The checker appends diagnostics, not verdicts, so an uncertified row with no diagnostic used to compile clean while the artifact marked the document law_failed — the same disagreement this change exists to remove — and left the row with no evidence for admission to find. Every non-holds row states its reason.

What the claim surface still owns is law SELECTION — which laws exist at all: clause enumeration, constitution adoption and identity, group resolution, the library/world tier rule. Selection is not judgment, and it has exactly one implementation, consumed by BOTH the checker and the artifact lowering: a selection refusal appears in the document’s law-issue account, so the two cannot report opposite answers about one program. It does not consult the bus graph: deciding which laws exist is a question about clause text, adoption and membership.

Selection’s verdict on each group declaration is CARRIED with the lowered laws, in four states — resolved; intentionally empty (may_be_empty on a group whose every selector resolved); selector failed; declaration refused (declared twice, or empty without saying so). Every GROUP OPERAND is a domain — the source and destination sets, only edges’ two ends, bound‘s from, an endpoint family’s group, and forbid reaches’ avoiding gate, whose members become the mask that removes paths from the walk. A law quantifying over a group in either invalid state, in ANY of those positions, is invalid, never a vacuous holds: a verdict over a domain the compiler rejected has no witness and describes no program. Only the intentionally-empty state holds vacuously — that is what declaring may_be_empty is for, and it authorizes an empty group, not a misspelled member.

The status is carried rather than re-derived because the two are not the same question. An unresolved selector leaves nothing behind in the model, so a member-count test reads { Missing } may_be_empty as intentional, { Worker, Missing } as whole, and a name declared twice as fine — while the model keeps the LAST declaration and selection keeps the first. Each judges a law against a domain selection refused.

Judgment runs only over a program that denotes a valid model. The model is a description of a checked program, and some parser-valid, checker-invalid programs derive deliberately unlawful models (a key filter on an unkeyed topic mirrors the checker’s own refusal). The checker therefore judges claims only once the resolver and the type checker agree; claim errors themselves do not gate it, since a program whose only failures are broken laws still has a valid model, and hiding the rest of its laws behind one failure would be its own fail-open.

The artifact’s own identity is pinned by a committed baseline of origin -> shape_hash over the corpus rather than by a rival serializer: a model change that would move an artifact hash — and with it the replay admission of every existing recording of that program — fails the baseline gate with a regenerate hint, so the move is a decision in a diff. A provenance section carries per-edge and per-decl source spans as bundle-global byte offsets; it is excluded from the hash on purpose — moving code must not change the shape identity. A topics section (unhashed, #399) carries the per-topic OBSERVATION identity: the wire subject, the canonical payload shape, and payload_hash = FNV-1a/64 over wire_subject ++ ':' ++ shape — byte-identical to the value the native emitter registers in the runtime observation manifest (lotus_obs.c; the shared implementation is hale_types::topic_identity, which codegen also calls, so binary and artifact cannot drift). This is the reconciliation ruling for the compiler-side vs runtime-side identities: they stay separate (payload field shape does not affect claim evaluation, so it is not part of the model shape_hash), and the artifact is the JOIN document — a recording/WAL segment carrying (name, shape_hash) matches a row here and thereby names the exact checked topology it ran under. The subject field stays raw (it is the join key); a subject-less topic registers under its declared — possibly mangled — local name, so shared topics should declare subject: to fuse across binaries. The exact definition and test vectors live in iris PROTOCOL.md §4. --check-topology-shape <path> gates the model identity alone — shape_hash — so claim renames and source motion pass while a changed graph fails. --check-topology <path> diffs against a committed baseline and fails with a regenerate hint — the .hale.effects precedent: an unreviewed topology or law change fails CI the way an API break does. Both gates compare the artifact of the model the check judged the laws over; a program that does not typecheck has no model, so they refuse it as --dump-topology does, naming the error kind, and exit 1. v2 scope: every claim verb replays independently over the exported relations — forbid/only edges including through-stdlib reachability, require/count cardinality, cover via the seed sort, during via the phase relation, bound over user classes via labels + weights (dispatch alternatives group by (from, interface, method) and fold with max). Remaining compiler-certified: bound over built-in classes (site counting through the stdlib interior, deliberately not serialized) and any walk past the step ceiling. The derivation (source → model) remains the trust root.

§8 — one schema of record (#392): every fn-grained certificate — each @effects assert, each @phase_effects phase contract, each @budget in both families — is REPORTED as the claim form it is pointwise sugar for, with its verdict, in the artifact’s lowered array (forbid reaches({F}, effects(money)), bound alloc <= N on paths from {F}, only effects {…} on {L} during birth, …). Rows come from the same evaluations that gate the build, so the report and the build cannot disagree. The traversal substrate is already one engine (the shared call-graph walker plus the shared summary and the shared fail-closed predicate); the annotation voices keep their diagnostics.

Library-tier claims (#392 thread 2): a TOP-LEVEL claims { } block — legal outside any locus — is the LIBRARY tier: a seed swears about itself and its own boundary, the block travels with the import, and it re-evaluates in every closing build over the merged world. Checked standalone, a library’s claims evaluate over its own world; at close, the same sentences quantify over everything the merge added — a second subscriber the app wires onto a library topic violates the library’s own count subscribers(topic T) <= 1, reported with seed attribution (pay::single_settle, never a mangled symbol) and pointing at the library’s own claim line. World-quantification stays main’s: a seed that declares main locus states its law inside that locus, and its writing the top-level form is a check error. That tier split is the enforcement surface for “a dependency may not brick downstream builds with world-claims” — a library can only NAME what it can see (its own decls and its own imports), and its traveling block is marked at the mangle stage, which only ever touches imported seeds. Group and topic references inside a traveling block canonicalize to mangled decls exactly as group decls do (#334); claim names are never mangled.

The world is every main locus the bundle declares. World law is read from a column of the entry row (EntryRow::world): the entry’s claims { } block, a module-nested main’s, and an imported application’s, each in declaration order. The column is wider than the entry on purpose. Which declaration a seed runs is the entry’s question (spec/semantics.md § Bundle-wide rules: an imported main locus is never the entry); whose law binds the world it closes is this one, and an application’s inline law keeps binding when another seed imports it, whether or not the importer has a main locus of its own. A bundle with any main locus closes a world, so a top-level claims { } block of the closing seed’s own is refused there.

An imported main’s inline claims keep their own seed’s groups (GH #733). A main locus’s claims { } block is world law, and an application that another seed imports still carries that block through the merge: an importing test seed, or a fleet composing the application, closes a world in which the imported main is the only main, so its clauses are the law evaluated there. The group names in those clauses are resolved in the seed that WROTE them — the same import-rename table a traveling library block and a constitution resolve through — so a claim means one thing whether its seed is checked directly or through an import, and an importer’s group of the same name is never substituted for the declaration the claim was written against. Resolution never widens: a name no declaration answers is still an unknown-group error, never an empty set — and (GH #774) an undeclared group is an error in the seed that WROTE the claim regardless of what an importer declares. A group reference nothing in its own seed answers is bound to that seed at the merge, so an importer’s same-named group cannot answer it and the error cannot disappear downstream; the diagnostic names the group the author wrote. This holds for an inline main-locus claim, for a library-tier block (both of which swear about their own seed’s boundary), and for a constitution declared by a seed that itself declares main locus — such a constitution can be adopted by nothing but that main. A POLICY seed’s constitution is the one exception, and by design: it closes no world, so its group vocabulary is the adopting entrypoint’s to declare (below, “Groups are not implied”).

Constitutions — one authored claimset, many closed worlds (GH #409)

Section titled “Constitutions — one authored claimset, many closed worlds (GH #409)”

A constitution is a named claimset declared outside any main and adopted by entrypoints with adopt NAME; inside a main-locus claims { } block. It is not a new quantification horizon: every clause is evaluated in the adopting main’s own closed world, exactly as if written there. Authoring is shared, evaluation is not.

Identity

Section titled “Identity”

A constitution’s display name is flat and unmangled so diagnostics cite it as written. Its identity is the digest of its normalized closure — its own (name, rendered form) pairs, sorted, plus its deduplicated bases’ digests, recursively. extends A, A and extends A evaluate identically, so they must digest identically. Two declarations are the same constitution iff their closures agree.

Identities come from the adoption traversal, not from inspecting which claims were emitted: a constitution that contributes no clause of its own (constitution Dev extends Base { }) has a meaningful closure and must still be compared. An evaluation reports both its roots — the constitutions named directly, by source adopt or by environment binding — and the closure those roots reach.

The distinction is load-bearing across entrypoints: two seeds may each declare Core with different clauses, so a binding by bare name proves only that each entrypoint had some constitution called Core, not that all were evaluated against one claimset.

Deployment environments

Section titled “Deployment environments”

hale.toml binds constitutions to deployment targets:

[claims]
base = "Core" # every environment carries this
[environments.prod]
constitution = "Prod" # …and prod adds this
entrypoints = ["apps/riskgw"]
[environments.dev]
source_only = true # explicit: this environment adds none
entrypoints = ["apps/riskgw"]

--env <name> injects the base and the environment’s constitution as if the source had written adopt. Binding here rather than in source is what lets one entrypoint satisfy different claimsets in different environments: it cannot write two conflicting adopt lines, but it can be checked twice.

[claims] base is what makes “an environment may add law, never drop it” true of the mechanism rather than only of extends — every evaluation carries the base by construction. A workspace declaring environments must decide about a base explicitly: either base = "…" or no_base = true. An absent [claims] section is indistinguishable from a misspelled one, and the intended baseline would vanish while every environment still looked valid.

An environment naming no constitution must say source_only = true for the same reason, and unknown keys in an environment section are rejected.

The base is compared workspace-wide (base::<name>); an environment’s own addition is compared within that environment (env::<env>::<name>). Keying the base per-environment meant two environments with disjoint entrypoint sets never shared a comparison key, so a base resolving to different closures in dev and prod went undetected — the mechanism proved consistency within each environment and nothing about the base being shared.

--env requires its target to be an entrypoint, whether or not the environment contributes any constitution: an environment binds law to a deployment target, and a source_only environment with no base would otherwise check a library and report success. An entrypoint is a seed with an entry, its own top-level main locus (spec/semantics.md, “The entry locus”): a seed whose only main locus is an imported library’s, or sits inside a module { }, is refused as a library is, and --matrix does not count it among the entrypoints it requires an environment for.

Roles (GH #1109). [environments.<name>.roles] is the deployment’s record of who holds which role: role = ["uid:1000", "gid:20", "user:riley", "group:ops", "*"], one key per role the entrypoint declares (spec/types.md § “Roles”). A key that is not an identifier, a member spelling outside those five, or an account name outside letters, digits, ., _, - and @ (the table travels as one line, so a name may not carry a separator) is a manifest error. --matrix proves, per (entrypoint, environment) pair, that every role the entrypoint declares is mapped there ([] says explicitly that nobody holds it), and that nothing is mapped that the entrypoint does not declare: an omission is indistinguishable from a mistake, and a misspelt key would otherwise map nobody quietly. Since R4 (GH #1417) the compiler does not bake the section into the program: a served surface’s role source is the one its serve site names (spec/api.md § Identity sources), and the stdlib’s std::api::StaticRoles takes its table as a param or, at run time, from LOTUS_API_ROLES. hale check --env <name>, hale build --env <name> and hale run --env <name> adopt the environment’s constitution.

Combinations that cannot be honoured are rejected rather than ignored. --matrix runs many evaluations, so a per-evaluation artifact flag (--dump-topology, the --check-* baselines) has no single artifact to emit or gate against; and --env / --workspace alongside it would select nothing the matrix does not already enumerate. --env with --workspace is likewise rejected: a workspace sweep includes libraries, and an environment binds law to an entrypoint.

A constitution required by the manifest has no source adopt line, so a diagnostic about it names the environment and the manifest rather than pointing at a main locus that mentions it nowhere.

A constitution’s identity does not depend on the import alias through which its seed was reached: the same policy seed imported as pol in one entrypoint and plc in another is one identity.

--matrix checks every declared (entrypoint, environment) pair. It does not short-circuit; the exit status reflects every pair. An entrypoint in no environment is an error, and a seed that fails to PARSE is an unknown entrypoint rather than a non-entrypoint — otherwise a syntax error would erase a seed from coverage. Within one environment, all entrypoints must resolve each constitution to the same closure digest.

A listed seed the check refuses (one with no entry: a library, or a seed whose only main locus is imported or inside a module { }) is reported by that refusal alone, and its pair fails. It has no entrypoint for the environment’s roles to be mapped to, so no role coverage is reported for it, and a constitution it can never deploy under takes no part in the closure-digest comparison. A pair whose seed loads but fails to typecheck keeps both: its role coverage and its constitutions’ identities are read from what it declares, whether or not it typechecks.

The artifact records three things: per-claim source (where this clause came from), and an evaluation section carrying the environment label plus roots and closure with their digests (which deployment this run certified, and under what law). All are inside the integrity-covered body — an evaluation context editable after the fact would certify nothing.

Source maps and model semantics (GH #408 Phase 0)

Section titled “Source maps and model semantics (GH #408 Phase 0)”

Spans in the artifact are file-local, resolved through a sources table:

"sources": [{"id": 0, "path": "apps/api/main.hl", "digest": "…"}],
"provenance": { "decls": { "App": {"source": 0, "span": [36, 39]} } }

They were bundle-global byte offsets — an artifact of concatenating the seed’s files, meaningful only inside the process that produced them. A consumer composing artifacts from separately compiled applications cannot turn [1204, 1231] into a location, so no cross-artifact witness could say where to look.

Every path is relative to one root, and no path is absolute. The root is the workspace — the nearest ancestor of the target holding a hale.toml — when every source is under it, and otherwise the deepest common ancestor of every source: an application in app/ with its own hale.toml importing ../lib names its files app/main.hl and lib/…. Paths are canonicalized before being made relative, so a target typed relative to the working directory is named as one typed absolute. Both matter: an absolute path makes the artifact machine-specific, and rooting at the target alone leaves an imported seed — which usually lives outside it — absolute anyway. The artifact must be byte-identical for the same sources regardless of the working directory it was produced from or the place the tree is checked out, because comparing two of them is the point.

The source map is the program’s one naming of its files: the execution identity a recording carries frames these paths, in the map’s order (runtime.md § Replay).

Each source carries a content digest, so a consumer can tell whether two artifacts were built from the same text, and can catch a stale artifact paired with edited source, without the source being shipped.

A span the map cannot place reports "source": -1 rather than being attributed to the nearest file: an unplaceable location is more useful than a confidently wrong one.

semantics is a version distinct from schema. Schema says a row has these fields; it cannot say that “an interface dispatch fans out to every conformer” or “unknown implies violation” were the rules in force when the rows were produced. Two compilers agreeing on the schema and disagreeing on the semantics would compose artifacts into a model neither would certify, with nothing in the document revealing it. A consumer that does not recognise the value must refuse rather than assume equivalence.

Fleet composition (GH #408 Phase 1)

Section titled “Fleet composition (GH #408 Phase 1)”

A fleet is a named deployed system of application instances — not “every main in a repository”. hale fleet check|dump <plan.json> composes artifacts, never source.

That distinction is the design. A source-merged “super-main” would be unsound in both directions: an unbound topic is in-process by default, so merging two binaries makes matching publishers and subscribers look locally connected when no deployed route joins them; deploy-time routes that exist only in configuration would not appear at all; and calls, which cannot cross a process boundary, would become ordinary reachability. So matching wire identities establish compatibility; only an explicit route creates a fleet edge.

A plan names exact, finite instances and routes. Autoscaling ranges and wildcard discovery are elaborator inputs, not sealed-plan contents: a bounded range is not one truth value for a cardinality claim.

{
"schema": "1.1", "name": "prod",
"instances": [{"id": "oms-0", "artifact": "artifacts/oms.json", "labels": ["oms"]}],
"routes": [{"id": "request", "transport": "unix",
"publishers": [{"instance": "oms-0", "topic": "t::OrderRequest"}],
"subscribers": [{"instance": "gw-0", "topic": "t::OrderRequest"}]}]
}

Composition:

  1. Validate every component. Integrity before meaning: the whole-body artifact_digest is checked first, because shape_hash covers the model half only and cannot vouch for the topics rows the join reads. Then the semantics version, then the component’s own verdict — local law is a precondition of fleet admission, and an artifact that fails it is not admissible.
  2. Namespace under the instance id. oms-0::Oms::on_intent. An application type is not a deployed instance, and cardinality, witnesses and multiple instances of one artifact all need that.
  3. Retain interiors. Calls stay strictly within one instance; there is no cross-process call to invent.
  4. Join on the wire identity — (subject, payload_hash), never the local topic name, which can mean different shapes in different applications. Endpoints that disagree are a plan that cannot be formed. A topic with no declared subject: is not portable and is rejected on a route.
  5. Insert route edges explicitly, so a cross-process hop keeps its boundary rather than collapsing into a direct call.
  6. Propagate unknowns. Uncertainty in a component stays uncertainty in the fleet: it may add paths, never delete one.

fleet_shape_hash covers the model half — instance identities and cardinalities, routes and their wire identities, component shape hashes, the composed relations. Provenance and unknowns stay outside it, mirroring the application artifact’s split: moving source must not change the identity of a deployment, but a changed transport or a second instance must.

Unknown keys in a plan are rejected, for the reason they are rejected in the environment manifest: a misspelled field and an omitted one look identical to a verifier.

The workspace’s deployments

Section titled “The workspace’s deployments”
[fleets]
production = "ops/fleet/prod.plan.json"
staging = "ops/fleet/staging.plan.json"

hale fleet check with no plan checks every declared deployment. A repository usually has more than one, and checking whichever one somebody remembered to name is the same partial-coverage problem --matrix solves for entrypoints. Every fleet runs even when an earlier one fails, and the exit status is the worst of them. --in <dir> checks the fleets another workspace declares (a candidate’s worktree); --if-declared makes a workspace that declares none a success that says so, for a step that runs on every workspace.

Schema 1.2 (GH #566 F5): seeds and nodes. An instance may name a seed — a directory relative to the plan — instead of an artifact; composition then cuts the artifact from the seed first (hale check <seed> --dump-topology, into .hale/fleet/<plan>/<id>.topology.json beside the plan), so the plan can describe the workspace’s own services and a change is checked as the fleet it would deploy. A seed that does not check is an instance with no admissible artifact; an instance with neither is a plan that cannot be formed. An instance may also name the node that expresses it (hale node <name>, spec/dna.md); composition ignores it. [dna] fleet = "<name>" in hale.toml names which declared fleet the DNA expresses.

[fleets] and [environments] are separate axes and a workspace may declare both. An environment binds law to an ENTRYPOINT at the application tier; a fleet is an ARRANGEMENT of deployed instances. production in one need not mean production in the other, and collapsing them would force every entrypoint’s law to be a function of some deployment it may not even appear in.

There is deliberately no coverage check over plans — unlike entrypoints, which are discoverable seeds, a plan is an arbitrary file path, so “every plan in the repository is declared” is not a question that can be asked without guessing.

Fleet claims (GH #408 Phase 2)

Section titled “Fleet claims (GH #408 Phase 2)”

Claims over the composed model, carried in the plan as normalized rows rather than source grammar — the plan is an IR, so a generator can produce one without Hale syntax committing to a deployment format.

"groups": { "strategies": {"labels": ["strategy"]}, "oms": {"instances": ["oms-0"]} },
"claims": [
{"name": "orders_pass_oms",
"forbid_reaches": {"from": "strategies", "to": "gateways", "avoiding": "oms"}},
{"name": "one_order_authority",
"count_publisher_instances": {"subject": "svc.order.request", "eq": 1}},
{"name": "gw_receives_orders",
"require_subscribes": {"group": "gateways", "subject": "svc.order.request"}}
]

A fleet group quantifies over instances, by id or by label; its vertices are every vertex of those instances — the same projection an application group makes from a locus to its methods, one altitude up. An unknown name or an empty resolution is an error, never an empty set.

A claim names exactly one verb. Several verbs under one name would be judged one at a time and recorded under that name as though the whole sentence held, so the shape is refused: split it, and each half gets its own name and verdict.

Fleet composition decodes each admitted component ONCE into a typed interior (GH #476 Change 7) — the vertex universe, both call relations unioned, the publish/subscribe rows, the topics join surface, the component’s own unknowns, decl provenance, and the hashed endpoint identity — and composes from those typed rows exclusively; no modeled fact is recovered from generic JSON after admission. Route identity is ONE grain (round 2): a plan endpoint names a local topic, so the role check AND the edge builder both read the typed rows carrying that topic identity — a literal end on the same wire is a different endpoint and satisfies neither — while fleet claims that deliberately quantify over wire subjects keep the wire-grain accessor. The component’s POSITIVE completeness account is honored WITH THE POLARITY OF THE LAW (rounds 5–6). forbid_reaches / only_edges certify an absence, so incomplete knowledge in an involved component prevents the proof: holds becomes uncertified, naming the instances and their withdrawn capability flags, scoped by the unreachable-unknown rule. The endpoint and count forms handle completeness INSIDE their evaluators: a known, routed require_* witness is a positive fact no incomplete set can erase, and a route-backed structural failure is definite (the plan’s route table is complete). Counts follow the canonical monotone rule over a [known, known+hidden] interval — known endpoint rows are the lower bound; an uncounted component that withdraws the RELEVANT completeness (publisher counts consult publish + cardinality, subscriber counts subscribe + cardinality) raises only the upper bound; min holds once the lower bound reaches it, max/eq definitely violate once the lower bound exceeds them, and the undecided interval is uncertified — with the eq/min/max conjunction judged over that interval. The claim row is serialized AFTER this final verdict. Track A is capped at the current schema exactly: the canonical layout table is versioned by it. And the declared identity is VERIFIED: both Track A and fleet recompute shape_hash from the raw model half before any decoding (verify_shape_hash) — a consistent hashed+unhashed endpoint edit under a stale shape_hash refuses, so the identity that replay and composition compare is always the identity of the bytes present. The typed decode is STRICT and fallible (round 3): a malformed semantic row — a call endpoint that is not a string, a non-string unknown reason — is REFUSED, never filtered, because a silently dropped edge or hole reason is precisely the omission the shared traversal must never see; only genuinely optional facts (provenance, unplaceable decl sources, a bus-free program’s absent endpoint section) may be absent. The top-level LAYOUT is canonical and closed (round 5): order is not JSON pedantry here — it DEFINES the verified hash ranges (shape_hash covers the bytes between its entry and sources; artifact_digest covers everything before itself), so known keys must appear in the emitter’s canonical sequence, unknown top-level keys are refused, and artifact_digest must be the FINAL entry — a model-half section cannot be moved outside the identity’s coverage, and no tail entry can escape the document digest. And every consumed document must carry ONE unambiguous value per key, judged the way a parser judges it (round 4): duplicate object keys are rejected before parsing on their DECODED names ("shape\u005fhash" and "shape_hash" are one key), and the identity fields are located STRUCTURALLY — one raw walk (scan_top_level) yields the top-level entries, and both verify_shape_hash and verify_artifact_digest read exactly the top-level field the parsed document consumes, so a nested decoy in some other section is data, never the verified value.

Route admission validates roles, not just topic identity. A publisher endpoint must publish the subject and a subscriber endpoint must subscribe it, as its own artifact records — declaring a topic is not using it, and any component importing a topic module declares every topic in it. Without this a plan could name a producer that does not exist and satisfy a law about the consumer side with nothing feeding it. A plan that misdescribes its components is an invalid model, refused before any claim is evaluated rather than reported as a law failure.

Uncertainty propagates. A component’s unknowns are part of the composed model, not a footnote beside it. A prohibition whose source can reach a vertex with an incomplete outgoing edge set answers uncertified — that vertex’s missing edges could lead to the target, so the absence is not proved. uncertified fails like violated; the distinction is recorded because the repair differs (resolve the unknown edge, versus fix the program). Two rules keep it usable: a concrete counterexample wins over a hole, and only a hole the claim’s source can actually reach counts, so an unrelated unknown elsewhere in the deployment does not poison every law. uninhabited_interface_call is not such a hole — in a closed world an interface with no conformers has no values, so the site is dead rather than unknown.

Endpoints resolve through each component’s topics table by wire subject, never by local name.

A violation renders a cross-artifact witness: the instance- qualified vertices, the route carrying each hop, and the source file each vertex lives in — which is what Phase 0’s source maps exist to make renderable.

fleet claim `orders_pass_oms` violated — witness:
prober-0::Probe::submit [rogue/main.hl]
-(route `bypass`)->
gw-0::Gateway::on_order [gw/main.hl]

Signed components & attestation (GH #408 Phase 7)

Section titled “Signed components & attestation (GH #408 Phase 7)”

A composition proves a world against artifacts it can read; a signature proves those artifacts are the ones a key-holder meant. It certifies provenance and integrity, never behavior — the artifact never claims a message will arrive, and a signature never claims the code is good. Out of scope, deliberately: a compromised builder, a malicious compiler, runtime memory tampering.

The scheme is ES256 (ECDSA P-256 over SHA-256), because the system already speaks it: it is std::crypto’s signature suite, OpenSSL-backed in the runtime, PEM keys, raw r‖s 64-byte signatures. One algorithm end to end means a Hale program — a supervisor, a deploy gate — verifies the same sidecar with the language’s own stdlib.

Signatures cover the artifact’s exact bytes. That is sound because artifacts are byte-reproducible (schema 1.8), and it is necessary because the in-band artifact_digest is FNV-1a — an integrity tripwire, not a trust anchor. Nothing signs a digest. The sidecar is <artifact>.sig, one line, es256:<128 hex>; an unknown prefix is a refusal, not a skip.

Terminal window
hale fleet keygen ops # ops.pem (0600) + ops.pub.pem
hale fleet sign app.topo.json --key ops.pem
hale fleet check prod.plan.json --trust ops.pub.pem

Trust is strict when declared. Passing --trust (repeatable), or declaring keys in the manifest, makes an unsigned or unverifiable component a composition error:

[fleet_trust]
keys = ["keys/ops.pub.pem"] # SPKI PEM, manifest-relative

There is no require = true knob, for the reason no_base exists in [claims]: a trust set that quietly admits unsigned artifacts is law that looks bound and binds nothing. An absent section means signatures are not checked — the pre-Phase-7 meaning of a composition, unchanged. Signature verification runs before the integrity digest: provenance before integrity before meaning, each check covering the bytes the next one reads. The all-fleets form (hale fleet check with no plan) takes trust from the manifest only; --trust there is an error, so one flag cannot quietly rebind every declared deployment.

The fleet artifact records what admitted each component — unhashed provenance, like the rest of the components section:

{"id": "app-0", "artifact": "artifacts/app.json",
"sha256": "59ad65d4…", "signed_by": "a88ca61a96ffa055"}

sha256 is the admitted bytes; signed_by is the key’s identity (first 8 bytes of SHA-256 over the SPKI DER) or null when trust was not declared — a fact, not an omission, so a reader can tell “unsigned admission” from “verified under this key”.

Attestation answers the remaining question: are the executables this plan deploys the ones the operator hashed? Plan schema 1.1 adds two optional rows per instance, and hale fleet attest is all-or-nothing over them:

{"id": "app-0", "artifact": "artifacts/app.json",
"binary": "bin/app", "binary_sha256": "de55ee70…"}

A missing row is a refusal, not a skip — a partial attestation would report coverage it does not have. binary_sha256 is cryptographic where artifact_digest deliberately is not: this hash is the thing an operator asserts across a trust boundary. Attestation checks bytes at rest at deploy time; whether a running process is still that binary is runtime territory (sent/delivered observation, 7b), and the artifact never claims otherwise.

Secrets — confine, classify, claim (GH #436)

Section titled “Secrets — confine, classify, claim (GH #436)”

Hale’s answer to secrets is not an analysis. It is the ownership primitive doing its job, plus one classified operation, plus law.

1. @sealed locus L — confinement. A sealed locus’s params are readable only from inside its own methods. Others may still CALL it; they may not read its state:

@sealed locus Signer {
params { key: Bytes; }
@effects(is: { secret_use })
fn sign(m: Bytes) -> Signature { … }
}

This exists because loci are otherwise not field-encapsulated — self.child.key typechecks from anywhere holding the locus, so without sealing “the key never leaves the locus that owns it” is a property you check rather than one that is true. The annotation is opt-in and breaks no existing program. There is currently no claim form for requiring an annotation, so a constitution cannot yet demand @sealed across a group — a require sealed(all G) form is the obvious follow-up, and the same gap applies to @supervised.

Only params are confined. Capacity slots and methods are untouched — sealing confines state, it does not make a locus uncallable, which is the entire point.

@sealed and contract { expose … } cannot be combined. They are contradictory claims about the same boundary and sealing wins, so an expose on a sealed locus reads as a permission it cannot grant — the contract consistency check passes, a matching consume binds, and every use of the field is then rejected. The pair is a check error.

expose cannot serve as the sealed allowlist without redefining it: it is the coordinator/coordinatee surface, so honouring it would grant reads to an accepting parent while still denying them to a parent holding the same child as a param — one field, public to one kind of holder and not the other.

Param initialization is deliberately not restricted. A parent writing Signer { key: … } already holds the value it passes, so sealing the initializer would cost ordinary configuration and buy nothing. Real secret material should be loaded inside birth from a vault, environment, or file rather than passed in.

A constitution can then demand confinement rather than trusting that every author remembered:

constitution SecretBaseline {
vault_confined: require sealed(all vaults);
no_plugin_secrets: forbid reaches(plugins, effects(secret_use));
}

2. One classified operation. The privileged method carries a user effect class, so every path that can touch the secret is visible on the call graph — frontier::infer_effects propagates it transitively with no further annotation.

3. Law. The domain states who may reach it and how often, using claim forms that already exist:

secret_use is a compiler built-in — the stdlib owns the mechanism, the compiler owns the class identity, the application states the law. Declaring effect secret_use; is an error: user classes intern per-Program, so a stdlib-declared class had no identity an application’s claims could name, and the law over std::secret was silently unenforceable and order-dependent.

claims {
no_plugin_secrets: forbid reaches(plugins, effects(secret_use));
one_op_per_request: bound secret_use <= 1 on paths from handlers;
}

A violation names the crossing call:

claim `no_plugin_secrets` violated: `plugins` reaches
`effects(secret_use)` — witness: `PluginHost::sneaky` -> `Signer::sign`

std::secret closes the initialization gap. Sealing protects the read side; a parent writing Signer { key: … } still holds what it passes. The stdlib loci therefore take the name of a source, never the bytes:

locus Gateway {
params {
s: std::secret::Signer =
std::secret::Signer { env_var: "SIGNING_KEY" };
}
fn go(m: Bytes) -> Bytes { return self.s.sign(m); }
}

self.s.key from Gateway is a compile error. The key is read during birth, so it exists only inside a sealed locus from the moment it enters the program and there is no construction site at which the caller held it. std::secret::Credential is the same discipline for a token or password, with a fingerprint() that is safe to log.

Sealing keys off the receiver’s resolved type. Since GH #470 every qualified path naming a Hale-source stdlib declaration resolves to the mangled name that source declares — the whole surface, not only sealed loci — so field-existence, method arity, and interface-satisfaction checking apply to stdlib-typed values exactly as to user types (the old Ty::Unknown tolerance was fail-open: a wrong-arity method coerced to a stdlib interface unchecked and corrupted memory at the fat-pointer call). Rust-implemented builtin handles keep the permissive typing; their path-call names are validated by the stdlib surface registry.

The recommended shape, a pattern rather than an enforced contract: an ordinary function prepares a request from public data and returns a closed plan; the sealed locus interprets the plan and performs the one privileged step. The planner never receives the secret, a handle, or any secret capability. Prefer a closed operation enum over a callback — a finite vocabulary is reviewable, a function parameter is a programmable oracle. Worked end to end in crates/hale-codegen/tests/fixtures/examples/secrets-sealed-handler.hl.

In the artifact. sealed is a hashed model row (schema 1.9), so a locus gaining or losing @sealed moves shape_hash and a --check-topology gate sees it. Confinement is a structural property rather than only a claim input: a seal changing with no topology diff would be exactly the invisible security change the artifact exists to surface.

require sealed replays from that row. require attributed does not: it turns on DIRECT effect sites, and the artifact exports inferred per-fn effect sets rather than the direct/transitive distinction, so that form is compiler-certified — the artifact carries its verdict and not the facts to recompute it.

What this guarantees, and what it does not

Section titled “What this guarantees, and what it does not”

The secret lives in a locus that owns it, the domain cannot obtain it, the only operations on it are classified, and the domain’s claims constrain who may reach those operations and how often.

This is not information flow and not noninterference. Two residual assumptions, both deliberate:

  1. The sealed locus’s own body is trusted. Sealing stops others reading the field; it does not stop the owner returning it. Keep that locus small enough to review.
  2. Primitive classifications are compiler-asserted facts. That crypto::hmac behaves as claimed is a declaration in the stdlib frontier, not a proof — the same trust every effect claim rests on.

Deliberately out of scope, each a separate and stronger theorem: derivation tracking (nothing derived from the key influences a public output), control dependence (a constant-time compare still lets the verdict be published), resource uniqueness (nonce counters, DRBG state), and zeroization.

Structural & design rules

Section titled “Structural & design rules”
Check Catches Severity Enforced by
CQRS / no-locus-return a locus fn member whose return type (or fallible(T) payload) names a user-declared locus type — returning a managed entity from a method is a Law-of-Demeter / CQRS / Dependency-Inversion violation that also leaks via payload-arena routing error check_no_locus_return
Stdlib error-type shadow a user-declared type IoError / ParseError / CryptoError / IndexError / KeyError / EmptyError whose shape doesn’t match the stdlib’s, when that error type is reached by a fallible stdlib call: the error a call fails with is its stdlib surface row’s (std::time::parse_iso8601 reaches ParseError, std::compress::gzip IoError), or, for a path that renames to a Hale-source stdlib fn, that fn’s fallible(E) error check_stdlib_error_shadowing (resolve)
Codec purity a bus codec whose encode / decode method isn’t pure (codecs may be dispatched off-thread): it writes a self field, publishes, violates a closure, prints, or calls a stdlib function whose row in the stdlib surface table carries syscall, block or publish, or a fn that does any of these error check_main_and_bindings, reading the purity column of the effect rows, whose stdlib leaves are derived from the surface table
ring_layout contract a foreign-ring layout declaration that’s internally ill-formed — unknown scalar/len_prefix repr, missing framing (or byte_records without a len_prefix / buffer_size, or slots without slot_size / slot_count), no cursor / a cursor without an at, unknown cursor ordering or unit, a missing magic / data_at, or a shm_ring(..., layout: N) whose N doesn’t resolve to a declared ring_layout error check_ring_layout + check_main_and_bindings
ring_layout geometry a cross-field inconsistency that would let a record header land out of bounds or silently corrupt the reader: a header scalar or the cursor overrunning data_at, two fields overlapping, a non-power-of-two align, a pad_sentinel too wide for the len_prefix, a len_prefix width > align, a non-8-aligned atomic_u64 cursor, or (producer side) a buffer_size: that isn’t a multiple of align error check_ring_layout + check_main_and_bindings
Foreign-ring payload shape a layout:-bound topic whose payload is neither flat-shapeable (typed mode — read by direct cast, needs a fixed byte layout) nor BytesView (raw-frame mode — a bounded view per record, for heterogeneous rings); e.g. a struct with String / Bytes / variable-size fields. Enforced regardless of where zero_copy error check_main_and_bindings
A value-returning violator is fallible a value-returning fn or locus method that may violate (a violate statement in its own body, or a call to a value-returning fn that may violate and is not fallible; a call to a method returning nothing does not count) and is not declared fallible(ClosureViolation): “`P.take` returns a value and may violate (`violate exhausted`): declare it `fallible(ClosureViolation)`; its callers then say what happens when the locus fails”, the parenthesis “through `helper`” for a call, the notes at the call and the violate. Lifecycle bodies, bus handlers and fn main are exempt (spec/semantics.md § “A value-returning method that may violate is fallible”) error violate_fallible_laws
One error type for a violator a fn that may violate declared fallible(E) with E other than ClosureViolation: “`P.take` may violate (`violate exhausted`) and is declared `fallible(E)`: a fn that may violate is `fallible(ClosureViolation)`, and a fn has one error type” error violate_fallible_laws
Violation in a method returning nothing a fn returning nothing that may violate and is not fallible, with the same exemptions: “`P.poke` may violate (`violate exhausted`): declare it `fallible(ClosureViolation)`; its callers then say what happens when the locus fails. This becomes a law in a later release” warning violate_fallible_laws
Violation in a perspective-served method a locus method serving a perspective fn that may violate and is not fallible, exempt from the two rules above until a fallible perspective call lowers: “`RouterV1.route` serves `Router.route` and may violate (`violate c`); a perspective call cannot carry the failure yet, so the method keeps today’s behaviour (the caller of a violated perspective call must not read the value)” (spec/semantics.md § “A value-returning method that may violate is fallible”, the stated gap) warning violate_fallible_laws
A perspective fn’s fallibility a method serving a perspective fn with a different fallibility, either way: “`RouterV1.route` is `fallible(ClosureViolation)` but `Router.route` is not: a method serving a perspective matches its fallibility” error check_serves_conformance
Cell slot-of-origin releasing a Cell<T> into a different (locus, slot) than it was acquired from error codegen
Recovery event alphabet a name in a closure’s persists_through(...) or resets_on(...) that is not a recovery event: the alphabet is closed, restart, restart_in_place and quarantine; the message names it at the name, and suggests the event a misspelling one edit away means error outside_the_alphabet (closure events)
Persisting through dissolve dissolve in a closure’s persists_through(...): an accumulator does not outlive its locus’s dissolve, so the clause can mean nothing error persists_through_dissolve (closure events)
Contradicting recovery clauses a recovery event one closure names in both persists_through(...) and resets_on(...): the two contradict each other. Reported at the resets_on name, with the persists_through name as its witness error in_both_clauses (closure events)
Unreached recovery event in a closed world (the program has an entry), a recovery event a closure of the program’s own seed names that no handler and no recovery statement applies to its locus, read from the handler rows and from the recovery statements outside the handlers as the checker types them, each on the locus its receiver’s type names, a generic body’s per specialization (a spent restart(c) for N bound is quarantine). The witness is each handler and statement that names the locus, with the events it applies, or the locus when none does. Not judged for an imported locus, nor for an event some recovery applies to a child no row names (a generic supervisor’s handler’s type parameter, or a receiver of a type parameter in a generic body no specialization binds) warning unreached_events (closure events)
Persistence with no accumulator persists_through(...) on a closure whose assertion has no sum, count or mean: there is nothing to keep. Reported at the clause, with the assertion as its witness warning nothing_to_keep (closure events)
Sealed confinement a read or a write of a @sealed locus’s params field from outside that locus’s own members (self.signer.key in its parent), naming the methods to call instead; see § “Secrets — confine, classify, claim”. Judged over the param-access rows the checker records for every access through a locus-typed receiver, sealed or not, which the --sealable survey reads too. A parameter’s default is judged where it is evaluated, at each call that leaves it, with that caller as the reader, and refused once, at the default. A receiver typed as a generic locus’s specialization (b: Box<Int>) reaches the generic locus’s params, and is judged against its @sealed. A generic body is judged in each of its specializations, so an access through a value of a parameter type (self.inner.key, inner: T) is refused where it is written when a specialization binds that type to a sealed locus, once however many do, through a chain of fields of any depth (self.h.s.key, h: Holder<T>) error outside_access (sealed access)
Module-nested entry a seed whose only main locus sits inside a module { }: it is not the entry (spec/semantics.md § “The entry locus”), and nothing else in the seed is, so the program would build with a main locus that never runs. Reported once, at that locus’s name, read from the entry row; every other rule still judges the declaration error module_nested_main_is_not_the_entry (lowering laws)
Cross-pool spawn as a value a locus literal used as a value (let-bound, an argument, a field, a sub-expression) where the ownership graph’s bubble plan posts it to an owner on another thread: a cross-pool spawn is fire-and-forget (spec/semantics.md § “accept bubbling”), so it may only be a bare statement. Judged at every place lowering builds the literal, a default’s expansions included, from the ownership graph’s sites and expansions and the typed bodies’ omitted arguments error cross_pool_spawn_used_as_a_value (lowering laws)
Self-containing locus a locus whose params defaults construct one of its own kind by value, by a literal or through a fresh-factory call, so its construction has no floor (spec/types.md § “A locus may not contain itself by value”). Reported at the param that closes the cycle, naming the ring error self_containing_locus (lowering laws)
Role declared once a role declared twice (spec/types.md § “Roles”): a role is one name the deployment maps, not a merge. Reported at the second declaration, with the first as its witness error declared_twice (roles)
Declared role a role an includes names that no role declares (a row’s requires is the surface laws’) error undeclared (roles)
Acyclic role includes a role reachable from its own includes chain: composition is grant-only and union-only, so a cycle says nothing error role_cycle (roles)
Pool starvation two or more run() bodies that statically never return sharing one cooperative pool of the deployed root (the main locus’s own run() counts on pool main): the pool runs each run() to completion in birth order, so the later ones never start (spec/semantics.md rules 7 and 8). Read from the placement table’s rows and the flow rows’ “never returns” column; not reported on a pool where the dead-receiver error fired warning check_pool_starvation
Birth-order trap a params field of the deployed root whose run() runs inline on the main thread and statically never returns, with params declared after it, which are then never born. Reported once, at the first such field, listing the fields it starves; read from the placement table’s rows and the flow rows warning check_birth_order
Dropped scope-local delivery a bus subscriber — or a locus that keeps one for its whole life: one its params defaults build, transitively, or its bodies build and accept or store in a field — built and dissolved inside one call (a let or a bare statement, neither stored in a field nor returned, with no accept owning it) whose enclosing scope runs on a classic cooperative pool (not where async_io; a free fn’s domains are its callers’): its subscription registers with that pool, whose cells are handler-atomic, and the call holds the pool’s worker for the subscriber’s whole life, so every delivery to it is dropped at its dissolve (spec/runtime.md § “Subscriptions follow the tower”). Reported at the literal, naming the locus, the call and the pool, and saying to place the pool where async_io or keep the subscriber beyond the call; a literal in a bus handler is type-check rule 20’s, and a scope whose domains are unknown proves nothing. Read from the allocation summary’s rows, the ownership graph and the placement table’s dynamic sites warning check_scope_local_subscriber_on_classic_pool
Hot-path allocation an allocation per loop iteration or per bus message in the program’s own fns: a locus instantiated, a factory’s result bound, an allocating receive in a loop; under @hot also snapshot() / finish() and a whole-struct self.<field> = replace. Read from the allocation summary’s rows; an error under @hot, otherwise a warning that @unbounded or @bounded_by(<name>) silences warning / error check_hot_path_alloc
Unit declared once a unit declared twice (spec/units.md § “Declarations”): a unit is one node of the catalogue. Reported at the second declaration’s name, with the first as its witness; a unit of a name the stdlib’s time catalogue declares (ms, U4) is that unit declared again, the message naming the catalogue error declared_twice (units)
Declared unit an equation’s target, a denomination or an origin that names no declared unit, at the name, suggesting the nearest declared one, a program’s own before the stdlib’s; a retired time unit (m) says what it is now instead (minutes are min) error undeclared_units (units)
Unit cycles multiply to one a cycle of unit equations whose product is not one, at the equation the closure found inconsistent, stating what it claims and what the other equations imply; the witness is the other path, each step at its declaration error inconsistent_cycles (units)
One quantity per component a second quantity declared with quantity and no policy over one component of the catalogue: the component’s other types are denominations of its one quantity. Reported at the second’s name, with the first as its witness; and a refinement of a quantity denominated in a unit outside its component error second_quantity (units)
Quantity counts an Int a quantity over anything but Int, a quantity with no in, and Int in D with no quantity, each saying what to write error quantity_shape (units)
Point over a quantity a point over anything but a quantity, an origin: on anything but a point, and a point’s denomination or origin in a unit outside its quantity’s component error point_shape (units)
Round and range clauses a round: naming none of floor, ceil, trunc, half_even, half_up, or on an identity or a range; a range: bound that is no integer literal, a range holding no value, and a refinement’s range outside its parent’s (with the parent’s range as its witness) error clauses (units)
Identity and range bases an identity that is not distinct Int or has a denomination; a range type over a primitive other than Int or with a denomination, and a refinement of Int with no range: error identity_and_range_bases (units)
Refinement of a scalar a scalar declaration over a name nothing declares; a refinement (no kind word) of a struct, an enum, an alias of one, a locus or another non-scalar; a refinement chain that comes back to itself error refinement_bases (units)
Unit named like a literal suffix a unit named d, the Decimal literal’s suffix (3d is the Decimal 3), or e / E and a digit, a Float’s exponent (3e5): the lexer reads either before a unit, so no literal could write the unit. Reported at the name error literal_suffix_names (units)
Named like a builtin type a quantity, a point, an identity, a range or a unit of a program named like a builtin type: a primitive (Bytes, Int, String, …; a type position reads it before any declaration, so the declaration could never be named) or a builtin error type (IoError, RangeError, …). Reported at the name: “type `Bytes`: `Bytes` is the builtin buffer type; a quantity cannot take a builtin type’s name” error builtin_type_names (units)

CQRS is GitHub issue #18 item 6; its three sanctioned remedies (parent-child + contract, bus mediator, delegation) are named in the diagnostic. See spec/semantics.md § Locus method dispatch.

Default-on & opt-in analyses

Section titled “Default-on & opt-in analyses”

One GitHub issue #18 analysis runs by default: item 4 (bus-graph property checks — errors, fail the build). The rest, including item 1 (memory-bound), are opt-in (behind a flag) or deferred. Only item 4 is a build gate; don’t assume the others in a build:

The item-1 whole-program survey and the hot-path lint are advisory (warnings). The one build-failing allocation gate is opt-in: @budget(alloc_per_call = N) on a fn — you ask for the ceiling, and a violation is a hard error. fd and thread bounds remain advisory / CI-gated (item 5), not automatic build failures.

Item 2 (race-completeness for substrate primitives) is a substrate quality bar, not a user-facing check: it model-checks the runtime’s own concurrent primitives under all C11 interleavings with GenMC, run as a standing CI gate (the genmc job). Every substrate primitive with a cross-thread synchronization surface is now modeled: the lockfree hashmap’s enter/drain/grow protocol, the pinned-locus mailbox monitor, the cooperative-pool bus queue’s conditional lock, and the arena subregion-slot freelist lock. (The per-thread chunk pool needs no model — it is __thread, with no cross-thread access.) See verification/.

Decorator stacks (GH #723)

Section titled “Decorator stacks (GH #723)”

A fn’s decorators are a LIST, not a slot. The identity decorators — @ffi(...), @export — say what the declaration is, and each is exclusive with the other. The contract decorators — @unbounded, @bounded_by(<name>), @hot, @budget(...), and the effect assertions (@effects(...) and its @no_* sugar) — say what it promises, and all of them stack, in any order:

@unbounded @no_syscall fn absorb(log: Lines, l: String) { … }
@no_syscall @unbounded fn absorb(log: Lines, l: String) { … } // same thing
@no_block @deterministic @unbounded @budget(alloc_per_call = 4)
fn label(n: Int) -> String { … }

Order carries no meaning: a stack is a set of contracts over one fn, each checked by its own analysis. @unbounded suppressing the allocation advisory and @no_syscall proving no reachable syscall are independent claims, and a fn that accumulates deliberately while promising it touches nothing outside its arena needs to state both. (Before #723 each decorator parsed the fn itself, so a second one was a syntax error — which forced the workaround of wrapping @unbounded methods in a free @no_syscall fn purely to carry the second contract.)

A lifecycle hook is the exception: @unbounded run { … } or @bounded_by(<name>) run { … }, once, and nothing else (a repeat or the pair is a parse error there, since a hook keeps no decorator list). A hook is one-shot, so a per-call ceiling has no calls to divide by, and it is not a fn a caller asserts over. Put the other contracts on a fn the hook calls.

What a stack cannot do is contradict itself. These shapes are errors at check time, reported at the decorator (not parse errors — the declaration is well-formed, its meaning is not):

A per-call ceiling above zero is not a contradiction: @budget(alloc_per_call = 2) @unbounded reads “at most two allocations per call, and their aggregate growth is intended” — a cache insert per call is exactly that shape. The two decorators measure different things (per call vs. in aggregate), which is why only the zero case collides.