Sabline 8.8.0

Changelog: 9.x: 9.0.0-alpha.7 to 9.0.0-alpha.1

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

9.0.0-alpha.7 - M3: the builtins under every budget, as far as they got #

M3's second checkpoint (plan/9.0.md, M3 - the interpreter, the budget, Secret and receipts), and still not its end: sabline-rt now does the work of every builtin a budget can grant short of the network, Python and a tool, and the agreement gate runs every program written for a budget under that budget, in a tree of files made afresh for each runtime - 106 of them, check_sandbox.py's rows and sabline-spec's L2 cases among them. The maintainer's three decisions on the questions the first checkpoint left are in both runtimes: a counted size limit stops a compared run that grows without end, a function value prints as its name, and a library's function value imported under a name is no longer refused. 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, and receipts and the audit stream are 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 #

The builtins' work (src/interp.rs, and src/host.rs for what a run reaches of the machine): read_file, read_file_secret, write_file and file_exists, each guarded as budget.allow_path guards it - normcase(realpath(path)) against each grant's, the credential rule (E318) before the ordinary one (E313), the count (E315) spent before the work, the read ceiling (E316) - and read and written as CPython's open does it, universal newlines and the system's line end included; now under --freeze-time and the clock; random as random.Random(seed).randrange, CPython's Mersenne Twister seeded as CPython seeds it, and its rejection loop; env as os.environ reads it, every name upper-cased on Windows; declassify; and hmac_sha256 and hmac_sha256_chain. A refusal to open a file is in OSError.strerror's words, the C runtime's on Windows.

The run document, version 2: a line of the list may give a budget, a seed, a frozen clock, a read ceiling and variables over the environment, {"path": ..., "allow": ..., "seed": ..., "freeze_time": ..., "max_read": ..., "environ": {...}}, and the document names which limit stopped a run, stopped_by.

A size limit for the run document (decided 2026-10-09): both runtimes count what a run makes - a text's UTF-8 bytes, a list's or a map's items, as runtime.size_of says, from every + that makes a text or a list, every list and map written, every builtin's answer but those that hand back a value the program already held, and what print, log and ask write - and stop the run at the operation that passes 4,194,304. Never wall-clock time or real memory; the real memory cap stays the operating system's (E611, M4). The program the gate did not run - a text doubled forty times, to be stopped by the memory cap it tests - is run now, and stopped at the same operation in both; so are the benchmark's five programs that grow forever, which made the gate slower than anything else did. It is state._SIZE_LIMIT in the Python package, set by run-dump and nothing else.

The agreement gate #

Text
the parsers: 6798 compared, 6798 agree, 0 differ
the checkers: 6798 compared, 6798 agree, 0 differ
the budget parser: 2967 compared, 2967 agree, 0 differ
the interpreters: 6798 compared, 6798 agree, 0 differ
the interpreters, under their budgets: 106 compared, 106 agree, 0 differ
the builtin tables: 1 compared, 1 agree, 0 differ

agreement gate: 23468 comparisons, 23468 agreements, 0 differences

The same on CPython 3.10, 3.12 and 3.13. Every program the gate holds is run (RUN_EXCLUDED is empty). The runs under their budgets: the runs' corpus's own table, agreement_runs.BUDGETED - files read, written and looked for under every kind of grant and count, the read ceiling at and past its size, each documented credential location refused by a broad grant and read when named, the clock frozen, randomness under four seeds and ranges past 32 bits, the environment, both HMACs - and every row of check_sandbox.py and every L2 case of sabline-spec whose budget grants only those effects, in the fixture both name, with ~ the tree's own home. The size limit's edges pin it exactly: at the limit a run ends, one past it stops, a two-byte character fits two bytes of room and a three-byte one does not.

check_gate.py's injections gain four, each of which turns the gate red: where the size limit stops a run, what it counts of a text, a file grant's refusal, and randomness under a seed.

Decided, and done in both runtimes #

A function value prints as its name: fn double, and for one written inline the name it was lifted to, fn fn#1, whether or not it carries values (SPEC.md 12a). Until now a broken promise's message showed a function's whole definition, and a value carrying names printed as a Python object with an address that changed from run to run, so no second runtime could agree with it.

A library's function value, imported under a name, is no longer refused. The loader renamed a library's lifted function and not the Closure that names it, so import "lib.vel" as lib of a library holding all_of(xs, fn(x: Int) -> Bool { ... }) was refused with E402 "unknown function value 'fn#1'", while the same library imported flat compiled. Both loaders rename it now, in the same commit (SPEC.md 10); a lifted name under a prefix, lib.fn#1, still reads as "this function value" in a message. The adversarial pass held thirteen libraries each way - values carrying names, nested, handed back and called, calling the library's own functions, looping, failing, breaking a promise, recursing through all_of - to the same run, flat and named, in both runtimes.

Found on the way, and fixed #

all_of and any_of recursed in C. They were Python's all() over a generator, so a function value that reached them again nested C calls, and from CPython 3.12 the C recursion limit ended the run first - as E609 "nested too deeply to print, compare or encode", at line 0 - where 3.10 gave the depth limit's own E609 at the right line. The same program, two answers, by CPython version. They are a loop now, and the depth limit fires on every CPython.

Two chores #

check_release.py's timing checks run on a counted clock. consistent and published --require with --timeout 0.3 asked for at least two polls inside 0.3 seconds of the machine's clock, and a runner whose first poll of the stand-in took longer gave none. release_checks now runs those checks on a clock that moves only when slept on: six polls, on every runner. Nothing is waived.

The sitemap's dates are made from what the pages hold. A page's <lastmod> was the day of the last commit that changed its sources, so a pull request built one UTC day and squash-merged the next re-dated every source it carried and left main's sitemap stale (#147, fixed by #148). Each page is now dated by a digest of its sources as git holds them - docs/sitemap-dates.json, committed beside the sitemap - and the day a build first saw that digest. check_site's STALE rule asks of HEAD alone whether every page's sources are the ones its date was given for, so it runs on a shallow clone too, and site.yml no longer checks out the whole history. It also ends the Windows checkout's false bumps: git status calling unchanged files modified dated them today.

compatibility: a pre-release publishes the sabline-rt crate and a GitHub release marked pre-release and nothing else; nothing a user of 8.8.0 has is touched.

compatibility: for 9.0's Python package, a function value prints as fn and its name everywhere a value is written: a broken promise's message names one as fn double where it showed the function's definition, and a value carrying names prints as fn fn#1 where it printed <...Bound object at 0x...>. print of one that carries none is unchanged. Messages are not covered by STABILITY.md; no code changed.

compatibility: for 9.0's Python package, a program importing a library that makes a function value under a name compiles, where it was refused with E402; and a function value that reaches all_of or any_of again stops at the depth limit's E609 on every CPython, where 3.12 and 3.13 gave the nested-value E609 at line 0. No program that ran before runs differently.

What is not done #

plan/9.0-m3-progress.md lists it in order - receipts and then the audit stream, the network's, Python's and the tools' work (each a design question), a run target for the fuzzer, sabline run through sabline-rt, the organisation ceiling file - and records two defects in the reference the port copies, for a decision: a file that is not UTF-8 ends a run with a Python traceback rather than a failure the program can handle, and a library function's broken requires is reported against the importing file.

9.0.0-alpha.6 - M3: the interpreter, as far as it got #

The third rung of 9.0 (plan/9.0.md, M3 - the interpreter, the budget, Secret and receipts) has begun, and this pre-release is where it got to, not its end: sabline-rt now runs a program, and the agreement gate compares every run of every program it holds as it compares every check - what was printed to each channel, the status the run ended with, and the error it stopped with, word for word. M3's exit criteria are not met and none is claimed: the builtins whose work is a file, the network, a clock, randomness, the environment, Python, a tool or a signature are spent against the budget and refused by every budget that does not grant them, which is all a run under io reaches of them, and their work is not ported yet; nor are receipts. 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 #

The interpreter (src/interp.rs): sabline/runtime.py's run, transliterated - calls, function values and what they carry, check and try, promises checked while running with their messages, loop invariants, the depth limit - and the work of every builtin that is pure or is the console's: the text builtins, lists and maps, the seven Money builtins, the JSON builtins, the encoders and sha256, format, print, log, read_line, ask, args and exit_with. Every builtin is spent against the run's budget first, with the reference's refusal.

A running program's values are CPython's objects (src/value.rs), and what a program sees of them is copied rather than reinvented: a Text is code points, so a lone surrogate read out of a JSON document is a value like any other (src/text.rs); upper, lower - the final sigma included - and what repr leaves unescaped come from tables generated from CPython (src/unicode_text.rs); to_text writes true where a broken promise's message, which is an f-string, writes True; a whole number literal past 64 bits is a value (src/bigint.rs); a container compares its items by identity first, so a record holding a NaN equals itself; a map is a dict, 1, 1.0 and true one key; and the JSON builtins are json.loads and json.dumps, every message with its line, column and character (src/pyjson.rs). rt/README.md lists each and the two places CPython 3.10 to 3.13 disagree with themselves, which the gate's corpus avoids.

The run document: sabline run-dump --list FILE from the Python package and sabline-rt run --install-dir DIR --list FILE from the crate, a program run as sabline <file> runs it with four things fixed - the budget io, the input, the arguments, and a step limit of 20,000 calls and loop turns, so that a program that does not end stops at the same one in both - and without the prover, native code or the operating system's confinement. The step limit is state._STEP_LIMIT in the Python package, set by run-dump and by nothing else.

The checkers on generated programs, and programs of several files - the two items M2 left recommended: fuzz_parsers.py --target agreement_checks writes programs on both sides of the checkers' rules and requires the same check document from both (two minutes in the agreement job, twenty monthly), and the gate carries programs of more than one file, which reach the type checker's "unknown function value" for a library imported under a name.

The agreement gate #

Text
the parsers: 6774 compared, 6774 agree, 0 differ
the checkers: 6774 compared, 6774 agree, 0 differ
the budget parser: 2967 compared, 2967 agree, 0 differ
the interpreters: 6773 compared, 6773 agree, 0 differ
the builtin tables: 1 compared, 1 agree, 0 differ

agreement gate: 23289 comparisons, 23289 agreements, 0 differences

The runs' corpus, agreement_runs.py: 72 texts where code points and UTF-8 part - lone surrogates, a surrogate pair written as two escapes, characters past the basic plane, combining marks with nothing to combine with, letters whose case is two or three characters or depends on the next - each through every text builtin and named in a broken promise; and 56 edges of arithmetic at 64 bits and at IEEE-754's, float printing, texts, lists, maps, records, money's rounding and parsing, every message the JSON reader gives, the input, exit statuses, recursion, and every kind of value a promise's message names. The first full run found one difference - CPython's identity-first comparison of a NaN inside a record

  • and the runs' corpus found three more, each now copied. One program is not run, and the gate says which and why: it doubles a text until the memory cap it tests would stop it, and a run here has no cap.

check_gate.py's injections gain four, each of which turns the gate red: a value a run prints, a run's error code, a run's error message, and where the step limit stops a run.

A defect in the reference, fixed #

A broken promise that named a record printed the record's memory address - <sabline.values.RecordValue object at 0x...> - so the same promise gave a different message on every run, and no second runtime could give the one the first gave. Twelve programs in the gate's corpora did it. A record is now written as a dataclass is, Pt(x=1, y='z'), beside an amount's MoneyValue(units=150, currency='INR'). Messages are not covered by STABILITY.md; no code changed.

What is not done #

plan/9.0-m3-progress.md lists it in order - the builtins' work for the other budgets, then receipts and the audit stream, a run target for the fuzzer, sabline run through sabline-rt, the organisation ceiling file - and asks three questions: what should stop a program that grows without bound in a compared run, how a function value should print (today the reference writes the Python object, an address included), and whether a library's function value imported under a name - refused today, by both runtimes - should be fixed.

9.0.0-alpha.5 - M2: the checkers, the budget parser, and a loop bounded by its input #

The second rung of 9.0 (plan/9.0.md, M2 - the checkers), as far as its exit criteria go: sabline-rt now checks a program the way sabline check does - every stage's problems, in the reference's order and in its words, and every loop's verdict - and parses a budget the way a run does. It still does not run a program, prove a promise, enforce a budget or write a receipt; the Python package does all four and 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 #

