Sabline 8.8.0

Changelog: 9.x: 9.0.0-alpha.13 to 9.0.0-alpha.8

Every release of this major version, newest first, as CHANGELOG.md records it.

9.0.0-alpha.13 - M4: the Windows AppContainer, and sabline eval through sabline-rt #

M4's third checkpoint (plan/9.0.md, M4 - confinement, and a real Windows AppContainer). A run through sabline-rt on Windows happens inside an AppContainer of its own, which is the layer the job object and the lowered token could not be: a process cannot enter one after it has started, so sabline-rt exec reads the request, loads the program to learn which files it reads - a lookahead, never an answer - creates a profile named for the run, grants its SID one ACE per file and per granted directory, starts a child sabline-rt exec --contained SID inside the container with its own standard output and error, pumps the request and the rest of its input through, waits, takes every ACE back and deletes the profile. The child does not take the parent's word for it: --contained is a claim, and the kernel - TokenIsAppContainer, TokenAppContainerSid - is what answers it.

So a Windows run under io is full where a container can hold it, against partial on every release before this one: a read, a write, a connection or a process outside the budget's grants is refused by the kernel, which check_confine.py --runtime rust measures with the fault-injection hook. 90 of its questions pass on Windows 11 and 93 on Linux, none broken. Where a container cannot hold it, it is left off and the receipt says why - a Windows Server, for one, where a process inside a container can open the file it was granted and still not be told what path that file has, so a run would be refused a file its budget allows. That is asked before a run is handed to a container, never assumed. The container is left off, with the reason in the receipt, for a budget granting a loopback host - Windows isolates a container from 127.0.0.1 unless an administrator exempts it - and for one that restricts neither reads nor writes, which a container cannot express without refusing what the budget allows. A run killed before its parent can take the ACEs back leaves them naming a SID nothing can hold again, and the next run sweeps them.

A container is asked before a run is handed to it. A process inside an AppContainer cannot always resolve a path as the process that started it does - on some systems GetFinalPathNameByHandle answers nothing there, and realpath then leaves the path as it was written. Every fs: grant is checked against what a path resolves to, and the host resolves the grant while the run resolves what the program asks for, so such a run would be refused a file its budget allows: a wrong answer, and not a safe one. So the container is asked, once, to resolve the paths the run was granted, and any answer that differs leaves the container off with the reason in the receipt. check_confine.py asks the machine the same question and holds every case to the answer rather than assuming one.

What the container costs, and the two bounds that hold it. An AppContainer reaches a file only through that file's own rights, and Windows' inheritance is a copy and not a lookup, so giving a directory an inheritable ACE writes it into every file under it and taking it back writes them all again - about half a millisecond a file, charged to the run's own deadline. A grant over a tree larger than a thousand files therefore leaves the container off, with the reason in the receipt. And a system whose containers cannot resolve a path remembers that answer for that build of the crate, so it stops making a profile, writing ACEs and starting a probe for a container it will not use; a yes is not remembered, because it would be a claim that outlived its measurement.

fix: the one privilege a confined run keeps on Windows - SeChangeNotifyPrivilege, which is what bypasses traverse checking - was found by LookupPrivilegeValueW alone, and a lookup that failed left the number at zero, which matches no privilege: every privilege was then removed, bypass-traverse among them, and a process that cannot traverse cannot walk to a file it has been given. It is the well-known number (23) unless the lookup answers now. Both runtimes.

ENFORCES, row for row, for both runtimes. sabline-rt os-policy --enforces writes how much of each row of the threat model's table that runtime's kernel holds, one word per system; check_confine.py holds it to be at least the package's everywhere, with a reason on every cell that differs, and all six are Windows'. The table and its exemptions moved out of THREAT_MODEL.md, which was near the size a page of the site may be, into docs/os-enforces.md, as the known-open table did before it.

sabline eval's run is sabline-rt's too. It was the last thing besides sabline trace that stayed in the package under SABLINE_REFERENCE_RUNTIME=rust; the profile's own refusals are still made here, before anything is sent anywhere, and the run goes through sabline-rt like any pool's. sabline-rt honours a stop file - looked for every 256 calls and loop turns, E615 at the next of either, as this package's interpreter has since 8.3 - and a run that has one is started in a process group of its own, so a console break meant for whoever started it does not kill a run that was asked to stop.

A kernel with no Landlock, measured on a runner that has one. There is no honest way to take Landlock off a kernel, and a self-hosted runner is not worth one machine, so the fault-injection hook is asked to take that path: SABLINE_FAULT_NO_LANDLOCK makes the ruleset not be built, the layer report this kernel has no Landlock, and seccomp-bpf be applied as ever. Reads and writes then go through, the network and a process are still refused, and --require-confinement full refuses the run. It is a simulation and the suite and the documentation say so.

The honesty test's hook is behind a feature now. A variable that stands a layer down in a released binary is a variable that turns confinement off, so confine_fault.rs lifts the crate's deny of unsafe with #![cfg_attr(feature = "fault-inject", allow(unsafe_code))] and that feature is not a default: a released crate carries neither its three C calls nor SABLINE_FAULT_NO_LANDLOCK. The suites that measure the confinement build with it. The modules that may lift the deny plainly are four again - confine_appcontainer.rs has joined them - and check_rt.py holds both lists.

