thrall 0.0.22

thrall: the foot — a tool-execution client for a yog server
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
# 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:

1. **advertise** — present what this box offers: for each tool, a name, a
   description, and a JSON Schema for its input.
2. **wait** — hold open one read on its own mailbox until the engine hands it
   an invocation.
3. **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 punch port an entry behind a NAT binds for
its own simultaneous open is the one stated exception (`docs/DESIGN.md` §2); **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 — and, for an engine that cannot be
dialled, the rendezvous pairing (*Reaching an engine behind a NAT*, below).

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.

## Reaching an engine behind a NAT

A channel directory holds four files — `ca.pem`, `client.pem`, `client.key`
and `address` — and dials the address. An engine behind a residential NAT with
no port-forward cannot be dialled (yog `docs/REMOTE.md` §13), so its operator
carries two more files into the same directory, exactly as they carry the
certificate:

| File | Holds |
|---|---|
| `rendezvous.pub` | the engine's rendezvous public key, 32 bytes, hex |
| `pairing.salt` | the pairing salt the two ends share, 32 bytes, hex |

Both or neither. With them, a dial that the address does not answer falls
through: the foot reads the engine's presence off the BitTorrent mainline DHT,
writes a sealed call into the engine's inbox there, and both ends punch a TCP
connection toward each other. The connection is then **held** between asks
rather than dialled per ask, the engine's keepalive pings are discarded, two
minutes of silence hangs it up, and a hang-up is dialled again through the same
ladder — held connection, direct address, a re-punch at the last endpoints, the
full rendezvous. `address` stays required: it is the name the engine's
certificate is verified against, whichever way the connection was made. A
channel without the two files never touches the DHT and never binds a port.
`docs/DESIGN.md` §3.11 is the design; the protocol is REMOTE §13.

## 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.

```json
[
  {
    "name": "Bash",
    "description": "run a shell command on this box",
    "input_schema": {
      "type": "object",
      "properties": {
        "command": { "type": "string", "minLength": 1 }
      },
      "required": ["command"]
    },
    "command": ["/usr/local/libexec/thrall-tools/bash-tool"]
  },
  {
    "name": "Build",
    "description": "build the project checked out on this box",
    "input_schema": {
      "type": "object",
      "properties": {
        "target": { "type": "string" }
      },
      "required": ["target"]
    },
    "command": ["/usr/local/libexec/thrall-tools/build-tool"],
    "cwd": "/srv/project",
    "subject_cwd": true
  },
  {
    "name": "fetch",
    "description": "Fetches a URL from the internet and optionally extracts its contents as markdown.\n\nAlthough originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.",
    "input_schema": {
      "description": "Parameters for fetching a URL.",
      "type": "object",
      "title": "Fetch",
      "properties": {
        "url": { "description": "URL to fetch", "format": "uri", "minLength": 1, "title": "Url", "type": "string" },
        "max_length": { "default": 5000, "description": "Maximum number of characters to return.", "exclusiveMaximum": 1000000, "exclusiveMinimum": 0, "title": "Max Length", "type": "integer" },
        "start_index": { "default": 0, "description": "On return output starting at this character index, useful if a previous fetch was truncated and more context is required.", "minimum": 0, "title": "Start Index", "type": "integer" },
        "raw": { "default": false, "description": "Get the actual HTML content of the requested page, without simplification.", "title": "Raw", "type": "boolean" }
      },
      "required": ["url"]
    },
    "command": ["/usr/local/bin/thrall", "mcp", "fetch", "--", "uvx", "mcp-server-fetch"]
  }
]
```

### 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`, `Label` and history entry. An `ENV` ships
  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, no `target/`.** The build stage is
  discarded whole; only the binary crosses.
- **No supervisor, no restart wrapper, and no entrypoint script.** `thrall run`
  dials 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/lib` or
  `/System/Library` path; 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.** `rustc`
  asks for one and this zig stamps its own; `cargo-zigbuild`'s
  `aarch64-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, where `mac-verify` prints 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-cap` is the one definition of both. 300 is a wall, not
  a target; `make line-cap LINE_CAP=199` lists 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-scan` reads 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.sh` is the one definition of what counts, and
  `--self-test` proves every rule still bites in both directions before the
  tree is scanned at all. `.githooks/commit-msg` runs 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 --version` says 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 hand `make install` is 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.