The checkers, ported (pull request #144): the loader with its imports and the shipped standard library, the effect checker, the type checker - Secret of T, Money of C, generics, records, maps, function values - the rules for main, and each loop's termination verdict. A literal transliteration, rule for rule and message for message; rt/README.md and src/checker.rs say which of the reference's behaviours are copied on purpose and why (infer runs again where the reference runs it again, because the number of runs is visible in the problem list).

The budget parser (src/budget.rs): sabline-spec's grammar of sections 4 and 5 - effects, scoped fs:, net:, ffi: and tool: grants, @N counts, every refusal in the reference's words - and the library's _budget_from, which is the path a budget case takes. A path in a grant is resolved when the budget is parsed, as normcase(realpath(path)), so src/pypath.rs now copies CPython's own realpath: the walk that resolves as much of a path as exists, through links and junctions, and joins the rest on. Four of CPython's behaviours are copied rather than Rust's - realpath's walk, ntpath.normcase lowering one character for one, str.isdigit() taking superscripts and circled digits (a new generated table, src/unicode_digit.rs), and int()'s 4,300-digit limit, whose message becomes the budget's refusal. The crate gains a JSON reader for the budgets the gate sends, and sabline-rt budget --list.

A third loop verdict, bounded by its input (decisions/0006 section c, and its amendment of 2026-10-08), in both runtimes in the same commit and written first into SPEC.md §9.5. A loop gets it only when its exit is keyed to the read's empty result: read_line() answers "" at the end of input and on every call after it, so a loop that reads a line every turn and leaves on the first empty one runs at most once more than its input has lines. §9.5 states the two shapes exactly. sabline check --strict does not refuse such a loop (E612 is for unshown loops), sabline audit does not count it in loops_unshown, and sabline explain names it. The benchmark's 18a, a correct line counter, is reported bounded by its input and passes check --strict.

0006 first worded the rule as a loop "whose condition changes only from that call's result", and that wording was not sound: a loop that clears its flag only when line == "x" fits it, and once its input ends it reads "" for ever. The maintainer amended the rule to key it to the empty read (0006's Amendment 1), and that loop is now a test in both runtimes that must stay unshown - beside 07d, which leaves only on "quit", and 18e, which reads once before its loop and never inside it.

sabline conformance --runtime rust: sabline-rt answers the conformance case kinds it implements - L1's 280 budget cases - through its binary, and every other case is answered by the Python package and labelled as its answer, never counted as sabline-rt's. The C ABI the plan first named is M7's, and conformance through it moved to M7's criteria; L3's 51 check cases are sabline capabilities check, which decisions/0002 keeps in Python, so they stay Python-answered (plan/9.0.md, amended 2026-10-08). check_conformance_rust.py holds the runner to that labelling, and to a wrong answer from sabline-rt failing its case.

The agreement gate #

Text
the parsers: 6570 compared, 6570 agree, 0 differ
the checkers: 6570 compared, 6570 agree, 0 differ
the budget parser: 2967 compared, 2967 agree, 0 differ
the builtin tables: 1 compared, 1 agree, 0 differ

agreement gate: 16108 comparisons, 16108 agreements, 0 differences

The budget comparison is the 280 L1 budget cases, 2,558 budgets made from them by fixed operations, 115 hand-written edges and 14 paths through a small tree both runtimes resolve; together they reach every statement of the reference's budget parser that a parse can reach. check_gate.py's injections - one per comparison class, each of which must turn the gate red - gain three: a budget refusal's message, the rule that the smallest count holds, and one shape of the input-bounded verdict.

Under --runtime rust, check_termination.py asks sabline-rt 67 questions (64 loops and three --strict verdicts), and the other four suites the check-time questions they asked of alpha.4's checkers; every one is answered as the Python package answers it.

What moved because 18a is no longer flagged #

The benchmark's Sabline column re-measured: 18a is not-applicable, and Sabline's false positives on the 22 correct controls are three - Euclid's loop (18b), and 25! and a product modulo 2^61 - 1, which do not fit 64 bits (18c, 18d). On the competitor page, Deno, WASI and the Python sandbox are each ahead on one row fewer. docs/known-open.md's row for correct programs the rules refuse says 18a is closed on main and ships with 9.0.

What is not done #

M2's exit criteria are met, as amended, for every question a checker answers. The five suites' questions that run a program wait for M3's interpreter; the prover's and the audit's stay Python's for good (decisions/0002). plan/9.0-m2-progress.md lists two recommended pieces of work that are not exit criteria - a checker target for the differential fuzzer, and a two-file case for the one reachable checker line no single-file program reaches - and says where the next session starts.

Outside M2 #

sabline-spec's CI had been red on main since 2026-09-28: its validator refused this repository's sabline.capabilities, whose surface had tool added by hand beside tool:search and tool:send_email. The validator was right - tool covers every tool grant, and the reference's own reduction drops the two - so the baseline is reduced now (#145), and the test job runs sabline-spec's validator on it; sabline-spec 0.15.1 adds the line its section 9.5 had been missing.

9.0.0-alpha.4 - The job that was read, and then run #

9.0.0-alpha.3 published its crate and made no GitHub release. The two hard things worked: release.yml dispatched publish-crate.yml, waited for the run with gh run watch --exit-status, and sabline-rt 9.0.0-alpha.3 went to crates.io through OIDC with no stored credential. Then the release job fell over on one line:

Text
Post https://uploads.github.com/.../assets: read assets/rt: is a
directory

The crate job gave upload-artifact two paths, so the artifact kept the directories they had in common - rt/target/package/... - and the release step globbed assets/*, handing gh a directory. gh refuses one, and then deletes the release it had just created, so there is no GitHub release under v9.0.0-alpha.3 although the tag and the crate stand.

Two fixes, and the second is the one that matters:

  • the step names files rather than globbing a tree (find assets -type f), so a directory can never reach gh whatever the artifact's shape, and it attaches what an existing release lacks rather than doing nothing - the same shape the ordinary release's job has always had;
  • the artifact is one flat directory, so assets/ holds the two files and nothing else.

The part worth keeping #

Three releases were spent on three defects, and all three were in workflow code I had verified by reading it. A skip that travels past a job which rescued itself; a registry that refuses a token minted under workflow_run; a glob that hands a directory to a command that refuses one. Every one of them takes seconds to see in a run and is invisible on the page.

This project already knew that. check_release.py has run the Marketplace job's and the pin-moving job's own steps, in bash, against stand-ins since 8.2.1 and 8.4 - because both had failed in ways reading did not catch. The jobs added for 9.0 did not get the same treatment.

They do now. run_prerelease_github_job lays the assets out from the crate job's own upload-artifact path, so the fixture tests the artifact this repository actually produces rather than one chosen to pass, and runs the release job's real steps against a gh that refuses a directory exactly as the real one does. Run against the job as 9.0.0-alpha.3 shipped it, the fixture fails with gh refused a directory; against the fix it passes. Writing it found two more things a reading had not: the notes step needs the checkout's CHANGELOG, and the "nothing else moved" step needs the registry to say the crate is there.

check_release.py: 175 passed, 0 broken.

compatibility: a pre-release publishes the sabline-rt crate and a GitHub release marked pre-release and nothing else, so nothing a user of 8.6.0 has is touched - not PyPI, not npm, not the VS Code Marketplace, not the MCP registry, not the Action pins, and not any "latest" anywhere.

v9.0.0-alpha.1, v9.0.0-alpha.2 and v9.0.0-alpha.3 stay where they are, neither moved nor deleted nor retagged. alpha.1's crate is on crates.io because a person put it there, which is how a crate's first version always gets there; alpha.3's is there because the workflow put it there, which is how every one after it will. None of the three has a GitHub release, and the entries below say why each.

9.0.0-alpha.3 - The crate is published by a workflow of its own #

9.0.0-alpha.2 tagged and did not publish either. Its crates_io job ran this time - the skip alpha.2 fixed stayed fixed - and failed at the one place nothing had exercised:

Text
Failed to retrieve token from Cargo registry. Status: 400.
Trusted Publishing does not support the `workflow_run` event trigger
due to security concerns.

crates.io refuses a Trusted Publishing token minted under workflow_run, and release.yml runs on workflow_run. It refuses it on purpose: crates.io removed workflow_run and pull_request_target because both have been behind supply-chain incidents. The triggers it allows are push, release and workflow_dispatch. push on a tag is the one this project cannot use - no workflow here listens for tags, precisely so that a tag pushed by hand publishes nothing.

So the crate's publish is publish-crate.yml, a workflow_dispatch workflow of its own, and release.yml starts it: the crates_io job tags, asks whether crates.io has the version, dispatches that workflow if it does not, waits for the run it started with gh run watch --exit-status, and asks crates.io itself afterwards. A publish that did not happen cannot be mistaken for one that did. release.yml still decides what is released; that workflow is the arm that reaches crates.io for it, and it stores no credential either.

A workflow anyone with write access can start by hand is only safe if it refuses everything release.yml would not have asked for. Before a credential is touched, and with no if: of its own to be stepped around, release_checks.py crate-publishable gives one of three answers:

  • red - the version is not a pre-release, or the checkout's own rt/Cargo.toml does not say it, or its tag is missing or names another commit. release.yml only ever tags the commit whose tests passed, so a tag that names this commit is the statement that they did.
  • publish - go ahead. Every step that touches a credential or the registry is conditioned on this answer and on nothing else.
  • skip - crates.io has it already. Not a refusal and not a failure: there is nothing left to do, the publish steps are skipped, and the run ends green having published nothing. A re-run lands here, and so does a version that landed while somebody was typing.

check_release.py holds every one of those to a fixture, the workflow's shape included: the guard comes before the token and before the publish, the guard has no condition, each step after it carries the guard's own answer and nothing else, the last step runs whichever way the guard went, and nothing in that workflow publishes anywhere but crates.io.

What a person does once, and only once: the trusted publisher on crates.io names a workflow file, and it now names publish-crate.yml rather than release.yml. RELEASING.md says where to change it. No token is involved and none is stored; the entry simply pointed at the wrong file.

compatibility: a pre-release publishes the sabline-rt crate and a GitHub release marked pre-release and nothing else, so nothing a user of 8.6.0 has is touched - not PyPI, not npm, not the VS Code Marketplace, not the MCP registry, not the Action pins, and not any "latest" anywhere. The Python package's version files still say 8.6.0, and the release gate refuses a pre-release whose commit moved one.

v9.0.0-alpha.1 and v9.0.0-alpha.2 stay where they are, tags and all, neither moved nor deleted nor retagged - a published thing stays where it is (3.4 was not retagged; 6.0.0 was yanked, not deleted). alpha.1's crate is on crates.io because a person put it there, which is how a crate's first version always gets there. Neither has a GitHub release, and the two entries below say why. What alpha.1 was meant to be, this is.

9.0.0-alpha.2 - The parser, twice, and a release that publishes #

9.0.0-alpha.1's content, released. The alpha before it tagged v9.0.0-alpha.1 and then published nothing, because of a defect in release.yml that this entry is mostly about. Its crate reached crates.io only because a person put it there by hand, which is how a crate's first version always reaches crates.io - trusted publishing can only be configured on a crate that already exists. What 9.0.0-alpha.1 was meant to be, 9.0.0-alpha.2 is; the CHANGELOG entry below it says what exists and what does not, and none of that changed.

v9.0.0-alpha.1 is not moved, deleted or retagged, for the reason 3.4 was not retagged and 6.0.0 was yanked rather than deleted: a published thing stays where it is. The tag stands, its crate stands, and this entry is the record of why there is no GitHub release under it.

What was wrong #

GitHub propagates a skip along needs transitively, and a job that rescues itself with !cancelled() does not rescue the jobs after it. Its own result is success, and the skip still reaches them.

9.0.0-alpha.1 added the crate job to what tag waits on. On a pre-release the seven Python build jobs are skipped; tag rescued itself and ran; and every job below tag inherited the skip - so crates_io did not run, and prerelease_github after it did not either. The run ended green having tagged and published nothing.

The same defect would have broken an ordinary release worse. There crate is the skipped job, so pypi, npm, vscode, github_release, mcp_registry, attach_attestation and consistency would all have been skipped: a release that tags and publishes nothing, on the path every release takes.

crates_io, pypi, npm and vscode now carry !cancelled() and check the job before them by result. Every job further down already had that shape - the trap was known, and 9.0.0-alpha.1 walked into it by changing what tag waits on.

Why the fixtures did not catch it #

check_release.py runs release.yml job by job against a stand-in, and 137 of its checks were green on the commit that shipped this defect. Its decides implemented the semantics a reader would assume - the implicit success() covering a job's direct needs - and GitHub's covers the transitive closure. The simulation agreed with the mistake, so it had nothing to say.

decides now models the closure. Against the workflow as 9.0.0-alpha.1 shipped it reproduces both failures exactly, the real one included. And thirteen structural checks were added: every job below tag must carry a status check function in its condition, which is the rule that keeps the next change to tag's needs from doing this again.

compatibility: a pre-release publishes the sabline-rt crate and a GitHub release marked pre-release and nothing else, so nothing a user of 8.6.0 has is touched by this release either - not PyPI, not npm, not the VS Code Marketplace, not the MCP registry, not the Action pins, and not any "latest" anywhere. The Python package's version files still say 8.6.0, and the release gate refuses a pre-release whose commit moved one.

9.0.0-alpha.1 - The parser, twice #

The first alpha of 9.0. decisions/0002-runtime-in-rust.md says why there is a second runtime and plan/9.0.md is the ladder; this is its first rung. sabline-rt reads a Sabline program and builds the same tree the Python parser builds. That is all it does.

This is a pre-release. It publishes the sabline-rt crate to crates.io and a GitHub release marked pre-release, and nothing else. 8.6.0 is still what pip install sabline-lang gives, what npm gives, what the Marketplace lists and what the MCP registry serves; the Action pins still name 8.6.0's commit; sabline --version still says 8.6.0. The Python package's version files were not touched, and the release gate refuses a pre-release whose commit moved one.

What exists #

rt/, a Cargo workspace holding one crate, sabline-rt. Edition 2021, minimum Rust 1.82, no dependencies at all. A lexer, a parser and a canonical dump of the tree, in fourteen files. #![forbid(unsafe_code)] at the workspace root, so there is none; rt/README.md states the rule for the day a later alpha needs some - a // SAFETY: comment naming the invariant and a test that would fail if it broke - and check_rt.py enforces it, against fixtures of its own so the scan is known to catch each way of getting it wrong rather than only to run.

One canonical AST dump, written the same way by both. sabline ast --json <file> from the Python package, sabline-rt ast <file> from the crate: keys sorted, no whitespace, ASCII only, bytes rather than text; a whole number as decimal text, because the reference's integer literals are arbitrary precision; a float as the shortest decimal that reads back as the same double, written d[.ddd]eE, because neither language's default printing is the other's. rt/README.md states the format. It is not a stable interface, it is not in tests/api/golden.json, and nothing but the gate reads it; sabline ast is not in sabline --help, and answers --help for itself.

The absence of indentation is a decision rather than a default, and it was made from a measurement: two spaces a level makes a document O(depth squared), and the dump of the deepest program the parser accepts - 40 KB of source, which the adversarial corpus holds on purpose - was 352 MB of mostly spaces, which the gate would have written twice on every leg of CI. Compact, the same document is 316 KB and the gate takes 10 seconds instead of 16.

The agreement gate, check_agreement.py, over every Sabline source this project has:

CorpusPrograms
examples/, including lib, ops and runner112
stdlib/14
benchmark/corpus/85
tests/error_messages/84
sabline-spec's conformance corpus216
the lie corpus (check_prover_lies.py)146
check_sandbox.py's escapes and honest programs58
check_refusals.py's wrong programs24
every example cut at fifteen points1,680
the adversarial corpus (agreement_edges.py)113
paths that are not files2
total2,534

2,534 programs, 2,534 agreements, 0 differences. 1,395 of them parse and 1,139 are refused, between them reaching every code the lexer and the parser give: E000 (158), E001 (2), E002 (4), E100 (409), E101 (539), E102 (11), E407 (3), E511 (3), E512 (6), E562 (4). The six corpora plan/9.0.md names hold no program the lexer or the parser refuses - every one of them parses and is refused later or not at all - so the other five are there because a gate that only ever compared trees would say nothing about the codes, the messages, the fixes and the lines, which is half of what this alpha claims.

The gate cannot be turned off, and it is proven to go red. check_gate.py reads the gate's syntax tree and fails on os.environ, getenv, a configuration file or any use of the command line beyond the corpus paths; runs it with eight plausible disabling variables set and requires the number of compared programs to be unchanged; and builds sabline-rt three times with one difference injected each time - an error code, an error message, a field of the tree - and requires the gate to go red for each, over the same number of programs, so that it found a difference rather than stopped early. check_workflows.py holds the agreement job to having no if:, no continue-on-error, no matrix and no environment.

Fuzzing. cargo fuzz has two targets, parse and dump, on arbitrary bytes; fuzz_parsers.py --target agreement feeds the same generated input to both parsers and requires the same answer, coverage- guided as the rest of that file is. Both run briefly on every push and longer in monthly.yml. No input may make sabline-rt panic, abort, overflow its stack or hang: the library publishes PARSE_STACK and on_parse_stack, every entry point that parses runs the parse there, and tests/limits.rs holds the deepest program the parser accepts against that number. A stack overflow is not a panic - it is the process going away with no message - so it is held by a measurement and not by hope.

CI. cargo build, cargo test, cargo clippy -D warnings, cargo fmt --check and check_rt.py on every leg of the matrix, arm64 and Windows included, with Swatinem/rust-cache; and four legs of their own: agreement (the gate, its own test's three injections, and two minutes of the differential fuzzer) at 169 s, rt_fuzz (a minute of each cargo fuzz target) at 144 s, supply_chain (cargo deny check - advisories, licenses, bans and sources, with a committed rt/deny.toml naming all five target triples a release builds for) at 30 s, and msrv (a build and test on 1.82, the version rt/Cargo.toml states and check_rt.py holds it to) at 22 s.

The cost, measured against the last green run of main before this branch: the eighteen matrix legs together went from 20,250 s to 21,016 s, up 3.8%, and the median leg from 938 s to 1,051 s. Wall clock did not move - 2,781 s to 2,719 s, which is noise - because the four new legs run beside a matrix whose slowest leg is three quarters of an hour. Per-leg noise on these runners is larger than the change: three legs got faster. cargo deny runs once rather than eighteen times, because it reads the lockfile and deny.toml already names every platform a release builds for; running it on each leg would tell nobody anything one run does not.

What does not exist #

sabline-rt does not check types, does not check effects, does not check termination, does not prove anything, does not run a program, does not hold a budget, does not ask the operating system for anything and does not write a receipt. It cannot tell you whether a program is safe to run. The Python package is what does that and is the reference: where the two disagree, the Python package is right and sabline-rt has a defect, until the demotion criteria in plan/9.0.md are met, and they are not. plan/9.0.md's alphas 2 to 8 are the rest, and none of them has started.

Nothing calls sabline-rt. sabline run, sabline.run and sabline.Pool are the Python interpreter, exactly as in 8.6.0. There is no C ABI and no binding. The crate is published so that the alpha is a shippable tag with a version anyone can point at, which is what plan/9.0.md asks an alpha to be.

What the reference was found to do that the spec does not say #

Writing the parser twice is what finds these, and four are worth the record. Three are copied into sabline-rt as they are, because the Python package is the reference and a second implementation that fixed things quietly would be a second specification. One was changed, in Python.

  • \d is every Unicode decimal digit. The NUMBER and FLOAT patterns are \d+ and \d+\.\d+, and \d in a str pattern is category Nd, not the ten ASCII digits - so let x = ١٢ is Num(12), because int() reads each character by its decimal value, and let x = ١.٢ is FloatNum(1.2). [A-Za-z_] is ASCII in the same regular expression, so a non-ASCII letter is E000 and not an identifier: the two rules disagree about what a character is. SPEC.md says nothing about either. rt/crates/sabline-rt/src/unicode_nd.rs carries the set, generated from CPython's own \d by scripts/gen_unicode_nd.py, and it follows the Unicode version of the CPython that generated it - so two CPythons this project supports can disagree with each other about whether a recently assigned code point is a digit. No program in any corpus is affected.

  • open(path, encoding="utf-8") in the loader decides three things the lexer then depends on, and none of them is written anywhere: strict UTF-8, so bytes that are not UTF-8 are E512 - with a message that says "cannot import" about a file nobody imported, because the entry file goes through that branch; universal newlines, so \r\n and a lone \r are both \n before the lexer sees one, which means the lexer's own [ \t\r]+ never sees a \r from a file; and a byte-order mark left in place, because utf-8 is not utf-8-sig, so a file that begins with one is E000 on line 1.

  • Parser.lambda_n is a class attribute the parser never resets, so the generated name of a lifted function value - fn#N, for#N - depends on how many the process has parsed before, not on the file. sabline ast --json sets it to zero for each file so that a dump is a function of that file alone; nothing else in the package does.

  • E000's message was not stable across the Pythons this project supports, and that one was fixed. It was f"unexpected character {source[pos]!r}", and repr writes a character raw when the Unicode database calls it printable - so the same program gave one message on a CPython with one Unicode version and another on a CPython with another, and a second implementation could match at most one of them. It is !a now, which escapes every character outside ASCII and asks the database nothing. The gate found it: unexpected character 'é' against unexpected character '\xe9', on the first run that had a corpus with a non-ASCII character in it. Message text is prose, which STABILITY.md does not cover, and nothing that was a code, a line or a refusal changed.

The release, and what a pre-release may do #

release_checks.py and release.yml learned what a pre-release is. A version of the form X.Y.Z-alpha.N (or beta, or rc) in rt/Cargo.toml is a pre-release; it needs a CHANGELOG entry of its own, it must be newer than every tag, and the Python package's six version files must still say what the newest ordinary release said - the gate refuses the commit if one moved, because a pre-release that changed what PyPI would serve is not a pre-release of the crate.

A pre-release run tags the commit, builds and packages the crate from the committed lockfile, publishes it to crates.io by trusted publishing (OIDC - no token is stored, and MAINTENANCE.md's secrets table gains no row), and makes a GitHub release marked pre-release. It does not publish to PyPI, npm, the VS Code Marketplace or the MCP registry; it does not move the Action pins; it does not move any "latest" anywhere. A last step asks each of those afterwards and fails if one moved, because reading the workflow and believing it is not the same as checking.

check_release.py holds both halves to fixtures: an alpha publishes the crate, the tag and the pre-release GitHub release and nothing else, with every other job skipped rather than failed; RELEASE_PAUSED stops a pre-release too; a crates.io publish that fails leaves no GitHub release behind it, and re-running the failed jobs finishes it with each publish made once; and an ordinary release is unchanged, with no crate job run.

perf: the numbers this entry would carry are not measured for a pre-release: it publishes no Python artefact, so there is nothing whose time changed. plan/9.0.md's alpha.8 is where sabline-rt gets a performance floor of its own.