Working state — a note taken while the work happens, not a specification. The system as it is meant to be is in Architecture.
Programs — shipping them, reloading them, debugging them
Rev 2, 2026-08-05 — RATIFIED. §3 rulings are John’s, given in conversation;
the execution plan and the remaining seven rulings were answered the same day in
plans/work-proposal.md §3 (R8 keep-list, clamp sequencing, R5 = compiler-emitted
stone op, exact-decimal literals, profile collapse, windows-as-target-not-CI, L2b
rescue). One framing correction from ratification: a program is not a new concept —
./pit itself is the “dev environment” program; this arc formalizes the levers
(see work-proposal.md header). Old plans retire per work-proposal.md §5.
The one-binary arc made the runtime one artifact: a C floor with a boot cart fused into it. This arc makes a program a first-class thing on top of that floor — something you can ship as a standalone executable, replace while it runs, and stop and inspect instruction by instruction.
1. What this arc produces
A. A standalone product. mcode — a gcc-shaped tool that reads source and
writes mcode. One executable, one heavily-linked cart, no daemon.
pit product pit-compiler/mcode.ce --include a,b is the whole definition;
what defines a product is a Makefile line, not a manifest file.
B. Hot reload. Edit a module in the dev loop; running actors pick up the new code without a restart, without re-running the module body.
C. A debugger. Attach to a live instance, freeze an actor, walk frames, read slots, step mach words, and see the mcode instruction and source line each word came from.
2. The running modes — what the seam actually is
One binary, one entry: source/main.c → pit_host_main
(platform/host/source/host.c:446), picking one of three roles before any pit
code exists:
| role | chosen when | what it does |
|---|---|---|
| client | posix, not daemonized, not PIT_INPROCESS, not app mode | never boots pit — forwards argv to a daemon over a unix socket, relays the reply, exits |
| daemon | PIT_DAEMONIZED=1 (set by the client when it spawns one) | boots pit, loops, owns pit.sock + pit.pid |
| in-process | PIT_INPROCESS=1, PIT_APP set, or sys_is_app_bundle(binary) | boots pit here, no daemon transport, runs to completion, exits |
Exit is not a mode. main_backend_run (source/scheduler.c:3642) loops
until engine.shutting_down, and the honest mechanism is a cascade, not a
flag: stopping the runtime is stopping its ROOT actor, everything is
transitively $coupled to it, and the engine exits when the last actor is
removed (actor_count() == 0). A one-shot is a program whose root finishes; a
daemon is one whose root does not. Same code path.
Three things are wrong for shipping products:
- The mode is environmental, never declared —
getenv("PIT_APP")plus a path probe, andsys_is_app_bundle(platform/posix-runtime/source/sys_os_posix.c:291) returns 0 unconditionally on Linux and the BSDs. A shipped Linux binary has no way to say what it is. - Every mode creates a shop.
resolve_paths(host.c:254) callsensure_shop_dirsunconditionally. daemonis all-or-nothing per recipe —build.capabilities.daemonselectsposix-daemonordaemon-stub, and both desktop recipes say true.
Erlang’s release model is the precedent: a release carries the VM, the
compiled applications, and a boot script naming what starts. The runtime
infers nothing. pit already has the slot — cart_boot.c:67 says the boot
entry decides “which pool is the walker, which exec is the root, what that
exec’s plan is”, and C places no interpretation on it at all.
3. Rulings
R1 — The cart declares the mode; PIT_APP is deleted. The boot entry
carries the start declaration. C reads it and stops probing the environment.
Consequence: the daemon must stop being a baseline assumption. Including
daemon actors in the cart should pull the C daemon code in naturally via cake;
today it is all-or-nothing at the recipe level and needs splitting.
R2 — A shop is a product property, toggled, not absent. A shipped game
with mods, or a game engine realizing the developer’s game, needs a shop and a
workspace. mcode does not. The declaration says which.
R3 — A product is a cart with an entrypoint. Nothing more. Not
package.json — that file identifies behaviour for a collection of modules
and programs and has no business here. The definition is the CLI invocation:
pit product <locator> [--include <locator>,...]
The entrypoint is exactly one locator. Its closure is found; a binary is
created that starts that actor on launch. Because the program may start other
actors, the closure includes the actors it can create, compiled in.
--include covers what static analysis cannot see, because starting can be
dynamic. Default is maximum optimization. This is not needed in the dev
loop, where it is understood that starting an actor may cause compilation.
Closure discovery is static analysis of $start sites, and it works:
$start(callback, program) (docs/language/actors.md:174) takes a locator as
its second argument and is the front door everybody uses — it is implemented
by sending {type: "start", …} to the shop
(pit-shop/shop_actor.ce:977), but that is mechanism, not surface. A literal
second argument is an ordinary literal operand at an intrinsic call site and
is findable in mcode. --include covers the dynamic residue.
R3a — the shop degrades gracefully, by actor availability. Including or excluding actors is the only lever. The shop is asked to start X; X is not in the cart; it tries its mounted repos; it has none, so it tries to mount or fetch — it cannot, because there is no fetch actor. Or it has one, gets source, and tries to compile — it cannot, because there is no mcode compiler actor. So it fetches mcode instead, and it does have the linker/lowering actor, so that works. Each step falls back on what is present. The dev cart therefore needs a minimum: enough to mount the bundle qop and to lower. The qop carries the fetchers and the compilers. Requirement this places on the implementation: every rung must refuse by name — “cannot compile X: no compiler actor in this cart” — never by mystery failure.
R4 — Hot reload repoints function objects in place. Module exports are stone, period. The model, exactly:
- A module’s export record is stone. A module can export a text; that cannot be hot reloaded.
- The module body does not run again. No re-initialization, no new record.
- The functions on the exported record are walked, and each one’s bytecode pointer is repointed at the new stone bytecode.
- You cannot add or remove functions on the exported record. A mismatch is a warning or an error from the reload machinery — never a silent partial swap.
This is compatible with stone exports by construction (nothing writes into the
record; only the function objects it names are mutated), and the C layout
already supports it: PitFunction.u.image is {binding, owner, outer_frame}
plus image_index (source/pit_internal.h:3115), all writable fields on an
ordinary heap object.
This ruling dissolves the frame-layout problem. You never migrate a live
frame. A call already running finishes on its old code and layout; the
repoint takes effect at the next call. restart_frame and its
re-runs-effects-already-performed problem are not needed and the doc section
proposing them should go. Two residuals remain, both small: the old pool stays
pinned until the last frame using it unwinds, and a closure already handed out
by an old-code frame keeps old code until it dies.
R5 — Module returns are stoned by the compiler at the module main’s
return. docs/spec/stone.md:54 already promises this; nothing implements it
(MACH_LOAD_BOUND_MODULE, source/mach_vm.c:5447, reads the realization
results array raw). stone is one header bit and shallow
(pit_stone_value, source/pit_internal.h:1505) — it freezes exactly the
member bindings and leaves nested state mutable. R4 depends on it.
R6 — A cart is always one mach pool. A cart is inseparable from the binary it is embedded in: one pool, actors cross-linked, fully optimized, and — since it knows the binary’s static C symbol table — able to call those C functions directly. It splits only for esoteric targets with ROM windows and paging (gameboy). A dev build’s cart is small; that is the point. Actors compiled and started at runtime are separate and more granular.
R7 — The compilation story, restated (supersedes rev 1’s “identity uniqueness” framing). The lever is how many mcode units per mach pool:
- The fundamental compiled unit is mcode. It lowers to mach in various ways.
- On a cart: always one pool. Not reloadable. Effectively a C binary extension.
- In the shop: one pool per mcode unit (default) — reloadable, finest rebuild.
- Or one pool per executable — mcode-level LTO, faster actors, not reloadable.
- Both dynamic forms give restart-level reload regardless: the next start of a changed actor picks up new code.
Identity uniqueness is the reason the granular form is the reloadable one
(LTO folds functions, so {unit hash, function id} stops being 1:1), not a
separate lever.
R8 — mcode is the only shared artifact. Mach blobs are neither shared nor shipped. Bundles and content stores have no need for them; they need no content-addressing except locally, to run an executable. Executables are not sharable either, since they depend strongly on their mach blob. The one cross-platform product is the C binary plus the heavily fused mach blob bound into it — legitimate cross-compilation work, and that is where it ends. A platform that wants to start a new actor may receive mcode, but it must link and lower its own mach blob, binding its C calls to its own pit binary. Any pass or stored artifact outside these bounds can be deleted.
4. The shared substrate
S1 — Stable function identity, end to end. {mcode unit hash, function id} surviving lowering, linking and pressing. R4’s repoint needs it to match
old function to new; the debugger needs it to name a frame. One column, two
customers.
S2 — Stepping. The freeze primitive already exists (§5, phase 5). Single-stepping does not.
S3 — The start declaration (R1/R3): what forge consumes and C reads at boot.
5. Phases
Phase 0 — measurement and CI. Designed and partly prototyped in
plans/measurement-suite.md (benchmarks/bench_suite.ce,
benchmarks/start_latency.ce). Organising rule: every row declares
class: "counter" or class: "wall"; CI gates counters and only reports
walls, with mandatory spread so machine load is visible in the artifact.
Three schemas, one JSONL line per run in perf/trend/, gated by
perf/bench_gate.py modelled on ir_census.py.
Two results bear on this arc: link is a first-class cost (5 units: compile
11.4 s, link 8.0 s), which is why whole-program linking belongs at press time
and not in the dev loop; and actor switching is not honestly measurable
today — the engine struct (source/scheduler.c:75-89) carries no counters
at all. ~40 lines of C inside the already-held engine.lock plus ~15 in
std_endowments/inspect.c fixes it.
CI is further along than the old plans say — .gitea/workflows/ci.yml carries
linux, profile-matrix, web, windows and playdate-sim jobs on the pit-ci
runner label. Remaining: verify the runner against the private gitea, automate
image-tag pickup instead of hand-pinning .gitea/ci-image.env, add the bench
job + artifact retention, rule on the windows runner, and wire make budget
— which exists, is honest, and which check-all never runs (Makefile:618).
(As of 2026-08-06 make budget is the shop budget only; the compiler half was
retired in favour of perf/ir_census.py and make bench.)
Phase 1 — the start declaration. R1 + R2 + S3. The boot entry carries it;
C reads it instead of probing; ensure_shop_dirs becomes conditional;
PIT_APP dies. Split the daemon out of the baseline so cake pulls the C
daemon code only when daemon actors are in the closure.
Phase 2 — pit product. R3 + R6. Closure discovery (see §7.1), one-pool
press, max optimization, stripping actually used. This is also where the press
moves out of the shop actor — pool_executable runs inside the shop actor
whose closure is the cart, which is the entire +292 KB the press switch cost.
Deliverable: mcode runs on a bare container.
Phase 3 — stone at return. R5. Small, and a prerequisite for phase 4.
Phase 4 — hot reload. R4 + S1. Walk the export record, repoint, refuse on shape mismatch. No frame migration.
Phase 5 — stepping, and debugging a product. Much of this exists already
and rev 1 was wrong to say otherwise: $runtime.suspend_actor
(std_endowments/runtime.c:111 → scheduler_debug_suspend/resume) freezes a
turn at its next backward jump and thaws it; shop_tools/debug.ce is a
working debugger actor with frames, locals, a bytecode window and source
resolution through pit.mach.origin@2; both of debug_resolve.lane_gap’s
named blockers are recorded closed. What is missing is single-stepping,
and the product question in §7.2.
6. What this closes, and what it does not
Closed by the rulings: morning decisions 1 (pool-lane press → R6), 2 (export-recognizer contract → R5), 4 (linker in the shop closure → phase 2), 8 (are three profiles real → R7).
Re-scoped by R8: P5 (the Pit lowering) is now needed for exactly one thing —
cross-target carts. If a platform must lower its own mach, the only case
where one host lowers foreign mach is building a cross-compiled binary and
its fused blob. The foreign-target refusal at pit-shop/mach_lower.cm:38 is
therefore correct and permanent for every other path.
L2b (the PGO drain) was carried forward here and is now KILLED by ruling
2026-08-07 — the rescued emitter branch is deleted and the refusals it was
counting down to are the standing answer. PGO may return later; PGO
application was never in scope and stays live. See plans/carried.md,
“L2b (the PGO drain) — KILLED BY RULING 2026-08-07”.
Carried forward, untouched: the panic-tail upvalue
(pit-compiler/panic_outline.cm:201 — 125 of 133 cross-unit inline sites
rejected; cp/outline-binding fixed the log binding, not this, and the
record says otherwise), the hpack miscompile, E3, and the platform
rollout.
Independent defects: the dangling jump_false in shipped mcode
(archive/qop.cm fn 56, 7 of 99 modules), the cache-hit linked_plan hang,
the 30s heap-test timeout plus five other pre-existing suite failures,
make local not depending on daemon-fresh, and a foreign daemon silently
owning a worktree’s socket so make builds into someone else’s worktree
(observed 2026-08-05; see plans/measurement-suite.md §0).
R9 — Optionality must move from the recipe to the product. The mechanism
already exists and is good; its granularity is wrong. $hook is the model:
a build without the endowment folds PIT_HOOK_ARMED to a constant 0 at every
site, with “no ring, no field” (source/pit_internal.h:41-53) — struct bloat
and hot-path cost both gone. Which endowment packages link at all is
recipe.lists.endowments / endowments_whole (cake/plan.cm:374-407), and
the daemon is the same shape (build.capabilities.daemon → posix-daemon or
daemon-stub). Everything optional today is optional per-recipe; products
need it per-product. One lever, doing both halves: strip the C (structs,
hot-path sites) and drop the module shim that makes the endowment reachable
from an actor, so an actor needing an absent feature is denied at
realization by name rather than failing strangely at run time.
7. Open questions
7.1 — CLOSED. Static analysis of $start sites works; see R3.
7.2 — Symbol distribution for remote debugging. The chain already exists
end to end: a live frame carries image_hash — the pool’s own content hash
out of the pmp1 header (source/runtime.c:10065, :10320) — and the origin
sidecar is keyed by exactly that (shop_store.mach_origin_key(pool_content_hash),
:685), resolving pc → mcode site → source span through pit.mach.origin@2.
So mach blobs are not content-addressed for distribution but they do carry a
stable identity, and that identity is the debug key. R8 needs this
exception stated: no sharing, but yes to a knowable hash. What is missing is
purely distribution — how a pdb user on one machine obtains the sidecar (and
the mcode, and the source) for a binary running on another. That is the
ship-the-symbols problem: build emits binary + sidecar, sidecar keyed by image
hash, debugger fetches by hash. Needs a design, not an invention.
7.3 — RESOLVED AS A DEFECT: mcode is host-rep-tainted today. The intent
(numbers are exact in mcode; precision affordances happen at mach lowering) is
correct and is not what the code does. tokenize.cm:281-284 eagerly parses
every literal with the host’s number(), and fold.cm:118 folds in host
arithmetic and re-renders the host result. Measured on this nan64 host
(./pit compile mcode):
| source | mcode literal |
|---|---|
1e300 | null |
123456789012345678901234567890 | 1.2345678901234568e+29 |
1e30 * 1e30 | null |
0.1 + 0.2 | 0.30000000000000004 |
An out-of-range literal does not lose precision — it becomes null in the
artifact. This confirms the 2026-07 audit’s “mcode rep-tainted but untagged”
with a concrete case. R8’s “mcode is the one shared artifact” is false until
this is fixed, since mcode built on a nan64 host embeds nan64’s limits.
Direction: keep the source spelling through to mcode (the row shape already
has a text field, compiler.cm:1231) and fold in exact decimal, or refuse
to fold what is not exactly representable in every supported rep. Integer
folding inside the common exact range is unaffected.
7.4 — What exactly gets deleted under R8? Mach in the content bundle, content-addressed mach sharing (not identity — see 7.2), cross-target mach lowering outside the forge path, and executable-sharing machinery. Needs an inventory pass before anything is removed.
Source: plans/programs.md