Skip to content

Releases: yottayoshida/sideeye

v1.12.0 — False PASSes closed, mounts and work dirs guarded

Choose a tag to compare

@yottayoshida yottayoshida released this 11 Oct 10:47
1b1e16a

Changed

  • The CI quickstart and the MCP container pin v1.11.0 (docs/ci-quickstart.md, docs/ci-quickstart/release/install-sideeye.sh, .github/workflows/quickstart-release.yml, docs/mcp-container/Dockerfile). The release lane installs the published v1.11.0 asset, and the container fetches it, with the two Linux digests GitHub recorded for it. The pins move after the release, as on v1.10.0, because both download the asset and it exists only once the release is published; spike/check-mcp-container.py holds the Dockerfile's version to the quickstart's, so the two move together. With the container on v1.11.0, sideeye_preflight and sideeye_evidence reach an operator who builds it, as docs/mcp.md describes.

Fixed

  • A file left holding bytes is accounted for only by a recorded operation that can have put them there (#814, ADR 0121; src/engine/snapshot.zig, src/refuse.zig, docs/report-schema.md, DESIGN.md, docs/contract-freeze.md). The per-path reconciliation counted any record naming a changed path, an open included, so GNU cp onto an existing file on btrfs or XFS — open(O_TRUNC), recorded, then ioctl(FICLONE), which neither witness counts as a write — and an io_uring write after the same open passed with oracle_verified: true, measured PASS 4/4, while the world holding the truncated file was never explored; the same cp on overlayfs FAILs. Such a file now needs a recorded write, the destination of a rename or a link, the source of a link whose other name was written after it, or a truncate that only cut or zero-filled it, and the runs above are refused state_changed_unaccounted (under --oracle a reflink clone is refused before that, as unsupported_syscall_observed — the next entry), whose sentence now says "accounts for", counts the paths only a non-writing record named, and names the stdio flushes the default mode leaves unrecorded (and, on Linux, the mode that counts them). A file an open created or truncated and left empty, and a path declared scratch, are accounted for as before (ADR 0032 §2). Not reached: such a call on a file another accounting record also names — a recorded write, a rename or link that put it there, a truncate whose result the bytes happen to match — or on a scratch path a checker reads. A saved FAIL case whose recording now reaches this refusal no longer replays. Acceptance: a libc open(O_TRUNC) refilled by a raw write refuses, the libc-write control is accepted.

  • Under --oracle, a reflink clone into a state file is refused (#814, ADR 0121; src/oracle.zig, docs/report-schema.md, DESIGN.md). FICLONE and FICLONERANGE were read as read-only because ioctl is on that list, so a file half-written with a recorded pwrite and half-cloned in passed with oracle_verified: true — and with oracle_verified_subject_only when a shell ran the tool — while the torn file was never explored (measured on btrfs). Such a clone into a state file, from the subject or a child (a child the v15 rule does not admit is refused first, as that), is now unsupported_syscall_observed with the message ioctl(FICLONE); a whole-file clone that failed before remapping anything is still read as a read, so GNU cp on overlayfs still falls back to copy_file_range and FAILs, while any other failed clone, which can have remapped part of the file, is refused, and a call split across two lines is judged on the half with its result. An ioctl on a state file whose request this reader cannot split — a file name holding a " or an unclosed bracket — is refused too. io_uring is not refused: Node 22.23.3 sets up rings it writes no file through, and refusing them would refuse every Node 22 target on a host that allows io_uring.

  • Under --oracle-fs-usage, a target that writes its standard output, or moves a file onto a standard descriptor and writes through it, is judged (#796, ADR 0122; src/main.zig, src/fsusage.zig, src/posix.zig, docs/cli.md, spike/fsusage/acceptance-local.sh). Measured on GitHub's macOS 26 runner, every such run was refused oracle_saw_nothing on its first write F=1 — a toy that prints done, a toy moving its state file onto 1 with dup2, and GNU dd of= alike — because the engine moves its capture onto 1 before the exec, on a thread the capture does not tie to the subject, 2 is the engine's own stderr, and -f filesys prints no dup2. The engine now tells the reader where 0, 1 and 2 point, fs_usage runs without a class filter so that dup and dup2 print with both numbers, the reader follows them and takes sockets as outside the state, and a buffer overrun on fs_usage's stderr, now kept and quoted when fs_usage fails to start, refuses the run. The probe's dd rewrite FAILs with a verified oracle (run 38116040180). Still refused: a pipe the target makes itself, a socketpair written before any socket call names it, and a dup2 from a number never seen opened.

  • --json <path> removes what is at its path only when it is a report Sideeye wrote or an empty file; anything else is refused and left as it was (#818, ADR 0123; src/report.zig, src/cli.zig, src/mcp.zig, docs/cli.md, docs/report-schema.md, spike/acceptance.sh). The flag removed whatever was at its path while the arguments were parsed, before anything was written, so replay case.json --json case.json deleted the case and left the refusal's report in its place, explore --config sideeye.toml --json sideeye.toml did the same to the define, and as root --json /dev/stdout replaced the device link with a regular file. Now a file holding one JSON document whose schema is sideeye/report or sideeye/preflight, however formatted, or an empty file, is removed as before; anything else is refused as SETUP_ERROR define_invalid, naming the path and its kind, with no report — at that path or an earlier --json in the same command — and no json sha256= line. What used to run and now refuses, so a CI step gating on the exit code sees 3: a symbolic link (one to a report included, which used to be replaced), a directory, a FIFO, a device — /dev/stdout, which never received the report (as root it went into a file at that name, as anyone else nowhere), and for a user other than root /dev/null, which used to run to its verdict with sha256=none — a non-empty file Sideeye did not write — a log of reports appended one per line among them — a report that cannot be read, and a name whose kind cannot be read. docs/report-schema.md no longer says a report at the path always describes this run: a run refused before --json was read never removed it, and a refused path stays as it was; the absent json sha256= line says so. The report is written through a temporary file this run creates new, rather than through <path>.tmp, and the MCP server reads its child's report only when the report's verdict, its exit_code and the code the child exited with agree. Measured in a Linux container against v1.11.0: a FIFO, a link, a device, a file that is not a report, the case and the define were each replaced by the refusal's report, and a directory was left as it was but refused without a word about the path; with this change all seven, and a log of reports, are refused naming the path and left byte for byte, and the legs that write — over nothing, over an earlier report, over one jq -c compacted, over an empty file — still write.

  • sideeye mcp answers a request line longer than its 256 KiB buffer, under the request's own id, instead of dropping it in silence (#820; src/mcp.zig, src/fuzz.zig, docs/mcp.md, spike/mcp-acceptance.sh). A line that filled the buffer was discarded with the rest of it up to the next newline and nothing was written back, so the client waited on that id until its own timeout — sixty seconds by default in the TypeScript SDK, which writes id after params, so a reply under id: null would not have stopped the wait either. The over-long line is now read through a streaming JSON scanner that keeps only its top-level id, and answered -32600 naming the limit (262143 bytes before the newline) under that id; a notification or a line of nothing but spaces gets no answer, and a line that is not JSON or whose top level is not an object gets what a short one gets. The differences from a short line are listed in docs/mcp.md. The MCP fuzz entry compares the scanner's reading of each over-long line with a whole-line parse, and the reply with the short line's where neither can carry an id, outside those differences, and gains seeds long enough to reach it. Measured: on v1.11.0, of four 300 KiB lines and an ordinary request the server answered only the ordinary one; with this change it answers the two requests under ids 41 and 42 (the second with its id last), the line that is not JSON with -32700, nothing for the notification, then the ordinary request, in that order, and answers an over-long line while the client still holds its stdin open.

  • A run stopped by SIGINT, SIGTERM, SIGHUP or SIGPIPE stops the child it is waiting on before it ends, and then ends by that signal (#819, ADR 0124; src/posix.zig, src/refuse.zig, src/main.zig, src/mcp.zig, src/supervise_linux.zig, docs/cli.md, spike/interrupt-run.py, spike/acceptance.sh, spike/mcp-acceptance.sh, the macOS job in ci.yml). Every child leaves the engine's process group, so Ctrl-C reached the engine alone, and the engine had no handler: it died and left the setup, the operation, strace and the run's cgroup running, a hung operation with nothing left to stop it. explore, replay, preflight and mcp now stop the child the way a world over --world-timeout is stopped — SIGKILL to its group, and its cgroup where there is one — stop fs_usage, write one line on stderr and end by the signal they received, with no verdict and no report; the MCP server hands the signal to its engine, which does the same. A c...

Read more

v1.11.0 — MCP preflight and evidence, fuzzed readers, permission bits

Choose a tag to compare

@yottayoshida yottayoshida released this 11 Oct 02:58
c847ea4

Added

  • Five readers of bytes a target or a caller chose are fuzzed: the trace, a sideeye.toml, the executable a define names, a saved case, and the MCP transport (#695, ADR 0112; src/fuzz.zig, src/fuzz_parsers.zig, build.zig, .github/workflows/fuzz.yml, SECURITY.md). Each has an entry point in the form Zig's fuzzer takes, reaching the reader as production does — the trace and the executable through a file on disk — and zig build test drives each with a thousand mutated inputs from a fixed seed; zig build fuzz -Dfuzz-seed=… -Dfuzz-runs=… runs them alone, and a weekly workflow runs them from a new seed, two hundred thousand times each. A run that dies leaves its input in a file and the same seed stops at it again. Not coverage-guided: Zig 0.16.0's own test runner does not compile under -ffuzz (measured), and the move that brings it is #782. To reach the case file and the MCP transport without a process exit or a spawn, replay's validation of a case moved from main.zig into case.read with every refusal word for word, and the MCP server's line framing and its JSON-RPC envelope check moved into mcp.Lines and mcp.route; what the server writes and runs is unchanged.
  • A weekly build notices a Zig release that will not build Sideeye (#698, ADR 0111; .github/workflows/zig-ahead.yml, spike/zig-ahead.sh, spike/zig-ahead-known.tsv, the zig-ahead-judge job in ci.yml). Zig 0.17.0 shipped on 2026-10-01 and stops a source build in build.zig (#782); nothing here had looked. The workflow builds and tests with the pinned Zig, the latest release and master, on Linux and macOS, and judges each lane against a list of the breakages already known: red only on a release or a diagnostic the list does not explain, on a row that has gone stale, or on the pinned lane failing — so a release after 0.17 that breaks the build is noticed before #782 lands. It never runs on a pull request; the judge's selftest does. docs/cli.md points source builders at it. Dependabot for action pins stays deferred: no pin has been bumped by hand since ADR 0061.
  • CI fails a pull request, or a push to main, whose tracked Zig files zig fmt would change (#695, its formatting half; spike/check-zig-fmt.sh, the fmt job in .github/workflows/ci.yml). Nothing had run zig fmt --check, and eight tracked files had drifted from what it produces; they are formatted here and nothing else in them changes. The files are collected by git ls-files, so a new directory is covered without a line, and the check's selftest goes red on an unformatted file two directories down, on a file the formatter cannot parse and on an empty set. CONTRIBUTING.md and CLAUDE.md name the command to run before pushing.
  • The platform probe measures four more forms before the table is rewritten (spike/platforms/, spike/platforms/RUNS-RULE-2026-10-10b.md): WSL2 with systemd turned off (Ubuntu's WSL image turns it on, so what the 2026-10-10 table called "without systemd" ran under it; the import now records the image's own setting), WSL2 on Windows on ARM with the WSL package installed (the runner has WSL's Windows features and not WSL), GitHub Actions' container: on aarch64, and cross builds for the rest of Debian 13's release architectures (armel, ppc64le, s390x).
  • Four fixes upstream, walked through (#722; docs/case-studies.md, linked from the README's line of counterexamples). dotenvx encrypt, Neovim :wshada, RuboCop -a and ImageMagick mogrify: for each, the two calls a kill fell between, what the user lost, what the project changed, and what re-running Sideeye against the fix showed, with links to the reports, the fixes and this repository's records. Release status is dated 2026-10-10. ADR 0114 also records the Linux run of the platform probe's hardened legs under --user: the same verdicts as root.
  • A run refused multiple_threads_detected from its own record of its threads names, in its next step and in every mode, a tool's own switch for running its file calls on one thread, and how a define declares it (#686, ADR 0113; src/contract.zig, src/boundary.zig, docs/apparatus.md, docs/report-schema.md, spike/dogfood/2026-10-10-uv-threadpool/). threads_limit keeps the README's limit and adds UV_THREADPOOL_SIZE=1 for Node and GOMAXPROCS=1 for Go, set where Sideeye runs and declared as env: in apparatus; under --observe supervised the refusal's step is the new threads_supervised, the same switch said without a shim, where it was the class wall. docs/apparatus.md lists each switch measured, what it moved and what it did not, and why. The measurement #686 asked for: the thirteen Node targets it lists all refuse at the recording on v1.10.0 without the switch, and ten get past with it (2026-10-09); declared, three of them reproduce 2026-10-09's verdicts. ADR 0097 had offered no flag, on the belief that none existed. The same reason raised under macOS's --oracle-fs-usage for a writer the shim never recorded keeps its step. next_step is not a frozen surface; contract_version does not move.
  • #687 answered: recording thread creations and exits from outside would order only 2 of the 50 recording runs --observe supervised refused for its threads wall, so it is not built (spike/dogfood/2026-10-10-thread-order/, docs/cli.md, ADR 0089, spike/dogfood/RUNS.md). The released v1.10.0 refused multiple_threads_detected in 50 recording runs of jj 0.46.0, OpenTofu 1.13.1, codex 0.160.0 and doctl 1.177.0, and each refused run's oracle capture was read for a thread creation or exit between one writer's write and the next's (order.py, checked against eight synthetic captures, three broken readers its selftest builds and four of its cases catch, and two C controls through the same pipeline). 48 hold a hand-over neither event orders; doctl's other 2 of 35 were ordered by a creation the Go scheduler happened to make, and a run is judged only when the recording and every world are ordered. notesmd-cli was not refused in 15 recording runs on v1.10.0; tombi, refused in 1 of 36 explores and 3 of 20 replays with its preflight accepted 26 times of 26, was not read. The owner's decision, from those counts.
  • docs/cli.md's platform table leaves a row unmeasured, or a cell not run, only where no GitHub-hosted runner can take the platform, the owner ruled it out, or the engine has no code for it — and says which (ADR 0105, amended twice; spike/platforms/2026-10-10b/, README). The v1.10.0 release, measured again on GitHub's runners: Linux kernels from 4.18 to 7.0 booted on their own (all three modes from 6.1 up; default and syscalls below, where supervised wants 5.19), glibc from 2.28 to 2.43, GitHub Actions' container: on both CPUs, docs/mcp.md's hardened container in the form the page now gives, NixOS with nix-ld, macOS 14 and 15 on the demo, and WSL2 on the Windows drive, as an ordinary user, and with systemd on (the image's own setting) and off — all supported. Declined, on the owner's rulings: glibc older than 2.28 (the binary does not start), kernels older than 4.18 (not measured), musl (built from source it runs; the release binary does not), WSL1 (its kernel has no statx, which the engine reads files through). Other CPUs are built and not run, on the owner's ruling: riscv64 and s390x build; for armhf, armel, i386 and ppc64le the shim does not. Unmeasured, with the reason in the cell: macOS 13 (no runner) and WSL2 on Windows on ARM (the runner cannot start it even with WSL installed: no virtualization passed on). The 2026-10-10 table's "without systemd" for WSL2 was wrong: Ubuntu's WSL image starts systemd itself. README's install line says macOS 14+. The probe found two Sideeye defects on every macOS version: --oracle-fs-usage never finds the subject when --work is reached through a symlink, the default among them (#795), and refuses a write on a descriptor moved by dup2, as GNU dd of= does (#796).
  • The acceptance suite runs against the build the release ships for aarch64-linux on every pull request and every push to main, so it has run before a tag exists (#694; .github/workflows/ci.yml job linux-aarch64-releasesafe, spike/release-build-flags.sh, .github/workflows/release.yml). On GitHub's ubuntu-24.04-arm runner, the release's own runner: spike/acceptance.sh uncontained and in a delegated cgroup, and spike/mcp-acceptance.sh, against one zig build of the shipped pair and every -Dtest-* apparatus with the arguments read from release.yml's build line for its aarch64-linux entry; the job checks that release.yml's runner and Zig version are its own, and that the shipped pair it tested is byte for byte a plain release build's. Until now the shipped optimisation was exercised only by release.yml's demo, which runs when a release is published or release.yml changes, and the suites ran Debug on x86_64. Measured before the job existed (Docker on Apple silicon, outside a delegated cgroup): the same 539 acceptance checks pass on a Debug and a ReleaseSafe build, with the same 4 not measured, and the ReleaseSafe shim's largest stack frame is exactly the 1024-byte ceiling, where the Debug build's is 928.
  • docs/faq.md answers the questions a first visitor asks, one paragraph each, and the README links it first (#718; docs/faq.md, spike/faq/2026-10-10/, README.md). Does it check my fsync; how it differs from a kill -9 loop, strace --inject, ALICE and LazyFS; how long it takes; how to call it from pytest, cargo test and go test; why a first run on macOS is UNKNOWN; and what to do when a tool keeps its state in two places. The answers were scattered across DESIGN and the ADRs, and two had never been tried. Everything measured was run before it was written (spike/faq/2026-10-10/RESULTS.md): each recipe green on a correct tool, red on a planted bug, red with the witness swapped for --allow-unverified — Sideeye exits 0 there, so th...
Read more

v1.10.0 — Pasteable FAIL commands, refusals name next steps

Choose a tag to compare

@yottayoshida yottayoshida released this 08 Oct 05:33
37a0588

Added

  • Eighteen tools whose state git does not hold, in four waves on the released v1.9.0: 11 FAIL, 5 PASS, 2 UNKNOWN, and nothing filed upstream (spike/dogfood/2026-10-07-user-data-3/; docs/target-classes.md, spike/dogfood/RUNS.md, spike/outcome-funnel.tsv, the B2 exclusions). Nine FAILs are the truncating open and the kill before the write; ezdxf's .bak rotation and libsndfile's in-place WAV lose nothing a rename or a reader does not restore. No FAIL loses data its user cannot get back from the tool's own documented command, so none was reported. roswell, a static image starting a dynamic SBCL, is turned away under unresolvable_path as refused by design although --observe supervised judges it — the shape #685 records under oracle_missed_operation.

  • The four targets whose result moved between runs of one define now have a measured cause (#690; spike/followup-690/, docs/target-classes.md, spike/fsevents/trace-ops.zig, build.zig). Re-run with every trace decoded (trace-ops --records now prints each record's sequence number, process and thread) and tally.py recomputing the tally block in NOTES.md: tombi's refusal is a write landing on another thread of its pool, the order supervised does not record (#687); mogrify writes the time into its PNGs and copies its inputs' change and modification times into them as well, which a restore that rewrites the inputs moves — leaving the date and time chunks out removes every baseline refusal without moving its FAIL 6 of 13; isort's count does not move between runs (8 for both recorded defines); dotter's kill_did_not_land was its define, which left the deploy cache outside the state — with it inside, FAIL 12 of 19.

  • The JSON report carries the figures its oracle, checker and processes sentences state, as eight optional fields (#711, ADR 0096; docs/report-schema.md, "The account as data"). oracle_witness and oracle_operations_agreed; checker_declared and checker_worlds; processes_children_admitted, processes_image_changes, processes_threads_created and processes_writer_threads. Each comes from the variable its sentence prints, and is present only where it was measured: a run that never reached the comparison has no agreement count rather than 0, and the process figures — the recording run's — appear only once every check on the recording's trace has held, so a run refused on a trace cut short or never announced has none. None is a closed set, the sentences do not change, and oracle_operations_agreed is not a gate — oracle_verified stays that. Additive under surface 2 of docs/contract-freeze.md, which records it.

Changed

  • When two clean runs leave different bytes, the refusal and preflight --twice now say where and what kind (#688; src/report.zig, src/defang.zig, src/engine/judge.zig, src/main.zig, DESIGN.md, docs/cli.md, docs/report-schema.md, docs/apparatus.md, docs/checker-cookbook.md, docs/ci-quickstart.md, spike/dogfood/README.md, spike/toys/toy.c, spike/acceptance.sh). A byte-layer baseline_violates_invariant names the byte offset where the recording's and the re-run's bytes first differ, how long the differing stretch is in each and what kind of bytes it holds — decimal digits, hex digits, printable text, or how many bytes fall outside printable text — and no byte of either, since its message reaches the JSON report and MCP. preflight --twice adds a bytes line under each path it names whose content differs, up to eight, quoting both stretches clipped at 48 bytes, in its own output only — a CI log or a transcript that keeps that output keeps those bytes. Neither names a cause; docs/apparatus.md gains a table mapping the common shapes to scratch and apparatus.
  • The refusals the dogfood records met most say what to do, where they said "Change the define" or "refused by design" (#710, ADR 0097; docs/report-schema.md). Counted from the 73 readable reports the outcome funnel points at, and taken back to the site that raises each on this build. recording_run_failed on an exit status nobody declared says to run the operation by hand and then --expect-status if that status is the tool's success; on a run that ended on a signal it names the signal in message and says to look at what stopped it, which on macOS can be the system refusing an image's signature. preflight --twice's second run ending differently says the restore rebuilds the names, kinds and bytes under --state and not their modes — the cause in both records, upx and argocd. The baseline's byte layer names scratch and preflight --twice. kill_did_not_land names preflight --twice in its two shapes. multiple_threads_detected names the README's threads limit, except under --observe supervised. no_shim_marker on a Mach-O whose code directory names a platform — /bin/cp, measured — names a build of the tool that is not part of macOS. Under --observe syscalls and for a toml with no cwd, the recording run's two new steps give way as fix_define did. unresolvable_path, unsupported_syscall_observed, the supervised threads refusal and oracle_missed_operation under --observe syscalls keep the class wall; for a static parent whose writer is a dynamic child, which reaches the last and unresolvable_path, --observe supervised is the way past, and #685 is to name it.
  • PASS, FAIL and UNKNOWN print the account lines they share under one key, in one column and one order (#711; src/report.zig's sayAccount, docs/cli.md). The three text blocks were three format strings: a FAIL's twelve-column key value, a PASS's indented key: value in another order (expected status: 0, processes after case), an UNKNOWN's columns in a third. One function prints them now. The order opens with the lines that say where the commands ran and what the define declared — cwd, apparatus, warning, recovery — so that on an UNKNOWN cwd stays under next, below divergence when there is one (ADR 0086), then a blank line, then explored, expected, atomicity, oracle, metadata, checker, l1, case, replay, evidence, processes, not tested. Each verdict prints a fixed set of them: a FAIL all, a PASS all but replay and evidence, an UNKNOWN the set it printed before. What moves on screen: a PASS takes a FAIL's columns and order; a FAIL's cwd group, and its checker red block when the two exhibits differ, move up under the counterexample; an UNKNOWN's expected moves above atomicity; a PASS's one-crash-point note sits under explored instead of under not tested. The text report is not a frozen surface (docs/contract-freeze.md), and the JSON report does not change. A script that read a PASS's key: value lines reads key padded to twelve columns now, as a FAIL's always were.
  • A FAIL's three command lines run as printed: replay, evidence and reproduce (#709, #711; src/report.zig, docs/cli.md, docs/evidence.md, README.md). The evidence line is the command that renders the bundle, sideeye evidence <path>, where it was the path alone, so a reader of the report learns the command exists; the README names it under "After the first find", held by check 10 of spike/check-readme-shape.sh. The reproduce line was a run of environment variables ending in the placeholder <operation>. It is a whole command now, in a subshell: cd to the directory the commands ran in, the variables Sideeye hands a world — SIDEEYE_OBSERVE with the run's mode among them, without which a run under --observe syscalls is counted the default way and the kill lands on another operation — with SIDEEYE_KILL_GROUP unset (the shim reads its presence, not its value, and the group kill would take the reader's shell with it) and the cgroup variables pinned empty, then the operation's own argv, reading standard input from /dev/null as a world does. It runs in a POSIX shell once the setup has left the state it starts from, as often as it is pasted: it empties its trace file first, because the shim numbers on from the highest number a trace holds, and a second paste of the old line counted past the crash point and ran to completion without the kill. The replay line under --observe syscalls gains --observe syscalls, which it lacked: a case does not record its mode (ADR 0052), so the printed line answered case_no_longer_applies instead of the FAIL. Every value in the three lines is single-quoted where a shell would split or expand it, so a path holding a space pastes as one word, and an ordinary path prints exactly as it did; the JSON replay field carries the same quoting, and the JSON evidence field stays the bundle's path. When the operation or its directory holds a byte the report defangs, when Sideeye could not name the directory, or when the command would not fit the report's output buffer, the line keeps <operation>. The demo toy reads SIDEEYE_STATE_DIR when TOY_STATE is unset, which is what lets the line find the state without the toy's own variable.
  • The CI quickstart pins v1.9.0 (docs/ci-quickstart.md, docs/ci-quickstart/release/install-sideeye.sh, .github/workflows/quickstart-release.yml). The release lane installs the published v1.9.0 asset instead of v1.8.0's. The pin moves after the release, as on v1.8.0, because the lane downloads the asset and the asset exists only once the release is published. Nothing else in the lane changes.

Fixed

  • measure.py --selftest's record-stream case no longer fails by a race of its own (spike/loop-closure-timew/measure.py). The case's subject appended a line to the record by name right after streaming three; when the recorder had not yet written those three, its write landed on the append and erased it, and the case failed though the recorder was right — 10 of 60 runs idle and 46 of 60 under CPU load in a Linux container, and once on main's ...
Read more

v1.9.0 — No empty PASS, define mistakes named

Choose a tag to compare

@yottayoshida yottayoshida released this 07 Oct 02:57
97bc80a

Added

  • sideeye completions zsh|bash|fish prints a completion script built from the usage lines (#712; src/cli.zig, docs/cli.md). It offers exactly the commands, a command's positional argument (a case file, a command name, a shell), and exactly the flags its usage lines list (explore's and preflight's two lines together, so --config and --state are both offered), with a flag's value completed as a file where the usage names a path and as its listed words where it lists them (--observe). The bash script runs on bash 3.2, macOS's /bin/bash, quotes a file name with a space, gives a directory its slash (except one named after a :, = or @ in the word, which completes without it), and completes a path holding :, = or @. spike/check-completions.py reads the usage lines on its own and asks each shell what the script offers at every position; acceptance runs it on Linux with all three shells installed, the macOS job with /bin/bash and zsh. A Homebrew formula can take the scripts with generate_completions_from_executable; the tap is not changed here.

  • On a terminal the verdict word is coloured — PASS green, FAIL red, UNKNOWN yellow, SETUP ERROR magenta — and nowhere else (#712; src/report.zig, docs/cli.md). Only standard output is asked, so a report redirected into a file from a terminal carries no escape sequence of Sideeye's own; NO_COLOR set to anything non-empty, or TERM=dumb, turns it off on a terminal too. The JSON report, the MCP server and every captured run (CI, acceptance) are unchanged. A CI that gives its steps a terminal and greps the text report should set NO_COLOR=1.

  • The README's first screen shows a trimmed real sideeye demo FAIL report, and CI holds every line of it to a fresh demo (#714; spike/check-readme-demo.py). Eleven lines under the opening paragraph, each one the demo prints, in its order, with the scratch directory shown as …; the macOS job's demo step and acceptance check 5 run the demo and compare, so a change to the wording of a line it shows turns CI red until the README follows. The one named exception to "never a silent PASS" — a directory a recorded rename moved in from outside the judged tree — went from two sentences to a clause of the promise's own sentence, with its explanation moved to DESIGN.md's known constraints, and spike/check-readme-shape.sh now holds the exception to that sentence (ADR 0032).

  • docs/found.md lists every report in spike/upstream-reports.tsv — the reports this project has filed on GitHub — with the tool, a link and the furthest state the ledgers record; generated, and compared in CI (#713; spike/outcome-funnel.py, ADR 0070). 35 reports today: 5 fixed upstream and re-measured against the fix, 1 fixed upstream, 28 filed, 1 withdrawn. The page is rendered from spike/upstream-reports.tsv and spike/outcome-funnel.tsv by the same --write-doc that renders the funnel's counts, and --check-doc now compares both pages. The README stops quoting a count of reports (it said 27) and links the page; spike/upstream-report-template.md does the same. check also refuses a report the two ledgers disagree on whether it was withdrawn.

  • The four targets #684 named, re-measured past the calls that change nothing: fish PASS, dotdrop and firewalld FAIL with no content lost, vim at its next wall (spike/dogfood/2026-10-06-read-only-684/; docs/target-classes.md, spike/dogfood/RUNS.md, spike/outcome-funnel.tsv). A strace-only survey written before the engine changed predicted each answer: vim writes the file's ACL back after saving and now stops on setxattr, in all three observation modes; fish's universal variables PASS 6/6; dotdrop install -f FAILs 2/15 and firewall-offline-cmd FAILs 1/6, each truncating a file before writing it, with the old bytes kept in its own backup (.dotdropbak, .old). Neither FAIL is reported upstream.

  • Thirty tools whose state git does not hold, in six rounds of five on the released v1.8.0: 22 FAIL, 8 PASS, and four reports upstream (spike/dogfood/2026-10-05-user-data-2/; docs/target-classes.md, spike/dogfood/RUNS.md, spike/outcome-funnel.tsv, spike/upstream-reports.tsv, spike/unknown-rate/b2-exclusions.txt). 359 names were screened, none at a project already reported to, and 57 went through the entry gate. Twenty-one FAILs are the truncating open and the kill before the write. SolveSpace's solvespace-cli regenerate empties the sketch and still reports success, PyMOL's save empties the session, WP-CLI's config set empties wp-config.php, and ffsubsync's --overwrite-input empties the subtitle; they are reported as solvespace/solvespace#1783, schrodinger/pymol-open-source#520, wp-cli/config-command#233 and smacke/ffsubsync#240. Fourteen of the thirty are static images named bare, and every one reached a verdict through v1.8.0's next step to --observe supervised. aliyun-cli, a static image whose child is dynamic, did not: the page's path ends at oracle_missed_operation and then at syscalls with no mode named, the shape lefthook met on v1.7.0. Two screens missed: fresh.sh did not read cohort 4's rejected candidates (ffsubsync), and uv was screened under a spelling no ledger carries; both left the slate with their explores kept, and go env -w took uv's place. The B2 exclusions now name each tool by the trixie package that ships it, looked up rather than recalled.

  • sideeye preflight --config sideeye.toml reads the toml as explore reads it, and refuses the define mistakes it can see before any crash world, in explore's words (#704, ADR 0094). Preflight refused --config ("sideeye explore --config answers strictly more"), so a define moved into a file — the README's next step after preflight — could no longer be asked the quick question, and a check that could not start or a marker the operation never printed surfaced only from an exploration. The refusal is gone; the toml goes through the same phases explore's does, so the detectors already there apply as they stand: the parser, the cwd, apparatus and scratch checks, a state that is not a directory, a command that cannot be started, a marker the recording never printed, no crash point, and the cwd a toml's define needed. Two of explore's refusals are added to preflight, at the point explore reaches them: with a declared check and a crash point, a check with no command in it is refused --check is empty, and one against an initial state with nothing to corrupt checker_not_falsified — each one function, refuse.checkerArgv and refuse.refuseNothingToCorrupt, now called from both. docs/cli.md lists what is refused, one mistake per run as in explore; what only a crash world shows is not on it. Nothing the toml declares is run: the report adds marker, checker and recovery lines saying what preflight did with each, and a recovery that cannot be started is not refused here, as it moves no verdict in explore (ADR 0072). The next line is sideeye explore --config '<the toml>', quoted for /bin/sh and named in the directory its relative paths were resolved against, so a toml that is a symlink keeps its own side; a toml read from /dev/stdin, from a shell's <(…), or through a link into /dev or /proc gets a line saying to save it first, and one whose name cannot be printed as typed a line saying to copy it to a file with a plain name. --twice works on a toml and names [world] state. With the flags, preflight accepts and refuses what it did and its accepted report is byte for byte what it was; it still refuses --check, --marker and --recovery by name, reworded to point at --config, and define flags beside --config get explore's "mutually exclusive" refusal. The synopsis has a preflight --config line, and #273's check covers it. spike/acceptance.sh check 6c runs one toml per listed mistake through both commands and compares the exit code, the refusal and its detail, with the check counting its own starts; against the parent commit with only the --config refusal removed, twelve of its legs fail, and #273's check with them.

Changed

  • A string-form command spelled for a shell runs as written, and is told so (#706, ADR 0095; src/config.zig, src/report.zig, src/main.zig, src/refuse.zig, src/mcp.zig, docs/cli.md, docs/report-schema.md, docs/contract-freeze.md). A string-form command is split on spaces and never run by a shell, so operation = "./kva put 'hello world'" handed the program two arguments with the quotes in them, "… && echo ok" handed it && — and both PASSed with nothing said. Now a setup, operation, check or recovery command whose string holds a quote, or a whole word a shell would act on (&&, |, ;, a redirection, a word beginning $ or ~/), earns one sentence naming the command and how Sideeye reads it, with the argv form that groups what quotes meant — none where a shell would have expanded a $ or backquote inside them. A toml command value cut at an inner quote by a # (check = "grep -c "#include" f" runs grep -c ) is accepted as before, and the sentence says what it became. It is in the report (warning), the JSON (define_warnings, a new optional field), sideeye mcp's summary, and on stderr as sideeye: warning: …, written once at the end so the first line of merged output is still the verdict — and so a run that ends in a SETUP ERROR, or a preflight, shows it too. Nothing runs differently and no spelling is refused. A symbol inside a word (x=n*10, <esc>, ~>1.6) is left alone, and a replay is not looked at. A toml value refused as "not one double-quoted string" (expected_status = 0, state = './state', operation = "tool "two words"") is refused with the line it would have been read as.
  • Every re-emitted checker line says which world it came from (#707; src/main.zig, src/recovery.zig, docs/cli.md, docs/evidence.md, ADR 0071 am...
Read more

v1.8.0 — Static targets named bare get supervised

Choose a tag to compare

@yottayoshida yottayoshida released this 03 Oct 06:20
cca8717

Six days, 48 commits and six entries since v1.7.0. The trace contract stays v18; next_step gains one value, observe_supervised. No contract_version, unknown_reason or setup_error_reason changes. The full section is CHANGELOG.md at this tag.

A statically linked target refused no_shim_marker is told to run again with --observe supervised, whether the operation names its image by path or by a bare name (#672, ADR 0090). In v1.7.0 the default mode named supervised only in the refusal's detail, and only when the first word was a path; a bare kubectl … or terraform fmt, written the way a user writes it, got a --shim next step that named no mode. A bare first word is now looked up along the engine's PATH, with a relative component taken against the define's cwd, and the image found is read the way a named path is. On a 32-bit static image the refusal keeps its class-wall step: supervised does not see i386-compat or x32 calls.

Dogfood through the released v1.7.0, four campaigns: six fresh tools to verified FAILs (2026-09-28); the twelve tools that had cleared the entry gate (twelve FAILs); the 28 targets no campaign had carried to a verdict (9 FAIL, 3 PASS); and twenty tools whose state git does not hold, in four rounds of five (12 FAIL, 7 PASS). Reported upstream from them: nbQA-dev/nbQA#908, hashicorp/terraform#39299, helm/helm#32709, tombi-toml/tombi#2265, ktlint/ktlint#3409, orhun/git-cliff#1650, Yakitrak/notesmd-cli#137, mbloch/mapshaper#706 and mhx/dwarfs#388. The last campaign also found a limit of this release, filed as #678: restore does not reproduce permission bits, so a target that checks a mode in its state root cannot be measured, and preflight's next step points at the define.

Changed. The CI quickstart pins v1.7.0; it moves to this release once these assets exist.

Assets: sideeye-v1.8.0-aarch64-macos.tar.gz, sideeye-v1.8.0-x86_64-linux.tar.gz, sideeye-v1.8.0-aarch64-linux.tar.gz, each carrying the sha256 GitHub computed on upload (docs/cli.md says how to check one). Homebrew: brew upgrade yottayoshida/tap/sideeye once the tap is updated.

v1.7.0 — Static binaries, counted from outside

Choose a tag to compare

@yottayoshida yottayoshida released this 27 Sep 23:49
ccc8c29

Six days, 20 commits and ten entries since v1.6.0. The trace contract stays v18; no contract_version, unknown_reason or next_step changes. The full section is CHANGELOG.md at this tag.

--observe supervised counts a statically linked target from outside the process (Linux 5.19+, aarch64 and x86_64; #217, ADR 0089). Where the other modes refuse no_shim_marker, the engine now starts the operation under a seccomp user-notification filter, takes each state-changing call as it is made, and writes the trace in the shim's format — no shim loaded, strace still the second witness. On the static C toy it finds the same FAIL at crash point 5 of 5 that its dynamic twin gets under the default mode. It needs a cgroup v2 the engine can create cgroups in. Its walls are named in docs/cli.md: writes from two or more threads of one process refuse, a target's own seccomp filter can hide a call, setuid children run without their privilege, and the MCP sideeye_replay_case tool cannot replay a supervised case. Under this mode the report names the engine as the recorder, the oracle line says who counted, and the oracle does not count a call a signal interrupted (= ? ERESTART…).

The static release binaries, re-measured under the new mode. busybox-static's sed -i — no_shim_marker directly, and killed by SIGSYS under --observe syscalls when a dynamic sh execs it — is PASS 3/3 both ways, judging the file it rewrites. gh, chezmoi (--force), lefthook (no checker) and gopass (rm) reach PASS 3/3, but the built-in rule judged no file any of them wrote: those say the mode reaches the tools, not that their writes are crash-safe. Jujutsu stops at this mode's thread wall, 5 of 5.

The loop-closure judge replays in a container of its own (#603, ADR 0088). The replay used to run as root in the container the agent's code had just configured and run, so the oracle, the checker's sh and libc were the subject's to replace — measured: a swapped sh moved the gate from fail_reproduced to other. It now runs from the image by id, --read-only, and is read only when that container exited 0.

Dogfood through the released v1.6.0, installed by the CI quickstart's installer the day it shipped: commitizen 4.18.1 FAIL 1/5 (cz bump empties pyproject.toml), ast-grep 0.45.3 FAIL 1/3 (--update-all empties the source file; reported as ast-grep/ast-grep#2959), and todo.txt-cli 2.14.0 PASS 4/4 after following its refusal's own next step to --observe syscalls. The dogfood entry gate now asks threads through the engine's preflight only.

Changed. BUILDLOG.md is closed at v1.6.0 and no longer required by CI. The CI quickstart pins v1.6.0; it moves to this release once these assets exist.

Assets: sideeye-v1.7.0-aarch64-macos.tar.gz, sideeye-v1.7.0-x86_64-linux.tar.gz, sideeye-v1.7.0-aarch64-linux.tar.gz, each carrying the sha256 GitHub computed on upload (docs/cli.md says how to check one). Homebrew: brew upgrade yottayoshida/tap/sideeye once the tap is updated.

v1.6.0 — Judged paths, command cwd, and a release-installed quickstart

Choose a tag to compare

@yottayoshida yottayoshida released this 22 Sep 03:04
ce6991b

Five days, 54 commits and twenty-one entries since v1.5.0. The trace contract stays v18; nothing frozen moves. The full section is CHANGELOG.md at this tag.

The report names what it judged, and where the define ran. A --json report carries l0_judged_paths — the paths the built-in atomicity form actually judged — and l0_judged_paths_omitted, the number it did not list (#638). Until now the report said N path(s) judged pre-or-post and nothing named the N, which is the wall the authoring study measured: a subject kept judging the one file it had written off because it never saw which one the count meant. Every report from the point the declared cwd is resolved also names the directory the define's commands ran in — command_cwd / command_cwd_declared in the JSON, a cwd line in the text, and in preflight's next hint (#647). A tool that only works inside its own project needed a cwd the old quickstart template did not show, and the first real adoption run stalled on exactly that twice.

A CI quickstart that installs a released Sideeye instead of building one (#620). docs/ci-quickstart.md now has a lane with no Zig on the runner — asserted, not implied: install-sideeye.sh takes a pinned version, maps the runner to an asset, refuses when the release carries none, and checks the download against the sha256 GitHub publishes. Two lanes run on every push: a correct target gating on exit 0 with the oracle verified, and a planted bug gating on exit 1. The page will pin this release once its assets exist.

The macOS fork stub asks the kernel where the child is, every time it is refused (#629, #632, #651). A child that could not be moved out of the engine's process group used to exit 126 before exec, and the world was reported kill_did_not_land with "Change the define" for a define that was fine. It now runs whenever the kernel reports it leading a group of its own — whichever call put it there — and when it still cannot, the line it writes names what both calls answered. The CI logs since the first fix showed the engine's own parent-side setpgid(pid, pid) had rescued five of six refused children, and that a refused setsid is that rescue having landed.

Smaller changes. child_touched_state_dir's next step follows which of its two walls refused the run, sending one shape to --observe syscalls on Linux (#634). The MCP sideeye_explore_config tool takes observe, so an agent can follow a next_step that names it (#617). README: preflight's exit codes (3 for a define it cannot set up, 1 when --twice finds different bytes) and the macOS oracle, --oracle-fs-usage.

Measurements published in this release, none of which changes the engine. What exhaustive exploration costs from 10 to 1000 crash points — 4.2 s on a one-file state, 30 s on a 20 MB one, and no budget or sampling added (#621). The authoring-cost study's four runs and the count it had been publishing wrongly (#618, #639). Generation g3 of the UNKNOWN-rate page, B2 measured for the first time: 26.3% UNKNOWN over nineteen explored, published beside B and never pooled (#619). Two dogfood runs through the adoption path: lefthook reached a named wall (static linkage), overcommit 0.73.0 a verified FAIL, reproduced and deliberately not filed.

Assets: sideeye-v1.6.0-aarch64-macos.tar.gz, sideeye-v1.6.0-x86_64-linux.tar.gz, sideeye-v1.6.0-aarch64-linux.tar.gz, each carrying the sha256 GitHub computed on upload (docs/cli.md says how to check one). Homebrew: brew upgrade yottayoshida/tap/sideeye once the tap is updated.

v1.5.0 — Threads that take turns, an evidence bundle, and recovery

Choose a tag to compare

@yottayoshida yottayoshida released this 17 Sep 09:24
4f8a54e

Four days, 72 commits and seventeen entries since v1.4.0. One trace-contract version, v18, and it is the one that judges a process whose threads take turns writing. The full section is CHANGELOG.md at this tag.

Read this first if a target of yours was refused multiple_threads_detected. Two writing threads of one process are judged now when every two of their writes are ordered by a thread creation or a join the shim recorded, and refused when any two are not (#539, contract v18, ADR 0067). The shim records pthread_join, pthread_detach and which thread created each new one; the engine passes the write between threads only where a recorded creation or join orders it. virtualenv 20.31.2 — writes, creates a worker that writes, joins it, writes again — is judged, PASS in both observation modes. beets 2.1.0's writers are pipeline siblings ordered by a queue the shim cannot see, and it stays refused, with a refusal that now names both threads, both operations, and that no recorded creation or join orders one before the other.

What a FAIL hands the person who has to fix it.

  • An evidence bundle, and sideeye evidence <case.json> to render it (#607, ADR 0071). A FAIL writes <work>/evidence/NNNNNN.json beside its case: every path whose before, completed and crashed states differ, whether it existed before, whether its old bytes survive at another path in the judged state, whether the define declared it scratch, the operations either side of the crash point, what the built-in layer and the checker saw, the replay command, and the run's own caveats. Every field is measured or unknown, and there is no severity. The report names the bundle in a new evidence field and line; the case file is unchanged.
  • A declared recovery, reported beside the verdict and changing none of it (#606, ADR 0072). [recovery] command and check in sideeye.toml, or --recovery with --recovery-check, run after the exploration has decided the verdict, against each saved FAIL's crash state rebuilt from its snapshot: pass, fail or unknown in earliest.recovery and checker_earliest.recovery, a recovery line, and the bundle's recovery.result. The recovery checker is trusted only after it rejects a corrupted state and accepts what the recovery leaves on the completed one; fail means the recovery ran, ended and was rejected, and everything else is unknown. Where the engine can make cgroups each recovery runs in one of its own. Replay accepts the two flags and the replay line prints them.

--json seals its own report (#597, ADR 0068). When the JSON report is in place, the last line sideeye writes to stderr is sideeye: json sha256=<hex>;, the digest of exactly the bytes it wrote, or sideeye: json sha256=none; when it could not write one — so a reader that gets the file through a directory the judged program can also write can tell sideeye's report from one rewritten after. Not a report-schema change.

Refusals and pages that say more. Under --observe wrappers on Linux, oracle_missed_operation now points at --observe syscalls and names where that mode's hazards are written, and the failures a process that mode killed produces take a step that says so instead of fix_define (#599, ADR 0069). On macOS, a crash point reached on a worker thread — a pthread or a GCD block — now kills the world instead of refusing kill_did_not_land (#569). The pages now say that macOS does not judge a target that replaces its own image (#571).

Records. Twenty targets in four slates with v1.4.0: eight counterexamples, seven of them one window in seven languages — the file opened for truncation and the process dead before the write — two reported upstream (codespell-project/codespell#4025, rubocop/rubocop#15720). Five targets past earlier walls: Bun, markdownlint-cli and google-java-format FAIL in the same window, xz PASS, ninja FAIL on an output it rebuilds. Five whose state lives outside version control: aws-cli (every profile's keys), neovim (history and registers), jbang and pyenv FAIL, hatch PASS; aws/aws-cli#10648 and neovim/neovim#41940 reported. cargo re-met in both observation modes (#538). What a whole exploration costs and how much of it repeats, measured over 123 judged runs: not a bottleneck at this scale, and among the 50 worlds walked one by one, every repeated outcome is a world that did not fail. The funnel from a define to an upstream fix is a counted record (#605, ADR 0070).

Inside. The loop-closure judge calls its steps only against digests the launcher took itself and runs from bytes it verified, rebuilds everything outside the agent's tree from the seal, and resolves the path spellings a transcript writes (#510, #512, #513, #515, #592, ADR 0066). class-exclusions.tsv states the criterion its rows meet, and a check holds each row to it (#598).

Upgrading. Saved cases from v17 replay as case_no_longer_applies; re-record them. A v17 shim under this engine refuses contract_version_mismatch. The unknown_reason closed set is unchanged at 34 members; next_step gains observe_syscalls and syscalls_may_have_killed, and recovery.result is a new closed set of three, present only when a recovery is declared. With --json, stderr's last line is now the seal line.

Known. A recovery's pass from a tool that decides by modification time or permission can be manufactured by the rebuild, which carries restore-time timestamps and fixed modes; ninja's is one, and the record says so. On macOS, and on Linux where no cgroup can be made, a process one recovery leaves can reach the next. A ptrace-grade observer for file I/O that bypasses libc is still after 1.0 (#217).

v1.4.0 — Detached workers, the kernel boundary, and a frozen report

Choose a tag to compare

@yottayoshida yottayoshida released this 13 Sep 12:52
f4ec233

Four days, 75 commits and twenty-four entries since v1.3.0. One trace-contract version, v17, and it is the one that lets Sideeye judge a target that detaches its workers. The full section is CHANGELOG.md at this tag.

Read this first if a target of yours was refused for a process leaving its group, for writes that bypass libc, or for a thread under --oracle-fs-usage. All three refusals have moved.

  • A process that leaves the process group is judged where the engine holds the run in a cgroup of its own (contract v17, ADR 0065). On Linux, where the engine can make cgroups — a writable cgroup v2 delegated to its user, or root over a writable one — the recording run, every explored world and the baseline, and preflight --twice's second run each run in a cgroup the engine made, and both the crash point's kill and the cleanup's reach it. A worker that calls setsid is stopped and judged like any other child, and what leaves the cgroup is watched and refused. ansible-core's lineinfile, refused in every earlier run, reaches PASS 2/2 in both observation modes as root in a --privileged container. Where the engine cannot make cgroups — a default container, macOS — the detach is still refused, and on Linux the refusal now says what would let it be judged. CI runs the acceptance suite both uncontained and in a delegated systemd scope.
  • --observe syscalls counts every kill point at the kernel boundary (ADR 0059): open, write, rename, unlink, fsync, truncate, mkdir, rmdir, link and symlink, in each spelling the architecture has — so a target that reaches the kernel without libc, a Go runtime above all, is judged rather than refused. copy_file_range and pwritev2 stay outside, and the report schema says so. A handler a target installs through libc sigaction no longer holds SIGSYS blocked while it runs (#556, ADR 0063), which is what node 20 needed to be accepted in that mode.
  • Under --oracle-fs-usage, a single-process run whose writes come from one thread the shim recorded is explored and judged (ADR 0060) — on macOS as on Linux.

Refusals that say more. A SETUP_ERROR report carries why as data: setup_error_reason, one of five classes, and a failing --setup's setup_exit_code or setup_signal (#518, ADR 0057). A state_unsnapshotable refusal names the entry it could not read, with the errno, and an entry that became unreadable during the run gets a step of its own, unreadable_entry_appeared (#535, ADR 0056). A run whose only writer is a thread the shim never recorded creating says so instead of calling itself a single process (#543). epoll_ctl and faccessat2 on the state directory are read as reads (#542).

The shim's footprint is bounded on Linux (#555): under 1 KiB of thread-local storage and at most 5 KiB of stack per interposed call, measured on every acceptance run. It used to carry 256 KiB of TLS, which stopped a target from starting a thread with a small stack.

The report says it is frozen. Every --json report now writes "schema_status": "frozen" (#565); every tag through v1.3.0 wrote "experimental". The freeze page records the change as the fourth break of surface 2, and the changelog's header now says compatibility is promised by docs/contract-freeze.md, not by the version number (#566, ADR 0064).

Inside. src/main.zig was cut into modules along measured seams, with no change a user can see (#572, ADR 0062). Every action a workflow runs is pinned by commit, with its tag beside it (ADR 0061). The README is an entrance again, with the flags, the MCP surface and each limit's reason on pages of their own. The fs_usage sentinels are named from entropy, so a target cannot forge the observer's closing line (#549). The past-walls dogfood run met again seven targets v1.3.0 had moved a wall for: oxipng FAIL (reported upstream), rsync PASS, newsboat PASS.

Upgrading. Saved cases from v16 replay as case_no_longer_applies; re-record them. A v16 shim under this engine refuses contract_version_mismatch. The unknown_reason closed set is unchanged at 34 members; next_step gains unreadable_entry_appeared, and setup_error_reason is a new closed set of five. A consumer that branched on schema_status reads "frozen".

Known. A world whose crash point is in a worker thread is not always killed there (#569), and on macOS a thread id changes across exec (#571); both are open.

v1.3.0 — Threads, awaited children, and the syscall boundary

Choose a tag to compare

@yottayoshida yottayoshida released this 08 Sep 23:51
2d49470

Five days, 115 commits and twenty-two entries since v1.2.0. Three trace-contract versions in one release — v14, v15, v16 — each one widening what Sideeye will judge rather than refuse. The full section is CHANGELOG.md at this tag; this page names what to read first.

Read this first if a target of yours was refused for threads or for a helper process. Both refusals have moved.

  • One writing thread per process is judged, however many threads the target created (contract v16, ADR 0055). Until now any thread refused the run. What actually stood in the way was the shim's process-wide state, the oracle reader calling a thread a child, and a record that named its process only; each record names its thread now, the shim keeps per-thread slots keyed by gettid() (0.09 µs per interposed call, measured), and a thread needs no oracle. What still refuses, by name: a second thread of the same process writing the judged directory, a run under --oracle-fs-usage (macOS, #544), and the 65th thread (#543). Measured on the targets behind the wall: sqlfluff PASS, bundler FAIL, vips FAIL, zstd PASS under --observe syscalls; beets refuses correctly — two of its threads open library.db. Threads that take turns are the next stage (#539).
  • A child that takes turns with the judged directory is explored and judged (contract v15, ADR 0053). A crash point is an address in the run, not in a process, so an awaited child's write can be killed at like any other. Two processes whose operations overlap, a writer nothing waited for, and a writer the shim never loaded still refuse, each with its own sentence.
  • --observe syscalls counts the write family at the kernel boundary (contract v14, ADR 0052), reaching the writes libc issues from inside fwrite and raw syscall(SYS_write, …). Linux only, behind a flag, 1.6–1.8 µs per trap. metaflac and fontforge go from oracle_missed_operation to verdicts; mutool, after the close exemption below, reaches FAIL there. Fourteen previously judged targets re-run in both modes reach the same verdict in both. One limit is unlike every other on the README's list: the seccomp filter is inherited across exec and a statically linked helper dies on its first write — check that before reaching for the flag.
  • That mode's oracle now watches the run it judges (ADR 0054): the kernel's SIGSYS line lets the reader retract the refused call, so oracle_verified is earned honestly and the two-run arrangement is gone. The field it carried in the meantime, oracle_verified_across_runs, is withdrawn — it was added after v1.2.0 and never shipped in a tagged binary; the freeze page records the break.

Refusals that were wrong, fixed. An unplaceable close no longer refuses a run (ADR 0003 §2 amended — the whole mutool wall was one close wide, and two refusal sites that did not exist were added with it, #522). A shell resolving a bare name down PATH is no longer read as seven image changes (ADR 0018 amended); hnb reaches a verdict for it. The processes account stops saying two things that were false, and four published statements of a rule that is false as stated are corrected. A failing --setup keeps what it wrote and the refusal quotes its last line beside the file (#483); an unplaceable operation names its descriptor (#485). Two ways a breached loop-closure seal came back clean are closed (#511, #514).

Two fixes the release's own CI day forced. A child the engine forked and could not arrange before exec — setpgid or a dup2 failing — used to exit 126 in silence; it now writes one line on the engine's stderr naming the call and the errno, every dup2 before exec retries EINTR, and the six sentences that read a 126 say the two indistinguishable causes instead of a state difference or "declare --expect-status" (#546). And the macOS fs_usage reader no longer refuses a run over a line it cannot attribute to anyone when that line reads only — a daemon's file named in combining characters had ended such a line inside the name, its tail arriving on the next physical line (#547 and #548, ADR 0031 narrowed).

Records. The loop-closure judge is seen refusing on every channel it declares (#63; thirteen refusals, ten mutations with exact attribution). The cohort ledger's prose counts are recomputed and drift is red in CI (#342). A refusal on an operation that is not an executable image names the define, not the shim (#481, #482, ADR 0051). The dogfood ledger gains slates 2 and 3 (isort PASS as the positive control; fonttools, beancount, pyupgrade, libjpeg-turbo filed upstream) and two measurements of ImageMagick's patches for #8939 — the third patch closes both reported items and breaks preserve-timestamp.

Upgrading. Saved cases from v13 replay as case_no_longer_applies; re-record. A v15 shim under this engine refuses contract_version_mismatch. The unknown_reason closed set is unchanged at 34 members.