Skip to content

Fix 30 review findings: a WSGI header spoof, a SQLite crash after close, and 28 more - #615

Merged
github-actions[bot] merged 13 commits into
mainfrom
quality/review-2026-10-10
Oct 11, 2026
Merged

github-actions[bot] merged 13 commits into
mainfrom
quality/review-2026-10-10

Conversation

@codetalcott

Copy link
Copy Markdown
Owner

This fixes the 30 findings of the 2026-10-10 review of m0-wsgi, m0-sqlite, m0-postgres, the m0 wheel (CLI, templates, scripts) and m0-core. The review was read-only, at b21ba9a3. Each finding was checked against the tree before it counted. The two serious ones are:

  • W1, security. A request header named with _ reached the WSGI environ under the same key as its dashed spelling. So a client's X_Forwarded_For, or X_Remote_User, could overwrite or forge what the proxy set. This is the CVE-2015-0219 class.
  • S1, crash. m0-sqlite passed a NULL handle to SQLite after close() or finalize(). Debian 12's and Ubuntu 24.04's libsqlite3 are built without SQLITE_ENABLE_API_ARMOR and segfault there; I checked both in containers, and both exit 139. Apple's library answered wrongly instead: in_transaction() said True on a closed connection.

m0-datastar had no findings.

What changed, by finding

Id Fix The test that fails without it
W1 header_is_excluded drops any header name containing _, as gunicorn has by default since 22.0. ASGI's executor still passes raw names. The buffered ASGI path (--blocking-threads) builds its scope from the environ, so it now drops them too. It had the same spoof. test_environ (G29); a smoke-wsgi wire assertion
W2 state["m0"].publish numbers frames from 1, as m0pub, pg-listen, WSHub and the host's Publisher do. The shim is re-rendered. smoke-asgi-fanout: the first id is 1, and m0pub + state["m0"] take distinct ids
W3 Tests for the journal's reads of the shared counter: the floor for a late worker, and _event_head. test_hold_resume.mojo (I33)
W4 --replay-frames / M0_REPLAY_FRAMES above 4096 is refused with 78 through m0_wsgi.checks, so --doctor agrees. test_checks; smoke-doctor rows (I43)
W5 A WebSocket hold clamps a stale Last-Event-ID to the head, as SSE holds already did. two handler tests (I12)
W6 --max-body / M0_MAX_BODY refuses a size whose product overflows. That is exit 2 for the flag; the variable lands in env_ignored. test_cli (C1)
W7 --doctor reports replay_frames: 0 without --realtime. smoke-doctor
S1 A closed Connection's accessors answer False or 0 without calling SQLite, and busy_timeout/register_array_module raise. A finalized Statement's clear_bindings/column_name raise. test_sqlite, test_file (O29): False on the Mac, a segfault on Linux
S2 notifies() drains what libpq already parsed before it raises on a lost connection. test_notify (O15), with pg_terminate_backend mid-burst
S3 reset() re-LISTENs every channel and raises once, naming the ones that failed. It no longer forgets the channels it had not reached. test_notify (O15), with a channel that cannot be re-LISTENed after a UTF8 reconnect
S4 execute() and the constructors refuse an embedded NUL before calling C (rc=21). test_sqlite (O30)
S5 prune_stamps rolls back only inside a transaction, and keeps the real error. test_stamps (O26), via RAISE(ROLLBACK)
S6, S7 Two m0-postgres docstrings are corrected. none (docs)
S8 Result.int in text mode refuses an overflow and still parses Int.MIN. test_wire (O31)
P1 The board template's frame always fits the 64 KB per-slot outbox. It renders the newest messages that fit, with escaping and per-line SSE framing counted. two test_board tests: one with quotes, one with line breaks
P2 The views and auth templates run their state routes on_loop=True, so one list serves every pool thread. MOJO_VIEWS.md and the scaffold's AGENTS.md now teach that. a scaffold wire phase under --blocking-threads 2; sabotage rules
P3 A signal death under m0 doctor is exit 1, inside the closed set 0/1/2/78. test_m0.py
P4 m0 new refuses a project named for a package the scaffold pins (m0, mojo, max-core, mojo-compiler). test_m0.py
P5 m0 dev runs the toolchain preflight before each rebuild. test_m0.py
P6 relocate.py and bundle_artifact.py fail on a failed -add_rpath or codesign, and check that every wanted rpath arrived. test_m0.py
P7 build --release on Linux checks for patchelf before it compiles. m0 doctor does not ask for it. test_m0.py
P8 m0 new . accepts a directory holding only .git. test_m0.py
P9 m0 image tags with [project].name. test_m0.py
P10, P11 The scaffold .gitignore covers its leftovers, and three template smokes bind 127.0.0.1. scaffold smoke
P12 parse_json_int answers None outside Int's range, and both ends still parse. test_json_parse
P13 The JSON escape and string reader state their UTF-8 precondition. This is docs only, by decision. none (docs)
P14 parse_json_bool needs a delimiter after the literal, so trueish gives None. test_json_parse
P15 The crypto docstrings name today's callers. none (docs)

