thrall
The foot. A tool-execution client for a yog server.
A thrall runs on a machine, holds an operator-issued certificate for it, and dials in to an engine somewhere else. Its entire wire surface is three acts:
- advertise — present what this box offers: for each tool, a name, a description, and a JSON Schema for its input.
- wait — hold open one read on its own mailbox until the engine hands it an invocation.
- post the capture — run the thing, and answer with what came back.
That is the whole of it. A thrall asks nothing and acts on nothing. It cannot start a conversation, read a transcript, spawn an agent, or address any part of the server other than its own queue. It never listens on a port; the engine never speaks first.
Why it is a separate program
thrall is one of four components that meet only at the wire — the server, the seat, the engine, and the foot. Execution was severed from the server so that the machine holding the conversations is not the machine running the commands, and so that adding execution to an installation is an explicit act rather than a property of having installed anything.
A server with no enrolled thrall is a valid, and the default, installation. It is structurally incapable of executing anything until a foot is enrolled — even a single-box install enrols its local foot deliberately.
Foot-grade trust
A thrall's certificate is foot-grade: it may advertise and execute, and nothing else. The grade is binary, not a policy layer — it is not a per-tool or per-verb access list and is not the beginning of one.
Two honesty clauses ride with that, and neither is decoration:
- Local config gates what is enabled. One operator-authored document on this box says what thrall offers. A tool absent from it is a tool this box does not have. Server-side adjudication is unchanged and stacks on top of it, and fails closed.
- The server cannot inspect this box. Execution happens on a machine the adjudicator cannot see into. Adjudication judges the invocation and nothing more; any containment beyond that is whatever thrall enforces locally. Neither end may claim otherwise.
Status
A working foot. thrall run serves every channel this box is provisioned
for: it dials each engine over mTLS with the protocol version stated by both
ends before either reads, refuses to open at all on a certificate that is not
foot-grade, presents what the operator's tool document enables, waits on its
mailbox, runs what comes back and posts the capture. It presents its set again
at the end of every hand-off, and says so on stderr when that presentation
actually changed anything: the engine's receipt distinguishes a comparison
from a write, so a write there means something replaced this box's advertised
set while a tool was running. It restores it and carries on — the notice is the
part a person needs. It does not return while a
channel is up.
A channel that drops is dialled again. A laptop that sleeps, changes network or crosses a relay switch loses TCP, and until this landed that engine's tools were silently gone for the life of the process — a live-looking foot offering nothing, because supervision restarts a process and a dropped channel does not kill one. So each channel takes itself up again, with a wait that starts at a second, doubles, and stops at a minute, so a box in airplane mode settles to a slow cadence rather than burning a core. Two refusals are told apart and never collapsed: the engine declining this box's read is its own predecessor's connection still dying, which frees within one hold's width and is waited past; the engine declining what this box offers is another connection serving under its name, and that ends the channel. And a protocol version the two ends do not share is neither of those: it is a fact about the two binaries rather than this engine's reading of this box, the wire admits no negotiation, and the refusal names the only remedy there is — upgrade the older component. So it ends the channel where it stands, with one sentence and no wait, instead of a line a minute into the operator's journal for the life of a process nothing was going to change. Restarting the process is still this machine's own supervision — a foot that cannot be a foot at all exits and says why.
Two files, both put there by the operator's hand and neither ever written by
thrall: <data root>/tools.json and one directory per channel under <data root>/wire/workspaces/, where the data root is $XDG_DATA_HOME/thrall or
$HOME/.local/share/thrall. A box with neither is a foot that refuses and says
which file is missing. The tool document below is a complete one.
Certificates arrive out of channel, by the operator's hand, and thrall mints nothing — there is no bootstrap flow and there must never be one.
thrall is published to crates.io. It was not, for as long as the decision of
whether, when and as what it first ships was open: Cargo.toml carried
publish = false and the flag was the enforcement rather than a note, because
a note is not a gate. That decision has been made and 0.0.1 shipped, so what is
left is the part the flag never covered — cargo publish is irreversible and a
yanked version stays downloadable, so what ships is decided deliberately
— but the deliberate act is the work, not a click. Arming the release workflow
first MOVED the act to merging a release PR; the standing ruling of 2026-09-03
then took that click away too, on the ground that the PR asks a question the
merged work already answered. So a push to main keeps one release PR open
proposing the next version and merges it in the same run, once the gate is
green (merge-release-pr in release-plz.yml, whose header carries the
derivation). Nothing untested ships: the merge waits on the very ci job the
release job waits on, and the merged bump is published by a second run of the
same gated workflow. Publication authenticates to the registry by a trusted
publisher — a short-lived token minted per run for one workflow file in one
repository — so this repository stores no registry credential at all.
A protocol bump waits for the engine (bl-635b). yog mints the wire protocol
version and this crate vendors a copy of the number in a repo-root
PROTOCOL file — one line, compiled into the constant by build.rs (bl-c618),
at the one address a module split cannot move, which is what both gates read;
the wire is fail-closed on a mismatch and does not negotiate (yog
docs/REMOTE.md §3). So
merge-release-pr also holds a release whose PROTOCOL exceeds the newest
published yog's — thrall 0.0.15 shipped 16 while the newest published engine
spoke 15, which composes with nothing exactly as the mirror-image defect did.
Strictly greater, not different: a thrall behind the engine is that mirror
image, and its release is the fix. Between the two gates — yog holds a bump
until the consumers' mains carry it, each consumer holds a release until yog
has published it — the ordering a bump requires is: the consumers' mains
first, then yog publishes, then the consumers publish. The decision is
scripts/protocol-gate.sh, proved both ways by make protocol-gate. A hold
clears when the engine publishes, which is an event this repository never sees,
so merge-release-pr runs on a workflow_dispatch of Release-plz as well as
on a push (bl-f3a5): that dispatch is the door for re-judging a held release,
and without it the release waited for an unrelated landing on main.
What guards a version's CONTENT was built before the first flip and stands
unchanged. Cargo.toml declares an anchored include allowlist — never an
exclude, because a missing include entry costs a build, which is loud and
reversible, while a missing exclude entry costs a publication that cannot be
recalled — and src/packaged_tests.rs reads the real cargo package --list
and fails on any path outside the ruled-in classes, in both directions. Without that list
the tree packages whole: the agent guide, the design document, every rule and
hook, and a corpus of deliberately fabricated secrets, shipped beside the
binary. The guard judges file CLASSES and never content; auditing the list
itself stays a human act.
Install
cargo install thrall --locked
thrall is published to crates.io, and that is the shortest way onto a box: one
command, a Rust toolchain and a C linker, and about ten seconds on a registry
some other crate has already warmed. --locked builds the dependency set the
gate actually ran, rather than whatever a resolver picks today.
Nothing else is needed on the box at runtime — no daemon, no service, no
shared library, no state directory thrall creates for itself — beyond whatever
the operator's own tools.json entries spawn. So the whole install is a binary
plus the two files the operator writes by hand: The tool document below, and
one channel directory under <data root>/wire/workspaces/ holding the
certificate an engine's operator issued.
The other route is the image (below), for a box that takes images rather than toolchains. Either way, Deployment at the end of this file is how a foot becomes a long-running program that comes back from a crash and picks up its own releases — and it installs by exactly the command above, on the box, on a timer.
make install is the contributor's route and not this one: it builds the
working tree and puts that binary on this machine.
The tool document
<data root>/tools.json is what this box offers, and the only thing that says
so. A tool absent from it is a tool this box does not have; the server's
adjudication stacks on top of it and fails closed, but it stacks on this.
The contract is stdin, stdout, exit code, and it is the thing to know before
writing a single entry: the invocation's input JSON — the object the model
produced, whole — arrives on the command's standard input; whatever the
command writes to standard output is the capture the model reads; its
exit code is the verdict, 0 for a tool that worked. Standard error is
carried back beside stdout. Nothing is interpolated into the argv, so an entry
that expects its input on the command line receives nothing at all and
advertises a tool that silently returns emptiness.
command is an argv, spawned directly — there is no shell. That is what
keeps the declared input_schema binding rather than advisory: a shell line
built from a model's output is a command-injection surface, and the schema
would be a suggestion. A tool that wants a shell writes one in its own first
line, on the box, where the operator can read it.
Each entry is two halves in one object, and the split is the whole of the file's
design: the first three keys ARE what this box advertises, verbatim
(REMOTE §5.1), and command, cwd and everything else is local and crosses no
wire. So what this box offers and what it can actually run cannot drift.
| Key | Half | Meaning |
|---|---|---|
name |
advertised | the handle an invocation addresses, a single path component — no /, no ., no .., and unique in this file |
description |
advertised | what it does, in this box's own words |
input_schema |
advertised | the JSON Schema, verbatim — neither validated nor rewritten here, because it is this box's statement to a model |
subject_cwd |
advertised | whether this box consents to running the tool at a directory the invocation names (absent reads false). Delete the key and the capability is gone |
command |
local | the argv, spawned directly |
cwd |
local | where to run it, absolute or the document is refused. A subject_cwd invocation that names a directory outranks it |
docs/tools.example.json is a complete document — one plain entry, one
carrying subject_cwd, and one bridged MCP server — and it is the one below,
byte for byte: a test holds the two together, so the example that ships is an
example the gate reads.
The binary carries it too, so an install that never cloned this repository still has it:
thrall --example-tools > <data root>/tools.json
That is a starting point and never a default — every entry names a command this box does not have until you write one, and thrall writes no file itself. The flag prints the same bytes as the block below and the same bytes as the shipped file; there is one document and three ways to read it.
The web tool is a pinned MCP server, not a thrall feature
The third entry is not something this crate implements. thrall is the suite's
MCP client (Model Context Protocol: a JSON-RPC protocol in which a server
process publishes a catalog of tools and a client calls them), and
thrall mcp <tool> -- <server argv> is an ordinary tool command whose program
happens to be this binary: it spawns the server for the one call, performs the
handshake, makes the call, renders the result to standard output and tears the
server down. The document gains no key, the engine learns no verb, and
deleting the entry deletes the capability like any other.
Write those entries by running the server once, at your own hand:
thrall mcp pin -- uvx mcp-server-fetch
It prints one complete entry per tool in the catalog, on standard output, and
writes nothing — you paste in the entries you vouch for and no others,
which is the allowlist. What the server says about each tool's reach
(readOnlyHint, destructiveHint, openWorldHint) goes to standard error
instead: those are hints from a program you installed but did not write, so
they inform the policy row on the engine side and are never advertised.
Two things about the fetch entry above are worth reading rather than
skimming. Its description and input_schema are the server's own words,
carried into your document verbatim — that text is what a model is shown, so
read it before you paste it. And uvx is yours to install: the shipped image
carries an Alpine floor and what can run on it is still the operator's problem
(see "The image"). A web search server needs a credentialed provider; that
one is your own pin, and the credential lives in the server's argv or in a
wrapper script on this box — never in this document, and never on the wire.
An empty array is a legitimate document: a foot that offers nothing, dials, presents the empty set and waits. An absent file is not, and thrall refuses naming it — starting a foot is a deliberate act and deserves a deliberate answer:
thrall: /state/thrall/tools.json: No such file or directory (os error 2) — this box has no tool config
Build
make is the build authority. make check is the whole gate and nothing runs
a step it does not:
make check # fmt-check -> lint -> coverage
make build # debug build
make test # cargo test
make install # release build, then the binary into ~/.local/bin
make image # the OCI image (podman or docker), tagged from the crate version
make lint is line-cap, then leak-scan, then cargo clippy --all-targets -- -D warnings, then rules-audit, then cargo deny check.
Every tool is pinned, or the gate is not reproducible: rustc 1.95.0
(rust-toolchain.toml), ast-grep 0.44.1 (sgconfig.yml), cargo-deny 0.20.2
(deny.toml), cargo-tarpaulin 0.35.2 (tarpaulin.toml).
Run make install-hooks once per clone to seat the pre-commit hook.
.github/workflows/ci.yml is the same gate on a runner: it installs those pins
and runs make ci, restating no step. It fires on every pull request, and on
main as the instance release-plz.yml calls inside its own run — so a push to
main runs the gate exactly once, and a release is gated on that run.
The image
make image builds an OCI image from Containerfile. The image is the unit
of install and nothing more — no part of thrall uses the container filesystem
as a feature, and the container is not a containment boundary. A foot runs what
its operator's tool document says to run; the server still cannot see into this
box, and what stops a tool doing something is whatever this machine enforces
locally, exactly as Foot-grade trust above says.
It builds under the pinned toolchain (rust:1.95.0-alpine, checked against
rust-toolchain.toml during the build so the two pins cannot drift) and copies
one static-pie musl binary into an alpine runtime layer. About 12 MB.
The runtime base is a decision. A binary that execs nothing can ship FROM scratch, and a statically linked thrall is that binary — until it does the one
thing it exists for. A foot execs operator-configured argv: what it runs is
named in tools.json on the box, is not knowable from this repo, and is
routinely a shell line. scratch would ship a foot that answers --version
and then fails every invocation it was installed to serve. So the layer is
alpine — a shell, a package manager the operator can add their tools with, and
system CA roots for the ones that speak HTTPS. The floor is provided; what
can run on it is still the operator's problem. A tool document naming a
binary this layer does not have is a tool this box does not have.
make image pushes nothing, and there is no push target. The registry is
named — ghcr.io/mudbungie/thrall, one package per repo, pushed only from that
repo's release workflow at tag time, and what publishes is the version tag and
the manifest digest, both immutable, never a moving latest (yog
docs/DESIGN.md §10.1, operator ruling 2026-08-30). The push still does not
live in this Makefile: it is not undoable, and a convenience target for an
irreversible act is how the act happens by accident. No image has ever been
pushed, and none can be today: the release workflow carries the shape and not
the trigger, and it has no image job whatever the ruling says. The crate is a
different channel and a decided one — bl-006e adjudicated it and 0.0.1 is on
crates.io — so the two publications are no longer one question.
The image-side disclosure gate
That registry ruling is conditional, and make image-scan is the
condition. It runs as the last step of make image, so no image exists on this
box that has not been read.
It is a second gate and not a reuse of the first. make leak-scan reads
the git INDEX; an image is built from inputs no commit has — the build context
as the engine actually receives it, the base image's layers, the package index,
and the image CONFIG. The list just below says the image carries no
certificate and no tool document; until this target, nothing had read a layer
to check that claim.
It reads three surfaces with the same rule table the commit gate uses
(scripts/leak-rules.sh, sourced and never copied):
- The authored filesystem — every file or symlink whose bytes differ from the pinned base image at that path. Both filesystems are exported and compared here rather than diffing layer digests: it needs no JSON parser, it works on docker as well as podman, and it is the finer answer, since a file the build rewrote to identical bytes is not authored content.
- The distro floor is accounted for, not exempted. The runtime layer runs
apk add, which adds hundreds of files this repo did not write. apk's own ownership ledger says which package owns each one; a symlink resolving into that set is aliased distro content; everything else above the base is this repo's and is scanned. A path exemption would be an allowlist, and an allowlist is where a leak hides. - The image config — every
Env,Labeland history entry. AnENVships to everyone who pulls whether or not a file holds it, and build arguments echo into history.
The posture the commit gate already holds carries over unchanged. Findings
locate and never reprint (truncated to twelve characters). Unreadable is
rejected, not skipped: the one binary this build authors is thrall, and the
expected set is DERIVED from the Containerfile's COPY --from= destination
rather than typed into the scanner — any other authored file the rules cannot
read is a refusal. And both directions, because a scan that has stopped
matching passes everything forever: make image-scan first builds a scratch
image that layers a fabricated secret into a file, another into an ENV, and
an undeclared binary beside them, and requires all three findings, before
scanning the real image.
It is not part of make check, deliberately: check runs on boxes with no
container engine and must not depend on an artifact a build step produced.
It is the image's gate and it runs where the image is made.
What it cannot promise, stated rather than implied: it scans one image, on the
box that built it, before the push. It does not read what is already in a
registry, it cannot un-publish a digest, and whoever runs the build can bypass
it exactly as --no-verify bypasses the commit hook. That is the same
prevention-is-local split The rules records for the source gate, one artifact
over.
What mounts where
The XDG contract is the runtime contract and the image carries no state.
XDG_DATA_HOME is set to /state, which puts thrall's data root — the same
$XDG_DATA_HOME/thrall the usage text names — at /state/thrall. The extra
level is XDG's and not the image's: XDG_DATA_HOME is a parent of
per-application roots by definition.
podman run --rm -v /path/to/provisioned/root:/state/thrall:Z thrall:0.0.1
That directory is the operator's provisioned data root: tools.json, and one
directory per channel under wire/workspaces/. Both are put there by hand,
neither is ever written by thrall, and neither is in the image — a
certificate baked into a layer is a certificate published to everyone who can
pull it.
There is no VOLUME instruction on purpose. A VOLUME would let an unmounted
run succeed against an empty anonymous volume; without one, the run refuses and
names the file it could not find, which is the answer an operator can act on:
$ podman run --rm -v "$(mktemp -d)":/state/thrall:Z thrall:0.0.1
thrall: /state/thrall/tools.json: No such file or directory (os error 2) — this box has no tool config
$ echo $?
1
What the image deliberately does not contain
- No certificates and no tool document. Both are operator-provisioned, and both are the reason there is a mount instead of a layer.
- No
cargo, no compiler, no source, notarget/. The build stage is discarded whole; only the binary crosses. - No supervisor, no restart wrapper, and no entrypoint script.
thrall rundials its own channels again when they drop, but it never restarts its own process: a foot that cannot be a foot at all is an exit naming why, because restart policy belongs to this machine's own supervision, and putting a process-level retry loop in the image would take that decision away from the operator who has to live with it. - No bootstrap of any kind. thrall mints nothing (REMOTE §1.4, DESIGN §3.3); an image that could provision itself would be exactly the flow that must never exist.
Every line of that list is a claim about bytes, and make image-scan is what
turns it from a promise into a check.
The macOS artifact
make mac-artifact # -> dist/aarch64-apple-darwin/thrall
A foot runs wherever the operator's tools are, and some of those boxes are
macs. make mac-artifact cross-produces the aarch64-apple-darwin binary
from the same Linux container line the image comes off — the same
digest-pinned base, the same toolchain pin checked against
rust-toolchain.toml, the same --locked dependency answer the gate judged —
so the mac binary is reproducible from the tree rather than being whatever came
out of somebody's laptop that afternoon.
The product is a file, not an image. The build's last stage is FROM scratch carrying one binary; the wrapper is a fixture, is never pushed, and is
deleted when the artifact has been lifted out. make image-scan therefore does
not apply to it and is not being skipped: the artifact's content is compiled
from the same tree make leak-scan reads, exactly as the Linux release binary
is.
The toolchain is zig cc, and osxcross is refused
There were two ways to link a Mach-O binary on Linux, and the choice was made on Apple's licence rather than on taste. osxcross drives Apple's own SDK, which the Xcode and Apple SDKs Agreement forbids twice over — either clause alone would settle it:
2.7 The grants set forth in this Agreement do not permit You to, and You agree not to, install, use or run the Apple Software or Apple Services on any non-Apple-branded computer or device, or to enable others to do so. … You agree not to rent, lease, lend, upload to or host on any website or server, sell, redistribute, or sublicense the Apple Software and Apple Services, in whole or in part, or to enable others to do so.
2.5 You may not alter the Apple Software or Services in any way in such copy, e.g., You are expressly prohibited from separately using the Apple SDKs or attempting to run any part of the Apple Software on non-Apple-branded hardware.
The first means the SDK may never sit in this repository nor in anything
published from it. The second means the usual escape — take the SDK path as a
build argument, keep it out of the tree, let the operator supply it — does
not work either, because the builder is not Apple-branded hardware. So this
repo does not hold the SDK at arm's length; it refuses the arm. A
Containerfile that accepted an SDK path would be inviting the operator into a
term they cannot satisfy on a Linux box.
zig acquires nothing from Apple: it ships one darwin stub of its own,
lib/libc/darwin/libSystem.tbd, in its own distribution and under its own
licence, and no Apple agreement is accepted anywhere on this path. It is
pinned by version and sha256; cargo-zigbuild (which filters the darwin
linker flags zig cc will not take) is pinned exactly and installed
--locked.
And yes, it is a C toolchain, deliberately. deny.toml bans
openssl-sys, native-tls and aws-lc-sys to stop a C toolchain arriving
implicitly, through a dependency edge nobody reviewed. This one arrives
explicitly, in a file that argues for it, in a build stage that is discarded —
and Containerfile already installs musl-dev for ring on the same terms.
The posture is against the accident, not against the compiler.
The limit that comes with it
zig ships libSystem and no framework stubs at all — no CoreFoundation, no
AppKit, no OpenGL, no libobjc. A crate graph that links only libSystem
crosses cleanly; one that links any Apple framework fails at the link step with
"unable to find framework", and there is no lawful way to supply the
frameworks on a Linux builder.
thrall links none of them. Its dependency set is rustls/ring and
serde_json over std, and the verified artifact loads exactly three system
libraries, all under /usr/lib. That is not luck — it is the same
single-binary discipline the approved dependency set in Cargo.toml is written
to keep, and it is what makes the foot the component whose mac build a Linux
container can honestly produce.
What is proven, and what is not
There is no mac on the build box, so the artifact is never executed. A
green build is not evidence: a wrong architecture, a dependency on a dylib no
stock mac carries, and a binary macOS would refuse to start all look identical
to a successful cargo build. scripts/mac-verify.sh reads the produced file
instead, on any platform, with no Apple tooling:
- Proven — 64-bit Mach-O,
arm64, an executable (not a dylib); platform macOS with the minimum-OS and SDK versions it declares; every dynamic library it will ask for at load time, each of which must be a stock/usr/libor/System/Librarypath; and that a code signature is present at all. - Not proven — that it runs. It has the shape of a working mac binary and has not been observed to be one.
It runs both directions, the discipline leak-scan and rules-audit
already hold here: five fabricated malformed inputs must be refused before the
real artifact is read, because a checker that has quietly stopped checking
passes everything forever.
Two properties are worth knowing before an artifact is handed to anyone:
- The minimum macOS version is the pinned zig's, not a setting.
rustcasks for one and this zig stamps its own;cargo-zigbuild'saarch64-apple-darwin.<version>target syntax does not survive this zig either. So the floor is a property of the pinned pair — read it off the artifact, wheremac-verifyprints it, and never from a document. - The signature is ad-hoc, and that is not notarization. An arm64 mac refuses to start an unsigned binary; the cross-linker's ad-hoc signature satisfies that and nothing more. A copy that arrives over a network carries a quarantine attribute, and clearing it — or replacing the signature with a real one — is an act on a mac, by the operator, and it is outside what this line can do.
Only aarch64 is produced, because that is what was asked for. x86_64-apple-darwin
would cross on exactly the same terms (the libSystem-only rule is about the
crate graph, not the architecture) and is not built because nothing wants it.
The rules
Two are hard and machine-enforced:
- 300 lines on every source file, inline tests included. Docs and config
are exempt.
make line-capis the one definition of both. 300 is a wall, not a target;make line-cap LINE_CAP=199lists the pre-split band. - 100% test coverage. If it can't be tested, it mustn't be built.
A third is hard and machine-enforced but is not about the code:
- Nothing discloses.
make leak-scanreads the index this commit would publish — not the worktree — for credentials, routable addresses, home paths, pasted dialogue, session artifacts and content no rule can read.scripts/leak-rules.shis the one definition of what counts, and--self-testproves every rule still bites in both directions before the tree is scanned at all..githooks/commit-msgruns it over the commit message, which no pre-commit step can see. It scans one tree and promises nothing about anything already published.
Beyond those, thrall follows the house contained Rust standard from birth:
complexity lives in function bodies, where the compiler catches it, not in type
signatures, where it is viral. No named lifetimes; a pub fn returns an owned
concrete type; no generic bounds on a pub item; no panic paths outside tests;
no #[allow] in prod — policy lives in Cargo.toml [lints], justified in one
place; no Rc/RefCell. Four more rules confine a kind of code to one
file: unsafe to src/sys.rs, Mutex/RwLock to src/state.rs, and both
building and forking a child process to src/spawn.rs. Two of the three are
still unbuilt, and that is the discipline working — a confinement rule names its
location before the first site is written, or the first site picks the location
by being written. src/spawn.rs was founded the moment the suite needed to run
one program, which is the order the rule exists to force.
The rules/ directory enforces what it can, and make rules-audit checks both
directions: the tree clean and every rule, run alone by its own id, still
flagging its deliberate violation in rules/fixtures. Per rule rather than per
directory, because nine live rules would answer for a tenth dead one forever —
and because the four confinement rules have nothing in src to match yet, so
their fixture is the only thing proving they work at all.
Authorities
docs/DESIGN.md— thrall's own architecture: the role, and the invariants inherited from the wire.- yog's
docs/REMOTE.md— the protocol authority. It is versioned, and every component implements against it. Where this crate and that document disagree, one of them is a bug; never invent a third answer.
Tasks are tracked with bl. Run bl --skill before using it.
License
MIT.
Deployment
A foot is a long-running program on somebody's box, and until bl-6c98 there was
no recipe for making it one: make install put a binary on the box and that
was the whole story, so a foot was whatever somebody last started in a terminal.
It stopped at the next logout, never came back from a crash, and never picked up
a release. Observed on a live box: a data root provisioned by hand, an engine on
the far end of one of its channels holding a foot certificate issued for it, and
no thrall process running at all — an engine with no live foot, with nothing
anywhere saying so.
make deploy-local # seat THIS box: unit, reconciler, timer, first pass
make deploy HOST=<ssh-host> # seat that one
make deploy-status [HOST=…] # what a box runs and when it next looks
One payload, two carriers (bl-605f). The files and the arming commands are
spelled once in scripts/deploy/seat.sh and do not know which way they arrived;
only put and run differ. HOST is an ssh destination and the only parameter,
so pointing this at a second box is a different argument rather than an edit, and
no address, account or machine name is committed anywhere here. A box that should
stop tracking releases is one systemctl --user disable away, with no file in
this tree to change.
The remote form is the door for the box class that most needs a foot. Until bl-605f a foot could be deployed only by a human logged in to the machine that would run it, which is exactly backwards: the always-on server is awake when a laptop is not, and it is the only box that can act on ITSELF. A deployment whose engine ran there while its only foot certificate was issued for somebody's laptop could execute nothing most of the time and could never look at its own box — and neither end could say so, because a foot that is not running is indistinguishable from a foot that has not spoken lately (§3.7) and the engine may not probe (§3.1). The local form stays the door for the box running no sshd, which cannot ssh to itself.
It refuses a box that is not provisioned, and every check runs on the TARGET.
tools.json and at least one channel are the two things thrall reads, and a foot
without them refuses at every start; seating over that would be a unit
crash-looping into failed behind a report of success. It also refuses a target
with no cargo: the box installs its own releases from crates.io, so a box with
no toolchain can hold a foot binary but can never pick up the next version, and
arming a timer that could never install anything would report CD it cannot do.
| what | where it lands |
|---|---|
scripts/deploy/thrall.service |
~/.config/systemd/user/ — ExecStart=%h/.local/bin/thrall run, Restart=always, five failures in five minutes then failed |
scripts/deploy/reconcile.sh |
~/.local/bin/thrall-reconcile |
scripts/deploy/thrall-reconcile.service |
~/.config/systemd/user/ — oneshot, Nice=19, idle IO and CPU |
scripts/deploy/thrall-reconcile.timer |
~/.config/systemd/user/ — hourly, Persistent=true |
Nothing is compiled by the seating and nothing is carried onto the box but those four text files: it installs from crates.io on its own schedule from then on. A foot's unit of install is a published version, and the registry already serves it.
What supervision is and is not for. A channel that drops is dialled again by the process itself, with a backoff that starts at a second and stops at a minute — restarting a process was never the answer to a dropped channel. The unit is for the other exit: a foot that cannot be a foot at all, which exits and says why.
What one pass does. Read the newest live version off the crates.io sparse
index; if it differs from what ~/.local/bin/thrall --version says, cargo install thrall --root ~/.local --locked --version <v> --force. Then decide
whether to restart, from four facts and one rule — the foot should be running
the newest live version, unless an invocation is executing or the operator
stopped it:
- the unit is
inactive— stopped on purpose, not ours to move. - the unit is
failed— no invocation to protect, and no point retrying the version that just failed; a version it has NOT run is the one useful act. - the running foot already IS the newest live version — nothing to do, read
as the two binaries' own answers rather than a flag anybody writes: what
/proc/<pid>/exe --versionsays against what the installed file says. The version and not the inode (bl-ad9c): an inode says only "some other file is there now", which is the same question only while the reconciler is the sole writer of the install path. A handmake installis a second writer — a new inode at no new published version — and under the inode read the next tick restarted the foot onto it, killing whatever invocation was executing at the moment the box looked idle enough to act. - an invocation is executing — defer. thrall runs every tool as a child
process (
src/spawn.rs) and its own concurrency is threads, so an idle foot is exactly one process in the unit's cgroup and an invocation in flight is a child beside it. No thrall-side API, no new wire act: thrall's whole wire surface is three acts by design and none of them is "are you busy". - anything unreadable — defer. A fact we could not read is never grounds for killing work we cannot see.
A killed invocation is worse than a killed turn on the engine, and that is why the deferral is here rather than left to luck: the command already ran half, its side effects on this box stand, the capture never comes back, and nothing settles that the way the engine's litany settles an unanswered tool window — because the act was not a message.
A yank is the rollback lever. Yanked versions are filtered in the reconciler
rather than left to cargo, so yanking a bad release makes the previous one
newest-live; the next tick sees it differ from what is installed and puts it
back, with nobody logging in. That is why the install passes an explicit
--version with --force — cargo install refuses to go backwards otherwise.
The seating refuses a protocol mismatch rather than reporting success over
it, and never claims service it cannot establish. is-active says a process exists, not that it is a foot, and thrall
writes no line when a channel opens — so the positive cannot be read from the
journal. The one refusal that can be is a wire protocol mismatch (§3.6):
every other failure there is transient by construction, and a mismatch is
fail-closed by design and resolves only when one of the two components
publishes. A box in that state has a seated timer and no foot, and the deploy
says so. The units stay armed, because that is what makes it self-healing: the
box adopts the fix by itself on the next tick after the release.
A dial that has simply not landed yet — an engine that is down, a listener not yet up, a name that has not resolved — is the opposite case and is reported as a notice: it is transient by construction, §3.8's backoff is what it is for, and the units are right where they should be. It earned its own arm the first time this script was run against a real server (bl-605f), where the foot was redialling a refused port and the last line said "serving" — the exact blindness the paragraph above exists to refuse, arriving through the one arm that had no case. The last line now says what can be established (which version is running, and that the box tracks releases) and not what cannot.
make deploy-selftest is the regression half and runs in make lint. It
drives the real reconciler under a fake curl, cargo and systemctl in a
scratch HOME, both directions: half the cases assert an install or a restart
happened and with exactly which arguments, and half assert cargo was never
invoked or the unit was never touched. A reconciler that installs on every tick
and one that has quietly stopped are both broken, and only one of them is loud.
The decision itself is a pure function with its own table
(scripts/deploy/reconcile.sh --self-test), because the restart arms need a live
/proc/<pid>/exe that no fake world can offer.