The model
Most compilers have an IR that describes how the program runs — blocks, instructions, registers. Hale has one of those too. This is about the other one: a…
Most compilers have an IR that describes how the program runs — blocks, instructions, registers. Hale has one of those too. This is about the other one: a typed description of what the program is, derived once per snapshot by the frontend and read by everything downstream (the graph registry names what still derives its own).
Derive a fact once
Section titled “Derive a fact once”A semantic fact about a program — who calls whom, which handlers a publish reaches, which locus owns which instance — has exactly one right answer. So it gets derived in exactly one place, and every consumer projects or queries that value.
That is the whole principle. In-process, it means hale check’s
verdict on a law and the verdict recorded in the topology artifact
are the same computation — not two computations held in agreement
by a test. DispatchPlan, which decides how a bus subject
dispatches, is derived from the same model that answered the
verification questions.
Out of process it is necessarily weaker, and worth being precise
about. The fleet tier, reasoning across binaries, cannot hold an
ApplicationModel — it decodes a deliberately smaller
ComponentModel from admitted artifact JSON. What it shares with
the single-binary checker is the reachability engine and the
admitted contract, not the value. That’s the boundary, and it’s
where the wire form would earn its keep.
Eight concepts stay distinct: source, plan, model, the lowered law
(ClaimIr), evidence, artifact, lowering plan, execution evidence.
Keeping them un-conflated is what lets each one be simple.
The principle was first enforced for the model’s consumers: artifact
and fleet code cannot walk source for a modeled fact. It now covers
every semantic fact the compiler acts on, in every crate, through the
graph registry (F.40): one entry per family — ownership, the bus
graph, dispatch, placement, effects, allocation, lifecycle order and
the rest — naming its authoritative producer, the legacy producers
still permitted while it migrates, its consumers and its tests. A
build fails when the tree grows a derivation-shaped function, a new
reference to a guarded producer, or a bare Debug rendering the
registry does not name. The rendered index is
spec/registry.md;
the contract is spec/model.md § The graph registry.
Facts, and an account of what is missing
Section titled “Facts, and an account of what is missing”The interesting design choice isn’t the entity tables. It’s that a model records what it could not determine, as data:
Hole { at: Function(cache_lookup), kind: IndirectCall, hides: CALLS | EFFECTS | COSTS, reason: "call through `f`",}A graph that stores only what it found cannot distinguish there is no such edge from I could not see. Those are opposite answers to every question worth asking. “Does this handler reach a syscall?” — if the walk met a function pointer and stopped, the honest answer is not “no”.
So Hale’s verdicts have four values rather than two:
holds |
proved |
violated |
disproved, and here is the counterexample |
uncertified |
well-formed, not provable here — the graph has an unknown |
invalid |
the statement itself is malformed |
uncertified fails the build, exactly like violated. The
difference is what you do about it: one is a bug in your program,
the other is a limit of what the compiler can see, and those are
different repairs.
The dual of a hole is a capability — a positive statement that some family of facts is complete. Positive on purpose: absence of recorded unknowns is not evidence of completeness, so a model that never noticed a gap does not get to claim exactness by default. Whether the model is adequate for a given question is then answerable directly, rather than inferred from the absence of warnings.
What that makes sayable
Section titled “What that makes sayable”Holding the whole arrangement in one typed value makes properties checkable that a per-function analysis cannot express.
Effects that travel over the bus. A call graph stops at a
publish. Hale’s message graph is declared over a closed topic set,
so the compiler continues across it: this handler, by publishing
orders, can transitively cause a filesystem write in the audit
subscriber. You state the causal set, and omitting one is the
error. A system whose message graph isn’t declared cannot ask the
question at all.
Influence arriving. The backward direction is the more useful
one. A locus that subscribes only to SumLookup looks isolated
from Recalled in every declaration it carries — but if some third
locus subscribes to Recalled and republishes onto SumLookup,
the influence arrives anyway, and nothing in the depending locus’s
source mentions it. @effects(depends: {…}) is the complete set of
subjects that can reach you, and the diagnostic names the path.
Amplification. @budget(fanout = 8) bounds the subscriber
deliveries one call can cause — transitively, per message, over
runtime registrations. Publishing once to a 200-subscriber subject
is a fan-out of 200. That is a property of the arrangement, and no
per-function count reveals it.
Each of these is a statement about the whole system. They are checkable because something holds the whole system.
Counting things correctly
Section titled “Counting things correctly”Bounds like fanout are quantitative, and quantitative analysis
over a graph is easy to get approximately right in ways that
matter. Two distinctions do most of the work.
A set is not a quantity. Reachability asks can this happen;
a budget asks how many times. A handler that calls one publishing
helper twice publishes twice — the reachable set says once. One
subscribe declaration on a locus arranged with replicas = 3 is
three runtime registrations. And a message carries one key, so a
keyed publish reaches the subscriptions that key selects, not the
union of every subscription it might ever reach.
This is why call and publish rows in the model are site-grained: two calls to one callee are two rows, because the callee runs twice. Collapsing them into a set computes reachability where the language means executions.
Aggregate last. Fan-out takes a maximum — over the key values a site can produce, and over the conformers of one interface dispatch. Taking that maximum early and continuing gives a different number:
max(immediate) + union(downstream) ≠ max(immediate + downstream)Σ over sites of max over alternatives ≠ max over alternatives of ΣThe left-hand forms pick one key for the immediate count and then follow every key’s downstream branch, or let two conformers both contribute when one dispatch runs one. So a whole scenario — one key, one conformer — is costed end to end, and only then compared. A call through a function value is the exception for now: it can reach each function of its type the program passes around as a value, and the model records each as a call site of its own, so a count over them adds where only one runs — an over-count, never an under-count.
Neither point is exotic. Both are the kind of thing that reads as obviously correct in one form and is quietly wrong in another, which is precisely why the semantics live in a written contract rather than in whichever walk happened to be written first.
The arrangement
Section titled “The arrangement”The instance tables — which locus instances exist, who owns each, the thread each runs on, the CPUs a thread may use — are not the model’s own walk. They are the placement table’s rows, projected: the compiler decides where every instance runs once, and the model reads that decision rather than deriving a second one.
So the arrangement holds the instances of the main locus the build deploys, each where it runs:
type Held = Holder;
main locus App { params { h: Held = Holder { }; // App.h, realizing Holder r: Router = RouterV1 { }; // App.r, realizing RouterV1 workers: Worker = Worker { }; } placement { h: pinned; // App.h and all below it: pinned:App.h workers: pinned(cores = 0..4, replicas = 4); }}An aliased field is arranged as the locus the alias names, and an
interface-typed one as the locus that was actually built. A replica
carries its own index (App.workers[2]), and the instances under it
carry it in their path. affined_to gives each pinned replica, and
each pool, the CPUs it may run on — a set of cores is something a
thread has, never a thread itself.
Some instances are deliberately not arranged, and the gap is stated
rather than hidden. An instance of a standard-library locus (a
std::io::udp::Reader field) is placed by the compiler but not
arranged, since the model’s declarations are the program’s own. An
imported library’s main locus is never the root. A locus fn main
builds besides the root is a birth outside the arrangement, recorded
as a hole. The placement table, not the arrangement, answers every
placement question the checker asks.
A served surface or a hub keeps the placement of its generated adapters in the model’s dispatch report, including the adapters’ I/O pool. Initializer expressions copied from a binding remain params defaults even though their diagnostic locations point back to the binding. Connection peers created as requests arrive still carry dynamic-placement holes.
An instance’s id is its position in path order, so adding a field
renumbers what sorts after it. Join instances across builds by path,
never by id. None of this is in the shape hash: rearranging instances
never changes a program’s shape_hash or its observation ids.
What it doesn’t do
Section titled “What it doesn’t do”The model lives inside the compiler process. There is one way to build one — deriving it from checked source — and no way to load one from anywhere else.
The topology artifact is a projection: it carries the model half under a shape hash, plus typed law, capability and adequacy sections, and it is what every out-of-process consumer reads. It is lossy by construction, and a model cannot be rebuilt from it. Giving the IR a reversible wire form is real work rather than a weekend: every table would have to serialise, provenance would have to survive, and admission would have to be impossible to trick into producing a model that no program denotes.
For now: in-process consumers query the model; everyone else reads the artifact and checks the digests that tie it to the compiler that produced it.
The contract — sorts, relations, the hole and capability laws, the
identity rules, and the discipline a new judgment family follows —
is in
spec/model.md.
For what an individual row holds, field by field, the API reference
is at /api/hale_model.