From 15db4919373a49080c04c555270bbd1d652cb78d Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Sun, 25 Oct 2026 09:45:50 +0200 Subject: [PATCH 1/3] test(forge): finish F0 conformance follow-ups --- ROADMAP.md | 4 + crates/forge/conformance/FINDINGS.md | 5 +- crates/forge/conformance/README.md | 31 +-- crates/forge/conformance/departures.json | 183 ++++++++++++++++++ crates/forge/conformance/run.py | 10 + crates/forge/conformance/slice-pending.json | 15 +- .../specs/2026-10-22-p38-forge-design.md | 15 +- 7 files changed, 235 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6f862f89..f8491770 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -848,6 +848,10 @@ smaller ones (protected-cache hygiene, theirs-side resolve carry, sparse and P28 review follow-ons, the port de-flake, the clippy allow-list) are recorded in `CLAUDE.md`, `ARCHITECTURE.md` and the ADRs. +- **Forge lifecycle and resource follow-ups (#316):** default-branch PATCH versus receive-pack + deletion, cancellation and shutdown evidence with real Git transfers, Linux memory-limit + execution, authentication-budget limits, scope recordings and quota budgets. These remain + separate from the F0 conformance checklist (#297) and F6 deployment acceptance. - **Remaining history-editing depth:** operation objects in the CAS (Jujutsu-deep upgrade to the file oplog), oplog entries for remote-tracking refs. diff --git a/crates/forge/conformance/FINDINGS.md b/crates/forge/conformance/FINDINGS.md index ae067148..b2253cb4 100644 --- a/crates/forge/conformance/FINDINGS.md +++ b/crates/forge/conformance/FINDINGS.md @@ -149,8 +149,9 @@ every failure is a difference listed in the last table. ## Not recorded or partial - SSH, LFS, Actions, webhooks, releases, labels, milestones and issues other than comments. -- `mergeable` is compared by type only, since it flips while a PR is checked; the conflict state is - kept as the note `conflicting_pr_mergeable_after_8s`. +- `mergeable` can flip while Gitea checks a PR. The conflicting PRs in G and T settle before recording + and compare `false` by value. The sc-forge gate additionally pins the clean PR lifecycle in F and + the clean merge in G through departures, because its mergeability is computed synchronously. - A non-fast-forward push to a protected branch is refused by the git client before it reaches the server, so only the forced variants exercise the server. - Which of two same-second rows lists first (tokens, statuses, branches) is arbitrary on some versions; diff --git a/crates/forge/conformance/README.md b/crates/forge/conformance/README.md index 32b82d10..9b574c42 100644 --- a/crates/forge/conformance/README.md +++ b/crates/forge/conformance/README.md @@ -7,8 +7,8 @@ serves, **recorded from real Gitea** rather than written by hand. A scenario run - `check` runs the same scenarios against any server and compares the result with the fixtures. - `diff` compares two fixture directories offline, for example `1.22.6` against `1.26.2`. -`show` prints a readable digest of a fixture directory. `selftest` (offline, run by `forge-check.sh`) checks that the compact fixture writer round-trips and that -every stored fixture is exactly what it would write. Everything is Python 3 standard library plus +`show` prints a readable digest of a fixture directory. `selftest` (offline, run by `forge-check.sh`) checks the comparator's negative controls, +that the compact fixture writer round-trips and that every stored fixture is exactly what it would write. Everything is Python 3 standard library plus `git` and (for recording) Docker. Always run Python as `python3 -I`. ## Record against real Gitea @@ -46,9 +46,12 @@ python3 -I run.py diff fixtures/1.22.6 fixtures/1.26.2 # offline version diff python3 -I run.py show fixtures/1.26.2 --only G --body # what was recorded ``` -Against the in-tree server use `./forge-check.sh` (it replays scenarios `A,B,C,L,N,R`; the others wait for their routes, see `slice-pending.json` and the spec's "F2a decisions"; it bootstraps an empty data directory with the `admin` CLI, +Against the in-tree server use `./forge-check.sh` (it replays all 24 scenarios; it bootstraps an empty data directory with the `admin` CLI, starts `sc-forge serve` on a free loopback port, runs `check` with the departures and `slice-pending.json`, and -stops the server; CI runs it). `check --pending FILE` takes a JSON map `{scenario: [N, "METHOD /path"]}` (a bare `N` is also accepted): the +stops the server; CI runs it). `slice-pending.json` is empty. K#55-56 compare their complete access-filtered +bodies; departures exclude Gitea's `X-Total-Count`, which leaks invisible repositories. sc-forge's visible +counts are pinned by `e2e_repos::listings_are_filtered_by_access_with_visible_counts`. +`check --pending FILE` takes a JSON map `{scenario: [N, "METHOD /path"]}` (a bare `N` is also accepted): the server is expected to answer the first N exchanges of that scenario, the scenario must then stop at the named setup call (a route from a later slice), and the rest are reported as pending. A server that answers more than N, or a scenario that completes, fails the run, so the file shrinks as slices land and is empty when the contract is whole. It is for the @@ -82,9 +85,14 @@ observations called notes). Exchange N of the run is compared with exchange N of normalized to placeholders, and the SSH authority a server derives from its own config (`ssh://git@localhost:2222/acme/alpha.git`, `git@host:acme/alpha.git`) becomes ``, so `ssh_url` is compared by repo path only. -- The one place `mergeable` is compared by value: the conflicting PR in scenarios G and T. The call polls +- The conflicting PR in scenarios G and T compares `mergeable` by value. The call polls until 8 consecutive reads agree (`settle_keys`), the settled value must be `false` when recording, and the exchange lists `strict_keys: ["mergeable"]` so `check` compares that value too. + For sc-forge, departure `fields` also pin every PR response in F to `mergeable: true` (independent, + conflict-free branches, including close/reopen and lists), G#1 to `true` before the clean merge and + G#6 to `false` after it. This checks synchronous answers without freezing Gitea's transient values. + `html_url` is always compared by value after URL normalization. `selftest` mutates a recorded PR's + boolean and repository URL and requires the real gate to reject both. - Error text: for every recorded exchange with status >= 400 whose body has `message` (or a non-empty `errors`), the actual text must equal the recorded text after normalization: lower case, and only id-shaped trailing details dropped (`[uid: 0, name: ]`, `[id: x, rel_path: ]`, numeric `id:`/`issue_id:` @@ -176,14 +184,15 @@ success, ...), as well as every deliberate sc-forge difference. - Teams (scenario B: create team, add member, carol's org permission) are tagged `team` and covered by departures because sc-forge has no teams. Basic-**password** auth on an ordinary API route (scenarios B, R, X) is covered by departures too: sc-forge accepts a password only on `/users/{u}/tokens*`. -- The `message_contains` words for sc-forge's own error texts (merge style, state, rename, branch) are - placeholders: reword them when sc-forge's messages are fixed. +- The `message_contains` words pin sc-forge's own refusal texts, including merge style, state, + rename, branch and ref-namespace refusals (#315). Not covered by a departure: the merge of a PR whose head is already in base. sc-forge reproduces Gitea's -`405 "Please try again later"` with `changed_files: 0`, so the recorded exchanges apply unchanged. The -combined status for a sha with no statuses (1.22.6 `state:""`, 1.26.2 `pending`) and for an unknown sha -(1.22.6 `200` with empty fields, 1.26.2 `404`) are not 500/empty-message/malformed cases, so the rule gives -no departure; add one when sc-forge's answer is decided. +`405 "Please try again later"` with `changed_files: 0`, so the recorded exchanges apply unchanged. +The combined status for a commit with no statuses is `pending` with the real sha (H#0), and an +unknown sha is 404 (H#48). These follow 1.26.2 because 1.22.6's bodies contain empty answers. +After a refused `bogus` status, the existing commit still has no statuses (H#49: `pending`, count 0). +The departures and spec's "Statuses" section record these decisions. ### Normalization diff --git a/crates/forge/conformance/departures.json b/crates/forge/conformance/departures.json index 90d4a221..59924ae5 100644 --- a/crates/forge/conformance/departures.json +++ b/crates/forge/conformance/departures.json @@ -1582,5 +1582,188 @@ ] }, "reason": "sc-forge refuses a read-only collaborator's tag push at the advertisement with 403 Forbidden; Gitea advertises and then declines in pre-receive" + }, + { + "scenario": "F", + "exchange": 0, + "kind": "http", + "match": "POST /api/v1/repos/acme/prs/pulls", + "expect": { + "fields": { + "mergeable": true + } + }, + "reason": "Clean independent branches remain mergeable across create, list, head update, close, reopen and edit; sc-forge computes this synchronously." + }, + { + "scenario": "F", + "exchange": 2, + "kind": "http", + "match": "POST /api/v1/repos/acme/prs/pulls", + "expect": { + "fields": { + "mergeable": true + } + }, + "reason": "Clean independent branches remain mergeable across create, list, head update, close, reopen and edit; sc-forge computes this synchronously." + }, + { + "scenario": "F", + "exchange": 11, + "kind": "http", + "match": "GET /api/v1/repos/acme/prs/pulls/", + "expect": { + "fields": { + "mergeable": true + } + }, + "reason": "Clean independent branches remain mergeable across create, list, head update, close, reopen and edit; sc-forge computes this synchronously." + }, + { + "scenario": "F", + "exchange": 15, + "kind": "http", + "match": "GET /api/v1/repos/acme/prs/pulls/", + "expect": { + "fields": { + "mergeable": true + } + }, + "reason": "Clean independent branches remain mergeable across create, list, head update, close, reopen and edit; sc-forge computes this synchronously." + }, + { + "scenario": "F", + "exchange": 22, + "kind": "http", + "match": "PATCH /api/v1/repos/acme/prs/pulls/", + "expect": { + "fields": { + "mergeable": true + } + }, + "reason": "Clean independent branches remain mergeable across create, list, head update, close, reopen and edit; sc-forge computes this synchronously." + }, + { + "scenario": "F", + "exchange": 24, + "kind": "http", + "match": "PATCH /api/v1/repos/acme/prs/pulls/", + "expect": { + "fields": { + "mergeable": true + } + }, + "reason": "Clean independent branches remain mergeable across create, list, head update, close, reopen and edit; sc-forge computes this synchronously." + }, + { + "scenario": "F", + "exchange": 25, + "kind": "http", + "match": "PATCH /api/v1/repos/acme/prs/pulls/", + "expect": { + "fields": { + "mergeable": true + } + }, + "reason": "Clean independent branches remain mergeable across create, list, head update, close, reopen and edit; sc-forge computes this synchronously." + }, + { + "scenario": "F", + "exchange": 7, + "kind": "http", + "match": "GET /api/v1/repos/acme/prs/pulls?state=all&head=acme%3Af2&limit=50", + "expect": { + "fields": { + "0.mergeable": true, + "1.mergeable": true + } + }, + "reason": "Clean independent branches remain mergeable across create, list, head update, close, reopen and edit; sc-forge computes this synchronously." + }, + { + "scenario": "F", + "exchange": 9, + "kind": "http", + "match": "GET /api/v1/repos/acme/prs/pulls?state=open&limit=50", + "expect": { + "fields": { + "0.mergeable": true, + "1.mergeable": true + } + }, + "reason": "Clean independent branches remain mergeable across create, list, head update, close, reopen and edit; sc-forge computes this synchronously." + }, + { + "scenario": "F", + "exchange": 17, + "kind": "http", + "match": "GET /api/v1/repos/acme/prs/pulls?state=open&limit=50", + "expect": { + "fields": { + "0.mergeable": true, + "1.mergeable": true + } + }, + "reason": "Clean independent branches remain mergeable across create, list, head update, close, reopen and edit; sc-forge computes this synchronously." + }, + { + "scenario": "F", + "exchange": 23, + "kind": "http", + "match": "GET /api/v1/repos/acme/prs/pulls?state=closed&limit=50", + "expect": { + "fields": { + "0.mergeable": true + } + }, + "reason": "Clean independent branches remain mergeable across create, list, head update, close, reopen and edit; sc-forge computes this synchronously." + }, + { + "scenario": "G", + "exchange": 1, + "kind": "http", + "match": "GET /api/v1/repos/acme/merges/pulls/", + "expect": { + "fields": { + "mergeable": true + } + }, + "reason": "The clean PR is mergeable immediately; Gitea recorded its asynchronous check before completion." + }, + { + "scenario": "G", + "exchange": 6, + "kind": "http", + "match": "GET /api/v1/repos/acme/merges/pulls/", + "expect": { + "fields": { + "mergeable": false + } + }, + "reason": "A merged PR is frozen and no longer mergeable; sc-forge reports this synchronously." + }, + { + "scenario": "K", + "exchange": 55, + "kind": "http", + "match": "GET /api/v1/orgs/acme/repos", + "expect": { + "ignore_headers": [ + "x-total-count" + ] + }, + "reason": "Gitea leaks all 11 organization repositories in X-Total-Count even when the body is access-filtered; sc-forge reports only visible repositories (0 for Dave, 9 for Carol). The complete body stays compared; e2e_repos::listings_are_filtered_by_access_with_visible_counts independently pins filtered counts." + }, + { + "scenario": "K", + "exchange": 56, + "kind": "http", + "match": "GET /api/v1/orgs/acme/repos?limit=50", + "expect": { + "ignore_headers": [ + "x-total-count" + ] + }, + "reason": "Gitea leaks all 11 organization repositories in X-Total-Count even when the body is access-filtered; sc-forge reports only visible repositories (0 for Dave, 9 for Carol). The complete body stays compared; e2e_repos::listings_are_filtered_by_access_with_visible_counts independently pins filtered counts." } ] diff --git a/crates/forge/conformance/run.py b/crates/forge/conformance/run.py index 75c6a101..b8a69514 100644 --- a/crates/forge/conformance/run.py +++ b/crates/forge/conformance/run.py @@ -1194,6 +1194,16 @@ def cmd_selftest(a): assert not cmp_exchange(step, dict(step), Bij()), "an identical git step matches" other = dict(step, cmd="git clone --depth=1 http://@/acme/a.git /w") assert any("git command" in t for _, t in cmp_exchange(step, other, Bij())), "changed arguments must fail" + # The real gate must reject a wrong mergeability value and a URL naming another repository. + pr = J.load(open(os.path.join(a.fixtures_root, "1.22.6", "F-pulls.json")))["exchanges"][0] + deps = load_departures(os.path.join(os.path.dirname(a.fixtures_root), "departures.json"), {s[0] for s in SCENARIOS}) + dep = next((d for d in deps if d["scenario"] == "F" and 0 in dep_indexes(d["exchange"], [pr])), None) + def compare_pr(act): + return apply_override(pr, act, dep, Bij()) if dep else cmp_exchange(pr, act, Bij()) + assert not any(lv == "fail" for lv, _ in compare_pr(pr)), "the recorded clean PR matches" + for key, value in (("mergeable", False), ("html_url", "/acme/wrong/pulls/1")): + wrong = dict(pr, body=dict(pr["body"], **{key: value})) + assert any(lv == "fail" and key in text for lv, text in compare_pr(wrong)), f"wrong {key} must fail" bad = [] for d in sorted(os.listdir(a.fixtures_root)): full = os.path.join(a.fixtures_root, d) diff --git a/crates/forge/conformance/slice-pending.json b/crates/forge/conformance/slice-pending.json index 2e73343f..0967ef42 100644 --- a/crates/forge/conformance/slice-pending.json +++ b/crates/forge/conformance/slice-pending.json @@ -1,14 +1 @@ -{ - "K": { - "defer": [ - [ - 55, - "GET /api/v1/orgs/acme/repos" - ], - [ - 56, - "GET /api/v1/orgs/acme/repos?limit=50" - ] - ] - } -} +{} diff --git a/docs/superpowers/specs/2026-10-22-p38-forge-design.md b/docs/superpowers/specs/2026-10-22-p38-forge-design.md index 1403b00f..e4c20e25 100644 --- a/docs/superpowers/specs/2026-10-22-p38-forge-design.md +++ b/docs/superpowers/specs/2026-10-22-p38-forge-design.md @@ -935,7 +935,7 @@ tests, the image and the operations guide are F5b. held, no other registered run on the repository and an age over 10 minutes. This closes the "lock left by a git writer killed at run time" gap. - **Not built.** Per-owner byte quotas and per-user transport limits (they need a budget decision first; Engine's clones come from - one service account; #297). No last-gc state: an idle repository is collected every interval. `Git::run` gained `Spec::cancel` + one service account; #316). No last-gc state: an idle repository is collected every interval. `Git::run` gained `Spec::cancel` but still sets no `RLIMIT`s (only transports do). ### F4c decisions (as built) @@ -1062,6 +1062,19 @@ exchanges of Q. `refusing to force-push protected branch ` and `refusing to delete protected branch ` (departure entries for I#7, I#8 and I#10; the default branch keeps F2b's wording and is checked first). +### Post-F5 conformance follow-ups (#297) + +All 24 recorded scenarios are in the gate and `slice-pending.json` is empty. K#55-56 now compare +the complete org repository lists; only `X-Total-Count` differs deliberately: Gitea counts invisible +repositories, while sc-forge counts only visible ones (the API's access-filtered-count test pins this). +F's clean PR lifecycle pins `mergeable: true` on create, lists, head changes, close/reopen and edit; +G pins the clean PR to `true` before merge and `false` after merge. G/T retain their strict settled +conflict checks. URLs already compare by value, including repository identity. The offline selftest +now requires wrong mergeability and wrong-repository URL mutations to fail the actual gate. +Refusal texts were fixed in #315; combined-status decisions are recorded under "Statuses" above. +Remaining lifecycle, resource and authentication limits from the implementation comments are tracked +in #316; F6 remains acceptance on the first consumer deployment. + ## Open questions 1. The merge of a PR whose head is already in base (recorded: 405 `try again later`, From 69df67990b1aea1df6ac42f1ff3ee56e126a8149 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Sun, 25 Oct 2026 09:55:04 +0200 Subject: [PATCH 2/3] fix(forge): keep selftest controls independent of fixture root --- crates/forge/conformance/run.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/forge/conformance/run.py b/crates/forge/conformance/run.py index b8a69514..0945c2d5 100644 --- a/crates/forge/conformance/run.py +++ b/crates/forge/conformance/run.py @@ -1195,8 +1195,9 @@ def cmd_selftest(a): other = dict(step, cmd="git clone --depth=1 http://@/acme/a.git /w") assert any("git command" in t for _, t in cmp_exchange(step, other, Bij())), "changed arguments must fail" # The real gate must reject a wrong mergeability value and a URL naming another repository. - pr = J.load(open(os.path.join(a.fixtures_root, "1.22.6", "F-pulls.json")))["exchanges"][0] - deps = load_departures(os.path.join(os.path.dirname(a.fixtures_root), "departures.json"), {s[0] for s in SCENARIOS}) + here = os.path.dirname(os.path.abspath(__file__)) + pr = J.load(open(os.path.join(here, "fixtures", "1.22.6", "F-pulls.json")))["exchanges"][0] + deps = load_departures(os.path.join(here, "departures.json"), {s[0] for s in SCENARIOS}) dep = next((d for d in deps if d["scenario"] == "F" and 0 in dep_indexes(d["exchange"], [pr])), None) def compare_pr(act): return apply_override(pr, act, dep, Bij()) if dep else cmp_exchange(pr, act, Bij()) From 0ce4fd86849ca955dcceb96a303cd9a92e1427e9 Mon Sep 17 00:00:00 2001 From: Toni Bergholm Date: Sun, 25 Oct 2026 10:05:32 +0200 Subject: [PATCH 3/3] docs(forge): reconcile conformance gate and coverage notes --- crates/forge/conformance/departures.json | 4 ++-- .../specs/2026-10-22-p38-forge-design.md | 21 ++++++++----------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/crates/forge/conformance/departures.json b/crates/forge/conformance/departures.json index 59924ae5..7cf1a679 100644 --- a/crates/forge/conformance/departures.json +++ b/crates/forge/conformance/departures.json @@ -1752,7 +1752,7 @@ "x-total-count" ] }, - "reason": "Gitea leaks all 11 organization repositories in X-Total-Count even when the body is access-filtered; sc-forge reports only visible repositories (0 for Dave, 9 for Carol). The complete body stays compared; e2e_repos::listings_are_filtered_by_access_with_visible_counts independently pins filtered counts." + "reason": "Gitea leaks all 11 organization repositories in X-Total-Count even when the body is access-filtered. The complete body stays compared; e2e_repos::listings_are_filtered_by_access_with_visible_counts checks visible-count filtering for a read collaborator and an outsider." }, { "scenario": "K", @@ -1764,6 +1764,6 @@ "x-total-count" ] }, - "reason": "Gitea leaks all 11 organization repositories in X-Total-Count even when the body is access-filtered; sc-forge reports only visible repositories (0 for Dave, 9 for Carol). The complete body stays compared; e2e_repos::listings_are_filtered_by_access_with_visible_counts independently pins filtered counts." + "reason": "Gitea leaks all 11 organization repositories in X-Total-Count even when the body is access-filtered. The complete body stays compared; e2e_repos::listings_are_filtered_by_access_with_visible_counts checks visible-count filtering for a read collaborator and an outsider." } ] diff --git a/docs/superpowers/specs/2026-10-22-p38-forge-design.md b/docs/superpowers/specs/2026-10-22-p38-forge-design.md index e4c20e25..74c72ee3 100644 --- a/docs/superpowers/specs/2026-10-22-p38-forge-design.md +++ b/docs/superpowers/specs/2026-10-22-p38-forge-design.md @@ -803,11 +803,8 @@ F2b is the git half of F2 (plan: `docs/superpowers/plans/2026-10-23-p38-f2b-git- (C#30, K#34; Gitea declines in pre-receive), the default-branch delete message (J#62), protocol v2 advertisements have no `# service=` preamble (V#5), the unborn-default behaviour above (N). Gitea's behaviour that the tests cannot reproduce and sc-forge does not copy: moving HEAD to the first pushed branch. -- **Conformance gate.** `forge-check.sh` replays `A B C J K L M N R V` (10 scenarios). `slice-pending.json` now has - `{"stop": [N, text], "defer": [[index, "METHOD /path"], ...]}` entries: a deferred exchange still runs and must be the - recorded call, but its mismatch waits for a later slice, and it fails the run when it starts passing. `B` stops at its - first contents call; J's `/tags` and `/git/refs/tags`, K's and N's `/contents`, and K's org listing counts (which depend - on scenarios that need F3/F4 routes) are deferred. `S` needs a PR (F4); `X`, `D`, `E`-`I`, `O`-`Q`, `T`, `U`, `W` stay out. +- **Conformance gate (current).** `forge-check.sh` replays all 24 scenarios (`A` through `X`). + `slice-pending.json` is empty; no exchanges are deferred. See "Post-F5 conformance follow-ups" below. A departed git step still has its command line compared; credentialed URLs compare equal under `http` and `https`. - **Differences from the plan.** Admission takes the per-repository slot first (the plan said global first, which lets a flood on one repository hold global slots while waiting); the realm and RPC cache policy are Gitea's (the plan departed @@ -856,9 +853,9 @@ write half (`POST`/`PUT`/`DELETE` contents) is F3b, below; F3a shipped them as 5 repository has no branch or tag at all. - **Not served.** `git/blobs`, `git/tags` and web `raw` URLs appear in response bodies as Gitea writes them but are not routes. -- **Conformance gate.** `forge-check.sh` now replays `A B C D E J K L M N R V` (12 scenarios). `E` stops at its first - contents write (`[42, KeyError: 'sha']`) with E#36 to #41 deferred to F3b; `D` defers its PR listing and `B` its status - post (F4). +- **Conformance gate at F3a (historical).** `forge-check.sh` replayed `A B C D E J K L M N R V` (12 scenarios). + `E` stopped at its first contents write, E#36 to #41 waited for F3b, and D's PR listing and B's status post waited + for F4. The current gate runs all 24 scenarios without pending exchanges; see "Post-F5 conformance follow-ups". ### F3b decisions (as built) @@ -940,8 +937,8 @@ tests, the image and the operations guide are F5b. ### F4c decisions (as built) -F4c adds migration 5 (`merge_intents`) and `POST /pulls/{n}/merge`; the gate now replays G, T, U, O and Q completely (the F4 -slices together leave only K's two deferred org-listing exchanges pending). The merge below replaces the corresponding +F4c adds migration 5 (`merge_intents`) and `POST /pulls/{n}/merge`. At that slice, G, T, U, O and Q joined the gate completely; +K's last two org-listing exchanges were enabled in the Post-F5 follow-ups below. The merge below replaces the corresponding steps of "Merge" and "Crash recovery" above where they differ (plan decisions R1-R5 after the Opus and Sol review). - **Order, all under the repository's write gate.** Settle any pending intent; re-read the PR (merged: 405 `The PR is @@ -986,8 +983,8 @@ steps of "Merge" and "Crash recovery" above where they differ (plan decisions R1 ### F4b decisions (as built) -F4b adds migration 4 (`pulls`, `mergeability_cache`, `reviews`, `issue_comments`) and gates F, O (the merge-dependent exchanges -stay `defer`red until F4c), P and D#13. +F4b added migration 4 (`pulls`, `mergeability_cache`, `reviews`, `issue_comments`) and gated F, O, P and D#13; +its merge-dependent exchanges waited for F4c and are now in the gate. - **PR object.** The recorded 1.22.6 shape (`id` is the row id, `number` the per-repository index; `user` is the creator's full account object; `base.repo`/`head.repo` are the repository object). Open PRs show the live tips; `mergeable`, `merge_base`,