Skip to content

Add host-backed GPU computation and a Metal graphics demo - #190

Merged
MelbourneDeveloper merged 28 commits into
mainfrom
gpu
Aug 12, 2026
Merged

Add host-backed GPU computation and a Metal graphics demo#190
MelbourneDeveloper merged 28 commits into
mainfrom
gpu

Conversation

@MelbourneDeveloper

@MelbourneDeveloper MelbourneDeveloper commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

TLDR

Adds typed GpuBuffer<T> computation with eleven pure-kernel built-ins and a deterministic CPU host backend across native and WebAssembly targets, plus a separate macOS Cocoa/Metal graphics demo. The gpu* built-ins do not offload work to GPU hardware yet; device code generation remains roadmap work.

It also now blankets the C runtime in assertion-dense tests behind a real per-library coverage gate, and cuts workspace duplication below the committed ceiling.

Details

Added

  • Add opaque dense buffers for int, float, and bool, with toGpu, fromGpu, gpuLength, gpuMap, gpuFold, gpuZipWith, gpuIota, gpuGet, gpuScan, gpuFilter, and gpuDevice.
  • Lower GPU combinators to counted host loops and link one dense scalar-buffer runtime ABI across default, GC, ARC, native, and wasm builds.
  • Require kernel purity at compile time, rejecting effectful callbacks even beneath handlers and failing closed when purity cannot be proven.
  • Add make gpu-demo for the host-backed raster suite and a separate macOS-only Cocoa/Metal FFI bridge with an animated fragment-shader demo.
  • C runtime test coverage. Six new suites (GPU buffers, effects/coroutines, JSON, FFI/random/terminal/TAP built-ins, the default allocator, and memory goldens) and ten strengthened ones, wired into a table-driven make test that both runs them and gates each library's gcov line coverage from coverage-thresholds.json. Two previously orphaned suites (test_system_runtime, test_http_length_validation) are now actually built and run.
  • Memory golden tests. ARC gained peak-byte tracking and live/peak accessors, so churn, chain build/teardown, realloc transients, and GPU buffer accounting are pinned to exact object and byte counts that fail on a spike; the GC has a budget-driven spike guard.

Fixed — real defects the new suites caught

  • osp_float_to_string used %.10g, which silently lost value: 1234567890.5 rendered as 1234567890 and then gained a fabricated .0. It now widens precision only as far as a round trip requires, so short decimals keep their short spelling and no printed float names a different double than the one held.
  • base64_encode never emitted RFC 4648 padding — the trailing zero octets encoded as literal As. Every 16-byte Sec-WebSocket-Key and 20-byte SHA-1 accept token was base64 no conforming peer could decode, so the handshake could not match RFC 6455.
  • generate_websocket_key re-seeded srand(time(NULL)) per call and drew from rand(), so two connections opened in the same second sent an identical nonce. It now draws from the runtime's existing OS CSPRNG.
  • parse_websocket_frame sign-extended its extended-length bytes through char, so every payload of 128–255 bytes was rejected.
  • OspProfSlot advertised fiber_id/label that fill_slot never populated, so every osp_prof_self_slot consumer read 0/"".
  • test_http_length_validation composed ~4 KB of JSON into a 2000-byte stack buffer (_FORTIFY_SOURCE trapped it the first time the suite was built).

Changed

  • Preserve concrete buffer element representations through code generation and reuse the existing callback, accumulator, and ownership machinery.
  • Reclaim codegen-proven unique values in the default allocator while leaving general default/GC releases unchanged.
  • Turn unresolved recursive-generic lowering into an actionable annotation diagnostic.
  • C coverage thresholds carry each library's measured gcov coverage with cross-platform slack instead of an aspirational 90, so the gate is real and ratchets upward; term_runtime/test_runtime are documented as unmeasurable under the fork harness rather than gated at a fake number.
  • Duplication 5.12% → 4.29% (the branch had breached the committed 5% ceiling; it is now ratcheted to 4.4%): one statement walker for the LSP outline and hover, one receiver-first desugaring in effect rows, one endpoint factory plus one trace helper in the web compiler, one shared support module for the two wasm smoke scripts, one Prism grammar module for the 11ty build and the browser studio, macro-generated type constructors, one group-scanning lookahead in the ML parser, shared list/range loop and predicate helpers in iterator codegen, and type_is_resolved delegating to has_type_var.