Every behaviour fix carries a test that was shown to fail with the fix reverted.

The owner's decisions

(a) W1 drops the header rather than answering 400. (b) W4 caps at 4096 through the checks list. (c) P13 is documentation only. (d) S1 gives neutral answers from accessors, and raises from methods that already raise. (e) This PR carries the automerge label.

Choices made along the way

  • The buffered ASGI path inherits W1's drop.
  • The replay cap applies with or without --realtime. It sits after the usage refusals, so a configuration that cannot be read still exits 2 first.
  • m0 doctor does not ask for patchelf; only build --release does. e9d832c's message says the doctor asks for it. 3b751bf reversed that, and 0019473 corrected the prose.
  • S1 also covers the statement handle. clear_bindings and column_name were the only statement entry points whose NULL check is armor-only.

How it was checked

  • Each task was reviewed on its own, and then the whole branch was reviewed. The branch review's fixes are b0fa398 and 0b3a600, re-reviewed clean.
  • Locally, on macOS arm64, at 1dbaa69: build-all + test-all exited 0 in 677 s, with every suite at 0 failed. The warning ratchet holds at 0 against a baseline of 0.
  • After the branch fixes: check-docs passes every part, and the gates for every touched area pass:
    • test-wsgi, smoke-doctor, test-postgres, and test-postgres-server against PostgreSQL 17;
    • check-templates, smoke-m0-wheel, test-core.
  • Also run locally: smoke-wsgi, smoke-asgi-fanout, smoke-django-realtime, test-shim, test-sqlite, check-sqlite-function, sabotage-sqlite-function, sabotage-vtab, and smoke-scaffold.
  • The pre-release harnesses that anchor edited lines were re-run:
    • sabotage-scaffold --skip image:: 71 rules caught. The new board rule was rewritten and is caught on its own.
    • sabotage-m0-wheel: 26 of 26 caught.
  • smoke-pg-notify (all four arms) passes against this branch's bin/m0serve.
  • Not run here: Linux. CI's matrix covers it, and its Ubuntu 24.04 leg is what exercises S1's crash path.

Left for later

  • At release: the 1.15.0 summary should say that the --replay-frames cap (and arguably W1) narrows what a flag or variable accepts, as 1.13.0 did for the port. The ### Fixed entries also want grouping by component.
  • Follow-ups the branch review judged safe to defer:
    • an ASGI wire assertion for raw versus dropped underscore names;
    • a multibyte offset test for first_nul;
    • a PG14+ note on the S2 test;
    • a few wording and ordering nits in checks.py, dev.py, MOJO_HOST.md and lib.mojo.
  • Three doc nits from the final re-review:
    • SPEC I43 says the cap follows every usage rule. workers-vs-parallel-runtime, which applies only in a binary that links MAX, still comes after it.
    • MAX_REPLAY_FRAMES's docstring lacks the "only under --realtime" qualifier.
    • N52 does not cite the new line-break board test.

🤖 Generated with Claude Code

codetalcott and others added 13 commits October 10, 2026 17:23
…5-0219)

CGI's mapping turns `-` into `_` and leaves `_` alone, so
`X_Forwarded_For` and `X-Forwarded-For` are two headers in the request
and one key in the environ, HTTP_X_FORWARDED_FOR, where build_environ's
later PyDict_SetItem wins. Behind a proxy that sets the dashed header
and forwards underscore names, a client sending the underscore spelling
after it replaced the address, scheme or identity header the proxy set.

header_is_excluded now drops any name holding `_` beside `Proxy`, as
gunicorn's header_map does by default since 22.0 and Django's runserver
does. Dashed names pass, X-Forwarded-* included. build_environ is the
one environ builder (every WSGI path, inline, pooled, threaded and the
synthetic /ws/message POST, reaches it through WSGIApp.serve and
PyBridge.run). The ASGI executor's scope (_build_scope, _py_headers) is
untouched and still carries every name raw; the buffered ASGI path
(--blocking-threads on an ASGI app) builds its scope from the environ,
so it drops them too, where it used to hand the app the collapsed
spoofed value under the dashed name.

test_environ.mojo:test_underscore_header_is_excluded_from_the_environ
(SPEC G29) walks a real header map as build_environ does; with the fix
reverted it fails on the predicate, and with that assert skipped the
environ holds 6.6.6.6 for 203.0.113.7. smoke-wsgi sends both spellings,
the underscore one last, to bin/m0serve: with the fix reverted it fails
with HTTP_X_FORWARDED_FOR='6.6.6.6'. AGENTS.md, RUNNING.md and the
changelog say the same.

Review finding W1 (2026-10-10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…head, the counter's reads are gated

The shim's state["m0"].publish took m0_shared_fetch_add's previous
value as its id, where m0pub, pg_listen, ws.mojo and the Mojo host all
take that value plus one. On one counter the two Python publishers
handed out the same id (m0pub 1, then the shim 1), and a hold that had
the first frame dropped the second with nothing logged; a fresh
server's first ASGI publish was 0, which no first-visit subscriber
receives. The lambda now adds 1. smoke-asgi-fanout's probe requires a
fresh server's first publish to be id 1 and an m0pub publish (a new
/publish-m0pub route on the feed app) followed by a state["m0"] one to
take two ids; against the unfixed shim the first fails with id=0 and,
skipped, the second with [3, 3] across two workers. SPEC L12 says so.

A WebSocket hold subscribed at the Last-Event-ID it presented, inline
and from a pool thread's H frame alike, so an id ahead of the counter
suppressed every frame until the counter passed it, where an SSE hold
has been clamped since I33. Both now subscribe at _socket_last_id, the
id clamped to _event_head(); nothing is replayed, and the comment that
said one rule covered both transports says what is true. SPEC I12.

The handler's two reads of the shared counter, the late worker's floor
and _event_head, had no gate that would fail without them.
test_hold_resume.mojo builds a --realtime WSGIHandler (an application
registered in sys.modules) over a SharedAtomics page named by
M0_SHARED_ID_ADDR: a resume at 30 under a counter at 40 is an m0-gap,
one at 40 is not, and one at 43 with the counter moved to 45 is
neither clamped nor a gap (covers I33). Two more tests hold the socket
clamp, one per way a socket hold lands (covers I12). Each was proven
against its fix reverted: the floor read removed, no gap frame; the
counter read removed from _event_head, a gap at 43; either socket
site unclamped, its test subscribed at 999999999.

--doctor reported replay_frames as the flag's value without
--realtime, where the handler keeps no journal; it reports 0 there now,
and smoke-doctor holds both (it read 7 for 0 with the fix reverted).

Review findings W2, W5, W3, W7 (2026-10-10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… capped at 4096 (W6, W4)

parse_size multiplied before checking, so 17179869184g wrapped to a 0-byte
cap and 9999999999g to a negative that left the default standing unsaid. It
now refuses digits > Int.MAX // multiplier: exit 2 as a flag, env_ignored as
M0_MAX_BODY.

A replay journal of any depth was accepted, though each loop keeps N frames
of up to 64 KB and a full journal shifts its lists per publish. A depth over
4096 is now a 78 from m0_wsgi.checks (rule replay-frames, SPEC I43), so the
server and --doctor agree, with or without --realtime, flag or env. Usage
text and RUNNING.md state the cap and the per-loop memory.

Findings W4 and W6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… before C, a prune raises its own error (S1, S4, S5)

close() leaves the handle 0, and in_transaction, last_insert_rowid,
changes, total_changes, busy_timeout and register_array_module passed it
to entry points that check for NULL only under SQLITE_ENABLE_API_ARMOR:
Debian 12's libsqlite3 (deploy/mojo's base) dereferences it, SIGSEGV;
Apple's answers 0, so in_transaction() read True after a close, and
register_array_module leaked its module buffer. The four accessors now
answer False/0 without calling SQLite; the two that change the
connection raise "... on a closed connection" as create_function does
(SPEC O29). Every other FFI call taking the db handle is guarded or
reaches sqlite3_exec / sqlite3LockAndPrepare, which check it whatever
the build; vtab.mojo's comment on create_module_v2's destructor names
the armor's MISUSE as the one refusal that skips it.

execute() and the Connection constructors refuse a NUL in the SQL or the
path before the call, ending (rc=21): sqlite3_exec and sqlite3_open_v2
read C strings, so "DELETE FROM t\0 WHERE id = 5" deleted every row with
rc 0 (checked against Apple's library) and a path lost its suffix. The
byte scan O21's name refusal used moved to ffi.mojo as first_nul (a
String.find, 36x faster than the loop at 4 MB), shared by all three
sites; O21's message is unchanged and sabotage-sqlite-function's anchor
for it is re-pointed (SPEC O30).

prune_stamps rolls back only if db.in_transaction(), and always raises
the error that stopped it: where SQLite had cancelled the transaction,
its ROLLBACK raised "no transaction is active" (rc=1) in that error's
place. A capped max_page_count cannot make the DELETE fail (it frees
pages and needs none: measured at 10, 1000 and 20000 tombstones with
max_page_count at the page count), so the test uses a trigger's
RAISE(ROLLBACK), which cancels the transaction on every SQLite (O26).

Each test fails with its fix reverted, on macOS: the closed-connection
test at in_transaction (True), and at busy_timeout's and
register_array_module's messages with those guards reverted alone; the
three other accessors' guards show only on an unarmored library, where
the call dies. The NUL tests at error_code (-1, not 21), the unfixed
open having created the path's head; the prune test at error_code (1,
not 19).

Findings S1, S4 and S5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
finalize() leaves a Statement's handle 0, and clear_bindings() passed it
to sqlite3_clear_bindings, which checks for NULL only under
SQLITE_ENABLE_API_ARMOR and otherwise reads p->db->mutex (3.53.4's
source): the same crash as S1's, for a statement. Armored, Homebrew's
3.53.4 answers SQLITE_MISUSE and Apple's 3.54.0 SQLITE_OK, so on macOS
the call silently did nothing. sqlite3_column_name is armor-only too;
column_name() could not reach it after finalize, since _check_column
reads a column count of 0 for NULL first, but no longer leans on that.
Both now raise "... on a finalized statement", as Connection's guards do.

The rest of the statement surface refuses NULL whatever the build, per
the amalgamation: step and every binder (vdbeSafetyNotNull, MISUSE;
bind_pointer and bindText run their destructor on the refusal), reset
and finalize (no-ops), column_count (0), the other column readers
(columnMem, columnMallocFailure), sqlite3_db_handle. No signature
changed.

test_sqlite.mojo:test_a_finalized_statement_is_never_passed_to_sqlite
(O29) calls both guarded methods after finalize() and pins what the
unguarded ones answer. Reverted on macOS: the clear_bindings guard fails
the test (no error raised, Apple answering OK), the column_name guard
fails it by its message (the column count's out-of-range refusal); a
crash shows only on a library without the armor. O29, the CHANGELOG's
S1 bullet (rewrapped) and README's counts follow.

Finding S1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…failed reset forgets no channel, a text integer never wraps (S2, S3, S8)

notifies() ran PQconsumeInput before PQnotifies and raised when the
consume failed. A burst read in one go and then the server's close left
the rest in libpq's queue while the next call's read found the close,
so it raised over them, and reset(), the recovery, frees the queue
(pqDropServerData): all but the first were lost, unlogged. It now asks
PQnotifies first, consumes only when the queue is empty, asks again
(pqReadData keeps what it read on failure), and raises only when
nothing is queued. A None still follows a read of the socket, so the
docstring's "every notification ... until it answers None" holds, and
pg_listen.mojo's loop (drain until None, reset on a raise) publishes
the whole burst before it reconnects.

reset() emptied _listening before re-listening, so a re-LISTEN that
raised left only the channels restored before it, and every later
reset restored fewer. It now walks a copy and never empties the list;
listen() already records a channel once.

Result.int's text parse wrapped past Int64 (2^64 read as 0). It now
checks each digit against the limit before taking it, the negative
limit one larger, so Int.MIN parses (SPEC O31, a new row; pure test).

S6, S7: lib.mojo's opening paragraph said m0-sqlite links SQLite, false
since O17/D49; notifies() claimed a header-derived PGnotify layout check
that does not exist, and now says no gate compares the offsets.

Tests, each failing with its fix reverted (macOS, PostgreSQL 17.9):
- test_notify.mojo:test_queued_notifications_are_delivered_before_a_lost_connection
  sends two NOTIFYs from a second connection, lets a statement read them
  into libpq's queue, terminates the listener's backend (waiting for the
  process), and drains with a poll before every call. Unfixed it drains
  1 of 2 before the raise, 10 runs of 10; fixed, 2 of 2 in 13 of 13.
- test_notify.mojo:test_a_reset_whose_relisten_fails_forgets_no_channel
  makes a re-LISTEN fail for real: a channel named under LATIN1 is
  invalid UTF-8 after the reset, so PQescapeIdentifier refuses it. No
  re-LISTEN can be made to fail ONCE against a real server without a
  race (a standby, a drop between statements), so the test withdraws
  that channel, as an application would, and the next reset must
  restore the two around it. Unfixed it restores 1 of 2.
- test_wire.mojo:test_a_text_integer_past_64_bits_raises_rather_than_wrapping
  "9223372036854775808" did not raise unfixed.

O15's capability and evidence name the two notification fixes;
CHANGELOG has three Fixed entries; README's counts are 84 and 1805.

Findings S2, S3, S6, S7 and S8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one list under --blocking-threads, the scaffold ignores its leftovers, three smokes bind loopback (P1, P2, P10, P11)

P1: the board template's frame carried all fifty messages, each escaped,
and a frame over a slot's 64 KB outbox is refused for every slot, so the
poster got a 200 and no tab updated. render_board now renders the newest
messages that fit a byte budget (the outbox minus a framing margin, with
the data: prefix counted per line break). _common/AGENTS.md says a
full-state frame must fit the outbox.

P2: views and auth routes ran on the pool, where each thread builds its
own list; every route is now on_loop=True as board's are. The scaffold
smoke serves both with --blocking-threads 2 and requires a created item in
twelve later reads.

P10: the scaffold's .gitignore lists .dist.next/, smoke.one, smoke.two and
smoke.jar. P11: the views, auth and live smokes pass --host 127.0.0.1.

SPEC N27, N45 and N52 extended; three sabotage rules added and one anchor
re-pointed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it requires, m0 dev preflights each rebuild, the release build checks patchelf and fails on a lost rpath or signature, m0 new . accepts a .git, m0 image tags with the project's name (P3-P9)

Findings:
- P3: m0 doctor passed a bin/server signal death through as a negative exit
  (250 or 245 from sys.exit); it is now exit 1, "died of signal N".
- P4: m0 new refuses (exit 2) a name the scaffold's pyproject pins
  (m0, mojo, max-core, mojo-compiler), read from the template.
- P5: m0 dev re-runs the preflight before each rebuild; a refusal prints
  its sentence and the old server keeps serving.
- P6: relocate.py and bundle_artifact.py no longer ignore a failed
  -add_rpath or codesign, and require every wanted rpath after.
- P7: a patchelf check in the one list, asked by build --release and the
  doctor; named in MOJO_DEPLOY.
- P8: m0 new . ignores a .git entry in both emptiness checks.
- P9: m0 image defaults its tag to [project].name, else the directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s (P7 follow-up)

A Linux machine that only runs m0 build needs no patchelf, so the doctor
exiting 78 there was a regression. run_all skips RELEASE_ONLY checks; the
check stays in the one list for build --release. The smoke's expected
doctor list returns to six names; SPEC N25, MOJO_DEPLOY and the CHANGELOG
no longer say the doctor reports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cepts the release-only patchelf (P7 follow-up)

MOJO_HOST, packaging/m0 AGENTS.md and README, checks.py, doctor.py and
image.py docstrings, and SPEC N25, N26 and N31 (with spec.json) still
said the doctor runs every check. Wording only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l needs a delimiter after the literal, the crypto docstrings name today's callers, the JSON escape and string reader state their UTF-8 precondition (P12-P15)

P12: parse_json_int wrapped past 19 digits ({"n":18446744073709551617} read as 1).
P14: parse_json_bool read trueish and falsehood as literals.
P15: hmac.mojo and sha256.mojo named a pending login and SPEC D15; they name m0_http.session, m0_http.grant and DECISIONS D15 (retired).
P13 (owner's decision c): documentation only; no behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…play-frames cap follows the usage rules and says a journal needs --realtime, and the board template's line-break framing has a test (branch review F5, F6, M1, M8)

F5: reset() stopped at the first re-LISTEN that failed, so one channel
that could never be restored left every channel after it deaf. It now
tries every channel, collects the failures and raises once at the end
naming each. test_a_reset_whose_relisten_fails_forgets_no_channel
asserts both neighbours of the failing channel listen right after the
raise; against the old reset it got 1 notification of 2. SPEC O15 and
the CHANGELOG entry say this.

F6: test_a_board_of_line_breaks_still_reaches_an_open_stream, 50
messages of "a\n" * 750. With _framing_bytes returning 0 the frame is
refused for every slot and the drain is empty.

M8: the replay-frames rule moves after the flag-combination rules, so
--replay-frames 5000 --pg-listen URL without --realtime exits 2 again,
not 78. workers-vs-parallel-runtime stays last. The order test and a new
smoke-doctor row pin it; the old binary answers that row 78.

M1: the doctor's detail is the configured depth and says whether a
journal is kept (only under --realtime). The refusal says the cap holds
with or without --realtime. SPEC I43 and the CHANGELOG match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ss, the buffered ASGI path is named beside the header exclusion, and seven smaller statements match the tree (branch review I1, F1-F4, F7, M4-M7)

I1: MOJO_VIEWS' first table is the views scaffold as it is now, every
state route on_loop=True, and a sentence after the table says why: under
--blocking-threads each pool thread builds a state of its own. The
scaffold's AGENTS.md gives the same rule beside max_workers.
F1: m0-wsgi AGENTS.md and header_is_excluded's docstring say the
buffered ASGI path builds its scope from the environ, so the exclusion
reaches ASGI applications under --blocking-threads N>0 too.
F2: RUNNING.md gives the ASGI half in "Coming from uvicorn" and a bullet
in "In front of it".
F3: test-wsgi's help names the three files that embed CPython
(test_blocking_pool as well as the two the review named).
F4: SPEC O29's evidence adds Ubuntu 24.04, CI's Linux leg.
F7: escape_json_string_into states the UTF-8 precondition.
M4-M6: test_m0.py's docstring, the m0 QUICKSTART's doctor paragraph
(prose only; the runner extracts the same eight blocks), and the four
refused project names in the m0 README and SPEC N27.
M7: SPEC N27, N45 and N52 name the three sabotage rules 593fb48 added,
by the harness's labels. The views rule goes in N27, the row that
describes views on the wire, since N45 is auth's and N52 is board's.

Docs and docstrings only; no behaviour change, so no test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codetalcott codetalcott added the automerge Merge automatically once the Tests workflow passes label Oct 11, 2026
@github-actions
github-actions Bot merged commit 41cef33 into main Oct 11, 2026
19 checks passed
@github-actions
github-actions Bot deleted the quality/review-2026-10-10 branch October 11, 2026 03:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automerge Merge automatically once the Tests workflow passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant