Skip to content

refactor(engine): make resolution-frame addressing a type, not a grep rule - #7489

Merged
matthewevans merged 2 commits into
mainfrom
refactor/resolution-frame-slot
Aug 16, 2026
Merged

refactor(engine): make resolution-frame addressing a type, not a grep rule#7489
matthewevans merged 2 commits into
mainfrom
refactor/resolution-frame-slot

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 16, 2026

Copy link
Copy Markdown
Member

Follow-up to #7485, which flagged this as the real fix and deliberately left it
out of a bug fix.

What the grep guard was standing in for

ResolutionStack permits three ways to reach a frame: the top, a frame adjacent
to one you already hold, and the frame a PostReplacementFrameId names.
Anything else is a positional guess about a structural relationship the stack
does not guarantee — the class of bug #7485 fixed, where cleanup probed the top
two frames and returned None once the continuation had raised two.

That rule was enforced by scripts/check-resolution-frame-boundaries.sh
grepping resolution.rs for frames.iter().position(..) and
frames.remove(..). A grep was the only instrument available: frames was
private, but Rust privacy is module-scoped and the module is 7,166 lines, so
"private" bought nothing against the code sitting beside it.

The type

FrameVec (types/resolution/frame_vec.rs, ~230 lines) owns the backing Vec.
Every accessor takes an opaque FrameSlot whose field is private to that
module, so slots come only from five minting methods: top, below, above,
by_id, and slot_at_captured_depth.

Stated precisely, because the imprecise version is tempting and wrong: a
positional scan still compiles, and the only thing that will accept its usize
is slot_at_captured_depth, whose argument is contractually a stack length
recorded before a child producer ran. So positional addressing cannot be reached
by accident or by ordinary-looking code, and the one way to reach it
deliberately names itself at the call site. That is weaker than "nothing accepts
a usize" — an earlier draft of this description, and of the commit message,
said exactly that and was wrong — and stronger than a grep rule.

The removal operations (remove, swap_remove, retain, drain, truncate,
clear) have no wrapper. Their use count was already zero, so absence is free,
and a future caller has to add the method and justify it rather than quietly
reach for one that exists.

Demonstrated, not asserted

A probe method that scans for a MultiDraw frame and feeds the result to the
accessors fails to compile with exactly the two errors the design predicts:

error[E0599]: no method named `remove` found for struct `FrameVec`
error[E0308]: mismatched types
              --- ^^^^^^^ expected `FrameSlot`, found `usize`

The probe was written to be maximally plausible — a real scan feeding a real
accessor inside a real method — because one that fails for an incidental reason
proves nothing about the design. The tree was restored byte-identical
afterwards, sha256 recorded on both sides.

The conversion is mostly documentation

All 112 .frames accesses were inside impl ResolutionStack; 30 methods
carried positional use. Every index turned out to come from
len().checked_sub(N) for N in 1..=3, or from the identity lookup — the code
already obeyed the rule and merely said so in arithmetic:

-let continuation_index = self.frames.len().checked_sub(1)?;
-let discard_index = continuation_index.checked_sub(1)?;
+let continuation = self.frames.top()?;
+let discard = self.frames.below(continuation)?;

post_replacement_frame_index is gone; its search is now FrameVec::by_id, the
only place in the crate that can turn a match into an addressable position.

Two doors deliberately left open

  • slot_at_captured_depth is the single usize entry point. An effect
    records resolution_stack.len(), runs a child producer, and hands the length
    back so the owner can be parked beneath the child stack that producer raised.
    That is not a search. Closing it means giving the captured depth its own type
    at ~35 origins across game/effects/, game/casting_costs.rs,
    game/casting.rs and their neighbours, plus 12 _at_child_boundary
    wrappers — a separate invariant ("don't confuse a depth with an index") with a
    far wider blast radius. The door is named so misuse reads as misuse at the
    call site rather than hiding behind an ordinary-looking get(i).
  • frame_at_offset returns a frame and never a slot, because validate
    must traverse the whole stack to check a whole-stack invariant. The line this
    module draws is that reading is not the hazard; addressing for mutation is.

What is left of the guard

The legacy-wire-key rules are untouched. The two frame-boundary scans are gone,
replaced by one structural check that the design itself is intact: FrameSlot
must be mintable only by the five documented methods, since a sixth would reopen
positional addressing with no compiler error to show for it. The guard now
protects the design rather than policing every call site, and reads ~230 lines
instead of 7,166.

Both directions were watched go red on an out-of-tree replica, with the replica
confirmed green on either side of each run:

control result
new -> Option<FrameSlot> method added red — unexpected: first_of_kind
a sanctioned minting method renamed red — unexpected: find_frame; missing: by_id