Spec / Docs

  • Add the GPU computation specification and staged device-backend plan, explicitly documenting the current CPU-only execution model.
  • Add an audit-only arithmetic-totality plan recording existing float correctness gaps.
  • Update builtin, memory-management, WebAssembly, messaging, generic-function, collection, plan, and specification documentation.

Breaking Changes

  • No intentional source-level break for valid programs.
  • Unannotated recursive generic functions that previously fell through to invalid backend output now fail at compile time; concrete annotations are the current workaround.
  • toString on a float now prints the shortest representation that round-trips rather than 10 significant digits, so a value needing more digits prints them (16.666666666666668, not 16.66666667). Two goldens updated accordingly.

How Do The Automated Tests Prove It Works?

  • Six paired .osp/.ospml golden suites mirror 34 cases across both syntaxes: dense round trips; integer, float, and bool kernels; every combinator and edge case; game and ML workloads; raster composition; and million-element stress pipelines.
  • Type-constraint and effect-row unit tests accept scalar buffers and provably pure kernels while rejecting unsupported elements, handled effects, fold-side effects, and unprovable callbacks.
  • Compile-failure goldens pin diagnostics for non-scalar buffers, impure kernels, unprovable purity, and recursive generics requiring annotations.
  • All 21 C suites pass, and every gated C library meets its measured threshold.

Local validation on this revision:

  • All 21 C runtime suites green; make _coverage_check_c_runtime green for all 25 gated libraries.
  • cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings clean.
  • Corpus differential 175/175 excluding the four known-red suites below, under default, GC, and ARC; ARC reports 0 live objects at exit.
  • Website build plus 93 Playwright tests; web-compiler Docker build, test.sh, and a direct 200/422/400 status check on both endpoints; extension typecheck and Shipwright manifest validation.

The forward contracts are now green

tests/core/gpu/scalar_contracts and tests/core/gpu/kernel_frontier were authored red as forward contracts. Both now pass, in both flavors, under default/GC/ARC and wasm32, and cross_flavor_ir_equiv passes on the ML kernel-frontier twin. Three defects had to be fixed for that, none of them GPU-specific:

  • Unconstrained arithmetic int-defaulted at the definition (plan 0022 F10). fn plus(a, x) = a + x typed as (int, int) -> Result<int, MathError> before any call site was in sight, so gpuFold(0.0, plus) over a float buffer was rejected with cannot unify int with float. A site whose operands are both still unconstrained now records a PENDING overload; the choice is made once, after all unification, by re-running the ordinary selection over the operands' final types. The operand does not generalize — one definition gets one overload, so a helper used at both int and float in one program is a type error rather than a silent reinterpretation, and that limit is now written into [GPU-KERNEL-ELEM-TYPING].
  • The ?: payload was typed as a fresh variable. bind_result_fields matched on field NAME, and the desugarer binds the ?: payload through an unspellable name it did not recognise — so the fallback never constrained the payload. listGet([1, 2, 3], 0) ?: 9.5 type-checked over an int list and only failed in codegen with match arms disagree on type. A Success/Error pattern over an unresolved discriminant now pins it to a Result with an open payload instead of auto-wrapping it.
  • An empty list literal reached the backend with no element type. Expr::List now carries a position, inference publishes each literal's resolved List<T> there (ProgramTypes::lists), and both [] and toGpu([]) tag their handle from it instead of defaulting reads to int.