This is a pre-release of the sabline-rt crate, and it publishes that and nothing else. 8.8.0 is still what pip install sabline-lang, npm, the Marketplace and the MCP registry give, the Action pins still name 8.8.0's commit, and sabline --version still says 8.8.0. What the Python package on main gained above ships with 9.0.

9.0.0-alpha.12 - M4: sabline-rt confines its own runs, and the command that starts it confines itself #

M4's second checkpoint (plan/9.0.md, M4 - confinement, and a real Windows AppContainer). sabline-rt asks the operating system for the budget's policy in its own process, before the program's first statement: os_policy is ported, confine_linux.rs asks Landlock and seccomp-bpf, confine_macos.rs sandbox_init, confine_windows.rs the job object and the token, and confine_fault.rs is the fault-injection hook that attempts one effect itself. The agreement gate compares the derivation before either runtime asks a kernel for anything - the policy, its digest, what it asks the system to hold and why it asks for nothing, for every budget it has, under --confine strict, without it and under --no-confine - and compares the seccomp filter, the Landlock masks for each ABI and the macOS profile for a fixed set of policies on every platform, not only on the one that applies them. 41,939 comparisons, no differences. check_confine.py --runtime rust asks every question that suite asks of the package of sabline-rt instead: 74 of them, on Windows, with none broken.

The crate has unsafe in it for the first time, in those four modules and nowhere else: every one of those calls is a C entry point the operating system offers, and a crate with no dependencies has no safe wrapper for them. The workspace denies unsafe_code rather than forbidding it - forbid cannot be lifted anywhere - each of the four lifts it with the reason in its own module comment, and check_rt.py holds that list of four, so a fifth is a failing check. Every block carries a // SAFETY: comment naming the invariant and a test that exists, as rt/README.md has required since alpha.1.

What is not done. sabline-rt does not enter a Windows AppContainer yet, so Windows holds neither reads nor the network, exactly as the Python runtime does not - its receipt says so in its own words, which are not the package's, because the reason is not the package's. sabline eval's run is still the package's. M4 is not finished and is not claimed to be.

This is a pre-release of the sabline-rt crate, and it publishes that and nothing else. 8.8.0 is still what pip install sabline-lang, npm, the Marketplace and the MCP registry give, the Action pins still name 8.8.0's commit, and sabline --version still says 8.8.0. What the Python package on main gained above ships with 9.0.

9.0.0-alpha.11 - M3: the network, Python and tools relayed to the package, and the organisation ceiling #

M3's sixth checkpoint (plan/9.0.md, M3 - the interpreter, the budget, Secret and receipts). With SABLINE_REFERENCE_RUNTIME=rust every run now goes through sabline-rt, a run of the network, Python or a tool included: sabline-rt spends the call's effect and relays the call back to the Python package, which makes it with its own code and tells back how it ended and what it recorded (the maintainer's decision of 2026-10-11, in plan/9.0.md). And the organisation ceiling (decisions/0007, item 32c) exists, in both runtimes: a file an operator cannot widen past, held before the budget is given, named in the refusal, the receipt and sabline audit. Every one of M3's exit criteria measures as met; M3 is not declared done, because the fourth criterion's check_pool.py passes only with two of its questions asking each runtime what is true of it - the relay split the ffi cliff - which is the maintainer's to accept (plan/9.0-m3-progress.md). The Python package is the reference, and where the two disagree sabline-rt has a defect.

This is a pre-release of the sabline-rt crate, and it publishes that and nothing else. 8.8.0 is still what pip install sabline-lang, npm, the Marketplace and the MCP registry give, the Action pins still name 8.8.0's commit, and sabline --version still says 8.8.0. What the Python package on main gained below ships with 9.0.

Decided #

The maintainer's answer of 2026-10-11 to the fifth checkpoint's question: sabline-rt does the network, Python and tools by relaying each call back to the Python host over the channel it already uses for output, input and audit events, so that what a call does is identical by construction. Porting the network into Rust is M4's and M5's, and their sections say so. An eval run stays the package's until M4, as chosen last time.

What exists #

The relay. sabline-rt spends the effect of each of the fourteen builtins of the network, Python and tools as it spends every builtin's, and then sends a call frame - the builtin, its arguments, its line - on the sabline-rt exec channel. The host answers with one line: a value (a handle as {"handle", "what"}, which sabline-rt prints and compares as HandleValue does), a failure the program can handle, a coded error, a granted module's exit, or the exception that escaped; and with what its recorder noted while it made the call, in order - each grant's event, each refusal a redirect makes, each tool call - what each grant let through, and the tool session's ceiling. sabline-rt records those in its own receipt and audit stream as they happened, tool_calls and tool_ceiling included. The host's side is runtime.outside_builtin, the package's own code for those fourteen, moved there from run_builtin unchanged, so the package's own runs make the same calls the same way.

Who the host is. For sabline run, sabline test and an unbounded sabline.run, the caller's own process, with the run's budget, seed, clock, arguments and handles installed for the run and put back after (library._hosting) - and the command line's tool session and recorded responses, so sabline run --tools and sabline replay --responses go through sabline-rt too. For a bounded run, a Pool and a PoolRegistry - and so the HTTP door's and the MCP server's runs - the pool's worker that checked the program: confined, capped, the process the package's own run would have made the call in. It answers each call the pool forwards until the run ends, and is killed with sabline-rt at the deadline; one that goes away in a call ends the run as a worker that goes away in a run does.

The fallback's notice is gone, and so is the fallback: what stays the package's is an eval run and sabline trace, each saying so as before. A run whose grants turn the operating system's layer off says so through sabline-rt too, as the command line always has - there is no such layer under sabline-rt before M4, which makes it no less true.

The gate compares those runs. sabline run-dump --through-rt makes the package the host of a run whose three records sabline-rt writes (run_dump::run_hosted), and the gate compares them with the package's own run of the same line, byte for byte: 81 runs - 31 rows written for it, the 24 sandbox rows and 13 sabline-spec L2 cases the gate used to skip, and the ceiling's and the examples' rows that grant more. They run against two stand-in servers on the machine - every reply's date and server fixed - and a stand-in tool host (run_dump.StandInHost). Six new injections in check_gate.py hold the relay's own classes: a failure handed back as a value, an error's line, a handle's text, a grant's event dropped, a grant counted twice, a tool call's secret mark lost.

The organisation ceiling (docs/ceiling.md). sabline.ceiling/1, a JSON object whose allow is a budget, at one system path (the Windows one found through the system's own API, not the environment) and, held as well, the file SABLINE_CEILING names. A budget a ceiling does not wholly cover is E325 before the program is read - naming the file, the first grant past it and what it allows - at every entry point: the command line, sabline test, sabline.run with and without limits, a Pool and a PoolRegistry, the doors, sabline eval and sabline replay. A ceiling that cannot be read refuses every run, saying why. The receipt's run_parameters.ceilings names each one a run was held to - path, sha256, what it allows - refused or not; sabline audit names it and any effect the program needs that it does not grant; sabline ceiling shows what is installed. Several are asked the same question, so no order of asking gives another answer. The rule is Budget.gap, which a door's --max-allow already held requests to; covers now words it. sabline-rt is handed each ceiling's text with its request and reads it, holds the budget to it, words the refusal and names it the same way (ceiling.rs); the gate holds the two to each other over 24 rows, and check_ceiling.py asks every entry point in both runtimes (38 questions), in CI on every leg. Two more injections hold the refusal's words and the receipt's field.

Every program as it is written to run. The gate now runs the twelve examples run_tests.py gives a budget under that budget, and check_through_rt.py runs them through the command line with their own arguments and input; check_benchmark_through_rt.py runs the benchmark's harness in both runtimes - every program under the budget its task needs, with its input, its stand-in server and its vendored library - and compares what Sabline made of each: 102 programs, 84 of them run, 0 differences.

The suites, through sabline-rt #

On Windows, suites_through_rt.py - which now asks check_sandbox.py too, M3's third criterion's own suite:

SuiteQuestionsFailedRan a programsabline-rt aloneA run the checks refusedA run the package madeRuns: sabline-rt / checks / package
check_library.py3091, by design58571061 / 1 / 0
check_money.py81041401040 / 1 / 0
check_hostile.py74039309030 / 10 / 0
check_self_budget.py1180525110296 / 1 / 0
check_pool.py400262510512 / 1 / 0
check_receipts.py410151311 (eval)19 / 1 / 2
check_sandbox.py58057570057 / 0 / 0
check_fallible.py --runtime rust33025250025 / 0 / 0
check_secret.py --runtime rust670141211 (trace)12 / 1 / 1
check_error_messages.py --runtime rust96024240024 / 0 / 0
check_termination.py --runtime rust6700----
check_refusals.py --runtime rust1700----

No run of the network, Python or a tool is the package's any more: the runs the package made are eval's (check_receipts.py) and sabline trace's (check_secret.py), which stay its by decision. check_library's one failure is the producer-version question decided to wait for M4. check_pool's two ffi-cliff questions ask each runtime what is true of it (Found, and not fixed, 1). The questions not counted as passed or failed are POSIX-only cases, audits and commands that stay Python, a symlink this machine cannot make, and the prover's.

The agreement gate #

Sabline
the parsers: 7053 compared, 7053 agree, 0 differ
the checkers: 7053 compared, 7053 agree, 0 differ
the budget parser: 2975 compared, 2975 agree, 0 differ
the interpreters: 7053 compared, 7053 agree, 0 differ
the interpreters, under their budgets: 158 compared, 158 agree, 0 differ
the interpreters, the network, Python and tools relayed: 81 compared, 81 agree, 0 differ
the receipts: 7292 compared, 7292 agree, 0 differ
the audit streams: 7292 compared, 7292 agree, 0 differ
the builtin tables: 1 compared, 1 agree, 0 differ

agreement gate: 38958 comparisons, 38958 agreements, 0 differences

The same on CPython 3.10, 3.12 and 3.13. The 312 new comparisons: the 81 relayed runs, 23 more under their own budgets (18 of the ceiling's, 5 of the examples'), and their receipts and audit streams. check_through_rt.py: 325 programs, 4 under sabline test, 228 runs under their own budgets, 12 examples under theirs and 88 relayed runs, through every entry point - 0 differences. check_gate.py holds the gate to 33 injections now, each of which must turn it red, in CI's agreement job on every push.

Found on the way, and fixed #

  1. A tool call held by two grants listed them as one. The package joined the grants that held a call's arguments with a backslash and an n, and the receipt split them on a line feed, so held_to for a call held to to= and cc= patterns was one entry holding both. The gate's new tool rows found it. They are joined by a line feed now.
  2. A pool dropped what its worker's receipt said of the run's parameters beyond the pool's own, replacing them whole with its limits; the ceilings were the first such field. It keeps the worker's ceilings now.

Found, and not fixed #

  1. The ffi cliff is split through sabline-rt. A granted module's code runs in the host, so it reaches what the host checks of a call - the network's hosts, the module grants, the tool grants - and not what sabline-rt holds: the effects, the file grants, the counts. A program that adds fs to sabline.state.EFFECT_BUDGET through unscoped ffi is refused its read through sabline-rt where the package lets it through. check_pool.py now asks each runtime what is true of it, and holds both to the reset that matters - a host grant a program widened is not there for the next program on the same worker. A question for the maintainer, in plan/9.0-m3-progress.md.
  2. E317 is reported at line 0, in both runtimes: the proxy refusal is raised by guarded_opener, which is not given the call's line. As before; recorded.

compatibility: a receipt's run_parameters gains ceilings, a list, empty where no organisation ceiling is installed. A consumer that reads the fields it knows is unaffected.

compatibility: a tool call held by two or more argument grants lists each in the receipt's held_to, where it listed them joined as one.

9.0.0-alpha.10 - M3: every run through sabline-rt, the limits held by the caller, and the suites counted per question #

M3's fifth checkpoint (plan/9.0.md, M3 - the interpreter, the budget, Secret and receipts), and still not its end: with SABLINE_REFERENCE_RUNTIME=rust, every run goes through sabline-rt - a run with a timeout or a memory cap, a Pool and a PoolRegistry (and so the HTTP door's and the MCP server's runs), sabline replay and sabline test, beside sabline run and sabline.run - and the suites of M3's fourth criterion are counted question by question, with who answered each run. The maintainer's decisions of 2026-10-11 are recorded in plan/9.0.md. M3's exit criteria are not met and none is claimed: the network, Python and tools are not ported, sabline-rt confines nothing (M4), and the organisation ceiling file is not started. The Python package is the reference, and where the two disagree sabline-rt has a defect.

This is a pre-release of the sabline-rt crate, and it publishes that and nothing else. 8.8.0 is still what pip install sabline-lang, npm, the Marketplace and the MCP registry give, the Action pins still name 8.8.0's commit, and sabline --version still says 8.8.0. What the Python package on main gained below ships with 9.0.

Decided #

The maintainer's answers of 2026-10-11: a run whose budget grants the network, Python or a tool to a program that uses it keeps the fallback - it runs in the Python package and says so on stderr, until sabline-rt does those itself; SABLINE_REFERENCE_RUNTIME stays the switch, with no runtime= on sabline.run, so M7's API golden is unchanged; and a run through sabline-rt prints no "not confined" notice - the receipt's confinement field is the record. check_library's question about the receipt's producer version waits for M4, as decided.

What exists #

A bounded run and the pool, through sabline-rt, the limits held by the caller. A pool's worker is confined at its start (8.4) and cannot start a process, so it does what it always did up to the run - checks and proves the program, under the pool's limits - and says whether sabline-rt may run it; the pool's own process then starts sabline-rt exec under the memory cap and kills it at what is left of the deadline. On Windows the cap is the job object a worker gets, made before sabline-rt runs an instruction; on Linux it is RLIMIT_DATA, set with prlimit before the request is sent - not RLIMIT_AS, because an idle sabline-rt exec maps 72 MB of address space it never touches (glibc's arena for its input thread), and the cap is the program's plus the 128 MiB its run's stack takes. Measured on Linux from a 16 MB cap to 300 MB: a small program runs, the doubling text is E611, deep recursion is E609 and a loop is E610 at its deadline. A run a limit stopped sends no end, so its audit stream is always asked for and the pool makes its receipt from what that says the run did - complete: false, named sabline-rt's. The worker is kept only after a clean run, as before; the sabline-rt process is used once, always.

sabline replay and sabline test through sabline-rt. A replay runs its copy of the program through sabline-rt, held to the replay's own directory and read ceiling; a bounded replay goes through its pool. check_receipts.py's three replays of a run recorded through sabline-rt, which differed in confinement last time, now pass: replayed through sabline-rt too, both say none. sabline test asks sabline-rt to call each test function in turn on one runtime (run_dump::test_relayed) and prints each verdict after what the test printed, as the package's loop does.

The import root in sabline-rt's loader (E515, as loader.py words it, by CPython's realpath), so a run held to an import root - the library's import_root=, a pool's, a replay's - goes through sabline-rt too. The gate holds it: thirteen programs in its tree importing from inside, outside, by .., by a link, from a library, a file that is not .vel, a sibling directory sharing the root's prefix.

What stays the package's, and says so in one line on stderr: a run whose budget grants the network, Python or a tool to a program that uses it (decided); an eval run, whose profile holds a run to the operating system's confinement and does not start one without it; and sabline trace, which shows the package's interpreter at work. A program the checks or the prover refuse runs nothing, and says nothing more.

suites_through_rt.py: the six suites of M3's fourth criterion run whole with SABLINE_REFERENCE_RUNTIME=rust, and M2's five with --runtime rust too, which now ask their run questions of sabline-rt where they counted them as not asked. Every run writes who answered it (SABLINE_THROUGH_RT_LOG), and what a suite prints goes to the same log, so each run is counted against the question it was made for. A question that fails is a failure unless it is listed by design, with its rung. In CI on Linux and Windows. On Windows:

SuiteQuestionsFailedRan a programsabline-rt aloneA run the package madeRuns: sabline-rt / package
check_library.py3091, by design58498 (net)53 / 8
check_money.py8104140040 / 0
check_hostile.py74039291 (ffi)29 / 1
check_self_budget.py1160512030 (ffi, net)186 / 101
check_pool.py39026214 (ffi)507 / 7
check_receipts.py41015104 (ffi, eval)15 / 6
check_fallible.py --runtime rust33025196 (ffi, net)19 / 6
check_secret.py --runtime rust67014121 (trace)12 / 1
check_error_messages.py --runtime rust96024204 (ffi, net, tool)20 / 4
check_termination.py --runtime rust6700---
check_refusals.py --runtime rust1700---

The rest of the questions that ran a program had a run the checks or the prover refused, so nothing ran; the questions not counted as passed or failed are POSIX-only cases, audits and commands that stay Python (decisions/0002), checks under a ceiling or an import root, and the prover's. Linux (WSL; CI's ubuntu leg runs the same) gave the same verdicts with its own platform's question counts.

check_through_rt.py holds every entry point to the package's own runs: 325 programs through the command line and sabline.run, every fourth of them bounded and on a Pool too (82), every budgeted row of the gate that ends (76) through sabline.run with limits and without and through a PoolRegistry, in a tree made afresh for each runtime with a frozen clock given as an instant, and sabline test on every way a test can end - 0 differences, in about ten minutes here; in CI beside the gate.

The agreement gate #

Text
the parsers: 7053 compared, 7053 agree, 0 differ
the checkers: 7053 compared, 7053 agree, 0 differ
the budget parser: 2975 compared, 2975 agree, 0 differ
the interpreters: 7053 compared, 7053 agree, 0 differ
the interpreters, under their budgets: 135 compared, 135 agree, 0 differ
the receipts: 7188 compared, 7188 agree, 0 differ
the audit streams: 7188 compared, 7188 agree, 0 differ
the builtin tables: 1 compared, 1 agree, 0 differ

agreement gate: 38646 comparisons, 38646 agreements, 0 differences

The same on CPython 3.10, 3.12 and 3.13. The thirteen import-root programs are the 39 new comparisons; both run documents take import_root.

Found on the way, and fixed #

  1. check_self_budget.py had never asked sabline-rt anything. It scrubs every SABLINE_ variable from its environment before it starts, the runtime switch with them, so "112 of 112 through sabline-rt" last time was 112 in the package. And its section C, which finds every variable the package reads and varies each, finds them by a pattern that the switch escaped: through_rt read it through a constant not named *_ENV. The two variables of the runtime are *_ENV constants now, the scrub keeps them, and C15 and C16 vary them - the other runtime must end every run as this one does. 116 of 116 in both.
  2. sabline.run(freeze_time="2026-01-01T00:00:00Z") through sabline-rt was refused by it: the request takes epoch seconds and the library handed the instant on. A replay always hands one. Converted as set_run_params converts it.
  3. sabline.run with a frozen clock its receipt cannot say - past the year 9999 - ran the program and then raised ValueError making the receipt, whatever the program had done by then; through sabline-rt it ran and returned. check_through_rt.py found it. It is refused before anything runs now, in both, as Pool.run already refused it.

compatibility: for 9.0's Python package, sabline.run(freeze_time=...) with an instant past the year 9999 raises ValueError before the program runs, as Pool.run does; it used to run the program first and raise after.

What is not done #

In the order to do it (plan/9.0-m3-progress.md): the network, Python and tools - the question for the maintainer, below; the organisation ceiling file (0007, 32c); eval through sabline-rt, which waits for M4's confinement; and M8's performance.

A question for the maintainer, in the progress file: how sabline-rt should do the network, Python and tools.

9.0.0-alpha.9 - M3: sabline run through sabline-rt, and what is written out counted first #

M3's fourth checkpoint (plan/9.0.md, M3 - the interpreter, the budget, Secret and receipts), and still not its end: sabline run and sabline.run go through sabline-rt when SABLINE_REFERENCE_RUNTIME is rust (decisions/0002: the command stays in Python and the run inside it goes through sabline-rt), and with that the suites of M3's fourth criterion can be asked of it. The maintainer's two decisions on the questions the third checkpoint left are in both runtimes. M3's exit criteria are not met and none is claimed: the network, Python and tools are not ported, a bounded run and the pool are not routed yet, sabline-rt confines nothing (M4), and the organisation ceiling file is not started. The Python package is the reference, and where the two disagree sabline-rt has a defect.

This is a pre-release of the sabline-rt crate, and it publishes that and nothing else. 8.8.0 is still what pip install sabline-lang, npm, the Marketplace and the MCP registry give, the Action pins still name 8.8.0's commit, and sabline --version still says 8.8.0. What the Python package on main gained below ships with 9.0.

What exists #

sabline run and sabline.run through sabline-rt. The package still reads the flags, builds the budget, and loads, checks and proves the program - the prover is the package's for good, and what it finds decides whether a program runs - and then, instead of interpreting it, asks sabline-rt exec to (sabline/through_rt.py, rt/.../src/exec.rs). The two talk in JSON lines: a request, then a frame for each thing the run does as it does it - what print and log write, read_line and ask as questions the package answers, each audit event - and last the run document and the receipt. What the program prints is written through the package's own sys.stdout and what it reads comes from sys.stdin (or the library's stdin=), so a run through sabline-rt writes and reads exactly what the same run in the package does; --receipt and --audit-stream write sabline-rt's receipt and stream, whose producer is sabline-rt. sabline-rt's loader takes the entry's text (sabline.run(source, path=...)), and a parent that goes away takes the run with it.

What a run through sabline-rt is not, each said where it applies: a budget granting the network, Python or a tool to a program that uses one stays the package's, and says so on the error channel (a question for the maintainer, below); nothing is asked of the operating system, and the receipt's confinement says none; a run with timeout= or max_memory_mb=, and the pool, stay the package's - their worker is confined at its first statement and cannot start a process; and an import_root stays the package's.

check_through_rt.py holds both entry points to the package's own runs, with the variable unset and rust, over every program of the run corpus and examples/ that ends within the run document's limits - 325 of them: the output byte for byte, the status, the error printed, the receipt and the audit stream by the gate's own normalisation, and RunResult field for field; a program the checks or the prover refuse must stay the package's. 0 differences; CI runs it beside the gate.

The seven suites of M3's fourth criterion, asked through sabline-rt (each run whole with the variable set): check_fallible.py, check_money.py, check_hostile.py and check_self_budget.py pass; check_library.py 302 of 303 - the one asks that a receipt's producer version is the package's, which a receipt made by sabline-rt is not; check_receipts.py 38 of 41 - the three replay a run recorded through sabline-rt, confinement none, in the package, which confines it; and check_pool.py passes, its runs being the package's.

The agreement gate #

Text
the parsers: 7053 compared, 7053 agree, 0 differ
the checkers: 7053 compared, 7053 agree, 0 differ
the budget parser: 2975 compared, 2975 agree, 0 differ
the interpreters: 7053 compared, 7053 agree, 0 differ
the interpreters, under their budgets: 122 compared, 122 agree, 0 differ
the receipts: 7175 compared, 7175 agree, 0 differ
the audit streams: 7175 compared, 7175 agree, 0 differ
the builtin tables: 1 compared, 1 agree, 0 differ

agreement gate: 38607 comparisons, 38607 agreements, 0 differences

The same on CPython 3.10, 3.12 and 3.13. The programs the gate holds gain what its corpora left out for the CPython running them - base64 padding at the start of a group, a JSON trailing comma and numbers past 4,300 digits, and eight budgets with counts and ports past 4,300 digits - and, for the size limit, eleven programs that write out a list nested forty deep or one text many times, every way a value is written out, and 220 that leave exactly the room around each writer's output, a byte under and a byte over. check_gate.py's injections gain two, each of which turns the gate red: a writer stopped exactly at the limit, and json_of's count a byte over; and its disabling variables are set a second time with SABLINE_REFERENCE_RUNTIME=rust, which must not change what the gate compares.

Decided, and done in both runtimes #

The maintainer's answers of 2026-10-10 (plan/9.0.md, Decided on 2026-10-10):

The reference says one thing on every CPython - 3.12's words. values.read_json gives 3.12's trailing-comma message at 3.12's place, values.whole_number refuses past 4,300 digits in 3.12's words wherever the reference reads one (a JSON document, a budget's count and port), and runtime._BASE64 takes only what 3.12's strict decoder takes. sabline-rt already copied 3.12; a brute-force comparison of 597,871 base64 texts and 300,010 JSON texts gives one answer on 3.10, 3.12 and 3.13. The gate's corpora hold those cases now, and the fuzzer passes none over.

compatibility: for 9.0's Python package, the three answers that depended on the CPython running it are CPython 3.12's on every CPython (9.0, M3). A JSON document with a comma before its closing bracket fails "Expecting value" or "Expecting property name enclosed in double quotes" at the bracket, where 3.13 said "Illegal trailing comma before end of array" (or object) at the comma; a whole number of more than 4,300 digits - in a JSON document, or a budget's count or port - is refused "Exceeds the limit (4300 digits) ...", where 3.10 said "(4300)"; and base64_decode fails on padding at the start of a group of four ("YWJj==", "="), which 3.10 decoded. Messages are outside STABILITY.md; the last refuses on 3.10 what 3.12 and 3.13 already refused.

What is written out is counted before it is made. An operation that writes a value out - to_text of what is not a text, print, log, format, a + that makes a text or a list, json_of, the three encoders, and a broken promise's or loop invariant's message - counts what it is about to make first, and the run stops there, before the value exists. A list of two of the level below, forty deep, costs eighty-two items and writes out as a terabyte; it is stopped at the operation that would write it, in both. The reference counts with walks of its own (values.written_size, shown_size, json_size, log_size), held to its writers over 4,500 generated values; sabline-rt's writers write to a Sink that keeps or only counts, so what it counts is what it would write. The fuzzer's generator prints a List of Text again, it runs under the gate's own size limit, and a MemoryError is a finding now - 8,515 runs, none.

Found on the way, and fixed #

  1. A function value written inline printed a name that depended on the process (the reference): Parser.lambda_n was a class attribute that nothing reset, so a program run by sabline.run in a process that had loaded others - sabline.run called again, a pool's worker, a door - printed fn fn#21 where the command line printed fn fn#1. check_through_rt.py found it. load_program counts from 1 for every program now, as sabline-rt's loader does.

compatibility: for 9.0's Python package, a function value written inline is named fn#N counted from 1 in every program, wherever it runs; in a process that had loaded other programs the count went on from theirs.

What is not done #

In the order to do it (plan/9.0-m3-progress.md): the question below; a bounded run and the pool through sabline-rt, started by the caller under the limits; the commands that contain a run (replay, eval, serve, mcp, test); the five suites' run questions under --runtime rust; the network, Python and tools; the organisation ceiling file (0007, 32c); and M8's performance.

A question for the maintainer, in the progress file: what a run through sabline-rt does under a budget granting the network, Python or a tool to a program that uses it - stays the package's and says so (today), is refused before it starts, or runs in sabline-rt until the first call it cannot make.

9.0.0-alpha.8 - M3: receipts and the audit stream, field for field #

M3's third checkpoint (plan/9.0.md, M3 - the interpreter, the budget, Secret and receipts), and still not its end: sabline-rt records each run's sabline.receipt/1 and its audit stream as the reference does, and the agreement gate compares both for every program it runs - the receipt field for field after normalising exactly plan/9.0.md's list, the stream event for event - and a differential fuzzer runs generated programs in both. The maintainer's two decisions on the questions the second checkpoint left are in both runtimes. M3's exit criteria are not met and none is claimed: the network, Python and tools are refused by every budget that does not grant them and not ported under one that does, sabline run does not go through sabline-rt yet, and the organisation ceiling file is not started. The Python package is the reference, and where the two disagree sabline-rt has a defect.

This is a pre-release of the sabline-rt crate, and it publishes that and nothing else. 8.8.0 is still what pip install sabline-lang, npm, the Marketplace and the MCP registry give, the Action pins still name 8.8.0's commit, and sabline --version still says 8.8.0. What the Python package on main gained below ships with 9.0.

What exists #

Receipts (src/receipt.rs, from sabline/recorder.py and sabline/receipts.py): the run's subjects by sha256, the standard library as <stdlib>/NAME and a file on another drive by its path; the budget; the seed, the frozen clock as an instant and the read ceiling; how many builtin calls each effect let through and how many operations each grant did; every refusal by code, effect and line, and every declassification by reason and line, each once per place with a count, an HMAC's with its key's fingerprint and many past sixteen keys at one place; and how the run ended. The run document writes it after each run (sabline.run-batch/3). It is the command line's receipt of the same run, with three things the run document fixes: nothing is asked of the operating system, so the confinement is none, nothing was asked of the operating system, in both; a run the step limit stops is a timeout (E610) and one the size limit stops is out of memory (E611), with what it used and refused until then; and there is none where the command line writes none.

The audit stream (decisions/0007, item 32b), in both runtimes: the receipt's fields as they are produced, one JSON object an event, sabline.audit-stream/1 - start, subjects, then as each happens effect (every builtin call the budget let through), grant, refusal, declassify and tool, and end with the receipt. sabline <file> --audit-stream FILE writes it a line each; sabline.run, Pool.run and PoolRegistry.run take audit_stream=, and a run in a worker sends each event as it happens, so a run the clock killed still ends its stream with the pool's receipt. No event holds a value the program handled, and check_library.py's Secret cases hold the stream to that as they hold the receipt - the decision's condition. docs/receipts.md says what each event holds. OpenTelemetry is not scheduled.

A run target for the differential fuzzer: fuzz_parsers.py --target agreement_runs runs generated programs in both interpreters, under every effect whose work sabline-rt does, in a tree made afresh for each, and compares the run document, the receipt and the stream; CI runs it on every leg and for two minutes beside the gate, and the monthly run for twenty. It respects the size limit's known residual: its generator never writes the one shape that reaches it (a list holding one text many times, written out), it runs under a size limit of its own (2^18) where the worst a mutated input can make in one operation is about 73 MB, and an input that reaches it anyway is counted and said, never a finding. The three places the reference answers differently by CPython version, which the gate's corpora leave out, it leaves out the same way, counted.

The agreement gate #

Text
the parsers: 6813 compared, 6813 agree, 0 differ
the checkers: 6813 compared, 6813 agree, 0 differ
the budget parser: 2967 compared, 2967 agree, 0 differ
the interpreters: 6813 compared, 6813 agree, 0 differ
the interpreters, under their budgets: 122 compared, 122 agree, 0 differ
the receipts: 6935 compared, 6935 agree, 0 differ
the audit streams: 6935 compared, 6935 agree, 0 differ
the builtin tables: 1 compared, 1 agree, 0 differ

agreement gate: 37399 comparisons, 37399 agreements, 0 differences

The same on CPython 3.10, 3.12 and 3.13. A receipt's normalisation is RECEIPT_NORMALISED - the producer's name and version, startedAt, wall_time_ms, a <source> subject's name - and the four confinement fields by rule (present in both, and sabline-rt's level at least Python's); check_gate.py holds that list to plan/9.0.md's, so a field cannot join one without the other. The runs under their budgets gain sixteen: a lone surrogate written over a file and to a new one, and what was there after; and what a receipt records - a declassification in a loop, two reasons on one line, an HMAC under twenty keys, grants counted, exit_with with and without a status, a refused effect, a frozen clock before 1970, after 3001, at both ends of what an instant can say and past them, and a run that does not end; and random under a seed with bounds past 64 bits. The read of a file that is not UTF-8 now reads four, each caught, and carries on. The programs the gate holds gain fifteen: seven of several files - a promise broken in a library, three ways, flat and named, and one called back - three amounts made past 64 bits, and whole numbers past 128 bits in every builtin that takes one.

check_gate.py's injections gain five, each of which turns the gate red: the file a broken promise names, a receipt's field dropped, a field beside the normalised ones, how many keys a receipt names at one place, and an audit stream's event dropped.

Found on the way, and fixed #

A receipt of a run with a frozen clock was a traceback on Windows. sabline x.vel --freeze-time 1960-01-01T00:00:00Z --receipt r.json ran the program, then ended in an OSError while writing the receipt, leaving the file empty: the instant was made with datetime.fromtimestamp, which asks the C runtime's gmtime, and on Windows that stops at 1969-12-31T12:00Z and at 3001-01-19T21:59:59Z. It is calendar arithmetic now, the years 1 to 9999 on every system, written with four digits.

compatibility: for 9.0's Python package, a receipt of a run frozen outside 1969-12-31T12:00Z to 3001-01-19T21:59:59Z is written on Windows, where the run ended in a traceback after it finished; elsewhere nothing changed.

An amount past 64 bits was made, and held - the run fuzzer's first finding, on its first day. SPEC.md 4.3 says an amount's units are an Int, with the same 64-bit range and the same E407 past it; money(9223372036854775808, "INR") made one anyway, printed INR 92233720368547758.08, and only arithmetic on it refused it, while sabline-rt, which holds 64 bits, answered NotPorted. money and with_units refuse it with E407 where it is made now, in both runtimes, as + does: "this 'money' made an amount too big to hold". parse_money already refused one.

compatibility: for 9.0's Python package, money and with_units of units outside -2^63 to 2^63-1 are E407 where they are made, where the amount was made and held and only arithmetic on it refused it.

A whole number past 128 bits stopped sabline-rt in thirteen builtins, and an amount times one - the fuzzer's next finding, on CI's Windows leg: a mutated example with a 400-digit amount. The reference's integers have no size, and a literal is the one place a program gets one past 64 bits; sabline-rt read them through an i128 and answered TypeError in money, with_units, the five _or_fail builtins, set_at, slice, code_at, exit_with, percent_of, divide_or_fail and random, and money(0, "INR") * n with n past 128 bits was E407 where it is zero. They are exact now (py_big, a BigInt round_ratio, and randrange of a bound past 64 bits as CPython draws it: getrandbits of its bit length in 32-bit words, rejected until below it). Nothing in the reference changed.

Decided, and done in both runtimes #

A file that is not UTF-8 text is a failure the program can handle. read_file and read_file_secret of one fail with cannot read file 'x': it is not UTF-8 text, which check catches; until now the run ended with a Python traceback, and sabline-rt copied it. Writing a text holding a lone surrogate is a failure too, with nothing written: write_file is not fallible, so it is E608, as a file that cannot be written is, and the text is encoded before the file is opened - a file that was there is left as it was, and a new one is not made. Until now open() emptied the file and then the traceback ended the run. SPEC.md 8 says it.

A promise names the file it is written in. A library function's broken requires (E600) named the importing file with the library's line, because the promise is checked before the callee's body and the caller's frame blamed it - flat or named alike, in both runtimes. It names the library's file now, and so does what had the same fault: a broken ensures (E601), an error raised while a promise is checked (an E602 in a requires), and the program's own promise broken when a library calls it back, which named the library's file. The prover's E701 was already right - the caller's file, at the call - but sabline check's one-line output printed the target it was given with the line of an error in an imported file; it prints the error's own file now, as --json, SARIF and run did, and sabline explain says line 8 of lib.vel for one. SPEC.md 10 says it.

compatibility: for 9.0's Python package, read_file and read_file_secret of a file that is not UTF-8 fail, where the run ended with a UnicodeDecodeError traceback; and write_file of a text holding a lone surrogate is E608 and writes nothing, where the run ended with a UnicodeEncodeError traceback after emptying the file. A program that ran to its end before runs the same; no exit code a finished run gave changed.

compatibility: for 9.0's Python package, a broken requires or ensures of a function in an imported file, and an error raised while one is checked, name that file - in the error a run prints, --json's file and every report built from them - where they named the file that called it; and sabline check's one-line output names the file an error is in. The code, the message and the line are unchanged.

What is not done #

plan/9.0-m3-progress.md lists it in order: sabline run and sabline.run through sabline-rt (decisions/0002), which is what lets the suites of M3's fourth criterion be asked of it; the network's, Python's and the tools' work, each a design question; the organisation ceiling file (decisions/0007, 32c); and performance. It records two questions for the maintainer: whether the reference should stop answering differently by CPython version in the three places the gate and the fuzzer leave out, and whether the size limit should count a value written out before it is made, which would close its residual.