Verification

Measured on the committed tree; digests re-checked after the rebase onto main
and found byte-identical to the tree these ran against.

gate result
cargo fmt -p phase-engine -- --check 0
cargo clippy -p phase-engine --all-targets -- -D warnings 0
cargo test -p phase-engine --lib 19340 passed / 0 failed
cargo test -p phase-engine --test integration 5106 passed / 0 failed
scripts/check-resolution-frame-boundaries.sh PASS

The integration run predates one doc-comment edit to frame_vec.rs; clippy
--all-targets compiled those targets on the final tree, and a doc comment
cannot alter behaviour.

Save compatibility is checked against a pre-change capture, not a round-trip

FrameVec is #[serde(transparent)], so it should serialize exactly as the
Vec<ResolutionFrame> field it replaced. A break there would be silent, and a
round-trip test would not catch it — it would write and read the new shape and
pass either way.

The evidence is the two committed captures behind
mycoloth_devour_drain_strand.rs. They were dumped from a player's wedged game
BEFORE this refactor existed, are byte-frozen (gzip -9 -n, so the digest is
reproducible), and carry:

resolution_stack.frames : list, 1 entry -> {"type": "PostReplacement", ...}

load_capture feeds that array through the production PersistedGameState
decoder. Had #[serde(transparent)] been missing or misapplied, FrameVec
would demand {"frames": [...]} one level deeper and decoding would fail with
invalid type: sequence, expected struct FrameVec. Those rows are in the 5106
that pass, so the read side is proven against a genuine pre-change save.

Behaviour is unchanged by construction — this is a pure re-expression of
existing addressing, with no rules logic touched and no CR annotation added or
removed.

… rule

`ResolutionStack` permits three ways to reach a frame: the top, a frame
adjacent to one you already hold, and the frame a `PostReplacementFrameId`
names. Anything else is a positional guess about a structural relationship the
stack does not guarantee — which is the class of bug #7485 fixed, where cleanup
probed the top two frames and returned `None` once the continuation had raised
two.

That rule was enforced by `scripts/check-resolution-frame-boundaries.sh`
grepping `resolution.rs` for `frames.iter().position(..)` and
`frames.remove(..)`. A grep was the only option available: `frames` was private,
but Rust privacy is module-scoped and the module is 7,000 lines, so "private"
bought nothing against the code sitting beside it.

This replaces the rule with a type. `FrameVec` (230 lines,
`types/resolution/frame_vec.rs`) owns the backing `Vec`, and every accessor
takes an opaque `FrameSlot` whose field is private to that module. Slots come
only from `top`, `below`, `above`, `by_id`, and the documented captured-depth
door. A positional scan still compiles and can no longer be spent: it yields a
`usize`, and nothing accepts one.

Verified by probe rather than asserted. A method scanning for a `MultiDraw`
frame and feeding the result to the accessors fails to compile with exactly the
two errors the design predicts:

    error[E0599]: no method named `remove` found for struct `FrameVec`
    error[E0308]: mismatched types
                  --- ^^^^^^^ expected `FrameSlot`, found `usize`

The tree was restored byte-identical after that probe (sha256 checked both
sides).

What the 30 converted call sites showed is that the code already obeyed the
rule; it just said so in `usize` arithmetic. Every index came from
`len().checked_sub(N)` for N in 1..=3, or from the identity lookup — so the
conversion reads as documentation: `let continuation = self.frames.top()?;
let discard = self.frames.below(continuation)?;` in place of two `checked_sub`
calls whose meaning had to be recovered by counting.

Two things are deliberately NOT closed:

- `slot_at_captured_depth` is the one `usize` door. An effect records
  `resolution_stack.len()`, runs a child producer, and hands the length back so
  the owner can be parked beneath the child stack that producer raised. That is
  not a search, and closing it means giving the captured depth its own type at
  roughly thirty-five origins across `game/effects/`, `game/casting_costs.rs`
  and their neighbours — a separate change with a far wider blast radius. The
  door is named so misuse reads as misuse at the call site.
- `frame_at_offset` returns a frame and never a slot, because `validate` must
  traverse the whole stack to check a whole-stack invariant. Reading is not the
  hazard; addressing for mutation is.

The guard keeps its legacy-wire-key rules untouched and loses the two
frame-boundary scans. In their place is one structural check that the design
itself is intact: `FrameSlot` must be mintable only by the five documented
methods, since a sixth would reopen positional addressing with no compiler
error to show for it. Both directions were watched go red out of tree — a new
minting method, and a renamed existing one — with the replica green either side.

Gates on the committed tree: `cargo fmt -- --check` 0, `cargo clippy
--all-targets -- -D warnings` 0, `--lib` 19340 passed / 0 failed, `--test
integration` 5106 passed / 0 failed.
@matthewevans
matthewevans enabled auto-merge August 16, 2026 18:12
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@matthewevans, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Limit details: You’ve used all 2 included reviews currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a55dfed-2598-4205-ad0b-408f61a9df32

📥 Commits

Reviewing files that changed from the base of the PR and between 12ea904 and a588333.

📒 Files selected for processing (3)
  • crates/engine/src/types/resolution.rs
  • crates/engine/src/types/resolution/frame_vec.rs
  • scripts/check-resolution-frame-boundaries.sh

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@matthewevans

Copy link
Copy Markdown
Member Author

@coderabbitai review

Requesting an explicit pass: the automatic one reported Review rate limited,
so this diff has had no independent review. It refactors the core resolution
state machine, so it warrants one.

Worth the most scrutiny:

  1. The 30 converted methods. Every one previously derived its index from
    len().checked_sub(N) (N in 1..=3) or chained checked_sub(1). I claim each
    conversion to top() / below() / above() preserves the exact frame
    addressed, including the None boundaries when the stack is too shallow.
    active_discard_or_direct_continuation_parent_mut and
    replace_change_zone_parent_at_child_boundary have the most branching.
  2. FrameVec::above bounds. It returns None past the top, whereas below
    cannot go out of range by construction. If above is wrong,
    take_active_post_replacement_child's adjacency test silently changes
    meaning.
  3. Serde. FrameVec is #[serde(transparent)], which should make the wire
    format byte-identical to the previous Vec<ResolutionFrame> field. A save
    compatibility break here would be silent.
  4. The two deliberate doorsslot_at_captured_depth (the one usize
    entry point) and frame_at_offset (returns a frame, never a slot). I argue
    in the PR body that neither reopens positional addressing. That argument is
    the thing most worth attacking.

@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== PR metadata and changed files ==='
gh pr view 7489 --repo phase-rs/phase --json baseRefName,headRefName,files,title,url \
  --jq '{title, url, baseRefName, headRefName, files: [.files[].path]}'

echo
echo '=== Changed-file summary ==='
git diff --stat HEAD^ HEAD

echo
echo '=== Relevant file discovery ==='
fd -t f -a '^(resolution|frame_vec)\.rs$|check-resolution-frame-boundaries\.sh$|Cargo\.toml$' . \
  | sed -n '1,160p'

echo
echo '=== Structural map: changed Rust files ==='
while IFS= read -r file; do
  case "$file" in
    *.rs)
      echo "--- $file"
      ast-grep outline "$file" --items all --type struct,impl,method,function --view compact \
        | sed -n '1,220p'
      ;;
  esac
done < <(git diff --name-only HEAD^ HEAD | grep -E '\.rs$' || true)

echo
echo '=== Diff for resolution/frame-vector/guard files ==='
git diff --find-renames --unified=80 HEAD^ HEAD -- \
  ':(glob)**/resolution.rs' \
  ':(glob)**/frame_vec.rs' \
  'scripts/check-resolution-frame-boundaries.sh'

Length of output: 3173


🏁 Script executed:

#!/bin/bash
set -euo pipefail

base=$(git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD^)
echo "=== Comparison base: $base ==="

echo
echo '=== Source maps ==='
ast-grep outline crates/engine/src/types/resolution/frame_vec.rs --items all --view compact | sed -n '1,260p'
echo '--- resolution.rs matching stack/frame methods ---'
ast-grep outline crates/engine/src/types/resolution.rs --items all --view compact \
  | grep -E 'ResolutionStack|active_discard_or_direct_continuation_parent_mut|replace_change_zone_parent_at_child_boundary|take_active_post_replacement_child|frame_at_offset|slot_at_captured_depth|frames' \
  | sed -n '1,300p'

echo
echo '=== FrameVec source ==='
cat -n crates/engine/src/types/resolution/frame_vec.rs

echo
echo '=== Full changed ResolutionStack conversion diff ==='
git diff --find-renames --unified=35 "$base" HEAD -- crates/engine/src/types/resolution.rs

echo
echo '=== Guard diff and final guard source ==='
git diff --unified=40 "$base" HEAD -- scripts/check-resolution-frame-boundaries.sh
cat -n scripts/check-resolution-frame-boundaries.sh

echo
echo '=== FrameVec construction and direct access sites ==='
rg -n -C 4 --glob '*.rs' \
  '(FrameVec|\.frames\.(top|below|above|by_id|slot_at_captured_depth|frame_at_offset)|\.frames\[[^]]+\]|frames\.iter\(\)|frames\.remove\()' \
  crates/engine/src

Length of output: 1896


🏁 Script executed:

#!/bin/bash
set -euo pipefail

base=12ea90497eb2ae13fcbf13ff4d50f3a937a8350b

echo '=== FrameVec source ==='
cat -n crates/engine/src/types/resolution/frame_vec.rs

echo
echo '=== ResolutionStack diff ==='
git diff --find-renames --unified=45 "$base" HEAD -- crates/engine/src/types/resolution.rs

echo
echo '=== Guard diff and final source ==='
git diff --unified=45 "$base" HEAD -- scripts/check-resolution-frame-boundaries.sh
cat -n scripts/check-resolution-frame-boundaries.sh

echo
echo '=== All FrameVec access sites in engine Rust ==='
rg -n -C 5 --glob '*.rs' \
  '\.frames\.(top|below|above|by_id|slot_at_captured_depth|frame_at_offset)\s*\(' \
  crates/engine/src || true

echo
echo '=== Named methods in head and base ==='
for rev in "$base" HEAD; do
  echo "--- $rev"
  git show "$rev:crates/engine/src/types/resolution.rs" \
    | rg -n -C 28 \
      'fn (active_discard_or_direct_continuation_parent_mut|replace_change_zone_parent_at_child_boundary|take_active_post_replacement_child|frame_at_offset|slot_at_captured_depth)\b' \
    || true
done

echo
echo '=== Direct FrameVec field usage outside its defining module ==='
rg -n -C 3 --glob '*.rs' '\.frames\.(0|iter|remove|swap_remove|retain|drain|truncate|clear)\b|\bframes\[[^]]+\]' crates/engine/src || true

Length of output: 180032


🏁 Script executed:

#!/bin/bash
set -euo pipefail

base=12ea90497eb2ae13fcbf13ff4d50f3a937a8350b

echo '=== Changed production function names and changed line ranges ==='
python3 - <<'PY'
import subprocess, re
base = "12ea90497eb2ae13fcbf13ff4d50f3a937a8350b"
path = "crates/engine/src/types/resolution.rs"
diff = subprocess.check_output(
    ["git", "diff", "--unified=0", base, "HEAD", "--", path], text=True
)
for line in diff.splitlines():
    if line.startswith("@@"):
        print(line)
PY

echo
echo '=== FrameSlot declaration, construction, and FrameVec API use ==='
rg -n -C 4 --glob '*.rs' \
  '\bFrameSlot\b|FrameSlot\s*\(|(impl\s+(?:From|TryFrom).*(FrameSlot|usize)|impl\s+FrameSlot)' \
  crates/engine/src

echo
echo '=== All FrameVec method calls, including non-addressing operations ==='
rg -n -C 2 --glob '*.rs' '\.frames\.[A-Za-z_][A-Za-z0-9_]*\s*\(' \
  crates/engine/src/types/resolution.rs

echo
echo '=== ResolutionStack serde declarations and wire conversion/test references ==='
rg -n -C 6 --glob '*.rs' \
  '(ResolutionStack|resolution_frames|serde_json::(to_value|from_value)|serde\(transparent\)|round.?trip|wire.*v2|v2_.*reader)' \
  crates/engine/src/types/resolution.rs crates/engine/src/types/resolution/frame_vec.rs \
  | sed -n '1,700p'

echo
echo '=== Captured-depth and child-boundary origins outside ResolutionStack ==='
rg -n -C 5 --glob '*.rs' \
  '(resolution_stack\.len\(\)|child_stack_start|insert_(change_zone_)?parent_at_child_boundary|replace_change_zone_parent_at_child_boundary)' \
  crates/engine/src/game crates/engine/src/types \
  | sed -n '1,850p'

echo
echo '=== Exact changed lines around every FrameVec call in the diff ==='
git diff --unified=8 "$base" HEAD -- crates/engine/src/types/resolution.rs \
  | grep -E -B 12 -A 18 '^[+-].*self\.frames\.(top|below|above|by_id|slot_at_captured_depth|frame_at_offset|replace|swap|insert_below|insert_at_child_boundary)' \
  || true

Length of output: 202720


@matthewevans I found no blocking correctness issue in this refactor.

I checked the converted addressing operations. The top() and below() mappings preserve the former checked_sub behavior, including empty and shallow-stack None cases. This includes active_discard_or_direct_continuation_parent_mut and replace_change_zone_parent_at_child_boundary.

FrameVec::above is correct. It rejects arithmetic overflow and returns None when the next offset is outside the vector. Therefore, take_active_post_replacement_child preserves the former direct-adjacency test.

#[serde(transparent)] on the single-field FrameVec preserves the Vec<ResolutionFrame> representation. I found no new serialization field or nesting level.

The two raw-offset doors remain bounded:

  • slot_at_captured_depth retains the old depth < len boundary and names the captured-depth operation at each use.
  • frame_at_offset returns only &ResolutionFrame. It cannot produce a FrameSlot, and its uses are within stack validation.

FrameSlot construction is confined to the five intended minting methods. I found no conversion implementation or constructor outside frame_vec.rs.

One non-blocking documentation issue remains. In crates/engine/src/types/resolution/frame_vec.rs, Lines 16-18 and Lines 42-43 describe four minting methods and omit slot_at_captured_depth. The implementation and guard correctly define five minting methods. Update these comments so the documented boundary matches the enforced boundary.

I did not execute the Rust test suite in this sandbox. The conclusion is based on source and diff inspection.

⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@matthewevans

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…t supports

Raised in review. `frame_vec.rs` documented FOUR minting methods in two places
while the implementation and the guard both define FIVE — `top`, `below`,
`above`, `by_id`, and `slot_at_captured_depth`.

The miscount is the smaller half. The module doc also claimed that a `usize`
obtained by scanning has "no way to spend it: every accessor takes a
`FrameSlot`, and no constructor accepts a `usize`". That is false, and false in
the direction that flatters the design: `slot_at_captured_depth` accepts exactly
such a `usize`. The parent commit's message carries the same overclaim.

The accurate statement, which is what both places now say: a positional scan
still compiles, and the only thing that will accept its `usize` is
`slot_at_captured_depth`, whose argument is contractually a stack length
recorded before a child producer ran. Positional addressing therefore cannot be
reached by accident or by ordinary-looking code, and the single deliberate route
names itself at the call site. Weaker than "nothing accepts a `usize`", stronger
than a grep rule, and worth stating exactly — a doc that overstates its
guarantee stops the next reader from checking it, which is the same defect this
branch fixed in the guard's own prose on #7485.

`frame_at_offset` is now named in the module doc too, with the reason it is not
a widening: it takes a `usize` but returns a frame and never a slot.

Documentation only; no code changed. `cargo fmt -- --check` 0, guard PASS.
@matthewevans

Copy link
Copy Markdown
Member Author

Fixed in a588333. Thanks — and the finding is worth more than "non-blocking
documentation", because the miscount was the smaller half of it.

The module doc also claimed a scanned usize has "no way to spend it: every
accessor takes a FrameSlot, and no constructor accepts a usize"
. That is
false, and false in the direction that flatters the design —
slot_at_captured_depth accepts exactly such a usize. The parent commit
message and the PR body carried the same overclaim; all three now state the
accurate guarantee:

a positional scan still compiles, and the only thing that will accept its
usize is slot_at_captured_depth, whose argument is contractually a stack
length recorded before a child producer ran. Positional addressing therefore
cannot be reached by accident or by ordinary-looking code, and the single
deliberate route names itself at the call site.

Weaker than what I wrote, stronger than a grep rule. The reason to be exact is
that a doc overstating its own guarantee stops the next reader from checking
it — which is precisely the defect this branch fixed in the guard's prose on
#7485, reintroduced two files away. frame_at_offset is now named in the module
doc as well, with why it is not a widening: usize in, frame out, never a slot.

On the rest of the review — thank you for checking the checked_subtop() /
below() equivalences and the above() bounds directly rather than taking the
PR description's word for them; those were the two I most wanted a second pair
of eyes on.

One addition to your serde note, since you flagged that you did not run the
suite. The read side is pinned by real data rather than by inspection: the two
captures behind mycoloth_devour_drain_strand.rs were dumped from a player's
wedged game before this refactor existed, are byte-frozen (gzip -9 -n),
and carry resolution_stack.frames as a JSON array holding a PostReplacement
frame. load_capture feeds that through the production PersistedGameState
decoder. Without #[serde(transparent)] those rows would fail with
invalid type: sequence, expected struct FrameVec, and they are among the 5106
integration tests that pass.

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

Generated for head a58833370f61565d659eea2a2e06c40a3e249078.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@matthewevans
matthewevans added this pull request to the merge queue Aug 16, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 16, 2026
@matthewevans
matthewevans added this pull request to the merge queue Aug 16, 2026
Merged via the queue into main with commit 3d02127 Aug 16, 2026
15 checks passed
@matthewevans
matthewevans deleted the refactor/resolution-frame-slot branch August 16, 2026 19:44
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