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:
| Suite | Questions | Failed | Ran a program | sabline-rt alone | A run the checks refused | A run the package made | Runs: sabline-rt / checks / package |
|---|---|---|---|---|---|---|---|
| check_library.py | 309 | 1, by design | 58 | 57 | 1 | 0 | 61 / 1 / 0 |
| check_money.py | 81 | 0 | 41 | 40 | 1 | 0 | 40 / 1 / 0 |
| check_hostile.py | 74 | 0 | 39 | 30 | 9 | 0 | 30 / 10 / 0 |
| check_self_budget.py | 118 | 0 | 52 | 51 | 1 | 0 | 296 / 1 / 0 |
| check_pool.py | 40 | 0 | 26 | 25 | 1 | 0 | 512 / 1 / 0 |
| check_receipts.py | 41 | 0 | 15 | 13 | 1 | 1 (eval) | 19 / 1 / 2 |
| check_sandbox.py | 58 | 0 | 57 | 57 | 0 | 0 | 57 / 0 / 0 |
| check_fallible.py --runtime rust | 33 | 0 | 25 | 25 | 0 | 0 | 25 / 0 / 0 |
| check_secret.py --runtime rust | 67 | 0 | 14 | 12 | 1 | 1 (trace) | 12 / 1 / 1 |
| check_error_messages.py --runtime rust | 96 | 0 | 24 | 24 | 0 | 0 | 24 / 0 / 0 |
| check_termination.py --runtime rust | 67 | 0 | 0 | - | - | - | - |
| check_refusals.py --runtime rust | 17 | 0 | 0 | - | - | - | - |
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 #
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 differagreement 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 #
- 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, soheld_tofor a call held toto=andcc=patterns was one entry holding both. The gate's new tool rows found it. They are joined by a line feed now. - 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 #
- The
fficliff 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 addsfstosabline.state.EFFECT_BUDGETthrough unscopedffiis refused its read through sabline-rt where the package lets it through.check_pool.pynow 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, inplan/9.0-m3-progress.md. - 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:
| Suite | Questions | Failed | Ran a program | sabline-rt alone | A run the package made | Runs: sabline-rt / package |
|---|---|---|---|---|---|---|
| check_library.py | 309 | 1, by design | 58 | 49 | 8 (net) | 53 / 8 |
| check_money.py | 81 | 0 | 41 | 40 | 0 | 40 / 0 |
| check_hostile.py | 74 | 0 | 39 | 29 | 1 (ffi) | 29 / 1 |
| check_self_budget.py | 116 | 0 | 51 | 20 | 30 (ffi, net) | 186 / 101 |
| check_pool.py | 39 | 0 | 26 | 21 | 4 (ffi) | 507 / 7 |
| check_receipts.py | 41 | 0 | 15 | 10 | 4 (ffi, eval) | 15 / 6 |
| check_fallible.py --runtime rust | 33 | 0 | 25 | 19 | 6 (ffi, net) | 19 / 6 |
| check_secret.py --runtime rust | 67 | 0 | 14 | 12 | 1 (trace) | 12 / 1 |
| check_error_messages.py --runtime rust | 96 | 0 | 24 | 20 | 4 (ffi, net, tool) | 20 / 4 |
| check_termination.py --runtime rust | 67 | 0 | 0 | - | - | - |
| check_refusals.py --runtime rust | 17 | 0 | 0 | - | - | - |
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 #
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 differagreement 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 #
- 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_rtread it through a constant not named*_ENV. The two variables of the runtime are*_ENVconstants 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. 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 asset_run_paramsconverts it.sabline.runwith 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.pyfound it. It is refused before anything runs now, in both, asPool.runalready 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 #
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 differagreement 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 #
- A function value written inline printed a name that depended on the
process (the reference):
Parser.lambda_nwas a class attribute that nothing reset, so a program run bysabline.runin a process that had loaded others -sabline.runcalled again, a pool's worker, a door - printedfn fn#21where the command line printedfn fn#1.check_through_rt.pyfound it.load_programcounts 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 #
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 differagreement 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.