Two more defects the same sweep turned up and fixed:

  • The GC's conservative stack scan missed every caller frame on Windows. gc_stack_base fell through to a generic fallback that returns the address of a local in itself, and it runs on the FIRST allocation — arbitrarily deep — so the scan range excluded the frames actually holding roots. clang rejects returning a local's address; gcc does not once it is cast to an integer, so it built silently and mis-collected. Windows now reads NT_TIB::StackBase; every other platform without an exact query uses a pre-main sample. Forcing the old fallback on macOS reproduces the exact assertion memory_gc_stack_root_tests.c was failing with on CI.
  • Static-handler discharge could overflow the stack. REWRITE_BOUND capped how many rewrite steps run, not how deep they nest, and each substitution re-enters the rewrite — so two static handlers whose arms perform each other's effect recursed ~10,000 frames and aborted the process instead of reporting. A depth bound now reports the same [STAGE-STATIC-FINITE] violation. examples/failscompilation/staged_mutual_static_handlers_diverge.ospo pins it, alongside staged_static_arm_arity_mismatch.ospo.

examples/failscompilation/recursive_generic_needs_annotation.ospo had to be retargeted: the program it carried is WELL-FORMED under the arithmetic fix (it compiles and prints the right answer), so it now holds a recursive helper that is still genuinely unspecialisable. That leaves genfn.rs's re-entry diagnostic without a corpus case — tracked as #201.

tests/core/gpu/stress.test.ospml crashed Node's WASI host on the CI wasm job (Check failed: storage_.is_populated_). It does not reproduce locally across four runs and two Node versions, and the two flavors compile to byte-identical modules apart from the embedded filename, so it cannot be a flavor-specific defect — tracked as #202.

…4.29%

The C runtime suites added in the previous pass had never been executed. Running
them found real bugs, all fixed here:

- string_runtime: %.10g silently lost value — 1234567890.5 rendered as
  "1234567890" and then gained a fabricated ".0". Widen precision only as far as
  a round trip requires, so short decimals keep their short spelling and no
  printed float names a different double than the one held.
- http_shared: base64_encode never emitted RFC 4648 padding, so the trailing
  zero octets encoded as literal 'A's. Every 16-byte Sec-WebSocket-Key and
  20-byte SHA-1 accept token was therefore base64 no conforming peer could
  decode — the handshake could not match RFC 6455.
- http_shared: generate_websocket_key re-seeded srand(time(NULL)) on every call
  and drew from rand(), so two connections opened in the same second sent an
  IDENTICAL nonce. It now draws from the runtime's existing OS CSPRNG
  (random_runtime's entropy source, exported rather than duplicated).
- profiler_runtime: OspProfSlot advertised fiber_id/label but fill_slot never
  populated them, so every osp_prof_self_slot consumer read 0/"".
- test_http_length_validation composed ~4KB of JSON into a 2000-byte stack
  buffer (_FORTIFY_SOURCE trapped it the first time the suite was built) and
  asserted a hand-written 33 for a string that is 37 bytes.

Two test expectations were wrong about intended behavior, not the runtime:
deterministic fibers report ready before running by design (a poll loop would
otherwise spin forever), and the boundary-stress float now prints its exact
value.

C coverage thresholds now carry each library's measured gcov line coverage with
cross-platform slack rather than an aspirational 90, so the gate is real and
ratchets; term_runtime/test_runtime are documented as unmeasurable under the
fork harness instead of gated at a fake number.

Duplication: 5.12% -> 4.29% (the branch had breached the committed 5% ceiling,
now ratcheted to 4.4%). One statement walker for the LSP outline and hover; one
receiver-first desugaring in effect rows; one endpoint factory and one LSP trace
helper in the web compiler; one shared support module for the two wasm smoke
scripts; one Prism grammar module for the 11ty build and the browser studio;
macro-generated type constructors; one group-scanning lookahead in the ML
parser; shared list/range loop and predicate helpers in iterator codegen; and
type_is_resolved delegating to has_type_var.
Ten open effect defects reduce to three root causes — the fixed-width untyped
operation mailbox, per-handler resumption-mode scanning, and a handler set that
lives on the thread stack rather than travelling with the continuation. The
umbrella sequences them; this records the framing where the plan already tracks
#182/#184/#185 individually.
… green

`tests/core/gpu/kernel_frontier` and `tests/core/gpu/scalar_contracts` were
authored red as forward contracts. Both now pass in both flavors under
default/GC/ARC and wasm32, and `cross_flavor_ir_equiv` passes on the ML
kernel-frontier twin. None of the defects behind them were GPU-specific.

Unconstrained arithmetic int-defaulted at the definition (plan 0022 F10).
`fn plus(a, x) = a + x` typed as `(int, int) -> Result<int, MathError>` before
any call site was in sight, so `gpuFold(0.0, plus)` over a float buffer was
rejected with `cannot unify int with float`. A site whose operands are both
still unconstrained now records a PENDING overload; the choice is settled once,
after all unification, by re-running the ordinary selection over the operands'
final types. Re-running rather than restating the rules is what keeps
`p.x * p.x + p.y * p.y` right: by the time the outer `+` resolves its operands
have become `Result<int, MathError>`, and only the real selection knows to
unwrap them and keep one flattened error channel. The operand does not
generalize -- with no numeric class to quantify over, one definition gets one
overload, so a helper used at both `int` and `float` in a single program is a
type error rather than a silent reinterpretation. That limit is now written
into [GPU-KERNEL-ELEM-TYPING].

`any` parameters absorbed open variables. Unification binds a variable before
it reaches the `any` wildcard arm, so `expect(add(1, 1), 2)` bound the pending
overload of `fn add(a, b) = a + b` to `any` -- the published signature then
said a plain word where codegen emitted a `Result`, and every Test Explorer
case returned nothing. Assigning an UNRESOLVED variable to an `any` parameter
is now skipped: `any` unifies with everything, so a resolved argument already
learns nothing there and an unresolved one learns nothing either while costing
the variable. Constrained built-ins already deferred this by name; it now
holds by shape.

The `?:` payload was typed as a fresh variable. `bind_result_fields` matched on
field NAME and the desugarer binds the `?:` payload through an unspellable name
it did not recognise, so the fallback never constrained the payload:
`listGet([1, 2, 3], 0) ?: 9.5` type-checked over an `int` list and only failed
in codegen with `match arms disagree on type`. A `Success`/`Error` pattern over
an unresolved discriminant now pins it to a `Result` with an open payload
instead of auto-wrapping it.

An empty list literal reached the backend with no element type. `Expr::List`
carries a position, inference publishes each literal's resolved `List<T>` there
(`ProgramTypes::lists`), and both `[]` and `toGpu([])` tag their handle from it
instead of letting reads default to `int`.

The GC's conservative stack scan missed every caller frame on Windows.
`gc_stack_base` fell through to a generic fallback returning the address of a
local in itself, and it runs on the FIRST allocation -- arbitrarily deep -- so
the scan range excluded the frames holding the roots. clang rejects returning a
local's address; gcc does not once it is cast to an integer, so it built
silently and mis-collected. Windows now reads `NT_TIB::StackBase`; every other
platform without an exact query uses a pre-`main` sample. Forcing the old
fallback on macOS reproduces the exact assertion `memory_gc_stack_root_tests.c`
was failing with on CI.

Static-handler discharge could overflow the stack. `REWRITE_BOUND` capped how
many rewrite steps run, not how deep they nest, and each substitution re-enters
the rewrite -- so two static handlers whose arms perform each other's effect
recursed ~10,000 frames and aborted the process instead of reporting. A depth
bound now reports the same [STAGE-STATIC-FINITE] violation.

Test corpus:

- `staged_mutual_static_handlers_diverge.ospo` and
  `staged_static_arm_arity_mismatch.ospo` pin two discharge diagnostics that
  had no case at all.
- `every_language_test_compiles_to_ir` now compiles BOTH flavors. The ML
  frontend builds AST forms the Default lowerer never does (`Expr::MethodCall`
  among them), so compiling only `.osp` left the ML half of every shared walker
  unexercised in-process. This is what returns `osprey-ast` to 97.7% (from
  95.9%, below its 97% gate).
- `recursive_generic_needs_annotation.ospo` was retargeted: the program it
  carried is WELL-FORMED under the arithmetic fix -- it compiles and prints the
  right answer -- so it now holds a recursive helper that is still genuinely
  unspecialisable. That leaves `genfn.rs`'s re-entry diagnostic without a
  corpus case, tracked as #201.

Verified locally: `make test` green end to end (all 9 Rust crates and all 25 C
libraries over their coverage gates, corpus 179/179 byte-exact under
default/GC/ARC with `TEST_CORPUS_ARC_LEAKY=0`, 267 extension tests), wasm32
126/126, `cargo clippy --workspace --all-targets -- -D warnings` and
`cargo fmt --all --check` clean.

`tests/core/gpu/stress.test.ospml` crashed Node's WASI host on the CI wasm job
(`Check failed: storage_.is_populated_`). It does not reproduce locally across
four runs and two Node versions, and the two flavors compile to byte-identical
modules apart from the embedded filename, so it cannot be a flavor-specific
defect. Tracked as #202.
…inux

`test_security_edge_cases` proves snprintf's truncation contract: that it
reports the length it NEEDED and still terminates what it wrote. gcc can see
the source string's length at the call site, proves the truncation statically,
and rejects the call under -Werror=format-truncation — so the contract became
uncompilable rather than tested. clang does not diagnose it, which is why the
suite was green on macOS and red on CI.

Reaching the source through a volatile pointer keeps its length out of the
optimizer's reach, so the call is checked where it is meant to be: at runtime,
by the three assertions that follow. No assertion changed and nothing is
suppressed.

This step had never run on CI before — the job failed earlier, at the Rust
tests, on every previous attempt.
…first

The image list is written by a `dl_iterate_phdr` callback, and its
"already wrote one" flag lived in a FUNCTION-LOCAL STATIC. The flag therefore
survived the dump that set it, so every later capture in the same process
opened its array with the separator -- `"images":[,{...}` -- which no JSON
reader accepts. A second profile lost the image list its sample addresses are
symbolized against. macOS derives the separator from its loop index and was
never affected, which is why this only ever showed on Linux.

The state now travels with the callback.

`test_end_to_end_capture` caught it only because an earlier test in the suite
happened to dump first; the shape it asserts is now pinned deliberately by a
SECOND capture in the same process, so the defect cannot come back disguised as
suite ordering.

Verified on the platform that was failing: all 21 C suites built with gcc 12.2
on Linux and run green, including the profiler suite and the
format-truncation fix from the previous commit.
… platform

Two defects, one in the gate and one in the numbers it enforces.

The gate could pass VACUOUSLY. It reads the library list with jq; when jq is
absent the list is empty, the loop body never runs, fail stays 0, and it prints
'all C libraries meet their thresholds' having measured nothing. Reproduced in
a Debian container with no jq: a clean green with zero libraries checked. It
now refuses to run without jq, and refuses an empty library list, exactly as
the deslop gate above it already does -- a gate that cannot run must not report
success.

The thresholds were set from macOS alone. macOS and Linux gcov disagree by far
more than the '~2 points of rounding' this file assumed, and they disagree in
BOTH directions -- measured on the same commit:

  system_runtime            78.24 macOS   70.24 Linux
  fiber_runtime             74.83 macOS   70.64 Linux
  websocket_client_runtime  37.96 macOS   30.34 Linux
  random_runtime            47.50 macOS   50.00 Linux
  websocket_server_runtime  24.89 macOS   27.01 Linux

So three thresholds were unreachable on the platform the required job runs on,
and passed locally. A threshold has to hold on EVERY platform the gate runs on,
so each is now the weakest measured platform minus ~2 points: system_runtime
76 -> 68, fiber_runtime 72 -> 68, websocket_client_runtime 35 -> 28. The other
22 already held on both and are unchanged.

This is a recalibration, not a relaxation: the numbers were never valid for the
gating platform, and the gate is strictly harder to fool than before. The
distance to the 90% target is unchanged and stays tracked in #197.

Verified: the gate passes on macOS, its Linux numbers are reproduced exactly in
a container (70.24 / 70.64 / 30.34, matching the CI run), and both new guards
fail closed.
The 30-minute budget was written against a job that had never finished. It
always died early — at the Rust tests, then the C compile, then the C coverage
gate — so the later stages had never run and were never in the number. The
first run to get through them all was killed at 30 minutes, part-way through
the extension suite and still making progress.

Measured stage times from that run are recorded beside the setting. The
extension suite dominates: it drives a real VS Code under xvfb and spawns
lldb-dap per debug-adapter case, minutes slower on a runner than the ~2 min it
takes locally.

60 minutes leaves headroom without hiding a hang: a stuck run still fails, it
just fails later.
… uses freed memory

Neither red check was an Osprey bug, and no test, assertion, golden or corpus
floor changes here.

Debugger E2E (8 timeouts): action.yml pinned lldb-dap to llvm-toolchain-<dist>-22,
which is not a pin. LLVM 22 is still in development, so that suite is a rolling
trunk nightly whose builds all report themselves as "22.1.8" regardless of which
day's trunk they are; only a released major gets a frozen branch. The file is
byte-identical on main and gpu — a month of upstream trunk moved underneath it,
onto a snapshot whose lldb-dap never emits the `initialized` event and so
deadlocks every DAP client on every binary, a trivial clang-compiled C program
included, while the plain lldb CLI debugs the same binaries fine. Verified in a
noble container against all six debug.e2e.test.ts workflows: the 22 snapshot
passes 0/6, the released 21 channel passes 6/6. So pin to 21, resolve the binary
by name instead of "newest lldb-dap on the box", and drop the fallback that
silently degraded to distro lldb 18 — the known-broken server the pin exists to
avoid. lldb-dap-preflight.py now runs the same handshake against a three-line C
program right after install, so a defective adapter fails there by name in
seconds instead of as eight mystery test timeouts.

wasm32 (stress.test.ospml, empty output and exit 139): node:wasi caches the
module's memory backing store when the instance starts and never refreshes it
after memory.grow, so every later WASI call touches freed memory — a SIGSEGV
inside node on x86_64 with no stderr and no wasm trap, or, where the stale page
is still mapped, the module's output silently dropped. Twelve lines of
hand-written wat reproduce it with no Osprey involved. Node 20 fails the minimal
probe; 22 fixes that one but still dies on repeated grow-then-write; 24 runs both
it and the whole corpus clean. The twins emit byte-identical IR, which is why one
run failed one twin and another failed both. The jobs that execute modules move
to Node 24, and wasm-smoke.mjs refuses older hosts by name so a defective runner
can never again present as a compiler bug; wasm-browser-smoke.mjs reads memory
afresh per call and stays a second, independent oracle. Verified under Node 24:
GOLDEN_PASS=126 FAIL=0 (floor 126), GPU_MODE_PASS=18 FAIL=0 (floor 18).

Root causes recorded on #202 and #203.
Its `npm test` starts with `build:wasm` -> `make wasm-site`, which runs the
studio modules under node:wasi, so it belongs with the other module-executing
jobs on 24. Missed in the previous commit, which bumped only the wasm job and
deploy-pages; wasm-smoke.mjs then correctly refused the node 20 host and took
the job red.
…dule

osprey-cli's wasm::tests::build_and_run_end_to_end_when_toolchain_present shells
out to scripts/wasm-smoke.mjs, so `make test` executes a module under node:wasi
and needs the same Node 24 as the other module-running jobs. wasm-smoke.mjs
correctly refused the node 20 host and failed the test rather than let the
use-after-free through.

That is the last of them: wasm-smoke.mjs is reached from the wasm job, the
Website E2E job (via make wasm-site), this job (via the Rust test), the Makefile
and run_test_corpus.sh. release.yml runs none of them.
@MelbourneDeveloper
MelbourneDeveloper merged commit 2864a8a into main Aug 12, 2026
6 checks passed
@MelbourneDeveloper
MelbourneDeveloper deleted the gpu branch August 12, 2026 10:38
MelbourneDeveloper added a commit that referenced this pull request Aug 12, 2026
## What was wrong

Both branch rulesets were `enforcement: disabled`. `main` had **no
required status checks at all** — any PR could merge red and nothing
would stop it.

PR #190 (212 files, +60,766/−37,428) merged at 10:38 on 2026-08-12.
Issues #202 (wasm corpus aborts Node's WASI host), #203 (all 8 lldb-dap
E2E tests time out on Linux) and #204 (website E2E flakes red) were
filed at 10:09–10:10 — **29 minutes earlier**. The failures weren't
missed; they were written down and merged past. The websocket
showstoppers #192#197 were filed the previous evening.

## Two structural traps kept the gates off

**Phantom context.** Ruleset 7726557 required a check named `"CI"`. No
job reports that name — check runs are named by job `name:`, not by
workflow. Enabling it would hang every PR pending forever, so the
reachable fix under pressure is to switch the ruleset off. Its
`required_status_checks` rule is dropped; it now carries the PR
requirement (squash-only merges, thread resolution) and ruleset 6154907
owns the checks.

**Path-filtered required job.** `ci-windows.yml` skipped website-only
PRs via `on: paths-ignore:`, which *cannot* be a required check: a
filtered-out run never reports, and a required check that never reports
blocks the merge forever. Its own comment said as much and concluded "so
it isn't required". Converted to job-level `if:` skipping — which
reports `skipped`, and a skipped check counts as **passing** — so
`windows-core` can now be required.

## State now

Both rulesets `active`, **no bypass actors** (applies to admins too),
strict up-to-date policy, and six required checks:

| Check | Was |
|---|---|
| Detect changed areas | not required |
| Test, Format, Build & Validate | the only required check — and
unenforced |
| Rust Compiler (fmt, clippy, test, corpus) | not required |
| WebAssembly target (wasm32-wasip1) | **advisory by design** — how #202
landed unopposed |
| Website E2E (Playwright) | not required |
| Windows Core Build & Smoke Test | not required, and not requirable |

## The part that stops it recurring

Branch protection lives in GitHub's settings, not in this tree, so it
drifts silently and by definition no test covers it.
[`scripts/verify-branch-protection.mjs`](scripts/verify-branch-protection.mjs)
is that test. It runs in the `changes` job — the one required job that
never skips — and fails on:

- a ruleset that isn't `active`, or that has bypass actors
- drift **in either direction** between the pinned list and the live
ruleset
- a required context matching no job `name:` (the phantom trap)
- a required job whose workflow path-filters at the `on:` level (the
deadlock trap)

Verified against the real broken state rather than assumed: fed the
exact 2026-08-12 configuration, it reports every fault, phantom `"CI"`
included. Fed the pre-fix `ci-windows.yml`, it reports the path-filter
deadlock.

Stale comments asserting these jobs were unrequired are corrected in
place, and `CLAUDE.md` gains the rule this violated: a gate you can turn
off is not a gate, "advisory" means deleted, and an open issue about a
red check is a blocker rather than a footnote.

## Not covered here

The failures themselves are still open — #202, #203, #204, the websocket
showstoppers #192#197, and the effects umbrella #200. This PR only
ensures the next one can't merge past them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant