Skip to content

The package repository's keys: toyos-root signs root 1 offline, and the publisher signs under it alone (stage 1 of apps leaving the tree) - #892

Merged
Japabu merged 6 commits into
mainfrom
wt/toyos-apps-out
Oct 13, 2026
Merged

Japabu merged 6 commits into
mainfrom
wt/toyos-apps-out

Conversation

@Japabu

@Japabu Japabu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Stage 1 of the accepted design is built: the package repository's keys. The design below was accepted at e2ebc2c68 with round 4's change (a) (6136355825), which this body now carries in its stage 2 text. The stage 1 review (6136858838) and the owner's rulings on its NOTEs (6136865450) are answered at ad6f61c49, the second review (6137091933) at 72caf9c0d, the third (6137214587) at c1e06aa01, and the fourth (6137327123) at 41bbac4f5. The branch is six commits on main's b6a556546, which is still origin/main: the stage 1 build 423f6fda7, the merge of main befc4449f, and the review rounds ad6f61c49, 72caf9c0d, c1e06aa01 and 41bbac4f5. Nothing is signed for real. The root-key ceremony is the owner's, and its steps are below.

What stage 1 changed, per decision

  • toyos-root (build/root, a new workspace member). It depends only on toyos-update with sign and on getrandom. It is built for a Unix triple, because it mints the root key 0600. It has two commands and no more:
    • toyos-root new <key file> mints the root key 0600 into a file that must not exist yet. The file holds the seed as toyos_update::sig::seed_text writes it. The command prints the key's fingerprint and public line.
      • It reads the new file's mode back from the open file before it writes the seed, in read_back_0600, which new calls with the open file. Where the mode is not 0600, it removes the empty file and refuses, naming the path and the mode it read. The host test a_key_file_that_reads_back_other_than_0600_is_refused_and_removed holds the refusal and the removal on a 0644 file in a tempdir, on every host. exFAT and FAT32, the formats USB sticks come in, keep no Unix mode: open(O_CREAT, 0600) succeeds on them and every file reads back the mount's one mode, measured as 0700 on both. Before this round, new printed a fingerprint there as though it had minted 0600.
      • sign takes no read-back. Root 1 is public, and stick C may be any format.
    • toyos-root sign --key <key file> --signing <public line file> --out <root.1.txt> writes root 1, mode 0644.
      • The root key alone holds the root role. The signing key holds the timestamp and targets roles. Each role is at threshold 1.
      • Root 1 expires 365 days from the machine's clock.
      • Before writing, it reads root 1 back through repo::self_signed.
      • It prints the document, both fingerprints and the expiry.
      • It refuses a line that names the root key itself, and any output file that already exists.
    • Its module header carries the ceremony's steps, as the design asked. A and B are named ROOTA and ROOTB, so <A> is /Volumes/ROOTA and <B> is /Volumes/ROOTB, and <disk> is taken only from an entry diskutil list marks (external, physical). After the erase, sudo diskutil enableOwnership is run on each (the orchestrator's ruling on round 3's first NOTE), because an APFS stick mounts noowners, and a volume that ignores ownership treats every user as the owner 0600 names; mount must then list neither as noowners. The step cites Apple's diskutil(8), enableOwnership, which defines it and gives its scope: the setting is written to "the on-root-disk Volume Database at /var/db/volinfo.database", persists "as seen by the current OS", and "is not kept on the target disk", and plug-in USB disks do not consider ownership by default. So 0600 on A and B binds only on the offline machine, and the header says the sticks' guard anywhere else is custody (step 2.5). Step 2.3 reads the mode back on both copies with stat -f %Sp; it reads no owner, because the owner read-back cannot fail here: on a noowners volume stat reports the current user, and with ownership enabled the on-disk owner is that same user. The mount line is the one check that discriminates. Step 1.2 names the one measured path (ruling 4): cargo build --release -p toyos-root --target aarch64-apple-darwin on this Mac, for an offline Mac of the same macOS and architecture. Every other offline triple is stated as unmeasured. --previous, renewal and rotation stay stage 9's.
  • cargo run -- --repo-signing-key-new mints the repository's signing key at ~/.config/toyos/repo-signing-key (0600), and refuses to replace one.
    • It prints the fingerprint on stderr and only the public line on stdout, so the line can be redirected onto the carrying stick.
    • The key is a signing::RepoKey (ruling 3). That type signs documents. It has no method that signs an image header and no floor scope. Key, the image key, loses sign_document and is otherwise unchanged.
  • The publisher (build/publish.rs) loses one_key, root minting and root renewal, along with ROOT_LIFE, RENEW, THROWAWAY_DIR and repository(). What it does now:
    • It receives roots as roots/root.<N>.txt beside its manifest, walking from root 1. Any other file in roots/ is refused by name (ruling 2). That includes a root past a gap, such as root.3.txt with no root.2.txt.
    • It copies each root the repository lacks. It refuses by name a received root that differs from one the repository holds.
    • It holds root 1 to repo::self_signed, because the client trusts the root it pins unchecked.
    • The grant check is gone (ruling 1). A root that does not grant the key the targets and timestamp roles is refused by the read-back through repo::refresh, before anything is written.
    • Names are flat: an archive is <name>-<sequence>-<target>.tar.gz at the repository's top level, and archives/ is gone.
    • After landing targets M, it prunes every targets file below M−1 and every archive that neither M nor M−1 names. It never deletes a root. pruned is a pure function over a file list, so stage 2 can apply it to the release's asset listing.
    • --publish <packages.toml> signs with the repository's signing key, read 0600 from its path, into ~/.config/toyos/pkg-repository.
      • The throwaway arm is gone, because no throwaway root exists to sign under. Stage 4's --packages brings back the per-checkout repository.
      • --publish itself goes in stage 2, as the design says.
  • One home for a seed's key-file text: toyos_update::sig::{seed_text, seed_of_text}, behind sign. The text is 64 lowercase hex digits and one newline. The reader accepts only text that seed_text would write back byte for byte.
    • All three key files go through it: the checkout's throwaway key, the repository's signing key and the root key.
    • Both seed_of_hex copies and hex_of are deleted. The old readers differed: one stripped a single \n and the other called trim(). Both accepted upper case and a + sign through from_str_radix.
  • One home for the SHA256: fingerprint: toyos_update::repo::fingerprint.
    • toyos-ssh now depends on toyos-update and calls it. Its own hostkey::fingerprint and the host_ed25519.fingerprint fixture are deleted.
    • The assert that held the two copies equal is deleted.
    • Why toyos-ssh depends on toyos-update, and not both on a new crate: toyos-update is pure and no_std, and already builds on every host. A crate for one function would be a third place to look.
  • toyos_update::repo gains five items:
    • self_signed;
    • fingerprint;
    • public_line and its parser parse_public_line (ssh-ed25519 <base64>, the words a root's key line already carried, which Root::of now parses through the same helper);
    • render::first_root, the one definition of root 1, used by toyos-root sign and by the publisher's tests.

Choices this build made that the design left open:

  • --signing takes a file holding the public line rather than the line itself. On the offline machine the line arrives as a file on the carrying stick, so nothing has to be typed or pasted there.
  • Both key files hold the seed in hex. The design named this form for the root key. The signing key uses it too, because its contents are pasted into CI's secret as one line.
  • The publisher finds its roots in roots/ beside its manifest, which is where packages keeps them.
  • The ceremony names the environment secret REPO_SIGNING_KEY. Stage 2's packages-publish reads it under that name.

One claim per test. Each test the review named is split into one test per claim:

  • toyos-root's two tests became seven;
  • signing's repository-key test became four;
  • publish's two refusal tests became eight;
  • repo's two new tests became five;
  • repo_oracle's new test became three, and its round-trip leg was cut because repo's unit test holds it.

The duplicate is cut. toyos-root's test no longer drives a repository through refresh, because repo's tests hold first_root's grants. Instead it holds the root that sign writes to render::first_root of the minted key, byte for byte, which m12 shows can fail.

Lines (git diff --shortstat origin/main...HEAD): 18 files, +996 −230, net +766. This round is +8 −6, all in build/root/src/main.rs's header: the diskutil(8) citation and its scope, and step 2.3 cut back to %Sp. Production code, manifests and lock are net +362. Tests are net +377, counted as the lines from each file's #[cfg(test)] on, plus repo_oracle.rs and the deleted fixture.

Gates

At 41bbac4f5, this round. Evidence is at ToyOSOrg/evidence pr-892 57cb84cd0, with every exit in exits.txt. The round changes only the header's prose, so no test or mutation is rerun.

gate exit log
cargo run -- --ci host 0 (76 steps, clippy included) ci-host.txt
diskutil(8) read on this host, the ownership step's oracle read, enableOwnership at lines 220-257 diskutil-8.txt

At c1e06aa01, the round before. Evidence is at ToyOSOrg/evidence pr-892 e92ddd9ee, with every exit in exits.txt.

gate exit log
cargo run -- --ci host 0 (76 steps, clippy included; it runs tests::a_key_file_that_reads_back_other_than_0600_is_refused_and_removed) ci-host.txt
m14 and m15 against that host test, applied, restored, tree clean the runner exits 0; m14 (the read-back refuses nothing) 101, m15 (the refused file is not removed) 101, restored cargo test -p toyos-root 0, git status empty mutate.txt, m14, m15, runner
the ceremony oracle, the head's step 1.2 build run from an ExFAT stick C: evidence for every expectation but the ownership step 4; every expectation but the ownership step's green medium-oracle.txt, oracle

The ceremony oracle (medium-oracle.sh) erases blank 64 MiB disk images with the header's own commands. C is ExFAT STICKC, and the binary is copied onto it: every toyos-root below runs from C, as steps 2.2 and 2.4 do.

  • Green. new from C onto APFS ROOTA exits 0, and stat reads -rw-------. Step 2.3's copy onto APFS ROOTB passes cmp and reads the same. sign from C exits 0, writes root 1 onto C, and prints the root key fingerprint new printed and the signing key fingerprint ssh-keygen -l prints. new from C onto ExFAT and onto FAT32 exits 1 with reads back 0700, not 0600, and leaves no file. date -u -j 101312002026 prints Tue Oct 13 12:00:00 UTC 2026.
  • The ownership step is not this run's evidence. Its four red expectations share one cause, sudo: a password is required: enableOwnership needs root, which an agent does not hold, so an agent can never make them green. The step is macOS's behaviour, not ToyOS's; its oracle is Apple's diskutil(8) (row above), and its measurement is the owner's mount check in the ceremony, which comes before new and stops the ceremony before any key exists if the step did not take.

The round-2 disk-image oracle stays as the evidence of what ExFAT and FAT32 read back; the suite never runs it. The gate on the refusal is now the host test, which m14 and m15 each turn red.

At 72caf9c0d, round 2. Evidence is at ToyOSOrg/evidence pr-892 659e45d56, with every exit in exits.txt.

gate exit log
cargo run -- --ci host 0 (76 steps, clippy included) ci-host.txt
m14 against the medium oracle, applied, restored, tree clean the runner exits 0; mutated oracle 4, restored oracle 0, git status empty m14-no-readback.txt, patch, runner, oracle

--build-only and console_image_boots were not rerun this round: the round touches build/root alone, which no image and no guest links.

The medium oracle (medium-oracle.sh). Three blank 64 MiB disk images were each attached unmounted and erased with the header's own command, diskutil eraseDisk <fs> <name> GPT <disk>. Then the step 1.2 build of toyos-root new was run onto each one. The script exits with the number of expectations that failed:

  • APFS: new exits 0, and stat reads -rw-------. Step 2.3's copy, cp and then cmp, reads -rw------- too.
  • ExFAT and MS-DOS FAT32: new exits 1 with reads back 0700, not 0600: its filesystem keeps no Unix mode, so the root key is not written there, and no file is left.
  • Step 2.1's clock command, parsed without setting the clock: date -u -j 101312002026 prints Tue Oct 13 12:00:00 UTC 2026. sudo date -u was not run, because it would set this shared machine's clock.

m14 drops the read-back. The oracle goes red with exit 4. On ExFAT and on FAT32, new exits 0, prints a fingerprint, and leaves a -rwx------ key.

At ad6f61c49, the round before. Evidence is at ToyOSOrg/evidence pr-892 4cfd338a3, with every exit in exits.txt.

gate exit log
cargo run -- --ci host 0 (76 steps, clippy included) ci-host.txt
cargo run -- --build-only 0 build-only.txt
cargo test --test toyos-build -- console_image_boots 0 guest-console-image-boots.txt
mutations, 13 patches the runner exits 0; each arm 101 in at least one crate; the unmutated tree 0 in all four crates and clean mutations.txt, patches and runner
OpenSSH oracle over the ceremony, with the step 1.2 build and --publish the script exits 0; every step 0; every control 1 or 255 ceremony-openssh-oracle.txt, script

Guest tests reached.

  • No guest calls toyos_update::repo. rg 'repo::' outside lib/update and build/ finds none.
  • The loader, update and the supervisor link toyos-update without sign, so none of them compiles seed_text or seed_of_text.
  • This round does change how the build reads the throwaway key that signs every guest image, and that path is reached by every boot. Round 1 ran update_writes_the_idle_slot_through_the_block_service, but Integration tests outside the kernel are cut, and unit and module tests hold what they checked #893 cut that test and moved what it held to update-copies's host tests. Those tests ran green in --ci host, and console_image_boots was run as the one guest boot.
  • No guest test is added.

Negative control. Each check was mutated by its own patch:

  • the patch was applied with git apply --check;
  • the tests of toyos-build (publish::, signing::), toyos-update, toyos-root and toyos-ssh were run;
  • the patch was reversed.

Every arm went red (exit 101) in the test that holds it. The unmutated tree was green, and git status printed nothing afterwards:

  • m1: roots() ignored what the walk does not reach. a_root_past_a_gap_in_roots_is_refused_and_nothing_is_written went red.
  • m2: root 1's self-signature was skipped. a_root_one_that_is_not_self_signed_… went red.
  • m3 and m4: pruning dropped targets M−1, and forgot what M−1 names. pruning_keeps_… went red.
  • m5: archives went back under archives/.
  • m6: self_signed vouched for nothing.
  • m7: first_root granted the signing key the root role.
  • m8: the repository key's mint replaced an existing key.
  • m9: the fingerprint kept its padding. the_fixtures_key_has_the_fingerprint_ssh_keygen_printed went red.
  • m10: the root key was minted 0644. the_root_key_is_minted_0600 went red.
  • m11: seed_of_text took any near form. a_text_not_in_the_seed_texts_form_is_refused went red.
  • m12: root 1 expired a day early. sign_writes_the_ceremonys_root_one_expiring_a_year_on went red.
  • m13: toyos-root replaced an existing file. new_over_a_key_is_refused_and_leaves_it and sign_over_an_existing_root_is_refused went red.

The grant-check mutation of round 1 is gone with the check. The publish read-back now refuses a root that does not grant the key, and a_root_that_does_not_grant_the_key_is_refused_and_nothing_is_written asserts that refusal's message. m7 does not exercise that refusal. m7 takes the root role off the root key, and the test goes red on root 1 is not self-signed (mutations.txt:947-948). No mutation in this record removes the grant refusal on its own. The test fails without it, because the publish would then return Ok.

m14, adapted to read_back_0600, and m15 are round 3's mutations, and they are listed under Gates.

Independent oracle: OpenSSH (ssh-keygen), run on this host. The ceremony was run end to end with throwaway keys, under a scratch $HOME and on scratch "sticks":

  • Step 1.2 as the header states it. cargo build --release -p toyos-root --target aarch64-apple-darwin exited 0, and file reports a Mach-O 64-bit arm64 executable.
  • The root key's mode. toyos-root new wrote it -rw------- under umask 0022, and a second new over it exited 1.
  • Fingerprints. ssh-keygen -l over each public line printed the fingerprint that toyos-root and --repo-signing-key-new had printed.
  • Root 1's signature. ssh-keygen -Y verify -n toyos-root accepted it under the root key's line (exit 0). It refused the same signature under the signing key's line, and a body with one role's threshold bent (255 each).
  • The publisher. --publish under that root 1 exited 0 twice. With a roots/root.3.txt added past the gap, it exited 1, naming the file.
  • The committed host tests (repo_oracle.rs) hold repo::fingerprint to the string ssh-keygen -l printed over the OpenSSH-signed fixture's key, and hold that fixture's root to self_signed.

The ceremony, for the owner

What you bring:

  • a Mac of this Mac's macOS and architecture (Apple silicon), with no network connection, that will never be connected again. This is the one measured path. An offline machine of any other OS or architecture is unmeasured.
  • two USB sticks, A and B. On the offline machine, run diskutil list and take each stick's whole disk only from an entry it marks (external, physical): any other entry is the machine's own disk, and eraseDisk would erase it. Erase A with diskutil eraseDisk APFS ROOTA GPT <disk> and B with diskutil eraseDisk APFS ROOTB GPT <disk>. exFAT and FAT32, the formats sticks come in, keep no Unix mode, and toyos-root new refuses them. Then run sudo diskutil enableOwnership /Volumes/ROOTA and sudo diskutil enableOwnership /Volumes/ROOTB, and check that mount lists neither as noowners. The setting lives in this offline Mac's volume database, not on the sticks (diskutil(8)), so on any other Mac A and B are guarded by your custody alone. Below, <A> is /Volumes/ROOTA and <B> is /Volumes/ROOTB;
  • a third stick, C, for carrying files between the machines.

1. Online, before, from a ToyOS checkout at main once this lands:

  1. cargo run -q -- --repo-signing-key-new > <C>/signing.pub. Write the SHA256:… fingerprint it prints on paper.
  2. cargo build --release -p toyos-root --target aarch64-apple-darwin, then cp target/aarch64-apple-darwin/release/toyos-root <C>/.
  3. Unplug C.

2. Offline:

  1. Run date -u. It must show today's date in UTC, because root 1 expires a year from that clock. If it does not, set the clock by hand with sudo date -u <MMDDhhmmCCYY>, for example sudo date -u 101312002026 for 2026-10-13 12:00 UTC, and run date -u again.
  2. Run <C>/toyos-root new <A>/root-key. It mints the root key 0600. Write its fingerprint on the paper.
  3. cp <A>/root-key <B>/root-key && cmp <A>/root-key <B>/root-key. It prints nothing when the copy is whole. Then stat -f %Sp <A>/root-key <B>/root-key must print -rw------- on both lines.
  4. <C>/toyos-root sign --key <A>/root-key --signing <C>/signing.pub --out <C>/root.1.txt.
    • Its root key and signing key lines must match the paper.
    • Its expires must be a year from today.
  5. Unmount and unplug A and B. Keep them apart and offline, each in its own place. Only C goes back.

3. What you bring back, and where it goes:

  • <C>/root.1.txt, and nothing else.
    • It becomes roots/root.1.txt in ToyOSOrg/packages and images/pkg/root.1.txt in ToyOS.
    • Hand it to the orchestrator. The orchestrator lands each copy with a read-back through repo::self_signed and through the publisher, whose read-back refuses a root that does not grant the signing key.
  • The signing key. Paste the contents of ~/.config/toyos/repo-signing-key (one line of 64 hex digits) into the REPO_SIGNING_KEY secret of ToyOSOrg/packages's publish environment, then delete the file.
    • Until stage 2 lands, that environment can wait, and the file stays 0600 on this machine.

Unsure

  • A secret is read as exactly 64 lowercase hex digits and one newline. Stage 2's workflow must write it to the file in that form. A secret pasted without its newline, or with one too many, is refused by name, not read.
  • toyos-root built here is only measured on this Mac's macOS. It is not measured on an older macOS release. The offline Mac is assumed to run this Mac's macOS, as ruling 4 states.
  • The mode read-back is measured on disk images, not on a USB stick. The images were erased with the same diskutil command the ceremony uses, and the mount's file system is what new reads back through. A real stick was not measured.
  • The read-back test for images/pkg/root.1.txt lands later. The ToyOS pull request that commits that file lands with the test. This stage writes no test that reads a file which does not exist yet.
  • One issue is left as it is. The build rounds are to rewrite issues/a-package-is-a-directory-under-apps-and-the-installer-is-a-program.md to this design. Stage 1's work list does not name it, so this stage does not touch it.

The design, as accepted

This round follows the rulings recorded on this pull request:

  • 6129963881: pkg gets network; repository name only; files stays; consent_prompt and app_view go with the test sweep.
  • 6130600934: an offline root key; recipes, their CI and publishing in a separate ToyOSOrg/packages; the orchestrator's nine technical rulings on round 1's review (6130406171).
  • 6136067341, on round 2's review (6136028679): revocation at launch is stated as holding only once the supervisor's newest root sits where no other row can write, and filed; the toolchain release dispatches only from a run still at main's tip, and release_as stays as it is; pkg install <file> goes and the staging file is pkg's; toyos-root is accepted, and stage 1 builds only new and the signing of root 1; the review's NOTEs are taken as written.
  • 6136312811, on round 3's review (6136309371): release gets one concurrency group with cancel-in-progress: false; a packages push or pull request builds against the ToyOS commit the repository release's notes name, one path with no fallback; "main's tip is always checked" holds only when the tip's own run reaches release.
  • 6136355825, on round 4's review (6136351880): (a), a key release's asset never changes once it is up; the design is accepted with it.

The design in one paragraph

Each app's source lives in its own repository. ToyOSOrg/packages holds one directory per package: the source as a gitlink pinned by commit, a recipe, and the Cargo.lock it builds with. After a merge to ToyOS main whose run still finds it main's tip, ToyOS puts up that commit's toolchain release and dispatches the packages CI with the commit. The CI builds every recipe for both ToyOS targets with that commit's toolchain, SDK and forks, and holds each recipe to the licence gate and the host gate. Where the commit is still main's tip when the builds end, it signs and publishes the repository as the assets of one GitHub release. One root key, which the owner keeps offline, certifies one signing key, which CI holds and which signs the targets and the timestamp. Every image pins the root committed in the ToyOS tree. pkg install gbae refuses an item built for another SDK before it fetches anything. It fetches through a second program that alone holds netstack, verifies the whole archive before it inflates any of it, and commits the package as a versioned directory under /apps/gbae/ behind one atomic pointer. The supervisor launches a package only when a signed targets names its binary's digest.

ToyOSOrg/packages

<name>/source          gitlink: the package's source, at the commit it is built from
<name>/recipe.toml     what the source does not say
<name>/Cargo.lock      the source's lock, resolved under ToyOS's patch table
forks.toml             each fork only packages use, by git URL and rev
roots/root.<N>.txt     every root the ceremony signed
.github/workflows/     build.yml, timestamp.yml
  • The source is a gitlink. Its commit is the pin, and git names objects by their hash. actions/checkout with submodules: true fetches every source at its pinned commit, so ToyOS's build system fetches no source itself. A source bump is a pull request to packages. ToyOS's own apps move to ToyOSOrg/<name>; gbae stays at Japabu/gbae. The owner creates the repositories, and each one's first commit names the ToyOS commit its history came from.
  • The recipe says only what the source does not:
    files = ["DOOM1.WAD", "soundfont.sf2", "LICENSE"]   # from the source, placed beside the program
    [licence]                                          # each file's terms, for the licence gate
    "DOOM1.WAD" = "…"
    "soundfont.sf2" = "…"
    • The package's name is its directory's name.
    • Its version, which a person reads, is the source's own Cargo.toml version.
    • Its sequence is the publisher's to assign.
  • The lock is what --locked holds the build to (measurement 2 below). cargo run -- --package-lock <packages>/<name>, run from a ToyOS checkout, writes it:
    • It starts from the source's own Cargo.lock, so every crate the patch table does not name keeps its author's pin (4c: only the patched crate's source line moved).
    • It moves each patched crate onto its fork.
    • It refuses a lock in which a crate the patch table names still resolves from crates.io.
    • A ToyOS fork that moves makes the lock stale, and the build then refuses by name (2f).
  • forks.toml declares each fork that no ToyOS crate uses. Once the apps leave, these are winit, softbuffer and raw-window-handle (used today by calc, doom and snake) and sysinfo (used by toyfetch).
    • These cannot stay in ToyOS's root [patch], because cargo warns on every run about a patch the crate graph does not use (2c, 2d).
    • Each fork is declared once: a crate named both in ToyOS's [patch] and in forks.toml is refused by name.
  • roots/ holds the public roots. The publish job uploads any root the release lacks before it uploads anything else.
  • Branch protection: main moves only by pull request, as ToyOS's does.
  • Licence: packages is MIT OR Apache-2.0, as ToyOS is.

The builder: three measurements, and what they decide

The two logs are cargo-patch-locked-profile.txt and cargo-patch-same-version.txt. Their scripts sit beside them. The setup:

  • cargo 1.98.1;
  • a fresh CARGO_HOME;
  • a fork and an app that are local git repositories reached by file:// URLs. Cargo resolves these as git sources, the same way it resolves a GitHub fork branch.
  1. A git-branch patch given with --config floats.

    • With patch.crates-io.itoa = { git, branch = "toyos" }, the build compiled the branch's commit A (1a).
    • After the branch moved, the same command compiled commit B (1c).
    • With rev = A, the build compiled A while the branch was at B (1d).

    So the builder never passes a branch. Each fork's commit is the rev that ToyOS's Cargo.lock records for it (for example winit's #f58e1f3b…, Cargo.lock:5474), or the one in forks.toml.

  2. --locked against the source's own lock fails under a patch, and a patch can go unused while the build exits 0.

    • Where the fork carries the version the lock pins, as ToyOS's forks carry upstream's:
      • cargo build --locked exits 101 (4b).
      • Without --locked, cargo moves only the patched crate's source line (4c).
      • With that rewritten lock, --locked exits 0 and the binary runs the fork (4d).
    • Where the fork carries another version than the lock pins, the patch is left unused, with only a warning:
      • cargo install --locked exits 0 after compiling crates.io's itoa 1.0.11 (2c).
      • cargo build does the same (2d) and records [[patch.unused]], and --locked then accepts that lock (2e).
    • cargo install without --locked ignores the source's lock: it compiled itoa 1.0.18 and ryu 1.0.23 where the lock pins 1.0.11 and 1.0.10 (2b).

    So the builder runs cargo build --locked in the source's checkout, with the recipe's lock, and never cargo install. cargo install can take no lock but the source's own, and does not refuse an unused patch. The builder also refuses a lock in which a crate the patch table names resolves from crates.io.

  3. cargo install builds the source's own release profile.

    • With no profile named, it builds release (3a).
    • The source's manifest decides that profile: its own [profile.release] debug-assertions = true took effect (3b).
    • --profile toyos, with every key of ToyOS's [profile.toyos] (Cargo.toml:320) given by --config, built toyos with debug assertions and overflow checks on (3c; 3e under cargo build).
    • --config overrode the source's own [profile.toyos] (3d).

    So the builder passes every key of ToyOS's profile. A source's own profile keys apply only where ToyOS's profile states none.

The builder's command, for one recipe and one target: cargo build --locked --profile toyos --target <triple>, run in the checkout of <name>/source with <name>/Cargo.lock in place of the source's own lock. It runs through cargo_build (build/build.rs:399) under GuestEnv whole (:370), as an in-tree guest build does today, so the environment is declared once:

  • RUSTUP_TOOLCHAIN names the commit's sysroot, which carries the linker;
  • cc is CSysroot::cc_env (build/clang.rs:150): CC_<triple>, AR_<triple> and CFLAGS_<triple> name the toolchain's clang and llvm-ar against libc's C sysroot, so a recipe's C, doom's included, is compiled for ToyOS and never by the host's cc;
  • RUSTFLAGS and RUSTC are removed, and the image key and floor scope are set, as for every guest crate.

Its arguments add --config carrying [profile.toyos], every fork by rev, and every SDK crate by path into the ToyOS checkout.

The path patch to the checkout is round 1's measurement (cargo-install-config-patch.txt).

The packages CI, against ToyOS main

Why CI builds the packages, not each author. The ABI is completely unstable:

A package built at every merge is built for the SDK that main has.

The trigger.

  • ToyOS's publish.yml already runs on every push to main and builds that commit's toolchain.
  • Its release job moves there from nightly.yml. release_as stays as it is: a run whose commit is still main's tip puts up its key's release, toolchain-linux-x86_64-<sysroot key>, and moves the sdk- alias; a run main has moved past returns before lay_out and puts up nothing (build/release.rs:344-346, sdk_at_tip at :373). Today's release is 460,242,927 bytes (github-release-asset-names.txt).
  • Only a run that put up its release dispatches. It then sends workflow_dispatch to packages's build.yml, naming the commit, through the build system's own GitHub client (build/release.rs). A run main has moved past dispatches nothing, so the merge it ran for is never built against the recipes on its own; the run of the merge that passed it checks both together. The last merge of any burst is never passed, so main's tip is checked whenever the tip's own run reaches release. A red toolchain or crates.io publish at the tip stops that run before release, which leaves the tip unchecked and the repository at the older SDK until the next merge (the roast).
  • How many merges that leaves unchecked. Over main's newest 100 publish.yml push runs (2026-10-08 to 2026-10-13), a later push run had already been created by the time all of the run's jobs had finished in 15 (publish-runs-overtaken.txt, script beside it, exit 0). The release job decides after its own checkout, cache restore and driver build too, so 15 is a floor, and a cold toolchain build raises it.
  • What moving the job changes in publish.yml and the gate, all stage 2's work:
    • The write-token gate is build/release.rs's PUBLISHER (:36) and publisher() (:124), not build/ci.rs. PUBLISHER names publish.yml, and publisher() accepts push and workflow_dispatch where it accepts schedule and workflow_dispatch today; its tests (:680) move with it.
    • The workflow-level id-token: write narrows to the crates.io publish job alone, so release holds contents: write and no OIDC token, and toolchain holds neither.
    • release gets one concurrency group of its own, release, with cancel-in-progress: false, as nightly.yml's workflow-level group gives it today. Without it, two pushes minutes apart can each pass sdk_at_tip (:344) and then act at once: two put_ups of one tag collide (:459-483), or the older run moves the sdk- alias back. With it, one release runs at a time, and GitHub replaces a pending older run with the newer one, which sdk_at_tip would refuse anyway. A reader checks this in the YAML, so no test is owed.
    • release needs: [publish, toolchain], so it reads crates.io only after this commit's crates are up; otherwise sdk_at_tip refuses with "crates.io holds no …". That is the fix issues/a-release-that-decides-before-its-tips-crates-are-up-reds-the-nightly.md names, and stage 2 takes that issue up with its test.
    • put_up refuses a Replace on a key tag, toolchain-linux-x86_64-<sysroot key>, by name, and only the sdk- alias's asset may be replaced. The put test (:743-757) names which tags may be replaced. A key whose toolchain is rebuilt to other bytes, after its cache entry was evicted while the key was still main's, then reds release loudly instead of swapping the asset under a packages run that is reading it (the roast).
    • nightly.yml loses release, and reviewer.md's "nightly.yml's release is the only job granted contents: write" names publish.yml's; stage 2 is briefed to edit that line.
  • The token is a fine-grained one that the owner mints for ToyOSOrg/packages alone. It carries the Actions write permission, which GitHub's documentation for that endpoint names as the one it needs.
  • A poll from packages was considered and refused: GitHub's documentation says it disables a public repository's scheduled workflows after 60 days with no activity, and the recipes can sit unchanged that long.

build.yml runs on that dispatch, on a push to packages's main, and on a pull request to it. It has three jobs.

  1. plan picks the ToyOS commit and writes the list of recipes as the matrix (cargo run -- --ci packages-plan). The job holds no secret.

    • For a dispatch, the commit is the one the dispatch names.
    • For a push or a pull request, it is the ToyOS commit that the repository release's notes name: the commit of the last publish. That publish installed this commit's toolchain release, and a key release's asset never changes once it is up: stage 2 makes put_up refuse a Replace on a key tag by name, leaving Replace to the sdk- alias (build/release.rs:449, :476-477). So the asset the commit names exists by construction, with the bytes it was installed with, and the verdict never waits on another repository's run. This is the one path; plan never falls back to main's tip or to another commit.
    • Before the first publish there is no repository release, and plan refuses a push or a pull request by name. Stage 2's first publish is a dispatch, so this lasts only until stage 2's exit.
    • It then checks out ToyOS at that commit, and packages.
  2. build runs once per recipe. It checks out ToyOS and packages with its gitlinks, then runs cargo run -- --ci package-build <name>. That command:

    • installs the commit's toolchain release into the runner's store, after checking it against the SHA-256 digest GitHub records for the asset and then against the commit's witness (release::lay_out's check), so the toolchain is the commit's own;
    • builds both ToyOS targets as described above;
    • runs the licence gate (build/licence.rs) over the package's crates and its files, and packs the package's corresponding source beside it wherever a licence requires one, as doom's GPL-2.0 does in §3;
    • runs the host gate (build/userlandhost.rs) over the source as any other OS builds it: with the source's own lock and no ToyOS patch, built and tested on the runner's host, and checked for the other two hosts, as --ci host judges an in-tree app today;
    • uploads its archives as the job's artifact.

    The job holds no secret. A recipe's build scripts and proc macros run here, and since each recipe builds in a job of its own, one recipe's code cannot touch another's archive.

  3. publish runs after every build job, whatever each concluded, and only for a dispatch or a push on main. It runs in the publish environment. It checks out ToyOS, and packages without its gitlinks, downloads the artifacts, and runs cargo run -- --ci packages-publish, which runs no code from any recipe. The command:

    • publishes only if the commit is still main's tip, so a run that main moved past while it built publishes nothing, and the run the later merge dispatches publishes. A push builds against the last publish's commit, so it publishes only while no merge has landed since; otherwise its recipes publish at the next dispatch;
    • writes the ToyOS commit into the repository release's notes, which "An image behind the repository" below points at;
    • gives the next sequence to each package whose archive bytes differ from its item's;
    • signs the result, reads it back through the client as build/publish.rs does today, and only then uploads it;
    • goes red when any recipe failed, naming it, after it has published the rest.

timestamp.yml runs daily. It signs a timestamp that names the same targets. From 30 days before the root expires it also goes red, after signing, naming the ceremony.

One writer at a time. publish and timestamp.yml share one concurrency group: they run one at a time, and neither is ever cancelled.

Hosting

The mirror is https://github.com/ToyOSOrg/packages/releases/download/repository/.

  • Flat names. A release's assets share one flat namespace, and GitHub's REST documentation says an upload renames a file whose name has special characters. Names made of a-z 0-9 . _ - survive the upload (github-release-asset-names.txt).

    • An item's url becomes <name>-<sequence>-<target>.tar.gz, which is_relative accepts (lib/update/src/repo.rs:930).
    • publish.rs's archives/ directory goes.
  • Immutable names, as in the TUF specification's consistent snapshots. Roots and targets carry their version, and an archive carries its sequence.

  • The timestamp. timestamp.txt is the only file that is replaced. GitHub replaces no asset in place, so the publisher deletes it and uploads the new one last. A client that reads in between is refused with Absent, by name. A job that dies between the delete and the upload leaves no timestamp at all, and every client is refused Absent until the next timestamp.yml run, at most a day, or a dispatch of it (the roast).

  • Pruning. After a publish whose timestamp names targets M, the publisher deletes:

    • every targets file below M−1;
    • every archive that neither targets M nor targets M−1 names.

    It never deletes a root. A client that is mid-install holds targets M−1 at the oldest.

  • Size. GitHub's documentation caps an asset at 2 GiB, which is above ARCHIVE_CAP (1 GiB, lib/update/src/repo.rs:53).

Keys

  • The root key is Ed25519, kept offline, and granted the root role alone at threshold 1. It signs roots and nothing else, and only in the ceremony.
  • The signing key is Ed25519 and is granted the timestamp and targets roles, each at threshold 1.
    • It is the secret of packages's publish environment, which is restricted to main by GitHub's deployment branch policy. It is kept nowhere else.
    • It is distinct from the image key (build/signing.rs), which this design does not change.
  • Why the targets key is online.
    • The TUF specification keeps every key except the timestamp's and the mirrors' offline ("Key management and migration").
    • PEP 458 signs its per-upload role online, but beneath an offline top-level targets that delegates to it.
    • This repository is built and signed by CI at every merge, so its targets key is online. The delegation that would keep a top-level targets offline is folded into the one role (see the roast).
    • Separate online keys for targets and timestamp would sit in one secret store and one job, so a second key would add no second barrier.
  • What the offline root buys. A stolen signing key is revoked by root N+1, and every machine's pkg walks to it at its next refresh with no new image: from then on an install or update refuses a targets the old key signed, and floors signed by the old key stop counting, which is repo.rs's floor rule.
  • At launch, revocation holds only once the supervisor's newest root sits where no other row can write. The supervisor walks to its newest root through the copies under /state/pkg/, and /state is served on the same data volume as /apps (lib/manifest/src/lib.rs:109). Until per-row views land, every row that can rewrite /apps can also delete root.<N+1>.txt; the walk then stops at root N, which still grants the stolen key, and a targets that key signed, naming an attacker's binary, launches. This is filed with issues/every-program-sees-only-the-files-it-was-given.md stage 2 as its exit, at which only pkg writes /state/pkg/ (below, "What the check does not close", and the roast).
  • build/publish.rs loses one_key (:194), root minting and root renewal.
    • It signs only with the signing key.
    • It refuses a root that does not grant that key the targets and timestamp roles. Stage 1 built this as the read-back's refusal, under the ruling on its review.
    • It never writes a root it did not receive.
  • The image pins root 1. It is committed in the tree as images/pkg/root.1.txt, and no build fetches it.
    • Every image pins it, a throwaway-key cargo run image included, because the image key and the repository's root are independent of each other.
    • The mirror's URL is committed beside it.

The root-key ceremony

The tool is toyos-root, a new crate that depends only on toyos-update with its sign feature, and on getrandom for minting. Stage 1 builds two commands and no more: new, which mints the root key, and sign, which signs root 1. Signing root N+1 from root N (--previous), renewal and rotation are a later stage's (migration stage 9).

  • Its source needs no C compiler, unlike the build system's, which compiles C because ring is among its dependencies (the cc row of issues/the-build-runs-host-tools-outside-rust-and-qemu.md). Its link still needs the target triple's linker. As built, step 1.2 is measured for aarch64-apple-darwin alone, and it is built for Unix triples.
  • The offline machine runs nothing else of ToyOS's.
  • Its key file is the seed written as 64 hex digits, as the throwaway key's is (build/signing.rs).
  • Once it is built, its module header carries these steps.

The owner needs three things:

  • a machine that has no network connection and will never be connected again;
  • two USB sticks, A and B, for the root key;
  • a third stick for carrying files between the machines.
  1. Online, before.
    1. cargo run -- --repo-signing-key-new mints the signing key at ~/.config/toyos/repo-signing-key (mode 0600) and prints its public key and fingerprint. Write the fingerprint on paper.
    2. cargo build --release -p toyos-root --target aarch64-apple-darwin, for an offline Mac of this Mac's macOS and architecture: the one measured path (ruling 4 on stage 1).
    3. Copy the toyos-root binary and the signing key's public line onto the carrying stick.
  2. Offline.
    1. toyos-root new <stick A>/root-key mints the root key (mode 0600) and prints its fingerprint. Write it on the paper.

    2. Copy <stick A>/root-key to stick B.

    3. toyos-root sign --key <stick A>/root-key --signing <the public line> --out <carrying stick>/root.1.txt writes root 1 and prints it. Root 1 carries:

      • the two keys;
      • the root role granted to the root key, and the timestamp and targets roles granted to the signing key, each at threshold 1;
      • an expiry one year away;
      • the root key's signature.

      Read the printout: both fingerprints must match the paper.

    4. Unplug sticks A and B. Keep them apart and offline, each in a place of its own.

  3. Online, after.
    1. Open a pull request that adds roots/root.1.txt to packages, and one that adds it to ToyOS as images/pkg/root.1.txt. Each repository's checks read it back through the client: it must be self-signed under its root key, and grant the targets and timestamp roles to one other key.
    2. Paste the signing key into the publish environment's secret on GitHub's settings page, then delete the local file.
  4. Yearly renewal, and any rotation, built in stage 9, not stage 1.
    • Offline, toyos-root sign --previous root.N.txt signs root N+1 with the same root key, which meets both its predecessor's threshold and its own.
    • A new signing key enters as a new public line in this step.
    • A new root key is minted as in step 2.1, and root N+1 is signed with both the old and the new.
    • Until stage 9 lands, no root 2 can be signed, so a signing key that leaks before then stays trusted until stage 9 is built: a leak makes it due at once (the roast).
  5. If the root key is lost or stolen.
    • If both sticks are lost, no root can ever be signed again, and every machine needs a new image that pins a new root 1.
    • If the key is stolen, the thief can sign a root of their own that every machine walks to. The recovery is the same.

What a package is

  • The format is a ustar .tar.gz that holds one directory, <name>/:

    • the program <name>/<name>, a static PIE (issues/a-package-cannot-ship-its-own-libraries.md);
    • its data and licences beside it, which the program finds through std::env::current_exe() on every host.
  • The item in targets.<M>.txt gains two signed fields:

    • sdk: the SDK identity the package was built against;
    • program: the SHA-256 of <name>/<name>.

    A targets still holds one item per name and triple.

  • The name and version come from the item. pkg holds the archive's top directory to the item's name, and takes the version from the item. Today it takes both from the archive (system/pkg/src/lib.rs:34-35, :100-112). version_of goes.

  • The SDK identity is one function of the build system, sdk::identity(root), built from the sysroot's witness rather than a second walk.

    • sysroot::witness (build/sysroot.rs:341) already writes one line per .rs, .toml and .h file of SYSROOT_SOURCES (:71: toyos-abi, toyos, std's backend, toyos-elf, toyos-osrelease and libc), each through identity::of, plus a line per SYSROOT_MANIFESTS entry (:83) over its bytes.
    • Its per-tree loop takes the trees it walks as an argument, so the same lines are written for toyos-window, filepicker-api, toyos-font and toyos-keymap.
    • The identity is the first 16 hex digits of the SHA-256 over the witness followed by those four crates' lines. A comment in a source file changes nothing, because each such line goes through identity::of; a manifest is read as bytes, as the sysroot's key reads it.
    • It reads the worktree, so a branch has its own identity, and it reads no index and fetches nothing.
    • The image build writes it into /system/etc/os-release, as a new field of toyos-osrelease. The builder writes it into each item.
  • An image behind the repository. pkg compares the item's sdk with os-release's before it fetches anything. It refuses by name, naming both identities, and says to update the system first (the orchestrator's ruling 3). pkg list shows such an item as needing another SDK.

    • Where the person gets that image: today, nowhere published. No workflow publishes an image: build/release.rs's one ASSET (:33) is the toolchain. The only source is a ToyOS checkout at the commit the repository was published from, which the repository release's notes name: the person builds it, signed with the image key the machine's loader trusts, and installs it with update (issues/the-machine-updates-itself-without-ubuntu.md). The refusal names that release page.
    • Stage 3 files this gap. Owner: the release module (build/release.rs). Exit: every release at main's tip carries an image per target that update installs, and pkg's refusal names it.

pkg

Two programs.

  • /system/bin/pkg holds no netstack. It writes /apps and /state/pkg, and it asks the person.
  • /system/bin/pkg-fetch is the fetcher. Its row receives netstack, and only pkg's row starts it.
    • It makes one HTTPS GET for one file under the mirror and writes the body to a pipe that pkg passes as its stdout. The launcher keeps a caller's pipes (system/supervisor/src/main.rs:17-19).
    • Its exit status tells "absent" apart from "failed".
    • It uses ureq 3 on rustls 0.23 with ring, verifies against /system/etc/ssl/cert.pem, and follows GitHub's one redirect.
  • pkg's Mirror (repo.rs) starts pkg-fetch for a file and reads at most cap + 1 bytes from its pipe.
  • The staging file is pkg's, not the fetcher's, as the owner accepted (6136067341, 3). pkg writes the bytes it hashes, so once per-row views land nothing the network-facing process can reach is read again after the check.
  • A gap until per-row views land (issues/every-program-sees-only-the-files-it-was-given.md stage 2). Stage 3 files it as one issue, owned by pkg and the supervisor's launch check, with that stage as its exit, at which /state/pkg/ is written by pkg alone and pkg-fetch's view holds no file. Until then, three things that issue names stay open:
    • pkg-fetch sees every file the kernel serves;
    • any row can rewrite /state/pkg/staging/<file> between pkg's hash and its inflate (install step 4);
    • any row that can rewrite /apps can delete the supervisor's newest root (Keys, and the launch check).

Install: pkg install <name>.

  1. Refresh the root chain, the timestamp, the targets and the floors (repo::refresh). The image's root is /system/etc/pkg/root.1.txt, and the machine's copies are under /state/pkg/.
  2. Find the item for this name and this machine's triple, and refuse on its sdk before fetching anything.
  3. Ask the person.
  4. Stream the archive from pkg-fetch through repo::Archive::take into /state/pkg/staging/<file>.
    • Archive::finish checks the length and the SHA-256.
    • Its Ok is the only way to make the token the inflater takes, so the inflater cannot be called on a stream that did not pass. The token proves that a stream passed; it does not prove that the staging file still holds those bytes when the inflater reads it.
    • Until per-row views land, any row, pkg-fetch included, can rewrite the staging file between the hash and the inflate. The launch check catches a swapped program, but not the data files inflated beside it. This is the second item of the gap above.
    • This is the TUF specification's client workflow ("Fetch target"): a target is verified against its targets before it is used.
  5. Inflate from the staging file into /apps/<name>/<sequence>.staged/, bounded as today by what the archive claims. Write manifest.toml (name, version, sequence, sdk, archive digest, program) and a copy of the signed targets the package was installed from.
  6. Commit. Rename the staged directory to /apps/<name>/<sequence>/. Then write /apps/<name>/current.staged, naming the sequence, and rename it over /apps/<name>/current.

The file server gains no operation for this:

  • A directory rename moves whole or not at all (Mounted::rename_all, lib/bcachefs/src/fs.rs:933).
  • A rename over a file is one operation of the format, which displaces the old file (rename_one, :943).
  • So current names either the old version or the new one, whole either way, and the volume's sync makes either durable.
  • The supervisor already replaces its grants store this way (write_store, system/supervisor/src/main.rs:1706).

Streaming to a file retires the 256 MiB in-memory ceiling (system/pkg/src/main.rs:24); the bound becomes the 1 GiB ARCHIVE_CAP.

Update: pkg update [<name>].

  • An installed package whose held item has a higher sequence is installed again by the steps above.
  • A rebuild for a new SDK takes a new sequence, so this one rule covers it. An item for another SDK is refused before it is fetched.
  • pkg keeps the version current names and the one before it, which a running instance may still read, and removes the rest.
  • The person runs it. There is no background updater.
  • /home/<user>/Apps/<name> is left alone.

Remove deletes /apps/<name> whole. The app's folder of the home stays.

pkg list names what is installed and what the held targets offers for this machine, and marks any item built for another SDK.

pkg install <file> goes, and SHA256SUMS and pkg::sums go with it.

  • The owner ruled "repository name only", and accepted that the file form goes with the URL form (6136067341, 3).
  • The launch check below refuses every binary that no signed targets names, so a file install could never start.

The supervisor's launch check

At each launch of /apps/<name>, the supervisor:

  1. reads current;
  2. digests /apps/<name>/<sequence>/<name>, as it already does for a grant (binary_digest, system/supervisor/src/main.rs:1699);
  3. launches only if the targets copy in that directory meets three conditions:
    • it is signed, at that root's targets threshold, under the newest root the supervisor finds, walking from the image's pinned root through the copies under /state/pkg/; that is the machine's newest root only while no row but pkg can delete a copy (below);
    • it names this package and this triple with that program digest;
    • it names the sdk that os-release names.

The check ignores expiry, which is a matter of an install's freshness: an installed app still launches offline. A refusal names its reason, and for an SDK mismatch it names pkg update <name>.

  • Grants stay keyed on the binary's digest (lib/manifest/src/grants.rs:10-14), as ruling 2 says.
  • From stage 6 of the track on, the same check reads the package's request from the signed item, never from the writable directory.
  • What the check does not close. The supervisor digests the file, and then the spawn reads it again by path (image_from); the kernel also pages it in on demand. issues/a-swapped-binary-lives-where-any-process-can-rewrite-it.md records the same window for a swapped service, and its exit, a spawn from what the supervisor verified, closes it here too.
  • A version that an earlier signed targets names still passes the check, so a row that can rewrite /apps can put back an older signed version (issues/whether-pkg-alone-writes-apps.md).
  • A revoked signing key still launches. Until per-row views land, a row that can rewrite /apps can also delete /state/pkg/root.<N+1>.txt, on the same data volume (lib/manifest/src/lib.rs:109). The walk then stops at root N, which still grants the revoked key, and a targets that key signed passes all three conditions. Revocation holds at launch only once the supervisor's newest root sits where no other row can write: the gap pkg's stage files, with issues/every-program-sees-only-the-files-it-was-given.md stage 2 as its exit. No high-water mark is built now (the owner's ruling).
  • The check covers the program alone: a data file beside it, swapped in the staging file before the inflate or rewritten after, is read as it stands.

What an installed app holds

This is unchanged, as toyos_manifest::Program::view gives it today:

  • its own /apps/<name>, read-only, every version in it included;
  • /home/<user>/Apps/<name>, read-write, as its HOME;
  • at most one folder the person granted;
  • the [apps] row's connectors: compositor, soundserver and filepicker.

It gets no device, no syscap, no launcher and no netstack. From the track's stage 6 on, it holds the signed item's request ∩ the person's grant ∩ the image's ceiling.

A branch whose SDK is not main's

An image pins the public root, so a branch that changes an SDK crate can install nothing: no item carries the branch's identity.

cargo run -- --packages <packages checkout> <name>… closes that gap:

  1. It builds those recipes against the checkout, as the CI would.
  2. It publishes them with the checkout's throwaway key into target/pkg-repository, the throwaway repository build/publish.rs writes today.
  3. It puts that repository on the image's volume.

That image then pins the throwaway root, and uses the directory as its mirror, in place of the public ones. pkg reads a directory mirror itself and starts no pkg-fetch. Each image has one repository, chosen by the build, as the image key is. repo.rs's Mirror has been a local directory since it was written.

The apps, weighed

app leaves? what it needs first
gbae already out; gets a directory in packages its Cargo.toml on crates.io's winit 0.30, softbuffer 0.4 and cpal 0.18, the versions the forks carry. Today it names fork branches …-sdk-0.2 and a winit 0.31 beta. The repository is the owner's, and only he can make this change
doom yes doomgeneric vendored in its own repository, so its build fetches nothing; its C compiled with cc's defaults on each host, so it builds on all three (issues/apps-that-build-or-run-on-toyos-alone.md); its data read from beside itself
snake, calc yes the SDK font call (issues/five-apps-read-the-system-font-by-a-path-only-this-tree-has.md); calc's engine tests move with it
toyfetch yes the host test of SysInfo::decode below
editor, paint yes, last winit and softbuffer, and a file pick with a transport off ToyOS. filepicker-api must also be published on crates.io: it is not in PUBLISHED (build/sdkversion.rs:23-30)
files no: it stays as system/files the owner's ruling

Tests

The owner's testing ruling, as the brief gave it: userspace is tested as units or modules, with at most a very cheap smoke test, and no test installs software and then drives it through steps. No guest test is added. The build rounds write these host tests:

  • toyos-root, stage 1: a root 1 it signs reads back through the client, self-signed under its root key and granting the targets and timestamp roles to the signing key. Stage 9 adds: a machine holding root N walks to the N+1 it signs with --previous.

  • The publisher:

    • it refuses a root that does not grant its key, and writes no root;
    • pruning keeps targets M and M−1;
    • asset names are flat.
  • The builder:

    • it refuses a lock in which a patched crate resolves from crates.io;
    • it refuses a stale lock;
    • it refuses a crate named both in ToyOS's [patch] and in forks.toml;
    • it refuses a recipe file that the source lacks.
  • sdk::identity: a comment-only edit keeps it, and a token edit to sdk/filepicker changes it.

  • pkg:

    • the client's refusals, which already exist in repo.rs;
    • an item for another SDK is refused with no request for its archive;
    • pkg-fetch against a loopback TLS server on the host, which is hermetic and reaches no internet;
    • an archive that fails its digest is never inflated, which the type already refuses, and its staging file goes;
    • inflating a bomb, and a short archive;
    • the commit sequence.
  • The file server: after a rename over current and a kill before the sync finishes, the remounted volume's current names the old sequence or the new one, and that directory is whole.

  • The supervisor's decision:

    • a binary whose digest a targets names launches;
    • any other binary is refused by name;
    • handed roots 1 and 2, where root 2 no longer grants the key that signed the targets, the binary is refused: a walk that stopped at root 1 turns it red;
    • an expired targets still launches.

    No test is written for a deleted root 2: until per-row views land the walk then stops at root 1 and launches, which is the recorded hole above, not a claim this design makes.

What goes with the apps. The searches are in tests-that-name-the-apps.txt.

test where it is what holds its behaviour after
toyfetch_reports (QEMU) tests/toyos.rs:471, [programs.toyfetch] in tests/images/testcases.toml toyos_osrelease's 8 host tests; a host test of toyos_abi::syscall::SysInfo::decode (sdk/abi/src/syscall.rs:66), which has none today and lands in the same diff as the cut; and toyfetch's own unit test in its repository
pkg_install_gbae (owed metal row) issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md:125 pkg's host tests above; the line is deleted, as is issues/pkg-install-gbaes-launch-client-exits-over-a-child-that-ends-with-it.md
doom_frames (owed metal row) the same issue, :207 doom's own host test in its repository; the line is deleted
the_shipped_bank_* ×3, doom1_wads_music_selects_the_instruments_it_selects build/soundfont.rs they move, with --regen-soundfont, into doom's repository
parser_identities apps/calc/tests moves with calc
consent_prompt (QEMU), app_view (metal) tests/toyos.rs the test sweep, per the orchestrator. Stage 3 lands after they are gone, because each starts a binary that no targets names

There is no T14 audio row that plays through doom: the audio rows play the test runner's own tone clients.

Migration order

Before stage 1, the owner creates ToyOSOrg/packages and mints the dispatch token.

  1. The keys.

    • Work: toyos-root's new and sign of root 1, and nothing else of it; --repo-signing-key-new; the publisher's new key model, flat names and pruning.
    • Exit: stage 1's host test of toyos-root and the publisher's host tests above are green. Then the owner runs the ceremony, and root 1 is committed in both repositories.
  2. The packages CI publishes gbae.

    • ToyOS work:
      • sdk::identity, os-release's new field, and the items' sdk and program fields;
      • --package-lock and --ci packages-plan, package-build, packages-publish and packages-timestamp;
      • --publish and its packages.toml go, since the publisher now reads what the recipes built;
      • put_up refuses a Replace on a key tag by name, leaving Replace to the sdk- alias, and the put test names which tags may be replaced;
      • release moves to publish.yml, needs: [publish, toolchain], runs in its own concurrency group with cancel-in-progress: false, and dispatches only after it has put up its release; nightly.yml loses it;
      • packages-plan takes a dispatch's commit, and for a push or a pull request the commit the repository release's notes name;
      • PUBLISHER names publish.yml and publisher() accepts push and workflow_dispatch, with its tests;
      • publish.yml's id-token: write narrows from the workflow to the crates.io publish job;
      • the builder runs through cargo_build under GuestEnv whole;
      • it takes up issues/a-release-that-decides-before-its-tips-crates-are-up-reds-the-nightly.md, whose exit's test it writes.
    • packages work: the two workflows and gbae's directory, which waits for the owner's Cargo.toml change.
    • This stage is briefed to edit .claude/agents/implementer.md:82, which puts a C program's recipe "in this repository", to name ToyOSOrg/packages, and reviewer.md's write-token line.
    • Exit: the builder's host tests are green, and the release's timestamp names a targets that holds gbae for both triples at main's tip, which the publish job read back through the client.
  3. pkg install gbae.

    • Work:
      • pkg-fetch and the pipe mirror;
      • verify-then-inflate;
      • the versioned directory and its pointer;
      • update, list and remove;
      • the SDK refusals and the launch check;
      • images pin root 1 and the mirror;
      • the file form and SHA256SUMS go.
    • It files two issues, and it lands after the test sweep has removed consent_prompt and app_view:
      • the gap until per-row views land: pkg-fetch's view, the staging file and the supervisor's newest root, with issues/every-program-sees-only-the-files-it-was-given.md stage 2 as its exit;
      • no published image at the repository's SDK, owned by build/release.rs, with the exit given under "An image behind the repository".
    • Exit: pkg's, the file server's and the supervisor's host tests are green, and the owner installs gbae by hand in QEMU with pkg install gbae and runs it.
  4. cargo run -- --packages.

    • Exit: a host test shows such an image pins the throwaway root and that its volume holds the repository; by hand, a branch that changes an SDK crate installs a recipe it built.
  5. doom. It gets its own repository and its directory in packages. The tree loses:

    • apps/doom, assets/DOOM1.WAD and assets/soundfont.sf2;
    • build/soundfont.rs and --regen-soundfont;
    • DOOM_LEAVES (build/licence.rs:166) and doom's sections of NOTICE;
    • [programs.doom] and its starts entries;
    • the doom_frames line;
    • the README's doom lines.

    Exit: the image's file list names no doom byte, the licence gate is green without DOOM_LEAVES, and the repository publishes doom with its corresponding source.

  6. snake and calc, once the font call exists.

    • These are the last in-tree users of winit, softbuffer and raw-window-handle, so their [patch] entries move to forks.toml. The packages pull request is merged right after the ToyOS one, and the run in between is red, by name.
  7. toyfetch.

    • sysinfo moves to forks.toml.
    • The SysInfo::decode host test lands in the diff that cuts toyfetch_reports.
  8. editor and paint, once they are portable, with filepicker-api added to PUBLISHED.

  9. Root renewal and rotation, owed before root 1 expires and at once if the signing key leaks.

    • Work: toyos-root sign --previous, so root N+1 is signed from root N; a new signing key as a new public line; a new root key signing beside the old; the ceremony's step 4 in the module header.
    • Exit, as the owner ruled: timestamp.yml's red, 30 days before root 1 expires, which names the ceremony. By then the host test that a machine holding root N walks to the N+1 toyos-root signs is green.

Each stage deletes the app's [programs.<name>] row, its starts entries, and the guest-suite and track lines it meets. The stage that empties apps/ also deletes:

  • apps/CLAUDE.md;
  • "apps" in PROGRAM_ROOTS (build/build.rs:199);
  • apps/ in the layout track's tree;
  • the in-tree app arm of the host gate. From stage 2 on, each recipe's package-build holds the same gate.

What pkg needs before it is production-ready

Open issues:

  • issues/a-package-is-a-directory-under-apps-and-the-installer-is-a-program.md, stages 2, 3, 4, 6 and 7. The build rounds rewrite this issue to this design: it is the issue this design takes up.
  • issues/whether-pkg-alone-writes-apps.md, the owner's question.
  • issues/every-program-sees-only-the-files-it-was-given.md stage 2, and issues/any-declared-row-can-write-the-grants-store.md.
  • issues/a-swapped-binary-lives-where-any-process-can-rewrite-it.md. Its exit also closes the launch check's window.
  • issues/an-installed-gbae-browses-to-no-rom.md, and issues/a-question-waits-unseen-while-a-file-is-picked.md.
  • issues/a-package-cannot-ship-its-own-libraries.md, and issues/toyos-builds-itself.md's "pkg installs a toolchain".
  • issues/remove-dir-all-empties-a-directory-and-leaves-it.md.
  • issues/the-internet-clients-work-unchanged.md stage 3, the https_tls13 row on the T14.

New with this design: everything in the migration above, and the two issues stage 3 files: the gap until per-row views land (pkg-fetch's view, the staging file, the supervisor's newest root), and no published image at the repository's SDK.

Servo

Servo is the motivating next package, and this design does not design for it. Beyond what doom needs, Servo needs:

  • stage 6, because no package can reach netstack until its request can ask for it;
  • a C++ runtime in a Rust std binary (issues/a-rust-std-binary-cannot-link-the-cxx-runtime.md);
  • a GL, between whose two kinds the owner has not chosen;
  • memory its JIT can make executable after writing it;
  • the isolation track's work first;
  • a row in issues/the-build-runs-host-tools-outside-rust-and-qemu.md for each host tool its build runs, before its recipe lands. Round 1's review names mozjs-sys's configure and bindgen's libclang; this round did not measure them.

The repository's one-SDK-at-a-time rule also rebuilds Servo for both targets at every SDK change.

The roast

  • A break is caught after it lands. A ToyOS pull request that breaks an app passes ToyOS's CI. Only the packages run after the merge goes red, and the fix takes a commit to the app's repository and a pull request to packages, which makes three repositories for one breaking SDK change.
  • A merge main passes is never checked on its own. Its run puts up no release and dispatches nothing; the next merge's run checks both, so a break is named at the later commit. That was 15 of main's last 100 merges at the least.
  • The tip goes unchecked when its own run stops short of release. A red toolchain or crates.io publish at the tip puts up no release and dispatches nothing, so the tip is unchecked and the repository stays at the older SDK until the next merge's run reaches release.
  • A packages pull request is judged against the last publish's SDK, not main's tip. A source bump that builds there can still break against a later merge's SDK, and that break shows only at the next dispatch, after the bump has merged. A push lands no package until a dispatch, unless no merge has landed since the last publish.
  • Every SDK change orphans every installed package, and every image behind the repository can install nothing until it updates. With six SDK releases in four days, an image older than a day will usually install nothing.
  • The signing key is online. Whoever controls the publish job can sign any package until a root from the ceremony revokes the key: through ToyOS's main, packages's main, or GitHub itself. The TUF specification would keep the targets key offline, and the delegation that PEP 458 uses to do so is folded away here.
  • A leaked signing key cannot be revoked until stage 9. Stage 1 builds only the signing of root 1, so no root 2 exists to revoke it until --previous is built.
  • Revocation does not hold at launch until per-row views land. Any row that can rewrite /apps can delete the supervisor's copy of root N+1, and the stolen key's targets launches again. The only exit is the isolation track's stage 2; no high-water mark is built meanwhile.
  • The root key is a seed file on a stick with no passphrase. A stick that is found is the root. The ceremony's tool is built on the online machine, so it is only as trustworthy as that machine was when it built it.
  • The dispatch token is the owner's. When it expires, ToyOS's release job goes red. If it leaks, anyone can dispatch or cancel packages's runs; the tip check means a dispatch naming an old commit publishes nothing.
  • GitHub release assets are a weak host. The timestamp's replacement is a delete followed by an upload, and pruning drops any archive older than targets M−1, so a client slower than two publishes is refused Absent.
  • A publish that dies mid-replace leaves no timestamp. Between deleting timestamp.txt and uploading the new one, a job that dies leaves every client refused Absent until the next timestamp.yml run, up to a day, or a dispatch of it.
  • A key's toolchain that rebuilds to other bytes reds release. A key release's asset never changes once it is up, so where a key's cache entry is evicted while the key is still main's and toolchain.yml rebuilds it to other bytes, release refuses the Replace by name and that run dispatches nothing until the key moves or the bytes match.
  • Reproducibility is unmeasured. If two CI runs build different bytes from the same inputs, every merge republishes every package (issues/two-checkouts-of-one-tree-build-different-guest-bytes.md).
  • The SDK identity is conservative. Any token change in any SDK crate a package can link orphans every package, including the ones that never call what changed.
  • A failed recipe falls behind. It leaves its package at its old SDK while the rest move on.
  • Recipe locks churn. A recipe's lock goes stale at every fork move (2f), so every ToyOS fork bump costs a packages pull request.
  • CI work per merge is large. Each merge downloads the 460 MB toolchain once per recipe and builds every recipe for two targets and a host.
  • There is no sideloading. A binary that no signed targets names never launches, so running an app under development takes stage 4's per-image repository.
  • Old signed versions still launch. /apps stays writable to every declared row, which can put back an older signed version, and the launch check's window between digest and spawn stays open until the swapped-binary issue's exit.
  • pkg-fetch is not yet isolated. It holds the network and still sees the whole tree until the per-row views land.
  • The verified archive can be swapped before it is inflated. Archive::finish's token proves a stream passed, not that the staging file still holds it; until per-row views land any row can rewrite it between the hash and the inflate, and the launch check covers the program, never its data files.
  • No image is published to update to. An image behind the repository is told to update, and the only way is to build one from the commit the release notes name.
  • A fresh image ships no apps. Running doom takes a network, a clock that is right, and pkg install doom.
  • Several rules move. The write-token rule leaves nightly.yml for publish.yml, its gate PUBLISHER and publisher() move with it, release takes a concurrency group that nightly.yml's gave it before, and implementer.md's recipe line changes.

Unsure

  • Whether the owner has a machine he can keep offline, and what its triple is.
  • Whether CI builds the same guest bytes twice.
  • Whether GitHub bounds how many assets one release may hold. Pruning keeps the count at a few files per package.
  • Whether doom's C builds with MSVC on Windows once build.rs uses cc's defaults.
  • Whether the organisation's policy allows a fine-grained token with Actions write, and what expiry it sets.
  • gbae's dependency change is in the owner's repository, and only he can make it.

Published specifications and vendor documentation cited

  • The Update Framework specification: the detailed client workflow ("Fetch target"), consistent snapshots, and "Key management and migration".
  • PEP 458: its key management, its online roles and its delegation.
  • GitHub's documentation:
    • REST API, "Upload a release asset": names are renamed;
    • "About releases": 2 GiB per asset;
    • "Managing environments for deployment": environment secrets and deployment branch policies;
    • REST API, "Create a workflow dispatch event": the token permission;
    • "Workflow syntax", permissions and jobs.<job_id>.permissions: a job-level grant replaces the workflow's for that job;
    • "Events that trigger workflows", schedule: disabled after 60 days of inactivity.
  • The Cargo Book: [patch] and rev, cargo install --locked, and custom profiles set from configuration.
  • GPL-2.0 §3.
  • RFC 8032 (Ed25519), and OpenSSH's PROTOCOL.sshsig, which lib/update/src/sig.rs implements.

🤖 Generated with Claude Code

https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy

@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Owner's rulings on the design's three open points, recorded by the orchestrator:

  1. pkg gets network access: "Yes, pkg gets network". It alone holds the netstack connector for downloads, and everything it fetches is verified (signed index, SHA-256) before it installs. This supersedes his earlier "no authority a shell does not have" for pkg.
  2. Repository name only. pkg install gbae from the signed ToyOS package repository. No pkg install <url>.
  3. files stays in the base system, as the design proposes.

Also from the orchestrator: under the owner's testing ruling (#890), consent_prompt and app_view (install-and-drive) go with the test sweep, not with this design.

@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Design review, round 1, at 226e393d6. The head is one empty commit, and git diff --shortstat origin/main...226e393d6 prints nothing: 0 lines of production code and 0 of tests. The measurements were read at ToyOSOrg/evidence@4bbc869d9. The owner's rulings are from comment 6129963881.

BLOCKER

  1. PR body, "How pkg finds…": Install step 3, and "What pkg gains" ("streaming unpack against a bomb and against a short archive").

    • What: gzip and tar are parsed while the bytes are still streaming, before repo::Archive::finish checks the SHA-256 (lib/update/src/repo.rs:672-680). So unverified bytes from any mirror reach the inflater and the tar reader, and they land in staging.

    • Why: today's pkg verifies the whole archive before it inflates anything (system/pkg/src/main.rs:67-75). The same order holds elsewhere:

      • apt checks a .deb against the signed index before dpkg reads it;
      • pacman checks a package's signature before it extracts it;
      • Android verifies an APK before it installs it.

      The owner's ruling is "everything it fetches is verified … before it installs".

    • Fix: stream the archive into a staging file held to the signed length, finish the digest, then inflate from that file. The 256 MiB in-memory ceiling still goes.

  2. PR body, "What an installed app holds", "New, and flagged".

    • What: keying a repository package's grant on its name and the root lets whatever bytes sit at /apps/<name>/<name> inherit that grant.
    • Why:
      • Every declared row can write /apps (issues/whether-pkg-alone-writes-apps.md), and the signature is checked only at install.
      • Today a grant is keyed on the SHA-256 that the supervisor computes of the binary it starts (lib/manifest/src/grants.rs:10-14, :170-180; system/supervisor/src/main.rs:1673-1677). That key is what stops a rewritten package from holding a grant.
      • The two precedents the design cites keep checking the identity after install: Android's install directory is writable only by the system, and TCC checks the code's designated requirement at every access.
    • Fix, one of:
      • keep the digest key;
      • have the supervisor check, at each launch, that the digest it already computes is one that a targets signed under the pinned root names for that package (the same check then covers the stage-6 request it reads from /apps/<name>/package.toml);
      • make /apps writable by pkg alone first.
  3. PR body, "What a package is", "The SDK identity": where the identity is carried.

    • What: the identity is only inside the archive.
      • pkg learns that an archive is for another SDK only after fetching all of it (Servo's is 49 MB).
      • pkg list offers items that cannot install.
      • Update's second case ("the item matches the image's SDK") cannot be decided from the targets.
      • A targets holds one item per name and triple (lib/update/src/repo.rs:331), so the repository serves one SDK at a time, and the body gives no rule for an image whose SDK is behind it.
    • Why: apt's Packages index, pacman's sync database and Fuchsia's package meta all carry compatibility in signed metadata that is read before the payload.
    • Fix: put the identity in the signed item (or in the targets) and refuse before the fetch. State the model for an image behind the repository: either pacman's "update the system first", or one item per SDK, the way apt serves one suite per release.
  4. PR body, "The SDK identity": what the identity covers.

    • What: "the set … the toolchain release lists" is build/sdkversion.rs:23-30's PUBLISHED, and it has two gaps:
      • It leaves out filepicker-api (sdk/filepicker). That is the protocol snake, editor and paint speak, and every file open through the picker. A change to the picker protocol would break them without any refusal.
      • Each listed identity carries a version that crates.io's index assigns (build/sdkversion.rs:79, :116). An image build that writes it into os-release would have to fetch the index, which is a new fetch in every build. A toolchain built locally on a branch has no listed identity at all.
    • Fix: define the identity over every SDK crate a package can link, computed from the tree alone (each crate's tree key). Write the output of that one function into both the image and package.toml.
  5. PR body, Install step 5 and "Update".

    • What: "commit with one rename" cannot put a new /apps/<name> "in place" while the old one still holds that name. Replacing it is either an exchange, or two steps with a window in which neither directory is there. With the operation the design names, stage 2's exit ("either the old directory or the new one") cannot be met.
    • Why: the published layouts swap atomically:
      • OSTree/Flatpak deploy each commit to its own directory and switch the active link;
      • Nix switches a profile-generation link;
      • Linux's renameat2(RENAME_EXCHANGE) and APFS's renamex_np(RENAME_SWAP) exchange two directories.
    • Fix: name the primitive the file server gains: an exchange, or a versioned directory under /apps/<name>/ with an atomic pointer. Either one also keeps a running app's files whole while its package is replaced.
  6. PR body, "Signing and verification", "Keys".

    • What: one owner key fills the root, targets and timestamp roles. The seven-day timestamp (build/publish.rs:49) means he signs with that root-capable key every week.
    • Why:
      • The TUF specification keeps every key except the timestamp's and the mirrors' offline (§6.1). PEP 458, which the body also cites, keeps root offline and separate from the other roles. pacman likewise keeps its master keys apart from packager keys.
      • With one key, a thief can sign a root N+1 of their own. Every machine walks to it, and nothing short of a new image recovers.
    • Fix: before any image pins the public repository's root 1, mint it with an offline root key that is distinct from the targets and timestamp keys. one_key (build/publish.rs:194) changes with it. Moving the timestamp to an online key can stay a later stage.
  7. PR body, "What pkg needs for this", item 1.

    • What: the process that holds netstack and parses HTTP and TLS from the network also holds /apps, /state (including the grants store, issues/any-declared-row-can-write-the-grants-store.md) and the home.
    • Why: the published package managers split the two:
      • apt runs its download methods as the unprivileged _apt user;
      • pacman 7 downloads as its DownloadUser, inside a Landlock sandbox;
      • Flatpak's system helper verifies what the user's pull fetched before it deploys it.
    • Fix:
      • The fetch runs in a pkg process that holds netstack and its staging file and nothing else. That keeps the owner's "pkg alone holds the netstack connector".
      • The process that commits to /apps holds no netstack.
      • Until per-row views exist (issues/every-program-sees-only-the-files-it-was-given.md stage 2), the fetching process still sees the whole tree. File that gap with stage 2 as its exit.
  8. PR body, "How it is built" step 1, and "Unsure".

    • What: "the build an in-tree app gets today" rests on three guesses, and one cheap probe each would settle them:
      • The root [patch] names fork branches (Cargo.toml:287-310), and only the tree's Cargo.lock pins their commits. Given as --config to another package's build, each branch resolves to its tip at publish time.
      • Whether --locked still holds the app's own Cargo.lock once a git patch replaces a crates.io source.
      • Whether cargo install builds with [profile.toyos] (Cargo.toml:321, with debug-assertions and overflow-checks on) or with its own release profile.
    • Why: the only probe measured was a path patch, with no lockfile involved.
    • Fix: before the design is accepted, measure all three with a git-branch patch, and decide how the fork commits reach the builder (for example, rev = read from the tree's lock).
  9. PR body: Install, "Migration order" stage 1, "What pkg gains", and the roast item "The URL form is mostly convenience".

    • What: the design still carries:
      • install <name>|<url>|<file>;
      • install <name>|<url> in stage 1's work;
      • a host test of "the match from a URL to its item".
    • Why: the owner ruled "Repository name only … No pkg install <url>".
    • Fix: delete the URL form everywhere it appears.
  10. PR body, "Migration order", stages 1 and 3.

    • What: stage 1's exit is the owner's pkg install gbae. But gbae's recipe, and the Cargo.toml change it needs, belong to stage 3, and without them the repository holds no gbae.
    • Fix: reorder the stages, or give stage 1 an exit its own work can reach.
  11. PR body, "Whether ports/ may hold our own apps".

    • What: the design depends on reading the owner's ports/<name>/ ("a third-party program packaged by a recipe", issues/the-tree-says-who-uses-each-thing.md:65) as covering our own apps.
    • Why: he has not ruled on that, and none of his three answers covers it.
    • Fix: his ruling is needed before the design is accepted.
  12. PR body: "Why the tree builds", the roast item "The tree's gates stop reaching app source", and the closing paragraph of "Migration order".

    • What: once the apps leave, nothing in the tree builds a recipe.
      • A pull request that breaks an app's source passes CI, so "a pull request that breaks an app's source also bumps that app's recipe … in the same diff" has nothing that would catch a break.
      • Deleting the app arm of the host gate (build/userlandhost.rs) leaves root CLAUDE.md's "Apps are portable" unchecked as well.
    • Fix: build every recipe for both ToyOS targets in --ci, and hold each one to the host gate, either in --ci or in the publisher before it signs. Otherwise, record the loss in issues/ with an owner and an exit.

NOTE

  • PR body, "How it is built" step 4. build/publish.rs:58 puts archives under archives/, and items name archives/<file>, but a release's assets share one flat namespace. Every SDK release would also republish every package for two targets, plus a GPL source beside doom, into one release whose asset count GitHub limits (the body's own Unsure). Measure how assets are named, and state the pruning rule, before stage 1.
  • PR body, "What pkg needs for this", item 3. Two things are missing:
    • where an image build gets the public root it pins (committed in the tree, never fetched at build time);
    • that a throwaway-key cargo run image pins the owner's root, which stage 1's by-hand install on such an image needs.
  • PR body, "What a package is". Today's pkg takes the name from the archive's top directory and the version from the archive's file name (system/pkg/src/lib.rs:34-35, :100-112). A repository install has to hold the archive's top directory to the signed item's name and take the version from the item. The body says neither.
  • PR body, "Update". A package installed with pkg install <file> has no repository sequence, and the update rule does not cover it.
  • PR body, "What goes with the apps", toyfetch_reports. One of the tests named as holding its behaviour after the cut, a host test of SysInfo::decode, does not exist yet. It has to land before the guest test is removed (reviewer.md, Guest tests: "a cheaper tier already holds").
  • PR body, "Servo". The list misses two things:
    • the host tools Servo's build brings in: mozjs-sys's configure runs Python and make, and bindgen loads libclang. Each needs a row in issues/the-build-runs-host-tools-outside-rust-and-qemu.md.
    • that publishing one SDK at a time rebuilds Servo for two targets at every SDK release.
  • PR body: "What pkg needs for this", "The apps, weighed" (files), and "This needs the owner's ruling". These still read as open, but the owner has now ruled on all three. This is prose, for the orchestrator to correct.

DESIGN NEEDS CHANGES:

  • verify the archive before unpacking it;
  • keep grants keyed on the binary's digest, or have the supervisor check the signed identity at launch;
  • put the SDK identity in signed metadata, checked before the fetch, covering filepicker-api and computed from the tree alone, with a rule for an image that is behind the repository;
  • name an atomic replace;
  • use an offline root key, separate from the signing key, before root 1 is pinned;
  • run the fetch in a separate process from the commit;
  • make the three builder measurements;
  • remove the URL form;
  • give stage 1 an exit it can reach;
  • get the owner's ruling on ports/;
  • give every recipe a check in the tree.

@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Owner's rulings on round 1's open points, recorded by the orchestrator:

  • A separate offline root key: "Yes, offline root key". One root key, kept offline, certifies the signing keys, and day-to-day signing uses a separate key. The orchestrator scripts the key creation for him to run, and the root key never touches an online machine.
  • Recipes live in a separate repository: "yes separate repo". A new ToyOSOrg/packages holds every package recipe, ToyOS's own apps and third parties' alike, as FreeBSD splits src from ports. It also holds its own CI, which builds every recipe against current ToyOS and runs after every merge to main, and the publishing of the signed repository pkg installs from. ports/ in the ToyOS tree is not used for this.

The orchestrator's technical rulings on the review's other blockers (#892 (comment)), each as the review names it:

  1. Verify before unpacking: a staging file held to the signed length, the digest finished, then the inflate.
  2. Grants stay keyed on the binary's digest. At each launch, the supervisor checks that the digest it computes is one a signed targets names for that package.
  3. The SDK identity lives in the signed metadata, is checked before the fetch, covers filepicker-api, and is computed from the tree alone. An image behind the repository is told to update first, as pacman does.
  4. An atomic replace: a versioned directory under /apps/<name>/ with an atomic pointer, as OSTree and Nix do.
  5. The fetch runs in a pkg process holding only netstack and its staging file, and the commit runs in a process holding no netstack. The per-row-views gap is filed with its stage as the exit.
  6. Make the three builder measurements before acceptance.
  7. Remove the URL form everywhere.
  8. Give stage 1 an exit it can reach.
  9. Recipes are built and held to the host gate in the packages repository's CI, per the ruling above.

@Japabu Japabu changed the title Design: apps leave the tree as packages pkg installs from one signed repository Design: apps leave the tree for ToyOSOrg/packages, whose CI builds them against main and signs the repository pkg installs from Oct 12, 2026
@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2 of the design: where each round 1 finding closes. The review is 6130406171, and the rulings are 6129963881 and 6130600934. The head is still 226e393d6, the one empty commit, and nothing is built. The body is rewritten. Evidence is at 2976ee365.

BLOCKERs

  1. Verify before unpacking (orchestrator's ruling 1). pkg streams the archive through repo::Archive::take into /state/pkg/staging/<file>. Only Archive::finish's Ok makes the type the unpacker takes, so an archive that has not passed its signed length and SHA-256 cannot reach the inflater. See "pkg", install step 4.
  2. Grants and the launch (ruling 2).
    • Grants stay keyed on the binary's digest.
    • At every launch, the supervisor requires that a targets copy installed with the package names that digest as the item's new signed program field. The copy must be signed under the machine's newest root, walked from the image's root.
    • The same check reads stage 6's request from the signed item.
    • The window between digest and spawn stays open: it is issues/a-swapped-binary-lives-where-any-process-can-rewrite-it.md's window, and that issue's exit closes it.
    • See "The supervisor's launch check".
  3. The SDK identity is signed and checked before the fetch (ruling 3).
    • Each item gains a signed sdk field, and pkg refuses on it before it fetches anything.
    • pkg list marks an item built for another SDK.
    • An image behind the repository is told to update the system first.
    • Every item carries its own sdk, because a recipe that fails at a merge keeps its old item.
  4. What the identity covers (ruling 3). sdk::identity(root) is one function over every .rs, .toml and .h file of every SDK crate a package can link: SYSROOT_SOURCES, plus toyos-window, filepicker-api, toyos-font and toyos-keymap.
    • It reads each file through identity::of, from the worktree, with no crates.io index and no fetch, so a branch's toolchain has an identity too.
    • The image writes its output into os-release, and the builder into each item.
    • One correction to the finding: filepicker-api is spoken by editor and paint, but not by snake. snake's Cargo.toml does not name it.
  5. An atomic replace (ruling 4). A version is installed as /apps/<name>/<sequence>/, and /apps/<name>/current names which one runs. That pointer is replaced by one rename over the old file, which is a single operation of the format (rename_one, lib/bcachefs/src/fs.rs:943), and a directory rename moves whole (rename_all, :933). The file server gains no operation.
  6. An offline root key, distinct from the signing key, before any image pins root 1 (the owner's ruling).
    • The root key signs only roots, in an offline ceremony that the body writes as steps, with a new toyos-root tool.
    • One online signing key holds the targets and timestamp roles, as packages's publish environment secret, restricted to main.
    • one_key, root minting and root renewal leave build/publish.rs.
    • Root 1 is committed in both repositories before any image pins it.
  7. The fetch runs apart from the commit (ruling 5).
    • /system/bin/pkg-fetch holds netstack and writes the body to a pipe. pkg holds no netstack, and it writes the staging file it hashes and commits from.
    • This is narrower than the ruling's wording, which puts the staging file in the fetcher, and it is flagged in the body: no bytes the network-facing process can reach are read again after the check.
    • The gap in its view is filed by the stage that adds it, with every-program-sees-only-the-files-it-was-given.md stage 2 as its exit.
  8. The three builder measurements (ruling 6). cargo-patch-locked-profile.txt and cargo-patch-same-version.txt, with their scripts.
    • A git-branch patch given with --config builds the branch's tip at build time (1a: commit A; 1c: B after the branch moved), and rev = pins it (1d: A while the branch was at B). The fork commits therefore reach the builder as rev, taken from ToyOS's Cargo.lock or from packages's forks.toml.
    • --locked against the source's own lock fails under a same-version patch (4b: exit 101). It holds against a lock resolved under the patch (4d: exit 0, and the binary runs the fork). A patch whose version the lock does not pin goes unused with only a warning, and cargo install --locked still exits 0 with crates.io's crate (2c). The builder therefore runs cargo build --locked with a lock that packages commits per recipe, refuses a patched crate that resolves from crates.io, and never runs cargo install.
    • cargo install builds the source's own release profile (3a, 3b). --profile toyos, with every key given by --config, builds ToyOS's profile and beats the source's own (3c, 3d, 3e).
  9. The URL form is gone everywhere (ruling 7). The file form goes too, with SHA256SUMS, because the launch check refuses any binary that no signed targets names. This is flagged for the owner, who ruled out only the URL form by name.
  10. Stage 1's exit is reachable. The stages are now ordered so each exit can be met:
    1. the keys;
    2. the packages CI publishes gbae;
    3. pkg install gbae, whose by-hand exit installs what stage 2 published.
  11. ports/ (the owner's ruling): recipes live in ToyOSOrg/packages, and ports/ is used for nothing.
    • Stage 2 is briefed to edit implementer.md:73, which puts a C program's recipe "in this repository".
    • The layout track's ports/ line is that track's to drop.
  12. Every recipe is checked (ruling 9). packages's build.yml runs after every merge to main, dispatched by ToyOS's publish.yml with the commit, and on every packages pull request.
    • It builds every recipe for both ToyOS targets with that commit's toolchain release, SDK and forks.
    • It holds every recipe to the licence gate and to the host gate. The host gate runs over the source with its own lock and no ToyOS patch, so "Apps are portable" is still judged.
    • The in-tree app arm of the host gate goes only when apps/ is empty, by which time stage 2's package-build holds the same gate.

NOTEs

  • Asset names and pruning. Names are flat (<name>-<sequence>-<target>.tar.gz; archives/ goes), because GitHub's REST documentation says an upload renames special characters, and github-release-asset-names.txt shows a-z 0-9 . _ - surviving. Pruning keeps targets M and M−1 and the archives they name, and never deletes a root. Whether GitHub bounds a release's asset count is in "Unsure".
  • Where the image's root comes from. It is committed as images/pkg/root.1.txt, never fetched, and every image pins it, a throwaway-key cargo run image included. A branch whose SDK is not main's gets an image that pins its own throwaway repository, through cargo run -- --packages (stage 4).
  • Name and version come from the item. pkg holds the archive's top directory to the item's name, takes the version from the item, and version_of goes.
  • Updating a file install. This is moot: the file form goes.
  • SysInfo::decode. Its host test lands in the same diff that cuts toyfetch_reports (stage 7).
  • Servo. Each host tool its build runs needs a row in the host-tools issue before its recipe lands. That the repository's one SDK rebuilds Servo for both targets at every SDK change is stated in the body.
  • Prose. The owner's three answers now read as rulings, not as open questions.

Also changed

At the owner's direction, the body decides by published specifications and vendor documentation and cites only those: the TUF specification, PEP 458, GitHub's and Cargo's documentation, GPL-2.0, RFC 8032 and PROTOCOL.sshsig. It no longer cites another operating system's practice. Round 1's Servo figures were read from its Linux nightly, and they are dropped.

@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Design review, round 2, at 226e393d6. I judged this against the prompts as they stand on origin/main 347e3313d, which includes #902. The head is still the one empty commit, and git diff --shortstat origin/main...226e393d6 prints nothing: 0 lines of production code and 0 of tests. I read the measurements at ToyOSOrg/evidence@2976ee365. The body cites only published specifications and vendor documentation, so #902's clean-room rule finds nothing to refuse.

Round 1's BLOCKERs

  1. Verify before unpacking: CLOSED. Install step 4 now streams into a staging file, Archive::finish checks it, and only then does the inflate run. One overclaim remains, under NOTE.
  2. Grants and the launch check: CLOSED as ruled. Grants stay keyed on the digest, and the supervisor checks the signed program digest at every launch. A new hole in the check is below.
  3. The SDK identity is signed and refused before the fetch: CLOSED. It is the item's sdk field, and an image behind the repository is told to update first.
  4. What the identity covers: CLOSED. sdk::identity includes filepicker-api and reads the worktree alone.
  5. An atomic replace: CLOSED. It is <sequence>/ plus current, replaced by renaming over it. rename_one (lib/bcachefs/src/fs.rs:943) reads the displaced entry out before the insert replaces it.
  6. An offline root key: CLOSED. The ceremony, the online signing key, and one_key going all answer it.
  7. The fetch runs apart from the commit: CLOSED, with the departure weighed below.
  8. The three builder measurements: CLOSED. Each probe carries its control (1a/1d, 2a/2b/2c, 3a/3c, 4b/4d), and every exit code is recorded. 4a adds one more fact: cargo install --locked under a same-version patch exits 0 against a lock that names crates.io. That supports never using cargo install.
  9. The URL form: CLOSED.
  10. Stage 1's exit: CLOSED. Keys, then CI publishes gbae, then pkg install gbae.
  11. ports/: CLOSED by the owner's ruling for a separate repository.
  12. Every recipe is checked: CLOSED. packages's build.yml runs the build, the licence gate and the host gate.

BLOCKER

  • PR body, "The supervisor's launch check", step 3, and "Keys: What the offline root buys" — a revoked signing key still launches.

    • What: the supervisor finds "the machine's newest root" by walking from the image's root 1 through the copies under /state/pkg/.
    • Why that fails: /state is served on the same data volume as /apps (lib/manifest/src/lib.rs:109), so every row that can rewrite /apps can also delete root.<N+1>.txt. The walk then stops at root N, which still grants the stolen key. A targets signed with that key, naming the attacker's binary, passes all three conditions.
    • Consequences:
      • The body's claim that root N+1 revokes a stolen key "with no new image" does not hold at launch.
      • The planned host test "a targets signed by a key the newest root no longer grants is refused" cannot fail on this path.
      • "What the check does not close" does not record the hole.
    • Fix: state that revocation at launch holds only once the supervisor's newest root is somewhere no other row can write. File that with issues/every-program-sees-only-the-files-it-was-given.md stage 2 as its exit, and add it to the roast. Alternatively, keep a root high-water mark that only the supervisor writes, and give the test a deleted-root arm.
  • PR body, "The packages CI", "The trigger", third bullet — a merge that main has already passed gets no toolchain release, so its packages run goes red.

    • What the body says: "A commit that main has already moved past still puts up its key's release. Only the sdk- alias waits for main's tip (sdk_at_tip)."
    • What the tree does: release_as (build/release.rs:344-346) returns "main has moved past" before lay_out and put_up, so such a commit puts up nothing.
    • Consequence: the dispatch for that commit reaches package-build, which finds no release to install and goes red. The design says that run "checks the recipes and publishes nothing", and that cannot happen.
    • Fix, one of:
      • name the change to release_as in stage 2's work: put up the key's release before the tip check, and gate only the alias on the tip;
      • dispatch only from a run that is still at the tip, and drop the claim that older merges are checked.

NOTE

  • PR body, "pkg", install step 4. "The type makes that unrepresentable" overstates what the type does.
    • finish's token proves that some stream passed. It does not prove that the staging file still holds those bytes.
    • Until per-row views land, pkg-fetch, and every row, can rewrite /state/pkg/staging/<file> between the hash and the inflate.
    • The launch check catches a swapped program, but not the data files beside it.
    • The gap the stage files should name the staging file as well as pkg-fetch's view.
  • PR body, "The builder's command" — the C environment is missing.
    • The builder's environment lists RUSTUP_TOOLCHAIN alone. GuestEnv also carries cc (build/build.rs:380-383, :420): the toolchain's clang against libc's C sysroot.
    • Without it, doom's C, which "cc's defaults" on a ToyOS triple cannot build, fails at stage 5.
    • Fix: the builder runs under GuestEnv whole.
  • PR body, "The trigger" — what moving release into publish.yml changes.
    • The write-token gate is build/release.rs's PUBLISHER and publisher(), not build/ci.rs. publisher() accepts only schedule and workflow_dispatch, so it moves too.
    • publish.yml's workflow-level id-token: write reaches any job that does not narrow it.
    • release needs crates.io's publish job first, or sdk_at_tip refuses with "crates.io holds no …".
    • Stage 2's work should list all three.
  • PR body, "Hosting", "The timestamp." A publish job that dies between deleting timestamp.txt and uploading the new one leaves no timestamp until the next run. Every client is refused Absent until then. Add this to the roast.
  • PR body, "An image behind the repository." "Update the system first" names no source for an image at the repository's SDK. No workflow publishes an image: build/release.rs's one ASSET is the toolchain. Say where the person gets one, or record the gap.
  • PR body, "What a package is", sdk::identity. It walks SYSROOT_SOURCES through identity::of again, as the sysroot's witness already does (build/sysroot.rs). Build it from that witness plus the four crates' identities, rather than writing a second walk.
  • PR body, "The apps, weighed", toyfetch row. Delete the net and spin applets, proctest, and the launch_authority metal row #889 has landed, and it deleted the launch_authority row, so the "whichever lands second" clause is moot. This is prose, for the orchestrator.

The two flagged departures, weighed for the owner's ruling

  • pkg install <file> goes: recommend accepting.
    • "Repository name only" already excludes a file path, so this is the ruling, not something wider than it.
    • The launch check refuses every binary that no signed targets names. A kept file form would therefore install what can never start: dead code, plus SHA256SUMS, kept for nothing.
    • The cost is stated in the roast: no sideloading until stage 4's per-image repository. Nothing in-tree depends on the file form between stages 3 and 4, because the in-tree apps are image rows, not /apps packages, until they leave.
  • The staging file is pkg's, not pkg-fetch's: recommend accepting.
    • The network-facing process ends up holding less than ruling 5 gives it: netstack and a pipe, and no file.
    • The process that hashes the bytes is the one that writes the file it inflates. That follows the TUF client workflow ("Fetch target": verify a target against its targets before using it).
    • Under ruling 5's literal wording, pkg would hash a file the fetcher owns and then read it again to inflate. That leaves a check-then-use window open even after per-row views land.
    • Before views land, the two are equal; after, the departure closes that window. Ruling 5's intent, no netstack in the committer and nothing else in the fetcher, is met either way.

toyos-root, against "machinery comes last"

  • Recommend accepting it as the least that meets the owner's ruling, with one cut.
    • "The root key never touches an online machine" requires some program offline that mints and signs. The one alternative without ToyOS code is OpenSSH's ssh-keygen -t ed25519 with -Y sign -n toyos-root, which repo.rs's format already accepts. That is a C host tool taken where a Rust tool does the job, which Arrivals refuses. It would also still need ToyOS online to render the root body.
    • A [[bin]] inside toyos-update would break that crate's pure description. A small package on toyos-update with sign and getrandom, read back through the one client, is the minimal shape.
    • The cut: stage 1 needs only new and the signing of root 1. --previous, renewal and two-key rotation are owed a year out, and timestamp.yml's red 30 days before expiry is their trigger. Move them to a stage with that exit, so stage 1 builds only what the ceremony runs.
    • The roast's plaintext seed is recorded, so it is not a blocker.

SEND BACK

@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Rulings on round 2 (#892 (comment)).

  1. Revocation at launch: take the first fix. The design states that revocation holds at launch only once the supervisor's newest root sits where no other row can write. It files that with issues/every-program-sees-only-the-files-it-was-given.md stage 2 as its exit, and adds it to the roast and to "What the check does not close". No high-water mark machinery now.
  2. The trigger: take the simpler fix. Dispatch only from a run still at main's tip, and drop the claim that older merges are checked. release_as stays as it is.
  3. The two departures are accepted: pkg install <file> goes, and the staging file is pkg's.
  4. toyos-root is accepted, with the review's cut: stage 1 builds new and the signing of root 1. --previous, renewal and rotation move to a later stage, whose exit is timestamp.yml's red 30 days before expiry.
  5. The NOTEs are taken as written.

@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2's findings, answered at e2ebc2c68 (the design commit 226e393d6 plus a merge of main's 1d5325376; git diff --shortstat origin/main...HEAD prints nothing). It applies the owner's rulings in 6136067341 to the review in 6136028679. Each line names the body section that now carries the answer.

BLOCKER

  1. A revoked signing key still launches. Answered with ruling 1, the first fix. No high-water mark is built.
    • "Keys" now states that revocation holds at launch only once the supervisor's newest root sits where no other row can write. It cites /state and /apps on one data volume (lib/manifest/src/lib.rs:109).
    • "What the check does not close" records the deleted-root hole.
    • The roast carries it.
    • Stage 3 files it in one issue with pkg-fetch's view and the staging file, with issues/every-program-sees-only-the-files-it-was-given.md stage 2 as the exit. At that exit only pkg writes /state/pkg/.
    • The supervisor test is restated so that it can fail: handed roots 1 and 2, where root 2 no longer grants the signer, the binary is refused, and a walk that stops at root 1 turns it red. "Tests" says why no deleted-root arm is written: until the exit, that arm is the recorded hole and not a claim the design makes.
  2. An overtaken merge gets no release, and its packages run goes red. Answered with ruling 2, the simpler fix.
    • "The trigger" now says release_as stays as it is (build/release.rs:344-346). Only a run that put up its release dispatches, and a run main has passed dispatches nothing.
    • The claim that older merges are checked is gone, from the trigger and from build.yml's publish bullet.
    • Measured: for main's newest 100 publish.yml push runs, I checked whether a later push run already existed by the time each run's jobs had all finished. That was true for 15 of them (publish-runs-overtaken.txt, its script beside it, read-only, exit 0). It is a floor, because release decides only after its own setup. The roast carries it as "A merge main passes is never checked on its own".

NOTE

  1. Install step 4 overclaims. The wording is fixed: the token proves that a stream passed, not that the staging file still holds those bytes. The step names the rewrite window and the unchecked data files. The gap issue stage 3 files names the staging file as well as pkg-fetch's view, and the roast carries it.

  2. The C environment. "The builder's command" now runs through cargo_build (build/build.rs:399) under GuestEnv whole, and names cc as CSysroot::cc_env's CC_/AR_/CFLAGS_<triple> (build/clang.rs:150). It is listed in stage 2's work.

  3. What moving release changes. Stage 2's work lists all three:

    • PUBLISHER (build/release.rs:36) and publisher() (:124) move to publish.yml and accept push and workflow_dispatch, with their tests;
    • id-token: write narrows from the workflow to the crates.io publish job;
    • release needs: [publish, toolchain]. With that, stage 2 takes up issues/a-release-that-decides-before-its-tips-crates-are-up-reds-the-nightly.md and writes its exit's test.

    The false build/ci.rs attribution is gone from the trigger and the roast.

  4. The timestamp gap. "Hosting" and the roast now say that a job dying between the delete and the upload leaves every client refused Absent until the next timestamp.yml run, at most a day, or a dispatch of it.

  5. No image to update to. "An image behind the repository" says that no workflow publishes one, since ASSET (build/release.rs:33) is the toolchain alone. Today's only source is an image the person builds from the ToyOS commit that the repository release's notes name, signed with the image key their loader trusts, and installed with update.

    • The refusal names that release page.
    • Stage 3 files the gap, owned by build/release.rs. Its exit is an image per target on every release at main's tip, and a refusal that names that image.
    • The roast carries it.
  6. sdk::identity walks twice. It is now built from sysroot::witness (build/sysroot.rs:341), whose per-tree loop takes its trees as an argument. The identity is the witness followed by the four crates' lines, through one walk.

  7. toyfetch row, Delete the net and spin applets, proctest, and the launch_authority metal row #889. The clause is gone.

Departures and toyos-root

  • The two departures are applied as accepted (ruling 3). "pkg install <file> goes" and the staging-file paragraph now cite the ruling, and no longer read as flagged.
  • toyos-root, with the cut (ruling 4).
    • Stage 1 builds new and the signing of root 1 alone. The ceremony's sign takes no --version or --previous.
    • A new stage 9 holds --previous, renewal and rotation, with the exit the owner ruled: timestamp.yml's red 30 days before root 1 expires.
    • The walks to N+1 test moves to stage 9.
    • The roast adds that a signing key leaked before stage 9 cannot be revoked until stage 9 is built.

Also corrected

These are citations that main's moves had shifted: Cargo.lock:5474, Cargo.toml:320, implementer.md:82, the guest-suite issue's :125/:207, and the supervisor's binary_digest :1699 and write_store :1706. "Unsure" adds one case: a packages push or pull request that runs while main's tip has no release up yet.

Nothing is built, and no gate was run.

@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Design review, round 3, at e2ebc2c68 (226e393d6 plus a merge of main's 1d5325376). git diff --shortstat origin/main...e2ebc2c68 prints nothing: 0 lines of production code, 0 of tests. I checked the body against the rulings in 6136067341 and the mapping in 6136268506. I re-read the cited sites at the head: build/release.rs:33,36,124,337-388,680, build/build.rs:370-420, build/clang.rs:150, build/sysroot.rs:71,83,341, lib/manifest/src/lib.rs:109, system/supervisor/src/main.rs:1699,1706, .github/workflows/publish.yml and nightly.yml, and implementer.md:82. I also read publish-runs-overtaken.txt and its script at ToyOSOrg/evidence@d64d43fa4.

Round 2's BLOCKERs

  1. A revoked signing key still launches: CLOSED as ruled (ruling 1). The fix appears in four places:

    • "Keys" states the limit and cites lib/manifest/src/lib.rs:109, where /state and /apps are on one data role.
    • "What the check does not close" records the hole.
    • The roast carries it.
    • Stage 3 files it with issues/every-program-sees-only-the-files-it-was-given.md stage 2 as its exit, and that stage's exit ("nothing still sees the global tree") is something a test can read.

    The supervisor test can now fail: given roots 1 and 2, a walk that stops at root 1 launches, and the test turns red. No high-water mark is built.

  2. An overtaken merge gets no release: CLOSED as ruled (ruling 2).

    • release_as stays as it is: it returns at :344-346 before lay_out.
    • Only a run that reached put_up dispatches.
    • The claim that older merges are checked is gone.
    • The listing supports 15 of 100. Every row's overtaken follows from its own columns. The script takes the latest completed_at over the run's jobs, which today are only publish and toolchain, the two jobs release would needs:. So "a floor" holds.

Round 2's NOTEs, the two accepted departures, and the toyos-root cut are each answered as ruled: install step 4's wording, GuestEnv whole with cc_env, PUBLISHER/publisher()/id-token/needs, the timestamp gap, no published image, sdk::identity built from the witness, the #889 clause, and stage 9 with the exit the owner ruled.

BLOCKER

  • PR body, "The trigger" and stage 2's work — moving release to every push gives it no concurrency, so two release jobs race.

    • What changes: in nightly.yml, release runs once a day under concurrency: nightly-${{ github.ref }}. In publish.yml it would run on every push. That workflow has no workflow-level group, and the one job-level group belongs to the crates.io publish job.
    • How the race arises: release_as reads the tip once (sdk_at_tip, :344) and then spends minutes on lay_out, pack (460 MB) and put_up. The listing has merges three to four minutes apart (19:25, 19:28 and 19:32 on 2026-10-12). Run A can pass the tip check, B can land and pass its own, and both can then act at once.
    • First consequence, same sysroot key: both write the same tag. put_up reads found and then creates or uploads (:459-483). Two uploads of toyos-toolchain.tar.gz collide, so a job goes red, or one deletes the other's asset through Put::Replace.
    • Second consequence, different keys: if A finishes last, it moves the sdk- alias back onto the older release. sdk_at_tip's doc says this cannot happen, but it only holds while the check and the act are not interleaved.
    • Fix: stage 2's work gives release one concurrency group with cancel-in-progress: false, as publish and timestamp.yml share one on the packages side. GitHub then replaces a pending older run with the newer one, which sdk_at_tip refuses anyway.
    • Test: a reader can check this in the YAML, so no test is owed. The body should name it beside the PUBLISHER move.
  • PR body, "Unsure", last item — the commit a packages push or pull request builds against is left open, and its named alternative is a fallback.

    • The default: plan takes main's tip, and package-build goes red whenever no release holds that tip's sysroot key. That window lasts from the merge until the tip's toolchain and release jobs finish. If the tip's run fails, it lasts until the next merge.
    • Why it is not short: the body's own listing has runs whose jobs took 43 min (57616a067) and 2 h 11 min (c757a86b8, the start of a four-run burst that ended at 20:19).
    • Effect: a packages pull request's verdict depends on whether another repository's run has finished. That is a red no change to packages can cause or fix, and root CLAUDE.md deletes such a check rather than re-running it.
    • The alternative: "plan takes the newest commit that has one instead" makes the tip the first path and an older commit the second. That is a fallback.
    • Nothing for stage 2 to measure: the deferral names no measurement whose result would choose between the two. How often the window opens is already in the evidence above, and neither option becomes correct or incorrect by its frequency.
    • Fix: the design picks one path now, whose verdict does not depend on another run's timing. One candidate the design already supports is the ToyOS commit that the repository release's notes name: a publish installed that commit's toolchain, so its release exists by construction. A push then publishes nothing, and the next dispatch publishes, since publish already requires the tip. If the design instead keeps the red, it records that as a weakness in the roast, with the window from the listing, and does not leave it as Unsure.

NOTE

  • PR body, "The trigger", "The last merge of any burst is never passed, so main's tip is always checked": this holds only when the tip's own publish.yml run reaches release. A red toolchain or crates.io publish at the tip leaves the tip unchecked, and the repository at the older SDK, until the next merge. The roast should say so beside "A merge main passes is never checked on its own". This is prose, for the orchestrator.

SEND BACK

@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Rulings on round 3 (#892 (comment)). (1) Stage 2 gives release one concurrency group, cancel-in-progress: false. (2) A packages push or pull request builds against the ToyOS commit named in the repository release's notes, whose toolchain release exists by construction: one path, no fallback. A push then publishes nothing new until the next dispatch. (3) The NOTE: "main's tip is always checked" says it holds only when the tip's own run reaches release.

@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Round 3's findings, answered at e2ebc2c68 (unchanged head: the design commit 226e393d6 plus a merge of main's 1d5325376, which is still main's tip; git diff --shortstat origin/main...HEAD prints nothing). It applies the owner's rulings in 6136312811 to the review in 6136309371. Only the body changed. Each line names the body section that now carries the answer.

BLOCKER

  1. Two release jobs race once it runs on every push. Answered with ruling 1.
    • "The trigger", under "What moving the job changes", now gives release its own concurrency group, release, with cancel-in-progress: false, in place of the workflow-level group nightly.yml gives it today.
    • It names both races it closes: two put_ups of one tag (build/release.rs:459-483), and an older run moving the sdk- alias back after both passed sdk_at_tip (:344).
    • With the group, GitHub replaces a pending older run with the newer one, which sdk_at_tip would refuse anyway.
    • No test is owed, because a reader checks it in the YAML, and the body says so.
    • Stage 2's work lists the group beside the move, and the roast's "Several rules move" names it.
  2. The commit a packages push or pull request builds against was left open. Answered with ruling 2: one path, no fallback.
    • build.yml's plan now takes the dispatch's commit for a dispatch. For a push or a pull request, it takes the ToyOS commit that the repository release's notes name. That publish installed the commit's toolchain release, and nothing in build/release.rs deletes a release, so it exists by construction. The verdict never waits on another repository's run.
    • plan never falls back to main's tip or to any other commit. Before the first publish there is no repository release, so plan refuses a push or pull request by name. Stage 2's first publish is a dispatch, so this lasts only until stage 2's exit.
    • The publish bullet now says a push publishes only if no merge has landed since the last publish. Otherwise its recipes publish at the next dispatch.
    • Stage 2's work lists packages-plan's choice.
    • The "Unsure" item is gone.
    • The roast adds "A packages pull request is judged against the last publish's SDK, not main's tip". A bump that builds there can still break against a later merge's SDK, and that shows only at the next dispatch.

NOTE

  1. "main's tip is always checked." Answered with ruling 3. "The trigger" now says main's tip is checked whenever the tip's own run reaches release. A red toolchain or crates.io publish at the tip leaves the tip unchecked, and the repository at the older SDK, until the next merge. The roast carries this as "The tip goes unchecked when its own run stops short of release", next to "A merge main passes is never checked on its own".

Nothing is built, no gate was run, and no evidence file was added this round.

@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Design review, round 4, at e2ebc2c68 (the head is unchanged: 226e393d6 plus a merge of main's 1d5325376). git diff --shortstat origin/main...e2ebc2c68 prints nothing: 0 lines of production code, 0 of tests. I checked the body against the rulings in 6136312811 and the mapping in 6136330715. I re-read these at the head: build/release.rs:337-388 and :430-488, plus its put test at :743-757; .github/workflows/publish.yml, nightly.yml and toolchain.yml.

Round 3's findings

  1. Two release jobs race once release runs on every push: CLOSED as ruled (ruling 1).
    • "The trigger" gives release a job-level group, release, with cancel-in-progress: false. It names both races: the put_up collision at :459-483 and the alias moving back past sdk_at_tip.
    • Stage 2's work and the roast carry the group.
    • Under the group the ordering holds. If the newer run's release starts first, the older one runs after it and sdk_at_tip refuses it. If the older one runs first and passes, it finishes before the newer one begins.
    • The group's name collides with nothing. publish.yml's only other group is publish, and toolchain.yml's is toolchain-${{ github.ref }}.
  2. The commit a packages push or pull request builds against: OPEN, as one residual (below). The path is single as ruled, with no fallback to the tip, and the "Unsure" item is gone. The refusal before the first publish is by name and lasts only until stage 2. The push-publishes-only-while-no-merge-landed rule follows from the tip check, and the roast records the lag. But the body's ground for "by construction", "nothing deletes one", is false of put_up.
  3. NOTE, "main's tip is always checked": CLOSED as ruled (ruling 3). It is at The trigger and in the roast's "The tip goes unchecked when its own run stops short of release".

BLOCKER

  • PR body, build.yml plan ("nothing deletes one, so the release exists by construction and the verdict never waits on another repository's run") — put_up deletes a key release's asset, so a packages pull request can still go red on another repository's run.
    • The code: the release toolchain-linux-x86_64-<sysroot key> is shared by every main commit with that key. release_as calls put_up on that tag for every such commit. Where the asset's recorded digest is not the packed tarball's, put returns Put::Replace (build/release.rs:449), and put_up sends DELETE releases/assets/{asset} (:476-477). It then re-uploads 460 MB (:482-483). The release survives, but its asset does not. The test at :752-755 pins this behaviour.

    • How it is reached: the key's sysroot cache entry is evicted while the key is still main's, so toolchain.yml rebuilds it and saves it under the same key. The sysroot is not shown to rebuild to the same bytes ("Unsure": "Whether CI builds the same guest bytes twice"; issues/two-checkouts-of-one-tree-build-different-guest-bytes.md).

    • The race: a packages pull request built against commit C, which has key K, runs package-build while a later main commit with key K is in its release.

      • It finds no asset (between the delete and the upload), or
      • it reads the old digest and then downloads the new bytes. That fails "checking it against the SHA-256 digest GitHub records".
    • Either way the verdict depends on another repository's run, which is exactly what ruling 2 removed.

    • Fix, one of these:

      • (a) A key release's asset never changes once it is up. Stage 2 makes put_up refuse a Replace on a key tag by name, leaving Replace to the sdk- alias. The put test then names which tags may be replaced. A red release on a non-reproducible rebuild is then the loud failure, and the body records it.
      • (b) The body drops "nothing deletes one" and records the window in the roast, with its condition: an evicted live key and a sysroot that rebuilds to other bytes.

      (a) keeps the ruling's "by construction" true. (b) states it more broadly than the code holds unless the roast says so. The orchestrator picks one.

NOTE

None.

The design is ready to build once that BLOCKER is answered. No other finding is open.

SEND BACK

@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Ruling on round 4 (#892 (comment)): (a). A key release's asset never changes once it is up. Stage 2 makes put_up refuse a Replace on a key tag by name, leaving Replace to the sdk- alias, and the put test names which tags may be replaced. A red release on a non-reproducible rebuild is the loud failure, and the body records it.

Design accepted at e2ebc2c68 with that change, after four rounds. Build stage 1 next. The offline root-key ceremony is the owner's: the build hands him a script and the steps, and nothing signs root 1 until he runs it.

…he publisher signs under it with the repository's signing key alone

Stage 1 of the design accepted on #892: apps leave the tree as packages
that `pkg` installs from a repository CI signs. This stage builds the keys.

- `toyos-root` (`build/root`), a new crate depending only on toyos-update
  with `sign` and on getrandom, so it compiles for any triple without a C
  toolchain. `new` mints the root key as a 64-hex-digit seed file; `sign`
  writes root 1, which gives the root key the root role alone and the
  signing key the timestamp and targets roles, each at threshold 1, expiring
  a year from the machine's clock, read back as self-signed before it is
  written. Its module header carries the ceremony's steps. Signing root N+1
  (`--previous`), renewal and rotation are migration stage 9's.
- `cargo run -- --repo-signing-key-new` mints the repository's signing key
  at `~/.config/toyos/repo-signing-key` (0600), distinct from the image key,
  prints its fingerprint on stderr and its public line on stdout, so the
  line can be redirected onto the stick the ceremony carries.
- `build/publish.rs` loses root minting, root renewal and `one_key`. It
  receives roots under `roots/` beside its manifest, copies those the
  repository lacks and never rewrites one it holds, holds root 1 to
  `repo::self_signed`, and refuses by name a newest root that does not grant
  its key the targets and timestamp roles. It signs with the repository's
  signing key only. Every name is flat (`<name>-<sequence>-<target>.tar.gz`,
  no `archives/`), which a GitHub release's asset namespace keeps. After a
  publish leaving targets M it prunes every targets below M-1 and every
  archive neither M nor M-1 names, and never a root.
- `toyos_update::repo` gains `self_signed`, `fingerprint`, `public_line`
  and its parser, and `render::first_root`, the one definition of root 1
  that toyos-root signs and the publisher's tests use. The fingerprint is
  held to OpenSSH's (`ssh-keygen -l` over the fixture's key).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
@Japabu
Japabu force-pushed the wt/toyos-apps-out branch from e2ebc2c to 423f6fd Compare October 13, 2026 02:20
@Japabu Japabu changed the title Design: apps leave the tree for ToyOSOrg/packages, whose CI builds them against main and signs the repository pkg installs from The package repository's keys: toyos-root signs root 1 offline, and the publisher signs under it alone (stage 1 of apps leaving the tree) Oct 13, 2026
@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Build review, stage 1 (keys), at 423f6fda7, against .claude/agents/reviewer.md on origin/main. The design checked against is the body as accepted in 6136355825, with the earlier rulings. Evidence was read at ToyOSOrg/evidence 440d0ac3f stage1/.

Lines (git diff --shortstat origin/main...423f6fda7): 10 files, +728 −180, net +548. The body splits that into production, manifests and lock at net +321 (138 of them the new crate's source) and tests at net +227.

Evidence read: --ci host exit 0 with 76 steps green, and the new tests appear in the log (toyos-root ×2, repo::tests ×3, repo_oracle ×1, publish::tests, signing::tests). --build-only exits 0. The one guest test exits 0. All 9 mutations go red (exit 101) and the unmutated tree is green and clean. In the OpenSSH oracle, ssh-keygen -l matches both printed fingerprints and -Y verify -n toyos-root exits 0, with both controls at 255. The --publish CLI exits 0 twice. On the triples, check exits 0 on all three, and build links only aarch64-apple-darwin.

BLOCKER

  • build/root/src/main.rs:121 (write_new, called by new). The root key is minted at the process umask, not 0600. The accepted design's ceremony step 2.1 reads "toyos-root new <stick A>/root-key mints the root key (mode 0600)". The build opens the file with OpenOptions::new().write(true).create_new(true) and no mode, so on a Unix offline machine the root key is readable by every account (typically 0644). The body lists "toyos-root checks no file mode" as a choice the design left open. The design was not silent on the mint's mode, and the build drops it without an acceptance on the PR. The build does not match stage 1. Fix: mint 0600 as accepted, or have the change accepted on the PR before it is built.
  • lib/update/src/repo.rs:294 with build/ssh/src/hostkey.rs:53, and build/root/src/main.rs:128 with build/signing.rs:188. Two siblings of what the tree already has.
    • The fingerprint. repo::fingerprint is a second SHA256: fingerprint beside toyos_ssh::hostkey::fingerprint. The tell is the new assert in build/signing.rs:362, which holds the two copies equal.
    • The key-file parser. seed_of_hex is copied verbatim into toyos-root, and the two readers of the one 64-hex key-file form already differ: toyos-root strips one \n, while read_repo_key calls trim(). new's inline hex writer also repeats signing::hex_of.
    • Fix. Give each one definition that both readers use. The hex seed form belongs in toyos-update's sign feature, which both build/signing.rs and toyos-root already link. The fingerprint takes one home: either toyos-ssh calls toyos_update::repo::fingerprint, or both call a single pure crate that names OpenSSH public keys. Then the equality assert goes.
  • Tests that make more than one claim (Tests: one claim per test, under a name that says it).
    • build/root/src/main.rs:181 a_root_one_it_signs_is_self_signed_and_grants_the_signing_key asserts all of these:
      • new refuses an existing file;
      • sign refuses an existing --out;
      • the expiry;
      • the printed fingerprint;
      • acceptance under the signing key;
      • refusal under the root key.
    • Its grant leg repeats repo.rs's new the_ceremonys_root_is_self_signed_and_grants_the_signing_key (m7 reds both). One of the two goes.
    • build/signing.rs:355 a_repo_key_is_minted_once_and_read_back asserts 0600, the read-back, fingerprint equality, refusal of a second mint, the key left intact, and refusal of a bent file.
    • build/publish.rs:430 asserts two refusals: a root that does not grant the key, and a root that is not self-signed.
    • build/publish.rs:447 gains a sixth refusal, of a rewritten root.
    • Fix: split each test into one claim per test, and cut the duplicate.

NOTE

  • build/publish.rs:116-123. The grant check guards nothing the read-back does not. m1's own log shows it: with the check skipped, the publish is still refused before anything is written ("timestamp 1 carries 0 valid signature(s) … its key is not one the root gives this role"), and only the message prefix the test matches changes. Delete the check, and hold the test to the read-back's refusal and to "nothing written". Alternatively, keep it as the refusal named in the design and accept that it is message-only.
  • build/publish.rs:236-245. roots() stops at the first absent version. A received roots/ holding root.3.txt without root.2.txt drops root 3 without a word. Refuse a received root that the walk does not reach.
  • build/signing.rs:274. The repository key is the image Key typed Whose::Owner. It therefore carries sign(&Header) and floor_scope() == Machine, and build.rs:1786 would call it "the owner's key". "Distinct from the image key" is a convention, not a type. Give the repository key its own type, which can sign documents and cannot sign a header.
  • build/root/src/main.rs:31-32. Ceremony step 1.2 builds for "the offline machine's triple", and the branch's own measurement shows that build exits 101 for x86_64-unknown-linux-gnu and x86_64-pc-windows-msvc from this host, because the link needs that triple's cc or link.exe. The header does not say so, and the body's "compiles for any triple without a C toolchain" holds only for check. Before the owner runs it, either name in the header a command that links for the offline triple, measured, or get the offline machine's triple first.

SEND BACK

@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Rulings on the stage 1 review's NOTEs (#892 (comment)).

  1. The grant check goes: the read-back refuses first.
  2. A gap in roots/ is refused by name.
  3. The repository key gets its own type, which signs documents and cannot sign an image header.
  4. The ceremony names one measured path: an offline machine of this Mac's OS and architecture (aarch64-apple-darwin), where step 1.2 links. Other offline triples are stated as unmeasured.

The BLOCKERs are taken as written, 0600 included.

Japabu and others added 2 commits October 13, 2026 04:28
…es have one home each, and the repository key is its own type

The build review of #892's stage 1 and the owner's rulings on its NOTEs.

- `toyos-root new` mints the root key 0600, as the accepted ceremony says;
  root 1 is written 0644. The crate is built for a Unix triple, and the
  ceremony names the one measured path: `--target aarch64-apple-darwin` on
  this Mac, for an offline Mac of the same macOS and architecture. Any other
  offline triple is stated as unmeasured.
- A seed's key-file text has one writer and one reader,
  `toyos_update::sig::{seed_text, seed_of_text}` behind `sign`: 64 lowercase
  hex digits and a newline, and nothing else reads. The checkout's throwaway
  key, the repository's signing key and the root key all go through it, and
  the two `seed_of_hex` copies and `hex_of` go.
- The `SHA256:` fingerprint has one home, `toyos_update::repo::fingerprint`:
  toyos-ssh calls it and loses its own, with its fixture, and the assert
  holding the two equal goes.
- `signing::RepoKey` is the repository's signing key: it signs documents and
  has no way to sign an image header or name a floor. `Key` loses
  `sign_document`.
- The publisher's grant check goes: the read-back refuses a root that does
  not grant the key before anything is written.
- A file in `roots/` the walk from root 1 does not reach, a root past a gap
  among them, is refused by name.
- One claim per test: the tests the review named are split, and toyos-root's
  repetition of `repo`'s grant check is cut; toyos-root's test holds the
  written root to `render::first_root` byte for byte instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Build review, stage 1 (keys), round 2, at ad6f61c49, against .claude/agents/reviewer.md on origin/main. Round 1: 6136858838. Rulings: 6136865450. Evidence read at ToyOSOrg/evidence pr-892 4cfd338a3 stage1-r2/. Reviewed: git diff befc4449f ad6f61c49 (the round's commit; the merge of main is #893's).

Lines (git diff --shortstat origin/main...ad6f61c49): 18 files, +950 −230, net +720 (body: production, manifests and lock +343, tests +377). The round itself is +383 −211, and most of that growth comes from splitting tests.

Gates at this head: exits.txt names ad6f61c49. --ci host exits 0 with 76 steps green, and its log runs the new tests (a_root_past_a_gap_…, a_repo_key_is_minted_0600, the_root_key_is_minted_0600, a_text_not_in_the_seed_texts_form_is_refused, the_fixtures_key_has_the_fingerprint_ssh_keygen_printed). --build-only exits 0. console_image_boots exits 0. The mutation runner exits 0, with the unmutated tree 0 in all four crates and a clean git status.

Round 1

  • BLOCKER 1, root key 0600: CLOSED on this host's disk. write_new opens with .mode(0o600). m10 (mode | 0o644) reds the_root_key_is_minted_0600 (exit 101). The oracle shows -rw------- under umask 0022. Where the key is written in the ceremony is still open: see the first BLOCKER below.
  • BLOCKER 2, siblings: CLOSED.
    • Fingerprint: toyos_ssh::hostkey::fingerprint and its fixture are deleted. toyos-ssh calls toyos_update::repo::fingerprint, and the equality assert is gone.
    • Key-file text: both seed_of_hex copies and hex_of are deleted. sig::{seed_text, seed_of_text} behind sign is the one home, read strictly; m11 reds it.
    • git grep finds no other SHA256: formatter at the head.
  • BLOCKER 3, one claim per test: CLOSED.
    • toyos-root has 7 tests, signing 4, publish 8, repo 5, and repo_oracle 3.
    • toyos-root's refresh leg, the duplicate, is cut. sign_writes_the_ceremonys_root_one_expiring_a_year_on holds the bytes, and m12 reds it.
  • Ruling 1, grant check goes: CLOSED. The check and its message are deleted. The refusal is the read-back's.
  • Ruling 2, gap refused by name: CLOSED. roots() refuses any entry the walk does not reach. m1 reds the test, and the oracle's root.3.txt control exits 1, naming the file.
  • Ruling 3, RepoKey: CLOSED. It has no sign(&Header), no floor_scope and no Whose, and Key loses sign_document. The type enforces this, so no test is owed.
  • Ruling 4, one measured path: CLOSED. The header names --target aarch64-apple-darwin and states every other triple as unmeasured. The oracle's step 1.2 exits 0 and file reads Mach-O arm64.

BLOCKER

  • build/root/src/main.rs:22,38 with :123: the 0600 the header promises on stick A rests on a filesystem the ceremony never names, and was measured only on this host's internal disk.
    • Step 2.2 says new "mints the root key 0600" onto <A>, and the header asks only that A and B be "formatted on that machine".
    • FAT32 and exFAT, the formats a USB stick usually comes in, have no field for a Unix mode. On such a volume open(O_CREAT, 0o600) succeeds, new prints its fingerprint, and the key's mode is whatever the mount gives every file. The same holds for step 2.3's copy to B.
    • The oracle wrote <scratch>/A on the host's own volume, so nothing measured the medium the claim is about. Under the ruling, 0600 is part of the design, and a tool that asks for it and reports success where it did not happen is the silent degradation "Fail fast" refuses.
    • Fix:
      1. Name the format of A and B in the header, with the command that makes it (APFS, e.g. diskutil eraseDisk APFS <name> GPT <disk>).
      2. Have new read back the mode of the file it created and refuse by name when it is not 0600.
      3. Measure both: new onto an APFS volume (an hdiutil create -fs APFS image attached is the cheap stand-in) reading -rw-------, and onto an ExFAT image refusing.

NOTE

  • build/root/src/main.rs:38,41: steps 2.2 and 2.4 run a bare toyos-root, which is on no PATH on the offline Mac. The binary is <C>/toyos-root, as the body's copy of the ceremony writes it. Write it that way in the header.
  • build/root/src/main.rs:36: step 2.1 checks the clock but does not say what to do when it is wrong. On an offline Mac the only fix is setting it by hand (sudo date -u <MMDDhhmmCCYY>), and root 1's expiry depends on it. Name the command.
  • PR body, "Negative control": "a_root_that_does_not_grant_the_key_is_refused_and_nothing_is_written … m7 turns red" is false of the record. m7's log (mutations.txt:947-948) shows the test reddened by root 1 is not self-signed, because m7 takes the root role off the root key, not by a grant refusal. The test does still fail if the read-back is skipped, since the publish would then return Ok. So no defect lands unseen. Only the attribution is wrong.

SEND BACK

…eeps no Unix mode, and the ceremony names APFS

The 0600 the header promised the root key rested on a filesystem the
ceremony never named. On exFAT and FAT32, the formats USB sticks come in,
open(O_CREAT, 0600) succeeds and every file reads back the mount's one mode:
measured 0700 on both, attached as disk images and erased by diskutil. `new`
printed its fingerprint as though it had minted 0600.

- `new` reads the mode back from the file it created, before the seed is
  written, and where it is not 0600 removes the empty file and refuses by
  name. `sign`'s root 1 to stick C takes no read-back: it is public.
- The header names A and B as APFS, erased with `diskutil eraseDisk APFS
  <name> GPT <disk>`, and says why.
- Step 2.1 names `sudo date -u <MMDDhhmmCCYY>` for a wrong clock; steps 2.2
  and 2.4 run `<C>/toyos-root`, which is on no PATH offline; step 2.3 reads
  the B copy's mode back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
@Japabu

Japabu commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Build review, stage 1 (keys), round 3, at 72caf9c0d, against .claude/agents/reviewer.md on origin/main. Round 2: 6137091933. Evidence read at ToyOSOrg/evidence pr-892 659e45d56 stage1-r3/. Reviewed: git diff ad6f61c49 72caf9c0d (build/root/src/main.rs only, +36 −17), plus build/root/src/main.rs read whole at the head.

Lines (git diff --shortstat origin/main...72caf9c0d): 18 files, +969 −230, net +739. This round adds 19 net production lines, all for the round-2 BLOCKER, and I accept them.

Gates at this head: exits.txt names 72caf9c0d. --ci host exits 0, "76 step(s), all green". Its log runs tests::the_root_key_is_minted_0600, and the FAILED lines in it all sit under control steps, each of which reached its verdict. m14's runner exits 0: mutated oracle 4, restored oracle 0, and git status empty. The body explains why --build-only and console_image_boots were not rerun: no image or guest links build/root. I accept that.

Round 2

  • BLOCKER, 0600 on an unnamed medium: CLOSED.
    • The header (:23-28) names APFS for A and B, gives the erase command, and says why exFAT and FAT32 are refused.
    • new (:99-107) reads the mode back from the open file (fstat, so there is no path race) before the seed is written, then removes the file and refuses.
    • m14-no-readback.txt, restored arm: on APFS, new exits 0 and the key reads -rw-------, and the B copy reads the same. On ExFAT and on MS-DOS FAT32, new exits 1 with reads back 0700 and leaves no file.
    • Mutated arm: on both ExFAT and FAT32, new exits 0 and leaves -rwx------.
  • NOTE, bare toyos-root: CLOSED. :44 and :48 run <C>/toyos-root.
  • NOTE, wrong clock: CLOSED. :42 names sudo date -u <MMDDhhmmCCYY>. The oracle parses date -u -j 101312002026 to Tue Oct 13 12:00:00 UTC 2026, which is the field order macOS date uses.
  • NOTE, m7 attribution: CLOSED. Body, "Negative control", now says m7 reddens on root 1 is not self-signed and that no mutation removes the grant refusal alone.

BLOCKER

  • build/root/src/main.rs:100-107: the read-back is a refusal that no gate holds, and a host test can reach it.
    • Measured: under m14 (m14-no-readback.patch, which replaces the if mode != 0o600 { … } block with let _ = mode;), --ci host stays green. The body says so: "No --ci host test reaches the read-back".
    • A later change that drops or weakens the refusal therefore lands unseen. The disk-image script is evidence for this head. It is not a check on any later head.
    • The body says only a filesystem without Unix modes reaches the refusal. That is true only because the decision is written inline after create_new. The decision is about a file that is already open, and a plain tempdir file created at 0644 reaches it on every host without hdiutil or diskutil.
    • Fix:
      1. Move the read-back into the function new calls with the open file and its path: refuse unless the mode reads 0600, and remove the file. new stays its caller, so nothing ships for a test alone.
      2. Add a host test, for example a_key_file_that_reads_back_other_than_0600_is_refused_and_removed. It creates a 0644 file in a TempDir, calls the check, and asserts the refusal's text and that the file is gone.
      3. Run m14, adapted to the moved check, and show the new test exit 101 under it.
    • The medium oracle stays as the evidence that exFAT and FAT32 read back 0700. That is a measurement of the medium, and the suite never runs it.
    • On the question the brief asked: the macOS disk-image script does meet the Evidence bar for this head. It has a command, exit codes, a log naming 72caf9c0d, and a negative control that goes red. What it cannot do is stand in for a gate where a cheap host test reaches the same refusal. "A mutation you suspect would still pass" is not a suspicion here, because m14 was measured passing the gate.

NOTE

  • build/root/src/main.rs:23-28: APFS keeps the mode bits, but the measured APFS mount is noowners.
    • The oracle's mount line reads (apfs, local, nodev, nosuid, journaled, noowners, …). On a volume that ignores ownership, every user of every Mac the stick is plugged into is treated as the file's owner, so 0600 keeps no other user out.
    • The stick's real guard is physical custody, which step 2.5 states. That is why this is a NOTE and not a BLOCKER.
    • Fix, either:
      • add sudo diskutil enableOwnership <A> (and the same for <B>) to the ceremony, and have the oracle read stat -f '%Su %Sp' back; or
      • say in the header that 0600 binds only where ownership is enabled.
  • build/root/src/main.rs:24-28,44: the header never says what <A> and <B> are, or how to tell the stick's disk from the Mac's.
    • diskutil eraseDisk APFS <name> … mounts the volume at /Volumes/<name>.
    • If A and B are erased under the same <name>, the second mounts as /Volumes/<name> 1 once both are plugged in for step 2.3.
    • Fix:
      • Give the two sticks distinct names, for example ROOTA and ROOTB, and write <A> as /Volumes/ROOTA.
      • Tell the owner to take <disk> only from an entry that diskutil list marks (external, physical). eraseDisk on the wrong identifier erases the offline Mac's own disk.
  • build/root/src/main.rs:44,48: steps 2.2 and 2.4 run the binary from stick C, and nothing measured that.
    • The oracle ran toyos-root from target/aarch64-apple-darwin/release/, not from a stick.
    • The ExFAT image is already attached in medium-oracle.sh. Copying $R onto it and running new from there would measure the step as the owner runs it.

SEND BACK

…st holds, and the ceremony enables ownership on A and B

`new`'s refusal of a key file that reads back other than 0600 moves into
`read_back_0600`, which `new` calls with the open file; a host test creates
a 0644 file in a tempdir and asserts the refusal and the removal, so the
refusal no longer rests on a disk-image script alone.

The ceremony names the sticks ROOTA and ROOTB, takes `<disk>` only from an
`(external, physical)` entry, enables ownership on both with
`sudo diskutil enableOwnership`, since an APFS stick mounts `noowners`, and
reads owner and mode back on both copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Build review, stage 1 (keys), round 4, at c1e06aa01, against .claude/agents/reviewer.md on origin/main. Round 3: 6137214587. Evidence read at ToyOSOrg/evidence pr-892 e92ddd9ee stage1-r4/ (exits.txt, ci-host.txt, mutate.sh/.txt, both patches, medium-oracle.sh/.txt). Reviewed: git diff 72caf9c0d c1e06aa01 (build/root/src/main.rs only, +38 −13), and that file read whole at the head.

Lines (git diff --shortstat origin/main...c1e06aa01): 18 files, +994 −230, net +764. This round adds 12 test lines and about 13 production lines: the extracted read_back_0600, which new calls, so nothing ships for the test alone, and the header's ownership step. I accept them.

Gates at this head: exits.txt names c1e06aa01. --ci host exits 0 with "76 step(s), all green", and its log runs tests::a_key_file_that_reads_back_other_than_0600_is_refused_and_removed, a test that exists only at this head. mutate.sh exits 0. m14 (the read-back refuses nothing) turns the test red with exit 101 on unwrap_err() on Ok. m15 (the file is not removed) turns it red with exit 101 on "the refused file is removed". The restored crate passes 8 of 8, and git status is empty.

Round 3

  • BLOCKER, the read-back is a refusal no gate holds: CLOSED. read_back_0600 (:147-157) is new's own path (:106). The host test (:199-208) does not depend on the umask: it forces 0644 with set_permissions. Both mutations of the refusal turn it red.
  • NOTE, APFS mounts noowners: TAKEN, by the orchestrator's ruling. The step is at :30-33. Its scope is left open; see the BLOCKER below.
  • NOTE, <A>/<B> unnamed and <disk> unguarded: CLOSED. :26-28 and :33-34.
  • NOTE, running from stick C was unmeasured: CLOSED. In medium-oracle.txt, new and sign run from ExFAT STICKC and exit 0 on APFS. They exit 1 on ExFAT and FAT32 and leave no file.

On the brief's question: is the red ownership step acceptable evidence for landing stage 1?

Yes, once the BLOCKER below is fixed, and only for the four checks it names.

  • What the red measures. All four red expectations come from one cause: sudo: a password is required. Every expectation that rests on code at this head is green. The refusal that code makes is gated by a host test that has two red controls.
  • Why that is enough. The ownership step is not ToyOS behaviour. It is macOS's, and its oracle is Apple's diskutil(8), the vendor's document, not an agent's run. The ceremony carries its own discriminating measurement on the real machine: mount must list neither stick as noowners. That check comes before new (:30-33, then step 2.2). So if the step is wrong, the ceremony stops loudly before any key exists. There is no silent weak key and nothing is lost, only a header correction before the ceremony is resumed.
  • What it does not establish. The disk-image oracle cannot be made green by an agent, so it is not this branch's evidence for that step, and the body should not present it as one that will go green later. Its green parts stand.

BLOCKER

  • build/root/src/main.rs:30-33 — the ownership step does not cite the vendor document that defines it, and leaves out the scope that document gives. Apple's diskutil(8) (man diskutil, enableOwnership, read on this host) says:

    • the setting is written to "the on-root-disk Volume Database at /var/db/volinfo.database";
    • it persists "as seen by the current OS", and it "is not kept on the target disk";
    • for "plug-in USB disks", ownership is not considered by default.

    So 0600 on A and B binds only on the offline Mac. Any other Mac the stick is plugged into mounts it noowners, and to every user there the key reads as their own.

    Fit refuses a step that a vendor document backs when the step does not cite it. Here the uncited document also answers what the body still lists under "Unsure" as unmeasured ("whether the setting stays with the volume or with the Mac that ran it"). That is a guess left open where one read would have settled it.

    Fix: in the header, cite diskutil(8) enableOwnership at the step, and state in one clause that the setting lives in the offline Mac's volume database, not on the stick, so the sticks' guard anywhere else is custody (step 2.5). Then drop that item from "Unsure".

NOTE

  • build/root/src/main.rs:52-54 — step 2.3's %Su check can never fail in this ceremony. Round 3 asked for it, and the body's own Red paragraph shows why it is no check: on a noowners volume stat reports the current user, and with ownership enabled the on-disk owner is that same user. The mount line at :32-33 is the only check that discriminates. Either cut %Su back to stat -f %Sp on both copies, or keep it and stop presenting it as a check on ownership.
  • PR body, "Gates", the ceremony-oracle row and its "Red" paragraph — the body reads as though this oracle will go green once someone answers sudo, but an agent can never make it green. The row should say that diskutil(8) is the oracle for the step and that the owner's mount check in the ceremony is its measurement, and that the disk-image run is evidence for every other expectation in it.

SEND BACK

…ts scope, and step 2.3 reads the mode alone

diskutil(8) enableOwnership writes the setting to the running OS's volume
database, not to the target disk, so 0600 on A and B binds only on the
offline machine; elsewhere the sticks' guard is custody. The %Su check in
step 2.3 could not fail (noowners reports the current user, and with
ownership enabled the on-disk owner is that same user), so it is cut back
to %Sp; the mount check is the discriminating one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
@Japabu

Japabu commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Build review, stage 1 (keys), round 5, at 41bbac4f5, against .claude/agents/reviewer.md on origin/main (b6a556546, still the branch's merge base; git merge-tree against it is clean). Round 4: 6137327123. Evidence read at ToyOSOrg/evidence pr-892 57cb84cd0 stage1-r5/ (exits.txt, ci-host.txt, diskutil-8.txt). Reviewed: git diff c1e06aa01 41bbac4f5 (build/root/src/main.rs only, +8 −6, all in the module header), that file's header read whole at the head, and the PR body's Gates, ceremony and Unsure sections.

Lines (git diff --shortstat origin/main...41bbac4f5): 18 files, +996 −230, net +766. This round adds no code: it is 8 header lines and removes 6. The production and test totals stand as accepted in round 4.

Gates at this head: exits.txt names 41bbac4f5916d2830d66a3d66991c56eb29fc1b5. cargo run -- --ci host exits 0. Its log ends Host: 76 step(s), all green. It runs tests::a_key_file_that_reads_back_other_than_0600_is_refused_and_removed ... ok (ci-host.txt:5080). Every FAILED in the log falls under a [ci] control step, such as wake-fence-off at :6711, and is that control's intended red. Because the round changes only comments, round 4's m14/m15 controls at c1e06aa01 still hold for this head's code.

Round 4

  • BLOCKER, the ownership step does not cite diskutil(8) and leaves out its scope: CLOSED. build/root/src/main.rs:31-37 now cites (diskutil(8), enableOwnership) at the step. It also says the setting "lives in the offline machine's volume database, not on the stick, so anywhere else A and B are guarded by custody alone (step 2.5)". That matches diskutil-8.txt at the cited enableOwnership lines: "the on-root-disk Volume Database at /var/db/volinfo.database", "as seen by the current OS", "the setting is not kept on the target disk", and "plug-in USB disks … it is not". The body's ceremony section says the same. The "Unsure" item on whether the setting stays with the volume or with the Mac is gone.
  • NOTE, step 2.3's %Su cannot fail: CLOSED. :56 is now stat -f %Sp <A>/root-key <B>/root-key reads -rw------- twice. The body's ceremony step 2.3 matches it, and the body names the mount line as the one check that discriminates.
  • NOTE, the body presents the disk-image oracle as one that will go green: CLOSED. The Gates table has a row for diskutil(8) as the ownership step's oracle. The ceremony-oracle row is now "evidence for every expectation but the ownership step". Its paragraph says an agent can never make that step green, and that the step's measurement is the owner's mount check, which runs before new.

BLOCKER

None.

NOTE

None.

LAND

@Japabu
Japabu marked this pull request as ready for review October 13, 2026 03:15
@Japabu
Japabu added this pull request to the merge queue Oct 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 13, 2026
@Japabu
Japabu added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 6080e83 Oct 13, 2026
6 checks passed
@Japabu
Japabu deleted the wt/toyos-apps-out branch October 13, 2026 05:07
Japabu added a commit that referenced this pull request Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant