$ tebako-shim which metanorma
tool: metanorma
payload: metanorma 1.2.3
version source: project /work/Doc/.tebako-tools.yaml
image: ~/.tebako/payloads/metanorma/1.2.3.tfs
entrypoint: /bin/metanorma
runtime: ruby "~> 3.3.0" → ruby 3.3.9 (cached)
exe: ~/.tebako/runtimes/ruby-3.3.9-0.16.9-aarch64-macos/tebako-runtime-0.16.9-3.3.9-aarch64-macos
image: ~/.tebako/runtimes/ruby-3.3.9-0.16.9-aarch64-macos/tebako-runtime-0.16.9-3.3.9-aarch64-macos.tfs
mounts:
~/.tebako/payloads/metanorma/1.2.3.tfs:0:/
exec argv:
tebako-runtime-0.16.9-3.3.9-aarch64-macos
--tebako-image ~/.tebako/payloads/metanorma/1.2.3.tfs:0:/
--tebako-entry /bin/metanorma
doc.adoc
ARCHITECTURE · 10
Dispatch.
Managed mode works as follows: the user types a command name, and per invocation tebako picks the payload version and the runtime that the command runs on. There is no version manager daemon and no shell eval hook, only one dispatcher binary behind every shim, re-resolving from scratch every single time.
|
Note
|
The dispatcher, its management verbs, the registry format, and the
dispatch-time registry cache are shipped and live in
tamatebako/tebako as
|
Four artifacts, four jobs
A lean tebako package already is a dispatcher: it resolves its runtime at run time. Managed mode splits that trick into four independent artifacts, each with its own publisher, its own release line, and its own trust anchor.
- Payload
-
A payload is a
.tfsimage of the app, versioned, immutable, and optionally signed. Pure-language payloads are runtime-independent and ship as one universal image for every platform; native-extension payloads ship per triplet per ABI line. - Runtime
-
The runtime is two artifacts — the interpreter executable plus its env
.tfsimage — published by the runtime factory, downloaded once per machine, SHA-256-verified, mounted (never extracted), and shared by every package and payload on the box. - Registry
-
The registry is a developer-hosted
tpkg-registry.yamlon any git host, mirroring only the resolution fields: versions, per-platform artifacts with digests, runtime requirements, entrypoints, and the default. The host’s releases are the storage, with zero central infrastructure. - Dispatcher
-
The dispatcher is
tebako-shim, one small static Rust binary linked once per command name into~/.tebako/shims/<tool>. It is the thing on PATH that picks the version and the runtime per invocation and hands off.
One invocation, end to end
Nothing is sticky. Every invocation of a shim walks the whole chain again, and a directory switch means the same command can resolve to a different version on a different runtime. That is the mise model: the project file is read at dispatch time, so there is nothing to activate and no shell state to go stale.
Figure 1 — The dispatch cascade: invocation name, version chain, runtime resolution, the exec handoff, and the interpreter running.
The driver’s treatment of the handoff, the mount order, longest-prefix dispatch, and the fail-closed rules, is on the anatomy page; this page covers everything that happens before the interpreter starts.
The version chain, link by link
Four links are evaluated in order; the first link that names a version wins, and later links are not consulted.
-
The environment link is
TEBAKO_<TOOL>_VERSION: the command name uppercased, with every non-alphanumeric character becoming an underscore (metanorma→TEBAKO_METANORMA_VERSION). One shell carries one pin, and nothing is written anywhere. -
The project pin is the nearest
.tebako-tools.yamlwalking up from the working directory, a flat YAML mapping of command name to version. One subtlety is locked: a nearer file that does not pin this command does not shadow a farther file that does, and the walk continues until a file actually answers. -
The user default is
defaults:in~/.tebako/config.yaml, which the user authors by hand. The dispatcher only ever reads this file; a plannedtebako usecommand will be the writer when it lands, and until then editing the YAML is the mechanism, not a workaround. -
The registry default is the
default:key that the publisher declared in the publisher’stpkg-registry.yaml, read through the dispatch cache (below). Registered registries are scanned in config order, and the first one carrying the payload answers.
If no link answers, the error says so, listing every link it tried and the versions actually installed (exit 69). A resolved version that is not installed, or that the user disabled, is likewise a named refusal with the remedy in the message. Dispatch never guesses a version.
Runtime resolution: cache first, then the factory index
The chosen entrypoint’s runtime_requirement (an engine plus a version
constraint) is evaluated against ~/.tebako/runtimes/, and the newest
compatible runtime already cached wins, with no download. Only a cache
miss reaches the network, which fetches the newest compatible interpreter
the
runtime factory
released for this platform; that interpreter is SHA-256-verified against the
release index before it enters the cache. Because the payload image is
immutable, swapping runtimes never touches it, and only this dispatch-time
choice changes.
- Pure-language payloads
-
The constraint is a range,
ruby >= 3.3, < 5.0. Any newer runtime inside the range works, and older payloads simply run on newer interpreters. One universal.tfsserves every platform, and the per-platform runtime supplies the machine code. - Native-extension payloads
-
Native-extension payloads are locked to the ABI line they were built against, and the line is two-dimensional: the version line (
ruby ~> 3.3.0) and the platform line (abi: arm64-darwin-23, the runtime’s own platform string). A newer line needs a new payload build, and a wrong line on either axis is a named compatibility error, never a segfault.
Runtimes published before the abi key existed carry no platform line and
stay eligible; this is the compat window, in which a payload’s abi check
never fails against an unknown line. An entrypoint with no
runtime_requirement at all, a native tool or a self-contained binary,
skips this entire section: zero-runtime dispatch runs the install-time
materialization straight from the store, with no runtime mounted and no VFS
in the child.
The handoff
One exec speaks one wire format, the same launcher ABI the standalone
bootstrap speaks. The registry payload is a bare image, so it mounts whole:
slot 0, mount point /. Dependency slices follow at their
consumer-declared mount points.
Environment on top of the inherited one: TEBAKO_RUNTIME_IMAGE names the
env image the driver mounts at the runtime root; when a jail policy applies,
TEBAKO_JAIL carries it, with TEBAKO_JAIL_SOURCE and
TEBAKO_JAIL_JOURNAL for the audit trail.
The shim consumes exactly three flags before handing over: --jail <spec>,
--mount <host:mount:ro|rw>, and --no-host, which form the tightening
surface and can only narrow what a payload’s manifest requests. Everything
else, unknown flags included, reaches the payload verbatim, and -- ends
flag parsing for arguments that literally carry those names. On unix the
shim execs, so the process becomes the runtime and signals and the exit
code are the program’s own; on Windows it spawns, waits, and exits with the
child’s code.
The registry: explicit, cached, never searched
A registry is any git-host repository carrying a tpkg-registry.yaml, and
the host’s releases are the artifact storage. The file mirrors only
resolution-relevant fields (versions, per-triplet artifacts with SHA-256
digests, runtime_requirement, entrypoints, default:), so the dispatcher
resolves without downloading every payload. Platform selection happens in
the file, declaratively: the dispatcher reads the entry for the host triplet
(or universal) and fetches exactly that artifact. Nothing guesses by
filename.
Registry use is explicit only: a fresh install of tebako has zero
registries. tebako add-registry <ref> registers one; there is no default
registry, no central index, and no search chain. Each reference form
resolves in exactly one place (the repo’s default-branch root via the
contents API, a pinned release artifact, a git blob path, or a local
file://), and an unparseable reference is a named error, never a guess.
The dispatch cache is the second property: remote registries use a
per-reference cache under ~/.tebako/registries/<sha256-of-ref>.yaml with a
24-hour TTL, so dispatch never blocks on the network twice in a row.
tebako update-registries renews it ahead of time, tebako add-registry
primes it with the bytes it already fetched, and TEBAKO_OFFLINE=1 turns
dispatch into cache-or-named-error, in which a stale cache still answers
offline and a missing one fails by name.
Install is the explicit verb: tebako install <name[@ver] | ref> resolves
the registry, selects the host entry, downloads, verifies (SHA-256 and/or
the OpenPGP signature), and lands the payload byte-identical in the
content-addressed cache, then registers the payload’s shims. Registry
installs always link the declared entrypoints; installing a local package
file links only with the explicit --shims; a run, of anything, ever, links
nothing.
Managing the shim layer
Invoked by its own name, the same binary is the management tool. This is the
whole surface: there is no tebako use, no tebako shim add, and no hidden
subcommands:
| Command | What it does |
|---|---|
|
It lists the installed payloads, their versions, the resolved default and its source, and every shim link. |
|
It re-enables a disabled tool, or one disabled version of it. |
|
It refuses dispatch of a tool, or of one version of it; dispatch of a disabled target fails with a named error. |
|
It previews a dispatch without running it: the resolved version and which chain link produced it, the runtime, the mounts, and the exact exec argv. |
|
It diagnoses the shim layer: PATH, shim → payload records, trust anchors re-hashed, registry cache freshness, and runtime cache entries. |
|
It prepends ~/.tebako/shims to PATH via a managed block in the shell startup file (Windows: the user PATH in the registry). |
|
It removes exactly the managed block, and nothing else in the file is touched. |
Disable state lives in ~/.tebako/shims/.disabled.yaml, shim-managed and
deliberately separate from the user’s authored config.yaml, so disable
never rewrites a hand-maintained file. doctor is the diagnostic path that
the error messages point at: it re-hashes payload images against their
install-time trust anchors and exits 1 when anything is wrong.
Shell integration: one directory, no eval hook
Shell integration is one directory on PATH: ~/.tebako/shims.
tebako-shim install-shell inserts a managed BEGIN/END block into the right
startup file for the user’s shell (.bashrc, .zshrc,
~/.config/fish/config.fish, .cshrc); it is idempotent, and
uninstall-shell removes exactly its block. On Windows the shim directory
goes onto the user PATH in the registry instead. The links themselves are
the platform’s own: symlinks on unix, and on Windows an NTFS hardlink first
(no admin and no Developer Mode) with a byte copy as the named fallback,
never a .cmd wrapper. There is also no eval "$(… init -)" hook for
switching versions, because the dispatcher reads the project file itself,
per invocation.
Two distribution forms, one press
tebako press produces both forms. For users without tebako, the form is
the standalone per-platform tpkg executable; for dispatcher users, it is the
registry payload, one universal image for a pure-language app with
per-triplet variants only when native code forces them. The release flow is
press → sign → upload the artifacts → commit the registry manifest, and
tebako publish automates it for GitHub releases and file:// mirrors.
Building every variant is a matrix CI job, not manual work.
Every refusal has a name
The dispatcher reuses the loader’s named exit set rather than inventing its own. The dispatch-specific truth behind the table is that a native-extension payload on a wrong-ABI-line runtime is exit 69 with both lines named, never a segfault.
| Exit | Refusal | When the shim raises it |
|---|---|---|
64 |
usage |
The management-command argv is malformed, and the error prints the verb’s usage line. |
65 |
manifest |
A payload record is missing or corrupt, a command is provided by two installed payloads, or a registry does not parse; the message points at tebako-shim doctor. |
69 |
unavailable |
No compatible runtime is cached or downloadable, the resolved version is not installed or is disabled, or an offline registry miss occurred; the chain links and the fix are named. |
70 |
checksum |
A downloaded artifact failed SHA-256 verification against the release index; it was deleted, and nothing entered the cache. |
74 |
I/O |
A filesystem, lock, or install operation failed. |
75 |
contract |
The runtime release speaks a handoff contract this shim does not, or declares none at all; the refusal comes before any download, with both sides named. |
Once the interpreter is running, exit codes are the program’s, because the shim is out of the picture by then.
The mnenv retirement, in practice
|
Note
|
This retirement is shipped. |
With tebako-shim shipped, mnenv — metanorma’s Ruby version manager — retires, and metanorma becomes the first dogfood consumer of this exact chain: a heavy native-extension app, pressed per (version × ruby line), published through its own registry, and dispatched by the shim. The whole model on this page is thus exercised by the very project it serves.
See also: Shims and versions · Where everything lives · The runtime as an image.