Skip to content

fix(node): fence a repo publish on the write attempt that owns it - #285

Merged
beardthelion merged 57 commits into
mainfrom
fix/279-advisory-lock-session-affinity
Nov 4, 2026
Merged

beardthelion merged 57 commits into
mainfrom
fix/279-advisory-lock-session-affinity

Conversation

@beardthelion

@beardthelion beardthelion commented Jul 30, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #283.

The advisory-lock fix this PR opened with is already on main. It landed in 752fd19 and reached main through the PR #173 merge, and #279 is closed, so the framing here has moved on. What is left is the work that grew around that pin, and it addresses a different failure: a write attempt whose future is gone can still land its bytes, and nothing downstream could tell that apart from a committed write.

What changes

A publish attempt has an identity. crates/gitlawb-node/src/git/publish.rs adds PublishAttemptId, PublishStage and PublishStageCell. The id travels in object user metadata and is stamped on the on-disk clone and the database row, so a reader can ask whether what it is looking at is this attempt's work, and how far a cancelled attempt actually got is observable from outside the future performing it.

Object-store writes are fenced on a precondition. Uploads and deletes now carry If-Match against the generation the attempt read, so a late PUT from an abandoned attempt is refused instead of overwriting a newer archive. Neither path carried any precondition before, which is what made "the lock is held, therefore nothing else can be writing" false in the first place.

The local directory swap is fenced on the same authority, which is #283. decompress_repo no longer removes and renames the live tree itself. It goes through swap_extracted_into_validated_repo, which takes the publish lock and then claims a commit token with a true -> false compare-and-exchange. A writer that loses lock ownership revokes that token, both on the explicit refusal path and in RepoWriteGuard::drop, so an extraction already running in an uncancellable spawn_blocking bails and removes its temp directory rather than deleting a tree a later writer is using.

An unrefreshable write sheds instead of writing. A failed archive HEAD on the write paths raises a typed RepoUnavailable that maps to a retryable 503 with a fixed body, rather than reading as "no archive exists" and proceeding.

close_issue authorizes before it takes the lock. It took the write lock and then ran the owner-or-author check, returning 403 with the lock held. Once exclusion actually works that is a wedge primitive for anyone with read access. The author fallback reads the issue's git-JSON blob without the lock as a pre-check, and the authoritative check runs again under the guard, because the tree that gets mutated is frequently not the one the pre-check read.

The pin the rest of this follows from

Pinning a connection per in-flight write means writes can no longer share the 20-connection application pool, so they get a dedicated one (GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS, default 32, connect_lazy). Without it a push burst starves ordinary reads.

Pinning also makes the post-write upload load-bearing. It was unbounded and free before, because nothing was held while it ran; now a stalled transfer holds a lock-pool slot, and enough of them deny every write on the node. Hence GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS, default 300, over any object-storage transfer that runs with the lock held.

The acquire is pg_try_advisory_lock with backoff rather than a blocking acquire, so a stale lock from a crashed connection cannot wedge a repo indefinitely. It is bounded on wall clock as well as attempt count, and a waiter hands its pool slot back before each backoff so spinners cannot starve the pool.

A cancelled .await does not cancel a SQL statement that has already been sent. That asymmetry is the whole design: cancelling an unlock is harmless because the statement completes server side, but cancelling an acquire strands the lock. So close_on_drop is armed before the try-lock goes out, via a wrapper that owns the connection in an Option and closes it in its own Drop unless the lock was positively not taken. PoolConnection::close_on_drop is a one-way setter, so disarming is Option::take rather than a second call.

Two settled calls

Readiness does not probe the lock pool. A node can report ready while every write fails, which two reviewers flagged. Failing readiness on a saturated pool would pull the node out of routing, take its reads down with it, and push its write load onto peers carrying the same load. Saturation surfaces in the request path instead: a retryable 503 to the caller and a warn line carrying the pool's own counters, so an incident can distinguish "the pool is full" from "the database is gone".

Entry concurrency is not bounded here. Bounding it belongs with hold time, not with this pin, and the arithmetic is in #282. A rate limit provably cannot close that one, so it is not #196's either.

Verification

Every guard here was checked by reverting the exact production line it protects and observing red first. That is not incidental: an earlier round of this work shipped with tests that did not observe what they claimed, including one that seeded a repo owner as their own issue author, which made it pass with the owner check disabled entirely.

The must-not tests observe pg_locks from a standalone connection, never from the lock pool, because pool reuse hands the observer the lock-holding session and reentrantly re-grabs the lock, hiding the leak. Lock-freed assertions poll with a deadline rather than asserting immediately, since PoolConnection::drop spawns the close. The conditional-write path is driven against an S3 mock that actually enforces preconditions, rather than one that records the header.

CI is green on the current head across all eighteen checks.

What this does not close

#282 is the entry-concurrency half, above.

#284 is the remaining cost lever on close_issue, which this branch improves on main without removing: the archive fetch no longer happens with the lock held.

#300 stays open. The two archive-HEAD sites on the write-refresh paths are fixed here, but the cache-miss site that issue actually names still reads a failed HEAD as "no archive", and the read endpoints still fold that into an empty 200.

#302 asks for the test that proves the store-layer refusal and the handler-layer 503 meet. This branch ships each half and not the join.

#205 and #343 are improved rather than closed. Fork attempts now stamp the directory and compensate an orphan archive, and the If-Match work speaks to the missing upload precondition, but neither issue's full scope is covered here.

Known gaps

The under-lock refresh timeout and the corrupt-archive fallback are correct by reading and not by execution. Driving either needs a seam to stall an object-storage response. For the same reason the author-path test cannot distinguish acquire from acquire_fresh: RepoStore::for_testing has no object-storage client, so the two calls are identical in every test in the suite. The test says so rather than claiming the coverage.

One open operational question: 20 application connections plus 32 lock connections per node needs to fit the fleet's Postgres max_connections. If it does not, the default is what should change.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
Walkthrough

The PR adds a dedicated advisory-lock pool, session-pinned repository locks, bounded Tigris transfers, conditional uploads, typed repository errors, and pre-lock authorization checks for issue closure.

Changes: Repository write controls

Layer / File(s) Summary
Dedicated lock-pool configuration and wiring
.env.example, crates/gitlawb-node/src/config.rs, crates/gitlawb-node/src/db/mod.rs, crates/gitlawb-node/src/git/repo_store.rs, crates/gitlawb-node/src/main.rs
Adds validated lock-pool and transfer-timeout settings. Creates a dedicated lazy advisory-lock pool and passes it to RepoStore.
Conditional storage and snapshot transfers
crates/gitlawb-node/src/git/tigris.rs
Adds ETag reads, conditional upload handling, bounded download modes, isolated snapshot extraction, and integration coverage.
Session-pinned locking and bounded transfers
crates/gitlawb-node/src/git/repo_store.rs
Pins locks to owning sessions, handles cancellation and deadlines, bounds transfers, checks unlock results, closes unsafe sessions, and tests cleanup and pool isolation.
Typed repository error propagation
crates/gitlawb-node/src/error.rs, crates/gitlawb-node/src/api/issues.rs, crates/gitlawb-node/src/api/pulls.rs, crates/gitlawb-node/src/api/repos.rs
Maps transient repository errors to 503 Service Unavailable and preserves acquisition and release errors through API handlers.
Pre-lock issue authorization
crates/gitlawb-node/src/api/issues.rs
Checks authorization before locking, revalidates it under the guard, distinguishes missing-issue responses, and adds regression tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

  • Gitlawb/node issue 282 — Addresses the lock-held transfer timeout used by repository write locking.

Possibly related PRs

Suggested labels: subsystem:storage

Suggested reviewers: kevincodex1

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly identifies the primary change: fencing repository publication to the write attempt that owns it.
Description check ✅ Passed The description gives detailed motivation, implementation scope, verification results, known gaps, and linked follow-up issues. It does not use every template heading or checklist, but it is mostly co…
Linked Issues check ✅ Passed The description includes "Closes #283" and identifies related issues and follow-up work, including #282, #284, #300, and #302.
Out of Scope Changes check ✅ Passed The changes support the stated repository-write fencing and advisory-lock objectives. The description identifies related work that remains out of scope, and the file summaries show no unrelated change…
Finishing Touches
Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/279-advisory-lock-session-affinity
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@beardthelion beardthelion added crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior labels Jul 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
crates/gitlawb-node/src/git/repo_store.rs (2)

1363-1372: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the fixed 300ms sleep with a poll loop.

PoolConnection::drop spawns the close, so on a loaded CI runner the close may not have completed when the next acquire() runs — the pool then hands back the same still-open connection and the assert_ne! fails spuriously. Polling until the pid changes (or a generous deadline elapses) makes this deterministic, matching the rationale already used in poll_until_free.

♻️ Poll instead of sleeping a fixed interval
-        // Give the spawned close a moment, then see which backend we land on.
-        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
-        let pid_after = {
-            let mut c = lock_pool.acquire().await.unwrap();
-            let pid: (i32,) = sqlx::query_as("SELECT pg_backend_pid()")
-                .fetch_one(&mut *c)
-                .await
-                .unwrap();
-            pid.0
-        };
+        // The close is spawned, so poll rather than sleeping a fixed interval.
+        let started = std::time::Instant::now();
+        let mut pid_after = pid_before;
+        while started.elapsed() < std::time::Duration::from_secs(10) {
+            let mut c = lock_pool.acquire().await.unwrap();
+            let pid: (i32,) = sqlx::query_as("SELECT pg_backend_pid()")
+                .fetch_one(&mut *c)
+                .await
+                .unwrap();
+            pid_after = pid.0;
+            if pid_after != pid_before {
+                break;
+            }
+            drop(c);
+            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/git/repo_store.rs` around lines 1363 - 1372, Replace
the fixed 300ms sleep before querying pid_after with a poll loop that repeatedly
acquires a connection and checks pg_backend_pid() until it differs from the
original pid, or a generous deadline is reached. Reuse the existing
poll_until_free approach and preserve the final pid comparison while preventing
transient failures on slow runners.

250-258: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider jitter on the retry sleep.

The backoff is a flat 1s with no randomization, so multiple waiters on the same repo tend to synchronize their probes and pg_try_advisory_lock gives no fairness ordering — a waiter can be starved for the whole 90s deadline while later arrivals win. A small random offset (or a short exponential ramp) spreads the probes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/git/repo_store.rs` around lines 250 - 258, Randomize
the retry delay in the probe loop around the existing tokio::time::sleep call so
concurrent waiters do not synchronize their pg_try_advisory_lock attempts.
Preserve the existing deadline clamp via left and the 1-second maximum, while
adding a small jitter or short exponential backoff without changing the retry
budget or connection-release behavior.
crates/gitlawb-node/src/api/issues.rs (1)

262-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Real errors are silently indistinguishable from "not authorized" here.

Ok(None) | Err(_) => None is a reasonable fail-closed default for the client, but a genuine git_issues::get_issue failure (disk/git corruption, IO error) is dropped with no log line, and will look identical to an ordinary "not authorized" 403 in the logs. Compare with the post-lock re-check a few lines down (Line 325-328), which does surface/log the equivalent error. Worth a tracing::warn!/debug! on the Err(e) arm here too, purely for operator visibility — the client-facing fail-closed behavior would stay exactly the same.

♻️ Proposed refactor
-        let author_did: Option<String> = match git_issues::get_issue(&disk_path, &issue_id) {
-            Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
-                .ok()
-                .and_then(|i| i.author),
-            // Cannot establish authorship, so fail closed. Deliberately 403 rather
-            // than 404 for a non-owner: a caller who is not authorized to write
-            // should not learn from this route whether the issue exists.
-            Ok(None) | Err(_) => None,
-        };
+        let author_did: Option<String> = match git_issues::get_issue(&disk_path, &issue_id) {
+            Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
+                .ok()
+                .and_then(|i| i.author),
+            // Cannot establish authorship, so fail closed. Deliberately 403 rather
+            // than 404 for a non-owner: a caller who is not authorized to write
+            // should not learn from this route whether the issue exists.
+            Ok(None) => None,
+            Err(e) => {
+                tracing::warn!(repo = %repo, issue = %issue_id, err = %e, "pre-lock issue read failed — treating as unauthorized");
+                None
+            }
+        };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/issues.rs` around lines 262 - 270, Update the
author lookup match around git_issues::get_issue to handle Err(e) separately
from Ok(None): preserve the existing fail-closed None result, but emit a tracing
warn or debug log containing the retrieval error for operator visibility. Keep
successful issue parsing and the client-facing authorization behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 930-941: Update the acquire_write error logging in the repository
write-lock flow to avoid unconditionally logging expected RepoBusy/transient 503
failures at error severity. Preserve propagation through the existing ?
operator, but classify contention consistently with repo_store.rs by using
warning-level logging or suppressing the duplicate log for RepoBusy while
retaining error logging for unexpected failures.

---

Nitpick comments:
In `@crates/gitlawb-node/src/api/issues.rs`:
- Around line 262-270: Update the author lookup match around
git_issues::get_issue to handle Err(e) separately from Ok(None): preserve the
existing fail-closed None result, but emit a tracing warn or debug log
containing the retrieval error for operator visibility. Keep successful issue
parsing and the client-facing authorization behavior unchanged.

In `@crates/gitlawb-node/src/git/repo_store.rs`:
- Around line 1363-1372: Replace the fixed 300ms sleep before querying pid_after
with a poll loop that repeatedly acquires a connection and checks
pg_backend_pid() until it differs from the original pid, or a generous deadline
is reached. Reuse the existing poll_until_free approach and preserve the final
pid comparison while preventing transient failures on slow runners.
- Around line 250-258: Randomize the retry delay in the probe loop around the
existing tokio::time::sleep call so concurrent waiters do not synchronize their
pg_try_advisory_lock attempts. Preserve the existing deadline clamp via left and
the 1-second maximum, while adding a small jitter or short exponential backoff
without changing the retry budget or connection-release behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c10504fc-f6f3-4c2e-a9a7-789138ba8d9a

📥 Commits

Reviewing files that changed from the base of the PR and between c83cbc5 and 1cc2c7c.

📒 Files selected for processing (9)
  • .env.example
  • crates/gitlawb-node/src/api/issues.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/config.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/git/repo_store.rs
  • crates/gitlawb-node/src/main.rs

Comment thread crates/gitlawb-node/src/api/repos.rs Resolved

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core lock-session fix looks ready; a few gaps in the new error and transfer layer should be closed before merge.

Findings

  • [P2] Align acquire_fresh HEAD failure handling with the under-lock refresh path
    crates/gitlawb-node/src/git/repo_store.rs:157-158, crates/gitlawb-node/src/api/issues.rs:258-277
    unwrap_or(false) on Tigris HEAD is pre-existing in acquire_fresh, but this PR now routes close_issue's non-owner author pre-check through it while acquire_write was fixed to refuse on RefreshFailure::Unknown. That leaves two freshness paths with different epistemics for the same operation. The author-denial scenario on a HEAD blip is largely the same as on main (both skipped download and read local), but owners and authors who pass pre-check on stale local can now hit a refused acquire_write (500) when HEAD fails under the lock — stricter, not looser. Please propagate HEAD errors out of acquire_fresh the same way the under-lock refresh does, or stop using acquire_fresh for auth until it does.

  • [P2] Map new transient Tigris refusal paths to a retryable 503, not HTTP 500
    crates/gitlawb-node/src/git/repo_store.rs:341-351, crates/gitlawb-node/src/git/repo_store.rs:365-369, crates/gitlawb-node/src/error.rs:82-94
    The under-lock HEAD failure and refresh-timeout arms are new in this series and return plain anyhow errors. AppError::from(anyhow::Error) only downcasts sqlx::Error and RepoBusy, so these surface as internal_error / HTTP 500 even though the comments call them retryable refusals. This is not a regression from main — acquire failures already mapped to 500 via AppError::Git — but it is a gap in the new error taxonomy you added for contention and pool exhaustion. Please introduce a typed retryable error (or extend the RepoBusy pattern) for HEAD failure and under-lock refresh timeout.

  • [P2] Keep repo-identifying detail out of client-visible error bodies on the new paths
    crates/gitlawb-node/src/git/repo_store.rs:365-368, crates/gitlawb-node/src/error.rs:168-172
    The under-lock refresh timeout embeds {owner_slug}/{repo_name} in the error string, which AppError::Internal returns verbatim in the JSON message. That contradicts the fixed-body policy you added for RepoBusy. main already leaked repo names in lock-contention 500s; this is a new instance on the timeout path. Please log operator detail and return a fixed retryable body to callers, consistent with RepoBusy.

  • [P3] Log expected acquire_write contention at warn, not error
    crates/gitlawb-node/src/api/repos.rs:939-940
    inspect_err logs every acquire_write failure at error severity. Base already logged acquire failures at error, but RepoBusy is new — expected 503 contention now hits tracing::error! while repo_store.rs logs the same condition at warn. Please downgrade or suppress logging for RepoBusy (and other expected transient 503 paths) while keeping error logging for unexpected failures.

Tracked follow-up (not blocking this PR)

  • #283 — orphaned Tigris extraction after transfer timeout
    crates/gitlawb-node/src/git/repo_store.rs:353-369, crates/gitlawb-node/src/git/tigris.rs:118-124, crates/gitlawb-node/src/git/tigris.rs:218-223
    spawn_blocking(decompress_repo) is not cancelled when bounded_transfer times out; a late extract can still remove_dir_all + rename after the lock is released. The mechanism is pre-existing; the timeout bound makes it more reachable. Refusing the write on timeout is the right call and is strictly better than the old path. You already track this as #283 — no action required here beyond keeping that follow-up open.

Reviewed and not raised as defects

  • Unbounded acquire_fresh on close_issue pre-check — acquire_fresh without a transfer bound is a pre-existing pattern (repos.rs git-receive-pack uses it too). This PR improves the stranger case (instant 403 vs lock wedge). Not a new amplification primitive worth blocking on.
  • Lock-pool saturation → db_unavailable — deliberate choice documented in repo_store.rs:220-241; operators get pool counters in the warn log. Client-code conflation is a tradeoff, not an oversight.
  • Proxy idle timeout vs composed write budgets — real operational tension, predates this PR; you already note reconciliation is tracked separately.
  • Fleet Postgres connection budget (+32 lock pool) — new default is intentional; PR body asks operators to budget. Deployment sizing, not a logic bug.

Maintainer decisions

  • Proxy idle timeout vs composed write budgets. Fly idle_timeout = 120 vs defaults of 90s lock wait, two 300s under-lock transfer spans, and up to 600s git service work. Please confirm the intended production limits as a set, or document the accepted failure mode when the edge drops the client first.
  • Fleet Postgres connection budget. Defaults now open up to 52 connections per node (20 + 32) with no startup validation. Please confirm fleet sizing or adjust the default before a broad rollout.

CodeRabbit follow-ups verified

  • Still open: repos.rs:939-940 — expected RepoBusy logged at error (see P3 above).
  • Still open: issues.rs:262-270 — pre-lock git_issues::get_issue I/O errors are silently folded into the unauthorized path with no log line (operability nit; client behavior is intentionally fail-closed).
  • Still open: repo_store.rs:1363-1372 — fixed 300ms sleep in release_that_did_not_hold_the_lock_closes_the_session can flake on slow CI; poll like poll_until_free.
  • Still open: repo_store.rs:250-258 — flat 1s backoff with no jitter on lock retry (fairness nit under contention).

What looks good

The core session-affinity fix is sound: the guard owns the lock-holding connection, LockProbe closes on cancellation, unlock results are observed, pool splitting is wired correctly, and the regression tests against pg_locks are thoughtfully constructed. close_issue authorization reorder and under-guard re-authorization close the wedge this series introduced. CI is green on the head commit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/git/repo_store.rs`:
- Around line 391-420: Prevent stale asynchronous extraction from replacing
newer repository data: update decompress_repo and the repository write/acquire
flow to track in-flight extractions per repository and make later writes wait or
fail until extraction completes, or validate a repository generation immediately
before publishing. Ensure the final remove_dir_all and rename cannot overwrite
changes made after the timed-out download.
- Around line 923-943: In the no-runtime branch of the write-guard drop logic,
replace the conn.leak() call with conn.detach() so the pool bookkeeping is
released and capacity remains replenishable. Preserve the existing synchronous
drop behavior for the detached PgConnection and update the nearby comment to
describe detach rather than a permanent leak.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 96e1a177-8f0f-4b1e-b34b-f4afde424e77

📥 Commits

Reviewing files that changed from the base of the PR and between 1cc2c7c and 281f0ee.

📒 Files selected for processing (10)
  • .env.example
  • crates/gitlawb-node/src/api/issues.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/config.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/git/repo_store.rs
  • crates/gitlawb-node/src/git/tigris.rs
  • crates/gitlawb-node/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/gitlawb-node/src/main.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/config.rs
  • crates/gitlawb-node/src/api/issues.rs

@beardthelion

Copy link
Copy Markdown
Collaborator Author

All four findings are addressed, plus two of the three CodeRabbit items. The branch is rebased onto current main and pushed as five follow-up commits, so the reviewed history is unchanged. 16 of 17 checks are green on 281f0ee (CodeRabbit has not reported yet).

P2, acquire_fresh HEAD handling

Took the first of your two remedies: acquire_fresh now propagates the HEAD error instead of collapsing it, so both freshness paths refuse on the same condition (9337300). The second remedy, dropping acquire_fresh from the auth pre-check, would have reintroduced the bug its comment documents, where a stale local copy hides an author's own issue and 403s a legitimate author.

Worth flagging that this helper has two callers, not one. The advertisement path in repos.rs wraps it in a map_err that bypasses the From chain entirely, so the typed error would have been stringified into a 500 git_error there. That call site now lets only the typed error through and leaves every other failure on exactly its previous behavior, because it also serves the read path and rerouting all of it would move the read path's error vocabulary. issues.rs needed no change; its bare ? already routes correctly.

P2, transient refusals mapping to 500

RepoUnavailable follows the RepoBusy pattern exactly: fieldless type, raised with the operator detail in a context string, its own downcast rung, mapped to a 503 (abc3c17). Both new refusal arms route through it.

P2, repo detail in client bodies

Same commit. The 503 body interpolates nothing, and the test asserts the negative directly: the response body contains the error code and does not contain the repo name or the owner DID. The detail stays in the log at the raise site.

P3, contention logged at error

Fixed at both call sites (07e4254). The second one matters: the acquire_fresh change above means the advertisement path now raises the same expected-transient class, so fixing only the acquire_write site would have shipped a new source of error-level noise for a condition this series just classified as ordinary. The classifier follows the startup path's permanent-versus-transient split, and anything it cannot classify still logs at error, so an unknown failure keeps paging.

One deliberate asymmetry: the 300s under-lock timeout logs at error at its raise site, not warn, with a comment saying why. It is not an ordinary blip, it held a lock-pool slot for five minutes, and it needs to keep paging through the handler demotion.

CodeRabbit items

Fixed: the swallowed get_issue error is now logged, with the fail-closed 403 unchanged (d4d2766), and the 300ms sleep is now a poll on a standalone connection (281f0ee).

Declined: jitter on the lock retry backoff. The node crate has no direct rand or fastrand dependency (the only rand in the tree is a libp2p-identity feature flag), so this means adding one for a fairness improvement, on a loop that two open PRs already touch. Happy to revisit if you think the contention case justifies it.

The two decisions you asked for

Connection budget. It fits, and the numbers are measured rather than estimated. Postgres gives 97 usable connections (100 minus the 3 superuser reserve), verified against a running instance, and nothing in the compose file or the Terraform template overrides max_connections. The shipped default topology is one Postgres per node, since use_rds defaults to false and the compose template only points at an external host when an operator opts in, so the node count multiplier is 1 and 52 of 97 leaves 45 spare. It breaks only on a shared external database, which is opt-in.

You are right that the missing piece is boot enforcement rather than the number. That belongs in Config::validate, which does not exist on main; it is in #174. Rather than build a second one here and put two open PRs on config.rs at once, it goes in as a clause on the existing validator once #174 lands. Worth noting the existing validator will need retargeting at the same time: its floor keys on the main pool, which is correct today but becomes the wrong pool once writes move to the dedicated one.

Timeout set. Not raising the Fly idle timeout. The 120 is deliberate and the config comment ties it to the 2026-06-12 outage, where long idle windows let hung clients pin connection slots. Not lowering the transfer bound either, since that is what stops a stalled transfer from pinning a lock-pool slot. The real reconciliation needs a different mechanism, and the code comment that said it was tracked separately was tracking nothing, so it is now #299.

On the test seam, and a correction

The earlier draft of this work recorded the wiring as unprovable without an object-store abstraction. That was wrong. RepoStore::new is public and takes the client, so a test-only constructor pointed at a closed port makes a failed HEAD reachable in process with no new dependency and no trait. Both refusals are now executed rather than read-verified, including the under-lock arm, and both tests run in under a second.

Two things remain read-verified and are recorded rather than implied: the timeout arm needs a hang rather than an error, so a refused connection cannot reach it, and nothing joins the store-layer raise to the handler-layer mapping end to end. That second one is #302, and it is cheaper than it looks because a router harness for the advertisement handler already exists.

Also a correction to something I would otherwise have claimed here. Refusing at the advertisement is not strictly cheaper than uploading a pack first. If a storage blip ends between the advertisement and the POST, the push succeeds today and will not after this change, and that window is the pack-upload duration, so it widens with push size. It is still the right call, because the alternative is advertising refs from a tree the write may not be allowed to use, but it is a real behavior change on a read surface and on the close-issue pre-check, where there is no pack upload to save at all.

Filed rather than fixed

Verification of the surrounding code turned up three things that are not in scope here: #300 (a failed HEAD on a cache miss renders a populated repo as an empty 200 on the read endpoints, which is worse than the 500 I first assumed), #301 (the advertisement leg runs an unbounded git subprocess where the other two legs are bounded), and #302 above.

@beardthelion
beardthelion requested a review from jatmn August 3, 2026 17:47
@beardthelion
beardthelion force-pushed the fix/279-advisory-lock-session-affinity branch from 281f0ee to 358dbe9 Compare August 4, 2026 01:56

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/gitlawb-node/src/git/repo_store.rs (1)

1631-1652: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the typed refusal instead of an outer timeout.

This test uses the default 90-second LOCK_ACQUIRE_DEADLINE and asserts only that the 8-second outer tokio::time::timeout fired. That assertion passes for any reason the future did not finish in 8 seconds, including a lock-pool stall unrelated to advisory-lock exclusion. It also adds 8 seconds to every suite run.

with_lock_acquire_deadline already exists and is used by contended_acquire_sheds_as_repo_busy_not_internal_error. Apply it here and assert the RepoBusy downcast, so the test proves exclusion positively and finishes in well under a second.

♻️ Proposed change
-        let store = write_store(&pool, &opts).await;
+        let store = write_store(&pool, &opts)
+            .await
+            .with_lock_acquire_deadline(std::time::Duration::from_millis(300));
 
         let _first = store
             .acquire_write("did:key:z6MkU3Excl", "same-repo")
             .await
             .expect("first writer acquires");
 
-        let second = tokio::time::timeout(
-            std::time::Duration::from_secs(8),
-            store.acquire_write("did:key:z6MkU3Excl", "same-repo"),
-        )
-        .await;
-
-        assert!(
-            second.is_err(),
-            "second writer must NOT be admitted while the first holds the guard \
-             (it should still be retrying when the deadline hits)"
-        );
+        let err = match store.acquire_write("did:key:z6MkU3Excl", "same-repo").await {
+            Err(e) => e,
+            Ok(second) => {
+                second.release(false).await;
+                panic!("a second writer must NOT be admitted while the first holds the guard");
+            }
+        };
+        assert!(
+            err.downcast_ref::<RepoBusy>().is_some(),
+            "the second writer must be shed as RepoBusy, got {err:#}"
+        );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/git/repo_store.rs` around lines 1631 - 1652, Update
two_writers_on_the_same_repo_are_not_both_admitted to configure a short deadline
via with_lock_acquire_deadline, matching
contended_acquire_sheds_as_repo_busy_not_internal_error. Replace the outer
tokio::time::timeout assertion with an assertion that the second acquire_write
call returns the typed RepoBusy refusal, while preserving the first writer’s
active guard.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/gitlawb-node/src/git/repo_store.rs`:
- Around line 1631-1652: Update
two_writers_on_the_same_repo_are_not_both_admitted to configure a short deadline
via with_lock_acquire_deadline, matching
contended_acquire_sheds_as_repo_busy_not_internal_error. Replace the outer
tokio::time::timeout assertion with an assertion that the second acquire_write
call returns the typed RepoBusy refusal, while preserving the first writer’s
active guard.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ca977d5-6794-4baa-baf0-9349aa9c6653

📥 Commits

Reviewing files that changed from the base of the PR and between 281f0ee and 358dbe9.

📒 Files selected for processing (10)
  • .env.example
  • crates/gitlawb-node/src/api/issues.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/config.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/git/repo_store.rs
  • crates/gitlawb-node/src/git/tigris.rs
  • crates/gitlawb-node/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (8)
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/main.rs
  • crates/gitlawb-node/src/git/tigris.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/config.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/api/issues.rs

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked head 358dbe97 after your follow-up commits. The core session-affinity fix looks ready; the prior P2 items from my earlier review are addressed on this head. One gap remains in the new RepoUnavailable error layer.

Findings

  • [P2] Map transient acquire_fresh download failures to RepoUnavailable, not HTTP 500
    crates/gitlawb-node/src/git/repo_store.rs:180-191, crates/gitlawb-node/src/api/repos.rs:579-594, crates/gitlawb-node/src/api/issues.rs:258-261
    acquire_fresh now refuses a failed Tigris HEAD as RepoUnavailable (retryable 503), but a failed GET when no local copy exists still returns a plain anyhow error. On git-receive-pack info/refs, the map_err closure only routes RepoUnavailable through AppError::from; every other failure is stringified to AppError::Git → 500. A transient object-storage GET blip during push advertisement therefore returns a non-retryable 500 while a HEAD blip on the same path returns retryable 503 — inconsistent client semantics within one endpoint. close_issue's non-owner pre-check has the same split via bare ?. Please raise download failures that leave storage state unknowable (archive present per HEAD, GET failed, no local fallback) as RepoUnavailable, matching the HEAD arm and the under-lock refresh path.

  • [P3] Tighten two_writers_on_the_same_repo_are_not_both_admitted to assert RepoBusy
    crates/gitlawb-node/src/git/repo_store.rs:1631-1651
    This acceptance test still wraps the second acquire_write in an 8-second outer tokio::time::timeout and only checks that the future did not finish. That passes for unrelated stalls (lock-pool saturation, slow CI) and adds ~8s to every suite run. CodeRabbit's suggestion still applies: use with_lock_acquire_deadline (as contended_acquire_sheds_as_repo_busy_not_internal_error already does) and assert the typed RepoBusy downcast while the first guard remains held.

Prior review items — verified fixed on this head

  • acquire_fresh HEAD failures now propagate as RepoUnavailable instead of unwrap_or(false) (aef72fa).
  • Under-lock HEAD/timeout refusals map to retryable 503 via RepoUnavailable with fixed bodies (d4c7af6).
  • acquire_write / info_refs contention and expected transient failures log at warn, not error (2cfee3d, repos.rs:579-584, 969-974).
  • close_issue pre-check logs get_issue I/O failures while keeping fail-closed 403 (07d98af).
  • Release-invariant test polls pg_stat_activity instead of sleeping 300ms (358dbe97).

Maintainer decisions (unchanged)

  • Proxy idle timeout vs composed write budgets. Fly idle_timeout = 120 vs defaults of 90s lock wait, 300s under-lock transfer (twice on a full push), and 600s git service work. Please confirm the intended production limit set or document the accepted failure mode when the edge drops first (#299).
  • Fleet Postgres connection budget. Defaults now open up to 52 connections per node (20 + 32) with no startup validation. Your measured single-node topology fits; please confirm fleet sizing for shared external Postgres or adjust defaults before broad rollout. Boot-time enforcement deferred to #174 is still the right place.

Tracked follow-up (not blocking this PR)

  • #283 — orphaned Tigris extraction after under-lock transfer timeout. Refusing the acquire on timeout is strictly better than proceeding; the uncancellable spawn_blocking swap can still race a later writer. Keep #283 open.
  • #300 — acquire() still swallows Tigris HEAD errors via unwrap_or(false) on read paths. Pre-existing; out of scope here but now inconsistent with the freshness paths this series fixed.

What looks good

The advisory-lock leak is fixed correctly: the guard owns the lock-holding session, LockProbe closes on cancellation, unlock results are observed, pool splitting is wired, and the pg_locks regression tests are load-bearing. close_issue authorization reorder and under-guard re-authorization close the wedge this series introduced. All 17 CI checks are green on head.

@beardthelion

Copy link
Copy Markdown
Collaborator Author

Both findings are fixed on bc00199.

[P2] Fresh download failures now refuse as RepoUnavailable. Raised at the source in acquire_fresh rather than patched at each consumer: From<anyhow::Error> for AppError (error.rs:98) already downcasts RepoUnavailable out of the context chain, so the info/refs closure and close_issue's bare ? both pick up the retryable mapping with no edit. The new test drives HEAD 200 with GET 500, which is the archive-present, GET-failed, no-local-fallback state you named.

I checked that test is load-bearing rather than trusting it green. Reverting the raise back to return Err(e).context("downloading repo from tigris (fresh)") turns it red at repo_store.rs:2244:

the refusal must be typed so the handler layer maps it to a retryable 503,
got downloading repo from tigris (fresh): tigris GET repos/v1/.../freshrepo.tar.zst: service error

That also confirms the downcast survives the .context() wrap, which is the part the single-site fix depends on.

[P3] The contention test asserts the typed refusal. two_writers_on_the_same_repo_are_not_both_admitted now uses with_lock_acquire_deadline(300ms) and asserts the RepoBusy downcast while the first guard is held, matching contended_acquire_sheds_as_repo_busy_not_internal_error. It fails loudly if a second writer is admitted, which the outer timeout could not tell apart from a stall. The three targeted tests finish in 1.14s.

fmt, clippy --locked --workspace --all-targets -D warnings, and deny_harness pass on the pushed head.

Still open on my side and not code: the proxy idle timeout against the composed write budgets, and the fleet Postgres connection budget. Both are decisions rather than fixes, so I'll answer them on their own rather than fold them into a resolution round. #283 and #300 stay open as tracked.

@beardthelion
beardthelion requested a review from jatmn August 9, 2026 05:16

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Do not refresh the live repository before acquiring the write guard
    crates/gitlawb-node/src/api/issues.rs:238-261
    The new non-owner pre-check calls acquire_fresh before taking the advisory lock. That call downloads and publishes directly into local_path; its publish step removes the existing repository directory and renames the extracted copy into place (tigris.rs:240-250), while the guard only serializes Postgres writers. Any signed non-owner can trigger that refresh before get_issue rejects them, concurrently with git_receive_pack or another guarded write on the same path. The refresh can therefore delete/swap the directory under an in-flight write. Use a non-mutating snapshot for the authorship pre-check, or coordinate this refresh/publish with the same write exclusion.

  • [P1] Do not unlock while a timed-out upload can still publish
    crates/gitlawb-node/src/git/repo_store.rs:845-864
    tokio::time::timeout drops the client future, but does not establish that the S3 PUT stopped; the comment correctly notes that it may finish later. The guard then unlocks, letting writer B refresh, modify, and upload the newer archive, after which A's late PUT can overwrite the one object key with A's older archive. A later node refresh then loses B's acknowledged update. Keep serialization until the publication outcome is known, or fence/version/conditionally publish so an abandoned upload cannot become visible after a successor.

  • [P2] Enforce the lock-acquire deadline around each database await
    crates/gitlawb-node/src/git/repo_store.rs:254-295
    The remaining budget is checked only before lock_pool.acquire().await; the pool checkout and the subsequent pg_try_advisory_lock query are not bounded by left. A checkout that begins just before the 90-second deadline may wait the full independently configurable DB acquire timeout (or a slow query may complete after the deadline), and a late successful query is accepted. This violates the advertised wall-clock cap and lets saturated/slow DB paths keep write tasks beyond the retry budget. Apply the remaining deadline to both awaits and reject any late acquisition.

@beardthelion

Copy link
Copy Markdown
Collaborator Author

All three findings are addressed on ea5af98, as seven commits rather than a fixup, because P1b turned out to need a different mechanism than the one I first reached for.

[P1] The author pre-check no longer touches the live directory. RepoStore::read_snapshot downloads to a throwaway temp dir and returns a guard that removes it on drop, so the pre-check still reads fresh data and the publish step never runs against local_path. read_snapshot_is_non_mutating asserts the snapshot path differs from the live path, that the live path is never created, and that the temp dir is gone after drop. The wedge invariant survives: stranger_is_refused_without_waiting_on_the_write_lock still passes, which is what ruled out the simpler fix of moving the check under the lock.

[P2] The deadline now bounds both awaits. The pool checkout and the pg_try_advisory_lock query each run under the remaining budget and shed as RepoBusy, so the advertised wall-clock cap no longer holds only on the fast path.

[P1b] You were right that unlocking is the wrong place to fix this, and my first attempt was wrong too. I initially kept the lock held on the timeout arm. That fences nothing, and I should have proven it before writing it: release takes mut self, so the guard drops the moment it returns and Drop closes the session. Measured with the upload parked for 10s behind a 200ms bound, a successor took the same repo's lock 5ms after release returned while the PUT was still in flight. That mechanism is deleted.

The fence is now on the publish itself, which is the only place that can actually reject a stale write. acquire_write reads the archive's ETag under the lock, the guard carries it, and release publishes conditionally on that generation. An abandoned PUT loses because the generation it was written against is gone. Driven end to end rather than argued: A's release is parked past its bound and returns with the outcome unknowable, B acquires and publishes, then A's captured PUT is replayed and the store answers 412 with B's archive intact. The create-only arm has its own test, and a control case pins that an abandoned PUT whose generation still matches does land, so the headline result is attributable to staleness rather than to replay.

Two consequences worth flagging, since neither was in your findings:

The three background uploads outside the write guard (init, acquire's backfill, release_after_write) were publishing unconditionally, which would have let our own code defeat the fence. init uploads an empty bare repo, so a push landing just before it could have had its archive replaced by that empty one. All three now publish create-only, and a refusal there is logged as the correct outcome rather than a failure.

Because init is now create-only, a first push to a fresh repo can lose the race against it. So a lost precondition gets exactly one supersede-retry: the writer still holds the lock, so it re-reads the ETag and republishes once, and a second loss refuses. At most two PUT attempts, ever. That keeps an ordinary first push working instead of surfacing a 503 on the most common operation there is.

A refused publish is surfaced rather than logged and dropped. release returns a #[must_use] outcome and the four publishing handlers propagate it before any trust bump, webhook, or success body, so a publish the store refused reads as a retryable 503 instead of a 201. The three release(false) sites deliberately do not map it, since they publish nothing and a 503 there would shadow the 403 or 404 the route means to return.

One classification call worth your eye: a 404 on a conditional PUT is treated as permanent, not as a lost precondition. AWS documents 404 for a delete racing a conditional write, but TigrisClient::delete has zero callers on this line, so that race cannot arise from our own code, and a 404 here means a wrong bucket or endpoint. Reporting that as retryable would send clients into a loop against a permanent fault. 409 under create-only is folded in, since that one is a genuine conflict.

Verification: the full suite passes locally, and fmt, clippy --locked, and cargo metadata --locked are clean, so the lockfile will not fail CI. Every guard added here was proven load-bearing by injecting the exact defect it names and confirming the named test goes red, 9 of 9. The uncontended-write test was separately confirmed to stay green under the same mutation that reddens the fence, so it pins the do-not-spuriously-refuse property rather than restating the fix.

Direction, not verified: the fence is checked against vendor documentation and a mock that implements the conditional semantics, not against the real backend. Tigris requires a Single-region or Multi-region bucket for conditional operations; against a Global or Dual-region bucket an ignored If-None-Match returns 200 and a publish that should have been fenced would land silently. There is a credentials-gated probe (tigris_honors_conditional_writes) that checks both arms against the real endpoint and cleans up unconditionally, but it has not been run. Worth settling before this is trusted in production.

#283 stays deferred, and no migration was added.

@beardthelion
beardthelion requested a review from jatmn August 10, 2026 12:08

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
crates/gitlawb-node/src/git/tigris.rs (2)

242-250: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider replacing the publish boolean with an explicit mode.

download_to changes both its mutation behavior and the meaning of its return value based on publish. At a call site, true and false carry no meaning without reading the doc comment. An enum such as ExtractMode::Publish and ExtractMode::Snapshot names both variants at the call site.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/git/tigris.rs` around lines 242 - 250, Replace the
boolean publish parameter in download_to with an explicit extraction mode enum,
defining named variants for publish and snapshot behavior. Update download_to’s
branching, return-value handling, and all call sites to use the corresponding
mode variants while preserving existing behavior.

268-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the shared temp-dir unpack step.

Lines 288-299 repeat decompress_repo lines 378-391 exactly: create a unique temp dir, unpack the archive, and remove the temp dir on failure. Only the directory-name infix and the final swap differ. A shared helper such as unpack_to_temp_dir(data, parent, prefix) -> Result<PathBuf> would let decompress_repo call it and then perform the swap.

Line 306 also logs path = %target.display() in snapshot mode, but the bytes landed in extracted. Log extracted instead so the message names the directory that was populated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/git/tigris.rs` around lines 268 - 307, The
temporary-directory extraction logic duplicated in the non-publish branch and
decompress_repo should be moved into a shared helper such as unpack_to_temp_dir,
parameterized by archive data, parent directory, and naming prefix; have
decompress_repo reuse it before performing its existing swap. In the download
log, update the path field to use extracted rather than target so snapshot mode
reports the populated directory.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/git/tigris.rs`:
- Around line 185-216: Update the status extraction in the request error
handling around UploadPrecondition and RepoWriteGuard::publish to use
SdkError::raw_response() for both service and response error variants. Ensure
unparsable 409 and 412 responses are classified as lost preconditions so the
existing supersede retry remains reachable, while preserving the current
status-based behavior for other errors.

---

Nitpick comments:
In `@crates/gitlawb-node/src/git/tigris.rs`:
- Around line 242-250: Replace the boolean publish parameter in download_to with
an explicit extraction mode enum, defining named variants for publish and
snapshot behavior. Update download_to’s branching, return-value handling, and
all call sites to use the corresponding mode variants while preserving existing
behavior.
- Around line 268-307: The temporary-directory extraction logic duplicated in
the non-publish branch and decompress_repo should be moved into a shared helper
such as unpack_to_temp_dir, parameterized by archive data, parent directory, and
naming prefix; have decompress_repo reuse it before performing its existing
swap. In the download log, update the path field to use extracted rather than
target so snapshot mode reports the populated directory.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f932b365-e4b9-442c-bb9a-58a6ed92a923

📥 Commits

Reviewing files that changed from the base of the PR and between bc00199 and ea5af98.

📒 Files selected for processing (6)
  • crates/gitlawb-node/src/api/issues.rs
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/git/repo_store.rs
  • crates/gitlawb-node/src/git/tigris.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/gitlawb-node/src/api/pulls.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/api/issues.rs

Comment thread crates/gitlawb-node/src/git/tigris.rs Resolved

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Rebase this branch onto current main before it can be merged
    The current head ea5af98 is not descended from the PR base 241b366 (its merge-base is c926e1e), and GitHub reports the PR as CONFLICTING. A three-way merge conflicts in .env.example, api/repos.rs, error.rs, repo_store.rs, and tigris.rs; the stale head also lacks current-main hardening such as the #174 admission/cleanup path and opaque internal-error handling. Please rebase and resolve these changes, then request a review of the resolved base-to-head diff rather than merging a conflict resolution that can roll those protections back.

  • [P1] Do not refresh the live repository outside the write exclusion
    crates/gitlawb-node/src/api/repos.rs:566-572, crates/gitlawb-node/src/git/repo_store.rs:200-227
    The receive-pack advertisement still calls acquire_fresh, which downloads and publishes into local_path. That publish removes and renames the live directory, but it does not take the advisory lock. A second advertisement can therefore replace the directory while a guarded receive-pack, merge, or issue write is using it. In the especially bad ordering where the mutation has finished but release has not compressed the tree, the guarded release uploads the replaced old tree with its still-valid ETag and reports success, losing the accepted write. The new snapshot implementation addresses the close_issue pre-check only; use a non-mutating snapshot for advertisement or coordinate this refresh with the same write exclusion.

  • [P1] Bound and authorize the pre-lock issue snapshot
    crates/gitlawb-node/src/api/issues.rs:241-269, crates/gitlawb-node/src/git/tigris.rs:252-304
    Any signed non-owner reaches read_snapshot before the handler establishes authorship or even read access. With Tigris enabled, every such request downloads the entire archive into memory and starts an unbounded blocking extraction into a unique directory; this route has no rate/concurrency limit. Disposable identities can issue parallel close requests for arbitrary issue IDs to exhaust transfer, memory, CPU, and disk. A cancellation while the blocking extraction is running occurs before RepoSnapshot is constructed, so its temp directory is not cleaned up. Require a cheap authorization/author lookup before this work, or explicitly bound and clean up the snapshot work.

  • [P1] Classify raw 409/412 responses as a lost conditional write
    crates/gitlawb-node/src/git/tigris.rs:185-215
    The new durability fence extracts a status only from SdkError::ServiceError, but this SDK exposes a raw response for both ServiceError and ResponseError. A Tigris/S3-compatible conditional PUT rejected with an unparsable 409 or 412 is a ResponseError, so this code returns UploadError::Other; RepoWriteGuard::release then only logs it and returns success instead of taking the retry/fenced-503 path. That acknowledges a write whose archive was definitively not published. Use e.raw_response() for the status and cover malformed-body 409/412 responses.

  • [P2] Preserve the retryable error for a cold-cache under-lock download failure
    crates/gitlawb-node/src/git/repo_store.rs:513-529
    When the under-lock HEAD succeeds but the GET fails on a node without a local copy, this arm returns the bare download error. The handlers route that through AppError::from, which maps it to a 500, unlike the equivalent acquire_fresh condition that is deliberately wrapped as RepoUnavailable and returned as a retryable 503. Wrap this no-local-fallback error in RepoUnavailable as well.

  • [P2] Do not accept a conflicting fork archive as a successful fork
    crates/gitlawb-node/src/git/repo_store.rs:631-650
    The new create-only fork upload treats a lost precondition as success because it assumes a missing DB row proves the object key is absent. Database and object-store writes are not atomic: for example, create_repo initializes and starts its background upload before db.create_repo, so a failed DB insertion can leave a permanent orphan archive. A later fork on a node without that local directory can clone its requested source, lose the If-None-Match upload to the orphan, and still create the DB record; other nodes then fetch the unrelated archive. Surface the conflict/refuse the fork, or make the DB and storage namespace transition coordinated and recoverable.

  • [P2] Recompute the lock-acquire remainder before the retry sleep
    crates/gitlawb-node/src/git/repo_store.rs:422-430
    left is measured before pg_try_advisory_lock; if that query returns false just before the deadline, the following sleep uses the old remainder and can run a full additional second past the advertised wall-clock acquire cap. Recompute the remaining duration immediately before sleeping, and skip the sleep when it has expired.

…id-acquire

A cancelled .await does not cancel an already-sent SQL statement, so a
pg_try_advisory_lock whose future is dropped still takes the lock
server-side while the caller abandons the result, leaving nothing to
release it. The connection then returns to the pool holding the lock and
wedges that repo until sqlx recycles the session.

Introduce LockProbe, which owns the connection across the in-flight
try-lock and closes it in its own Drop if it is still held. close_on_drop
is a one-way setter, so the arming lives in Drop rather than being set up
front and cleared on success; disarming is Option::take, which is what
into_conn does once an acquire is actually observed. This is now the only
place that issues pg_try_advisory_lock.

The committed gate drops a probe without taking its connection, which is
the state a cancellation leaves behind, and polls a standalone observer
until the lock frees. Deterministic on purpose: the timing sweep that
found this window leaks roughly 1 in 600, which is not something a CI
gate can rest on. Observed RED before this change with the lock still
held for the full 10s window.

Refs #279
Pinning a connection for the lock's lifetime is only safe if those
connections come from somewhere other than the pool serving ordinary
request handlers, otherwise a push burst starves every other query. Add
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS (default 32) and a Db::lock_pool
builder, with the sizing tradeoff documented on the field and in
.env.example: every in-flight write pins one connection here, so the
value is a hard ceiling on simultaneous writes node-wide.

The pool connects lazily on purpose. The main pool must connect eagerly
because it runs migrations, which is why it needs connect_db_with_retry's
backoff and degraded-server handoff; that function is not a generic retry
helper and the lock pool is built well after the db-ready handoff has
already resolved. A lazy pool has no startup work, so it adds no new way
for the process to fail to boot and needs no second copy of that
machinery. If Postgres is unreachable when the first write arrives, that
write fails on the pool's own acquire timeout, like any other
database-backed request.

Pure configuration, so no proof-first cycle: the knob is covered by a
parse/default/reject-zero test. Db::lock_pool has no caller until the
guard wiring lands, hence the temporary dead_code attribute.

Refs #279
Postgres advisory locks are session-scoped: only the backend that took one
can release it. acquire_write took the lock through fetch_one(&pool) and
release unlocked through execute(&pool), two independent checkouts, so the
unlock usually landed on a session that held nothing and returned false.
Measured on main: two writers on one node and the same repo BOTH acquired,
50 of 50 sequential cycles leaked, and 100 writes left 100 orphaned
advisory locks on the server.

The guard now owns the PoolConnection that took the lock, drawn from the
dedicated lock pool, and releases on that same session. The retry loop
probes through LockProbe so a cancellation mid-acquire cannot strand the
lock, and hands the connection back before each backoff so a spinner on a
contended repo does not pin a slot while idle.

Pool exhaustion is deliberately not retried. It is a different condition
from lock contention, and retrying it would spend all 60 attempts on a
capacity problem unrelated to this repo while reporting it as someone else
holding the lock.

Both #279 acceptance tests were observed RED first: the exclusion test
admitted the second writer, and the leak test reported 1 lock held where 0
was required. Both GREEN after. Full crate suite 516 passed.

Db::pool() is removed because this change was its only caller.

Refs #279
…empt

Closes the four lifecycle findings by consuming the attempt/stage boundary at
the sites that were deciding on coarse state.

P1 — cancellation before PUT dispatch is not publish durability.
`PublishDurabilitySlot` is armed on the guard's publish STAGE rather than on
a "release started" flag, and its `Drop` classifies an abandoned handler by
how far the attempt actually got: a cancellation during compression records a
definite refusal, a cancellation after dispatch records unknowable, and a
cancellation after the store acknowledged records `Released` (which the flag
could never say). `publish_durability_confirmed` now requires `Released`, so
the detached tail no longer does IPFS/Pinata/P2P/GraphQL/Arweave/peer work
for refs that exist only in a rejected local write. A guard with no storage
backend seeds `NoBackend`, keeping Tigris-less deployments' tails running.

P1 — an unknowable generation is quarantined, not served as ordinary cache.
`release` writes a sidecar marker naming the unresolved attempt beside the
live tree (never inside it: it would be tarred into the next archive), and
`acquire`'s existing-path fast path reconciles it before serving. Only the
store confirming it holds that attempt lifts the quarantine; anything else is
a retryable `RepoUnavailable`. The tree is deliberately NOT deleted — the PUT
may have landed and this can be the only local copy. A confirmed publish, an
under-lock refresh, and cache invalidation each clear the marker, so the
quarantine is a bounded refusal rather than a standing outage.

The release-side timeout arm also stops treating every stall alike: a bound
that expires at `PreparingArchive` is a definite non-publication, and a bound
that expires after dispatch spends one short, separately bounded HEAD trying
to reconcile the attempt before falling back to unknowable.

P1 — fork confirmation and cleanup bind to the creating attempt. The DB row
id is minted up front and used as the attempt id, so the object's metadata,
the clone's sidecar stamp and the row all name one attempt.
`confirm_fork_repo_row` looks the row up BY ID and reports Ours / Foreign /
Absent, so a concurrent create, mirror registration or retry can no longer be
returned as this request's own commit. Every destructive step —
`compensate_fork_archive`, its background retries, and `ForkCloneGuard::drop`
— is conditional on the resource still belonging to this attempt, so recovery
that observed `None` before a successor committed can no longer erase that
successor's archive or directory afterwards.

P2 — response-loss ambiguity is preserved rather than compensated. Guarded
writes map an ambiguous publish to `UploadUnknowable` + quarantine instead of
`UploadFailed` + cache invalidation + the caller's undo, so `create_issue` no
longer deletes an issue ref whose archive is durable. Fork creation
reconciles by attempt id: a create-only PUT that committed and lost its
response is RECOVERED and the row inserted, and an unresolved one keeps its
only local clone and refuses retryably. The fork name is no longer fenced
behind the attempt's own orphan.

Tests, each RED-checked by reverting its guard:

- slot_drop_during_compression_records_a_definite_non_publication
- slot_drop_after_dispatch_records_unknowable
- slot_drop_after_the_store_acknowledged_records_released
- slot_drop_with_no_storage_backend_records_released
- publish_durability_confirmed_accepts_only_released
- publish_durability_confirmed_refuses_quickly_after_unrecorded_slot_drop
- receive_pack_cancelled_during_compression_publishes_nothing_and_runs_no_tail
  (+ receive_pack_that_completes_its_publish_still_runs_the_tail as control)
- a_bound_that_expires_before_dispatch_is_a_definite_failure
- an_unresolved_publish_quarantines_the_tree_and_a_later_read_refuses
- a_quarantined_tree_is_served_once_the_store_confirms_the_attempt
- the_next_write_clears_an_inherited_quarantine
- a_confirmed_publish_leaves_the_tree_readable (control)
- a_guarded_write_whose_response_is_lost_is_not_compensated
- a_put_that_commits_and_loses_its_response_is_ambiguous_and_reconcilable
- a_closed_response_with_no_http_status_is_ambiguous
- upload_classifies_404_as_a_definite_non_publication
- upload_classifies_500_as_ambiguous_not_definite_failure
- a_generation_that_moves_between_the_head_and_the_delete_is_not_deleted
- an_attempts_own_object_is_deleted_and_a_foreign_one_is_not
- fork_confirmation_never_claims_a_concurrent_attempts_row
- fork_confirmation_recognizes_this_attempts_own_committed_row
- fork_confirmation_reports_absent_when_nothing_owns_the_name
- fork_clone_guard_leaves_a_successors_mirror_alone
- fork_recovery_resuming_after_a_successor_deletes_neither_object_nor_path
- fork_compensation_removes_what_this_attempt_still_owns
- fork_publish_that_loses_its_response_stays_recoverable
- a_fork_whose_publish_lost_its_response_is_recovered_not_fenced
- a_fork_whose_publish_is_unresolved_keeps_its_clone_and_refuses_retryably
- an_ordinary_fork_publishes_and_commits (control)
- mock_round_trips_the_attempt_metadata_a_put_stamped

The S3 mock gains attempt metadata, conditional DELETE, a commit-then-lose-
the-response mode, a delivered-but-not-committed mode, and a per-key object
store (fork creation touches the source and fork keys in one request, and a
single slot would have let an assertion about one silently read the other).
The compression seam is a condvar gate so no test holds a guard across an
await.
@beardthelion beardthelion changed the title fix(node): release the advisory lock on the session that took it (#279) fix(node): fence a repo publish on the write attempt that owns it Sep 5, 2026
…tempt

The publication boundary this branch introduced was applied at call sites
that each had to be remembered, and five were not. Close the eight gaps
that survived, grouped by what actually failed rather than by symptom.

The marker's lifecycle was answered by the wrong event in three places.
The swap that replaces a live tree now owns the clear, so a cache-miss
download no longer leaves a stale marker that fences the archive it just
installed, and the under-lock refresh no longer lifts a quarantine on a
HEAD that downloaded nothing. init clears a predecessor's marker.

A cancelled handler never reached release, so it applied the tail
contract and not the cache contract. RepoWriteGuard::drop now settles the
tree it abandoned, keyed on how far the publish actually got, and a guard
that handed out its path for writing marks the tree whatever stage it
reached. A marker naming no attempt is a definite non-publication rather
than a permanent refusal: it invalidates and the next read serves the
stored generation, so an abandoned first push cannot wedge a repo at 503.
Only an unreadable sidecar still fails shut.

Both of the store's live-path hand-outs now go through one confirming
helper, so read_snapshot can no longer return an unconfirmed tree as an
ordinary snapshot. The gate binds ahead of the lazy-migration spawn,
because a quarantined tree backfilled to object storage by an IfAbsent
PUT would publish exactly the refs the marker exists to withhold. The
marker no longer rests on a single fs::write; an unwritable sidecar falls
back to an in-memory record that reads consult.

Fork cleanup claims ownership by renaming the stamp before it touches the
tree, so a successor that claims the same path cannot lose its directory
to a predecessor's check-then-unlink, and the directory-absent path no
longer unlinks a stamp it does not own. A create-only precondition loss
whose reconciliation HEAD fails is refused retryably instead of reported
as a permanent name conflict, and its clone is released so the retry can
re-clone rather than finding the destination occupied.

The pin sweep skips a tree whose publish is unresolved, so provider CIDs
are not rewritten from refs that may never become durable. That is the
sidecar marker, distinct from the operator-level quarantined row flag.

26 tests, each written and observed failing before its fix. Suite 1211
passed, 0 failed, 1 ignored; fmt, clippy -D warnings and --locked clean.
… exit

The settlement that marks an abandoned write ran from RepoWriteGuard's
Drop, and on a client disconnect that guard is held by the Arc clone
riding the admission guard into the detached reaper, so it ran only once
the process group had been reaped. Reads take no advisory lock and
consult only the sidecar, so for the width of that window a concurrent
fetch was served the abandoned refs off the live tree with no marker
beside it. Confirmed by execution before the fix: no marker on disk,
acquire returning the live path, and the abandoned ref still on the tree
it served.

Only the lock release has to wait for the reaper; the settlement does
not. TreeSettlement is a token carrying the path, the publish stage and
the hand-out flag, holding the exhaustive stage match that used to live
in Drop. The handler takes it out of the guard before the guard is
shared with the reaper, so on a disconnect the handler future drops
first and the marker lands at the disconnect instant while the reaper
still holds the lock. A successful release disarms it. Drop still
settles a guard nobody took the token from, so the arms have one home.

The regression test reads inside the window and asserts the abandoned
refs are not served. It samples pg_locks from an independent session
first, so a run where the reaper had already finished reports itself
inconclusive rather than passing for the wrong reason.

Suite 1212 passed, 0 failed, 1 ignored. The five settlement mutations
are load-bearing, including one that reverts this split and reddens the
window test.
Moving settlement into a token the handler holds left two classifiers
able to run for one write. release() classifies the tree and only then
awaits the unlock, while the handler disarms the token after that await
returns, so a cancel in the unlock gap dropped a still-armed token and
settled a second time. After a definite refusal that is a quarantine
marker written beside the tree release had just deleted, which sends
every later read down the refuse-and-reconcile path for a repo whose
state was already resolved. Observed before the fix: the tree gone, the
marker there.

The arm is now an AtomicBool shared between the guard and its token.
release stores false at the classification point, above the unlock
await, and settle takes the arm with compare_exchange, so whichever
classifier gets there first is the only one that acts.

tree_settled stays, and now has a test that says why: after the handler
takes the settlement the arm is still true, so tree_settled is the only
thing stopping the guard's own Drop, which on a disconnect runs in the
detached reaper, from settling again at reaper exit.

The reap-window test was passing for weaker reasons than it claimed. Its
fake git took SIGTERM and let the group die, so the reaper could free
the advisory lock before the probe read; it now traps TERM and re-parks
so only SIGKILL ends it. It asserts the marker exists at the disconnect
instant rather than only mentioning it, and the served-refs half no
longer passes when acquire fails for an unrelated reason.

Suite 1214 passed, 0 failed, 1 ignored. Nineteen mutations load-bearing,
including one that reverts the release-side disarm and one that reverts
the settlement split.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

All four findings are addressed. They shared one root cause, so they got one mechanism rather than four patches: git/publish.rs names the identity and the publication stage of a single write attempt, and every caller decides from it. PublishAttemptId is minted before the request is built and travels with the bytes as object metadata, so "is what the store holds mine?" stays answerable when "did my request succeed?" is not. UploadError now encodes what the client knows about dispatch rather than which status came back, and proves_not_published is a whitelist, so a variant added later defaults to unsafe-to-compensate.

Cancellation before dispatch is a definite refusal. PublishDurabilitySlot::drop maps Idle | PreparingArchive | Refused to UploadFailed and PutDispatched | Ambiguous to UploadUnknowable; publish_durability_confirmed admits only Released, so an unreconciled dispatch no longer licenses the tail. Injecting PreparingArchive => Released fails slot_drop_during_compression_records_a_definite_non_publication and publish_durability_confirmed_refuses_quickly_after_unrecorded_slot_drop, and both pass again on restore. The handler test you asked for, receive_pack_cancelled_during_compression_publishes_nothing_and_runs_no_tail, blocks compression and cancels before the PUT. One caveat: under that mutation it hangs rather than failing cleanly, so I would not call its RED observed.

An unknowable generation is quarantined, not served. release_maybe_compensate writes a .{name}.git.quarantine sidecar next to the tree rather than inside it, and acquire reconciles at the top of the function, ahead of the existing-path fast path. Only the store confirming it holds this attempt's archive lifts it; both "belongs to someone else" and "cannot reach the store" refuse retryably, and the tree is never deleted.

Fork confirmation and cleanup are keyed on the attempt. confirm_fork_repo_row looks up the exact record.id and returns a three-way Ours / Foreign / Absent, so a concurrent row under the same name is Foreign and never claimed. The object is deleted only through delete_if_attempt_matches and the directory only through remove_fork_clone_if_ours, gated on a .{name}.git.fork-attempt sidecar where a missing stamp answers no. Stubbing that ownership check to true fails fork_clone_guard_leaves_a_successors_mirror_alone and fork_recovery_resuming_after_a_successor_deletes_neither_object_nor_path while the confirmation test stays green, which is the blast radius I would expect.

Response loss is no longer compensated as failure. ConstructionFailure is the only pre-dispatch arm; timeout, dispatch and response errors are all ambiguous, as is any future variant. On an ambiguous create-only PUT the fork path asks the store whether the archive is this attempt's, and if it cannot say yes it keeps the clone, keeps the object and refuses retryably instead of dropping the only local copy and fencing the name. a_fork_whose_publish_lost_its_response_is_recovered_not_fenced uses a server that commits the PUT and then loses the response; collapsing the split makes it fail with "a committed publish must not be reported as a failure".

Then I went looking for the same root cause on paths the fix had not reached, and found eight more. All eight are closed here, in three commits on top of the head you reviewed.

Five were the cache contract being applied at sites that each had to be remembered. The swap that replaces a live tree now owns the clear, so a cache-miss download no longer leaves a stale marker fencing the archive it just installed, and the under-lock refresh no longer lifts a quarantine on a HEAD that downloaded nothing. init clears a predecessor's marker. Both of the store's live-path hand-outs go through one confirming helper, so read_snapshot can no longer return an unconfirmed tree as an ordinary snapshot; that gate binds ahead of the lazy-migration spawn, because a quarantined tree backfilled by an IfAbsent PUT would publish exactly the refs the marker exists to withhold. And the marker no longer rests on a single fs::write.

Two were on the fork path. Cleanup claims ownership by renaming the stamp before it touches the tree, so a successor cannot lose its directory to a predecessor's check-then-unlink, and the directory-absent path no longer unlinks a stamp it does not own. A create-only precondition loss whose reconciliation HEAD fails is refused retryably rather than reported as a permanent name conflict, and its clone is released so the retry can re-clone.

The last was the pin sweep reading trees whose publish is unresolved, which is the sidecar marker, distinct from the quarantined row flag.

Two of those eight are worth describing properly, because the first attempt at each was wrong and a cross-family review caught it.

The abandoned tree was settled too late. The settlement ran from RepoWriteGuard::drop, and on a client disconnect that guard is held by the clone riding the admission guard into the detached reaper, so it ran only once the process group had been reaped. Reads take no advisory lock, so for the width of that window a concurrent fetch was served the abandoned refs with no marker beside them. I confirmed it before changing anything: no marker on disk, acquire returning the live path, and the abandoned ref still on the tree it served. Settlement now moves into a token the handler takes before the guard is shared, so the marker lands at the disconnect instant while the reaper still holds the lock.

That fix then allowed two classifiers for one write. release classifies the tree and only then awaits the unlock, while the handler disarmed the token after that await returned, so a cancel in the unlock gap settled a second time. After a definite refusal that is a quarantine marker written beside the tree release had just deleted. Observed by parking at the pre-unlock gate: tree gone, marker present. The arm is now shared between the guard and its token, release clears it above the unlock await, and settle takes it with a compare-exchange, so whichever classifier arrives first is the only one that acts.

Every gap-driving test added here was written and observed failing on its assertion before its fix existed. Each new guard was then checked by injecting the defect it names and confirming the named test goes red for the named reason, including one injection that reverts the settlement split and one that reverts the release-side disarm. Two of those started out proving nothing and were rebuilt rather than accepted: a marker-shadow test that asserted only after the clear that removes the thing it was checking, and tree_settled, whose old proof went slack once the shared arm landed. The reap-window test was also passing for weaker reasons than it claimed, since its fake git took SIGTERM and let the reaper free the lock before the probe read.

The full suite passes on CI.

Four residuals I did not close, so they are not a surprise later:

  • A SIGKILL or power loss between the refs landing and the settlement still leaves no marker. Closing that needs eager marking when the tree is first handed out, which is a larger change: an attempt-less marker currently means definite non-publication and would invalidate a tree a live push is writing into, so readers would first have to tell "write in flight" from "write abandoned".
  • The in-memory fallback for an unwritable marker is a process-lifetime map keyed by path, evicted only by a clear or a later successful write, and lost on restart while the disk may still be unwritable.
  • Discovery may settle a source-less legacy pin row whose only warm holder is publish-unresolved without a retryable signal; the provenance loop sets one, discovery does not. Raised by review, not reproduced by me.
  • The source scan that would enumerate every direct reader of the live path is deliberately not here. It is a repo-wide gate rather than one of these gaps, and it belongs in its own change.

@beardthelion
beardthelion requested a review from jatmn September 7, 2026 09:02

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@beardthelion

Copy link
Copy Markdown
Collaborator Author

@kevincodex1 LGTM

@euxarist euxarist left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This closes the sharpest blades of the #283 family: the swap no longer self-authorizes (publish lock plus a revocable commit token with CAS, so an abandoned future cannot remove or rename the live tree), and the object-store writes carry If-Match/IfAbsent against the generation the attempt read, tested against an S3 mock that actually enforces preconditions. The timeout-after-dispatch quarantine (never compensating an unknowable outcome) is the right epistemology. Residuals are self-declared and real: #282 entry concurrency stays open, #343/#205 are improved rather than closed, and acquire vs acquire_fresh coverage is not distinguishable in the suite. Merge-order note: this rewrites the receive-pack region and the RepoWriteGuard::release contract that #384 calls, so land it first and make #384 rebase for real.

@beardthelion
beardthelion added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit ed76adf Nov 4, 2026
19 checks passed
@beardthelion
beardthelion deleted the fix/279-advisory-lock-session-affinity branch November 4, 2026 22:57
euxarist pushed a commit to euxarist/node that referenced this pull request Nov 6, 2026
… 2/4) (Twigpine#524)

* fix(node): ANS-104 transport, three-outcome probe, verify endpoint (Twigpine#26 split 2/4)

Reviewer 2 closed PR Twigpine#224 on 2026-08-28 with a directive: split
into four narrow PRs. This is Split PR 2 (Arweave transport and
verification).

The P1 finding the reviewer assigned to this split: the legacy
`anchor_item_present` mapped `BAD_REQUEST` and `GONE` to `Ok(false)`,
and the recovery code interpreted false as permission to reclaim the
row and pay for another upload. Neither 400 nor 410 proves a
previously paid item was never accepted: 410 can describe an
artifact that existed but is no longer served, and 400 can be
produced by gateway, proxy, or routing failure. The concrete
failure sequence the reviewer named: bundler accepts the item,
the response or terminal DB write is lost, recovery probes the
persisted item_id, the gateway returns 400 or 410, and the node
pays for a second immutable item for the same transition.

Fix: model the probe result as three outcomes, and authorize a
re-upload only on a trustworthy, protocol-defined absence.

NEW MODULE crates/gitlawb-node/src/ans104.rs
  - DataItem: the on-wire ANS-104 shape (signature, owner, target,
    anchor, tags, data — all base64url-encoded except target/anchor).
  - deep_hash: the canonical signing input per the spec. The
    signature is over a SHA-256 mix of the dataitem/list/map
    discriminators, the signature type, owner, target, anchor, the
    recursively-hashed tag list, and the data payload. A
    hand-rolled sha256 of the JSON body would produce a hash no
    Arweave gateway would recognize; the deep-hash is the only
    correct form.
  - sign_data_item: Ed25519 over deep_hash.
  - verify_data_item: parses the owner, checks the signature against
    an expected pubkey, and reports a specific failure reason on
    each branch.
  - 7 unit tests covering round-trip, flipped signature, wrong
    expected key, mutated data, wire-shape round-trip, deep-hash
    stability, and the empty-tags edge case.

NEW MODULE crates/gitlawb-node/src/arweave_v2.rs
  - ProbeOutcome: Present (2xx, body parses as ANS-104, sig
    verifies), DefinitivelyAbsent (404 with a known JSON body
    shape), Indeterminate (everything else). The reviewer's
    three-outcome model.
  - ProbeOutcome::permits_reupload: the recovery policy. ONLY
    DefinitivelyAbsent authorizes a paid re-upload. Indeterminate
    keeps the outbox non-terminal; Present skips re-payment.
  - probe_anchor_item: the gateway probe. 2xx bound to a different
    item id is Indeterminate, not Present. 2xx with a body that
    does not parse as ANS-104 is Indeterminate. Oversized bodies
    are Indeterminate (defense against body-stuffing).
  - read_capped_body: enforces PROBE_MAX_BODY_BYTES (1 MiB) on the
    full body, with a Content-Length fast-path that rejects
    before reading.
  - verify_anchor: the full path the public verify endpoint takes.
    Fetches the data item, parses it, verifies the Ed25519
    signature against the persisted node_did, decodes the data
    payload as JSON, and reports the result.
  - 15 unit tests pinning each classification boundary:
    400/410/5xx/transport/2xx-non-json/2xx-bad-sig/2xx-different-
    owner are all Indeterminate; 404 with empty or known-JSON
    body is DefinitivelyAbsent; 2xx with a valid signed item is
    Present; the re-upload policy is exhaustive.

NEW DB METHOD crates/gitlawb-node/src/db/mod.rs
  - get_arweave_anchor_by_item_id: looks up an existing
    arweave_anchors row by its id (which is the Irys tx id, the
    same value the Arweave gateway uses to serve the data item).
    Used by the public verify endpoint to fetch the persisted
    node_did so the envelope signature can be verified against it.

NEW CONFIG crates/gitlawb-node/src/config.rs
  - GITLAWB_ARWEAVE_GATEWAY_URL: the Arweave gateway the probe
    reads from. Defaults to https://arweave.net; operators can
    point it at a private mirror.

NEW HTTP ENDPOINT crates/gitlawb-node/src/api/arweave.rs
  - GET /api/v1/arweave/anchors/verify/{item_id}: the public
    verify endpoint. Returns {item_id, status, verified,
    owner_did, data_payload, error} where status is one of
    "verified" / "definitively_absent" / "indeterminate".
  - 3 endpoint-level tests covering the verified, 404, and 400
    shapes via the live router, asserting the public surface
    surfaces the three outcomes.

WIRE-UP crates/gitlawb-node/src/server.rs
  - The verify route is registered alongside the existing
    /api/v1/arweave/anchors list route.

NOT IN THIS SLICE (the bundler upload, the next commit):
  - The handler that builds a DataItem from a ref-cert and POSTs
    it to the bundler is not in this PR. The ANS-104 module is
    ready to be called by it; the cert/push-event flow in PR 1
    persists the row that the bundler upload would consume.
  - PR 1's recovery drain produces an `anchor_jobs` row keyed on
    the (repo, ref, old, new) tuple; the next slice reads that row,
    builds the ANS-104 data item, POSTs to the bundler, and writes
    the resulting tx id to arweave_anchors.

Compiles clean, 1113 tests pass with 0 regressions, clippy clean
under -D warnings, fmt clean.

Cross-PR overlap (declared in the PR description):

  - Twigpine#134 (anchors auth): composes. The verify endpoint reads from
    arweave_anchors; Twigpine#134's auth layer applies unchanged. The
    probe model is server-side and unauthenticated by design.
  - Twigpine#285 (advisory-lock session affinity): independent. The probe
    is read-only.
  - Twigpine#306 (Content-Digest on signed requests): independent. The
    probe does not sign requests.
  - Twigpine#314 (small-order Ed25519): composes. The verify path calls
    Did::to_verifying_key which already enforces the small-order
    check from Twigpine#314.
  - Twigpine#324 (libp2p keypair persistence): independent.
  - Twigpine#325 (gossip ref-update auth): independent. The verify path
    is for the public Arweave gateway, not gossip.
  - Twigpine#382 (replication withheld-subtree trees): independent.

* fix(node): address reviewer findings on Twigpine#26 split 2/4 (transport)

- P1 (DB lookup): get_arweave_anchor_by_item_id now filters on
  irys_tx_id (the externally-routable item id) rather than the
  internal UUID primary key. The production writer stores a fresh
  UUID in `id` and the Irys response / ANS-104 derived id in
  `irys_tx_id`; the old filter would always 404. New
  integration tests exercise the production writer path with
  distinct values for the two columns.
- P1 (verify-gate): verify_anchor now gates on repo read via
  authorize_repo_read. The route is split in server.rs: the
  public list_anchors stays ungated (Twigpine#134 tracks that surface),
  the verify route gets optional_signature per the team memory
  `axum-layer-vs-merge-pitfall.md`. Denials on row-lookup, gate,
  and missing repo all collapse to the same opaque 404 so the
  public endpoint does not leak anchor-row existence.
- P1 (ANS-104 deep-hash): rewritten to the verified SHA-384
  recursive list/blob algorithm (matches `arbundles` JS
  reference). deep_hash returns [u8; 48]; signature is over the
  raw 48-byte digest. Three reference vectors from
  `Irys-xyz/arbundles/src/__tests__/deepHash.spec.ts` are
  bit-exact-asserted as the interop canary per the team memory
  `self-roundtrip-tests-do-not-prove-interop.md`.
- P1 (artifact-identity): v2 verify now derives the protocol id
  via DataItem::id() (base64url(SHA256(signature))) and requires
  equality with the requested URL item_id. A node key signs
  many data items, so a valid signature only proves who signed
  the response — not that the served item is the one the caller
  asked to verify. The team memory
  `verify-against-artifact-id-not-signer.md` is the policy.
- P1 (verifier can't read this head's writes): verify_anchor
  now accepts BOTH the v2 ANS-104 format and the v1 raw-JSON
  shape the live path on this branch actually writes. The v1
  path does a field-equality check on repo, ref_name, old_sha,
  new_sha, node_did against the persisted row (no signature in
  v1; Irys storage plus the JSON parse are the integrity
  guarantee).
- P2 (ProbeOutcome in result): AnchorVerifyResult now carries
  outcome: ProbeOutcome. The HTTP handler maps the structured
  field to the status string instead of parsing the human-
  readable error message — any future wording change in
  arweave_v2.rs can no longer silently reclassify a
  DefinitivelyAbsent as Indeterminate.
- P2 (empty 404 body): classify_404 returns Indeterminate for
  bodyless 404s. The team memory
  `distinguish-unknown-from-empty.md` is the policy: a
  bodyless 404 from a proxy is not a proof of absence, and the
  recovery policy (permits_reupload -> true on
  DefinitivelyAbsent) authorizes a paid, irreversible re-upload.
- P2 (streaming cap): read_capped_body no longer uses
  resp.bytes() (which buffers the full response before checking
  size). It now reads chunk-by-chunk and aborts the moment the
  cumulative byte count crosses the cap, so a chunked response
  without Content-Length cannot force unbounded allocation.

* fix(node): address three reviewer findings on Twigpine#26 split 2/4 (round 2)

* fix(node): address five reviewer findings on Twigpine#26 split 2/4 (round 3)

* fix(node): address reviewers round-4 on Twigpine#26 split 2/4 (interop)

* fix(node): address coderabbit on Twigpine#26 split 2/4 (raw tags, vint overflow, golden key)

* fix(node): enforce artifact-id in probe_item on Twigpine#26 split 2/4

* fix(node): require expected owner for probe Present on Twigpine#26 split 2/4

* fix(node): narrow Twigpine#26 split 2/4 to codec and policy types

* fix(node): silence dead-code for deferred slice API on Twigpine#26 split 2/4

* fix(node): doc and bounds-check cleanup in ans104 on Twigpine#26 split 2/4

* docs(node): point tests doc at Ed25519 golden script on Twigpine#26 split 2/4

* fix(node): strict owner lengths, sign-before-hash on Twigpine#26 split 2/4

* fix(node): keep ans104 tags and field widths consistent on hash and encode

DataItem keeps both the decoded `tags` and the original Avro `raw_tags`, both
public. After a parse, changing `tags` left deep_hash and verify_data_item
using the stale raw bytes, and to_binary wrote a header tag count that no
longer matched the payload, so the frame failed to parse back.

Take the tag block for hashing and encoding from one helper that decodes
`raw_tags` and requires it to equal `tags`, and fail closed otherwise. The
header count comes from the same validated pairs. Parsed multi-block and
size-prefixed items still re-encode byte-exact and keep their signatures.

deep_hash now also rejects a non-empty target or anchor that is not 32 bytes,
as to_binary already does, so sign_data_item fails instead of signing an item
that cannot be encoded. The legacy non-base64url anchor fallback is kept.

The module and signature-type docs now say binary parsing accepts sigtypes 1
to 4 while signing and verifying are Ed25519 only on this slice.

* build(scripts): declare the golden script's direct npm dependencies

ans104_golden_ed25519.mjs imports bs58, @noble/ed25519 and base64url, and
ans104_golden.mjs imports base64url, but package.json listed only arbundles, so
they resolved only through hoisting. Declare them at the versions already
locked, update the lockfile root entry (no resolved version or hash changes),
and note the regeneration command in the golden output file.

---------

Co-authored-by: Gravirei <gravirei@users.noreply.github.com>
Ayush7614 pushed a commit to Ayush7614/node that referenced this pull request Nov 10, 2026
…plit 3/4) (Twigpine#548)

* fix(node): version ref_certificates for forward compat (Twigpine#26 split 3/4)

Reviewer 2 closed PR Twigpine#224 on 2026-08-28 with a directive: split
into four narrow PRs. This is Split PR 3 (certificate / CLI
compatibility). No findings are assigned to it; it is required
because the protocol/client work is independently reviewable and
should not remain coupled to post-receive recovery.

The wire-format problem: the ref-cert payload is a fixed 7-field
JSON blob the node signs. Future changes (additional pusher
attestations, content-binding fields, secondary signatures) would
either break every existing cert or be impossible to ship without
a flag day. Versioning the cert makes the format forward-compat
and explicit.

NEW MIGRATION v28
  ref_certificates gains `version INTEGER NOT NULL DEFAULT 1`.
  DEFAULT 1 means an existing row reads as v1 with no backfill
  needed; NOT NULL means a missing value is a hard error rather
  than a silent v0.

NEW FIELD ON RefCertificate
  `version: u32`. v1 is the pre-versioning 7-field payload
  (no `version` key in the signed JSON, so a v1 cert is
  byte-for-byte identical to today's cert). v2+ certs will
  include a `version` key and any new fields, both
  backwards-compatible: an old client reading a v2 cert sees the
  unknown `version` field and either ignores it (graceful) or
  refuses to verify (explicit).

API CHANGES
  - list_certs and get_cert now include `version` in the JSON
    response.
  - issue_ref_certificate sets `version: 1` on every new cert.

GL CHANGES
  - gl cert show reads the `version` field; missing field
    defaults to 1 (forward-compat with old servers). v1 certs
    go through the unchanged verify path. v2+ certs are
    explicitly rejected with an "upgrade the client" message
    rather than silently guessing the payload shape — a future
    client that supports v2 cannot accidentally treat a v1 cert
    as v2.

GOLDEN-FORMAT FIXTURE
  v1_payload_matches_frozen_canonical_form pins the v1 signing
  payload byte-for-byte. Reverting this to a v2 shape (adding a
  `version` key) is what the versioned format forbids: an old
  client reading a v1 cert must not see a `version` key in the
  signed JSON. The same byte-form is what gl's verify_signature
  reconstructs in crates/gl/src/cert.rs, so any drift between
  node and CLI breaks the test instead of silently rendering
  every real cert INVALID.

TESTS
  - 3 v1_payload_tests in crates/gitlawb-node/src/cert.rs:
    frozen-canonical-form, round-trip structural, default
    version is 1.
  - 2 new tests in crates/gl/src/cert.rs:
    missing_version_defaults_to_1_and_verifies,
    explicit_version_1_takes_v1_path.
  - 12 existing ref_certificate_tests still pass with the
    version column added.

Compiles clean, 1091 tests pass with 0 regressions, gl 4 cert
tests pass, clippy clean under -D warnings, fmt clean.

Cross-PR overlap (declared in the PR description):

  - Twigpine#134 (anchors auth): independent. The cert API is unchanged
    in shape; auth gates apply as before.
  - Twigpine#285 (advisory-lock session affinity): independent.
  - Twigpine#306 (Content-Digest on signed requests): independent.
  - Twigpine#314 (small-order Ed25519): independent. v1 certs verify
    through the same path, which already enforces the small-
    order check from Twigpine#314 on the embedded node DID.
  - Twigpine#324 (libp2p keypair persistence): independent.
  - Twigpine#325 (gossip ref-update auth): independent. PR 1's signed
    HTTP push is the durable-intent producer; this PR owns the
    cert shape. The two PRs read and write the same cert table
    but do not step on each other (PR 1 calls
    issue_ref_certificate_idempotent; this PR keeps the
    deterministic cert id but does not change who calls what).
  - Twigpine#382 (replication withheld-subtree trees): independent.

* fix(node,gl): address review feedback on cert version compat

- Drop the duplicate `version` ALTER from the v1 migration bundle; v28
  is now the sole owner of the column.
- Add a v28 upgrade-path test (v25/v26 pattern) that seeds a legacy
  pre-v28 ref_certificates row, drops the column + migration record,
  re-runs migrations, and asserts DEFAULT 1 + legacy row reads as v1.
- Extract a shared `v1_signing_payload` builder so the live signer
  and the frozen-vector test share one literal; a regression in the
  signer now fails the test.
- Tighten gl's `version` parsing: missing key → v1, present value
  must be a JSON integer that fits in u32 and equals 1. Null, strings,
  floats, overflow, and unknown versions all return Err instead of
  collapsing to v1 (Reviewer 2). Add a truth-table test pinning every
  cell of the parser.
- Extend the v1 forward-compat tests to actually round-trip through
  `verify_signature`, and add a regression test that pins the verdict
  branch rejects v2 even when the v1 signature would otherwise verify.

* fix(cert): address CI failures on PR Twigpine#386

- cargo fmt --all (CI fmt check was red on the prior commit)
- v28 upgrade-path test: switch version_column_default from
  fetch_one to fetch_optional, so a pre-v28 database with the
  column dropped returns Ok(None) rather than RowNotFound. The
  precondition assertion is what detects a missing column;
  RowNotFound would have masked a regression.
- clippy `needless_borrow` on the v2 verdict-branch test:
  drop the leading `&` on `cert_json["signature"].as_str()`.

* fix(gl,node): make the v2 rejection and issuer anchor real, and test them as such

Round-2 findings, all three demonstrated by execution:

- cmd_show's Ok(v != 1) verdict arm was dead code (parse_cert_version
  admits only 1), and the test aimed at it copied the match instead of
  calling the command. The match now mirrors the parser's contract and
  two end-to-end mockito tests drive cmd_show itself: a version-2 cert
  with a valid v1 signature fails --verify with the version named.

- --verify no longer accepts the queried node's self-reported DID as a
  trust anchor: the node that served a forged cert can serve a matching
  self-report, and that fallback passed a wholly forged certificate with
  exit 0. --verify now requires --expect-node before claiming a trusted
  issuer, with a test proving the refusal (and the anchored positive
  control passing).

- The node-side version test asserted version == 1 on its own literal;
  it now cross-checks the issuing construction: a version-1 claim must
  verify against the v1 payload builder's bytes rebuilt from the cert's
  own fields, and the v1 payload must carry no version key.

* fix(node,gl): address round-2 reviewer findings on Twigpine#26 split 3/4 cert compat

BREAKING CHANGE: gl cert show <id> --verify now requires --expect-node <did>; the node's self-reported DID is no longer trusted as a cert anchor.

* fix(node): bind cert v2 test to live issuer via sqlx::test

* fix(node,gl): address round-3 reviewer findings on Twigpine#26 split 3 cert compat

- Stamp new certs v1 until v2 verifier ships; v2 issuance refused by shipped gl
- Add v2_signing_payload with version in signed bytes, downgrade test
- Verify signature in well-formed test, add DB read-back to issuer test
- Fix README/db comments and version-refusal message

* fix(gl): route cert list through error_for_status; correct cert show example

Round 4 reviewer findings on Twigpine#26 split 3 cert compat:

- P2 (cert.rs:113): cmd_list called .json() straight through on the
  cert list endpoint and let a gated 404 (whose body lacks a
  `certificates` key) fall into the empty-list branch, printing "No
  ref certificates" with exit 0. Wire cmd_list through the same
  error_for_status() gate that cmd_show and resolve_cert_id already
  use, and pin the new behavior with a load-bearing test
  (`cmd_list_surfaces_gated_404_as_err`) that drives cmd_list end
  to end against a 404 mock. cmd_show is unchanged.

- P3 (README.md:314): the documented `gl cert show <id> --verify`
  example bound the certificate ID as the REPO positional, because
  the parser is `gl cert show [OPTIONS] <REPO> <ID>`. Insert the
  missing `<repo>` positional so the example matches the CLI
  parser.

The original BREAKING CHANGE footer in bb553b6 carried the same
shortened `gl cert show <id> --verify` example; this commit's
footer supersedes it with the canonical command, which is what
release-note scrapers read. The round-2 commit message itself is
left in history unchanged; the new footer is the one callers
should rely on.

BREAKING CHANGE: gl cert show <repo> <id> --verify requires --expect-node <did>; the queried node's self-reported DID is no longer accepted as a cert anchor.

* style(gl): fix fmt in cert list response parsing

* fix(gl): label version refusals distinctly and clean cert show help

A cert declaring an unsupported version printed INVALID, which reads as
a failed Ed25519 check even though verify_signature never ran. The
display now prints UNSUPPORTED VERSION for version refusals and keeps
INVALID for real signature failures. The Show help no longer cites
review rounds or commit history.

* docs(node): align RefCertificate.version rustdoc with the signed-bytes v2 contract

---------

Co-authored-by: Gravirei <gravirei@users.noreply.github.com>
Co-authored-by: Kevin Codex <kevin@gitlawb.com>
Ayush7614 pushed a commit to Ayush7614/node that referenced this pull request Nov 10, 2026
…split 1/4) (Twigpine#558)

* fix(node): durable post-receive outbox at the DB layer (Twigpine#26 split 1/4)

Reviewer 2 closed PR Twigpine#224 on 2026-08-28 with a directive: split the
work into four narrow PRs. This is Split PR 1 (durable post-receive
lifecycle) at the DB layer; the handler refactor in
crates/gitlawb-node/src/api/repos.rs:2007 (git_receive_pack) lands in
the next slice so the test can drive the failure injection end-to-end.

The pre-outbox crash window the reviewer flagged: receive_pack can
apply a ref to disk and return Ok, and a process exit, a dropped
future, or a DB failure before the bookkeeping at
crates/gitlawb-node/src/api/repos.rs:2361 (push event + cert + webhook)
loses the recovery record. Startup drain enumerates only sources
written from that bookkeeping, so it cannot reconstruct the missing
work. The partial fallback that re-derives from a row present in the
bookkeeping substitutes did:key:recovered and an empty attestation,
which is not equivalent to the original authenticated push.

This commit adds the durable boundary the handler will lean on.

NEW TABLE pending_ref_transitions (migration v27):
  - Written by the handler BEFORE smart_http::receive_pack, in state
    'prepared', carrying the verified pusher DID, the raw RFC 9421
    signature header, signature-input, and content-digest that
    authorized the push, the request id, and the parsed ref update.
  - The handler transitions the row to 'applied' on receive_pack Ok
    or 'cancelled' on Err. The drain reads only 'applied'.
  - A failed or cancelled receive-pack therefore leaves the row in
    'prepared' or 'cancelled', which the drain never promotes. This
    is what closes the reviewer's second proof ("a failed or cancelled
    receive-pack does not turn a prepared intent into completed
    accounting or anchoring").

NEW TABLE anchor_jobs (migration v27, owned by PR 1, consumed by PR 2):
  - One row per (repo_id, ref_name, old_sha, new_sha) transition.
    PR 1 inserts it on 'applied'; PR 2 reads it and updates claimed_at.
  - ON CONFLICT (id) DO NOTHING makes the insert idempotent on the
    deterministic id, so a recovery re-pass cannot create a second
    upload request. This is the handoff boundary; the bundler call
    itself is PR 2.

NEW DB METHODS on Db:
  - insert_pending_ref_transitions: writes one 'prepared' row per
    ref update, returns the persisted rows.
  - mark_pending_ref_transitions_applied / _cancelled: state flip,
    gated on the FROM state, idempotent.
  - list_pending_ref_transitions_applied: drain query, oldest first.
  - delete_pending_ref_transition: called by recovery after the
    artifacts land; a third pass is a no-op.
  - record_push_with_id: ON CONFLICT (id) DO NOTHING on the
    deterministic id.
  - insert_ref_certificate_idempotent: ON CONFLICT (repo_id, ref_name)
    DO NOTHING, returns None if a live-path cert already exists.
  - insert_anchor_job_idempotent: ON CONFLICT (id) DO NOTHING on the
    deterministic per-transition id.

NEW HELPERS in db/mod.rs:
  - deterministic_id: SHA-256 hex with an ASCII Unit Separator
    between fields so two distinct tuples never collide on prefix
    overlap.
  - push_event_id_for, ref_cert_id_for, anchor_job_id_for: the
    derived ids above, one helper per artifact so a caller cannot
    derive a wrong id by mistake.

NEW STRUCTS:
  - PendingRefTransition: the row shape.
  - AnchorJob: the handoff row shape.
  - pending_state: const strings ('prepared' / 'applied' /
    'cancelled') shared by tests, the producer, and the drain so a
    typo on one side cannot silently mismatch the other.

NEW TESTS in db::pending_ref_transition_tests (8 tests, all green):
  - insert_then_mark_applied_flips_state_for_every_ref: producer
    contract.
  - mark_applied_is_idempotent_on_repeat: re-fire is a no-op.
  - cancelled_rows_are_not_returned_by_the_drain: reviewer's second
    proof at the DB layer.
  - prepared_rows_are_not_returned_by_the_drain: same proof for the
    pre-flip state (handler crashed before reaching post-Ok).
  - mark_cancelled_is_idempotent_on_repeat: counterpart.
  - drain_then_re_derive_is_idempotent: reviewer's first proof at
    the DB layer. Inserts a row in 'applied' state directly via
    insert_pending_ref_transition_for_test, drains it, derives the
    artifact ids twice, exercises record_push_with_id and
    insert_anchor_job_idempotent directly, asserts exactly one push
    event row and exactly one anchor job row regardless of how many
    times the drain runs.
  - deterministic_id_avoids_prefix_overlap_collisions: the
    separator regression test.
  - push_event_id_for_is_stable: derived ids match across calls and
    differ on each varied input.

OTHER:
  - Make RefUpdate and its fields pub(crate) so the DB methods can
    iterate the parsed ref updates. No public API change.

NOT IN THIS SLICE (the handler refactor, next commit):
  - The receive-pack handler does not yet call insert_pending_ref_
    transitions before the receive_pack call, nor mark_applied /
    mark_cancelled after. The DB layer is in place for it; the
    handler will call these methods and the startup drain will be
    wired in main.rs.
  - The startup drain in main.rs is not yet called; it will iterate
    list_pending_ref_transitions_applied, re-derive the artifacts,
    and delete the row.
  - The cert/push event issuance in cert.rs and the bookkeeping in
    api/repos.rs:2361 are not yet changed to use the deterministic
    ids. The helper functions exist and are tested; the callers
    follow.

Compiles clean, clippy clean under -D warnings, fmt clean.

* fix(node): wire durable outbox into the receive-pack handler (Twigpine#26 split 1/4)

This is the handler-level half of Split PR 1. The previous commit
added the migration and the DB methods; this one threads them
through crates/gitlawb-node/src/api/repos.rs:2007 (git_receive_pack),
the cert issuer, and the startup drain.

CHANGES IN THE HANDLER
======================

In git_receive_pack, AT THE LAST POSSIBLE MOMENT before the
smart_http::receive_pack call, the handler now:

  1. Generates a per-handler request_id (UUID).
  2. Captures the raw Signature, Signature-Input, and Content-Digest
     headers from the request.
  3. Calls db.insert_pending_ref_transitions(request_id, ...) which
     writes one row per ref update in state 'prepared'.

The receive_pack call runs as before. After it returns:

  4. On Ok: db.mark_pending_ref_transitions_applied(request_id) —
     the row is the ONLY thing that promotes a 'prepared' row to
     'applied', and the drain reads only 'applied' rows. A process
     crash before this call leaves the row in 'prepared', which the
     drain never promotes.
  5. On Err: db.mark_pending_ref_transitions_cancelled(request_id) —
     a failed receive_pack leaves the row in 'cancelled', which the
     drain never promotes.

This is what closes the reviewer's two proofs:

  Proof 1 (crash window): if the process dies after
  mark_pending_ref_transitions_applied but before the bookkeeping
  writes, the row is in 'applied' and the next startup drain
  re-derives the push event, the per-ref certificate (carrying the
  ORIGINAL pusher DID, not a placeholder), and the anchor handoff.
  The drain uses the persisted authentic pusher DID and signature
  header, not a recovered placeholder.

  Proof 2 (failed receive-pack): the row is only ever flipped to
  'applied' in the explicit Ok branch above. A 'prepared' or
  'cancelled' row is invisible to the drain, so a failed or dropped
  receive_pack cannot turn a prepared intent into completed
  accounting or anchoring.

BOOKKEEPING IS NOW DETERMINISTIC-ID
===================================

The post-Ok bookkeeping at api/repos.rs:2448 now uses:

  - record_push_with_id with push_event_id_for(request_id, first_ref)
    — ON CONFLICT (id) DO NOTHING, so a recovery re-pass is a no-op.
  - issue_ref_certificate_idempotent with
    ref_cert_id_for(request_id, ref_name) — ON CONFLICT (repo_id,
    ref_name) DO NOTHING, returns None if a live-path cert already
    exists.
  - insert_anchor_job_idempotent with
    anchor_job_id_for(repo_id, ref_name, old_sha, new_sha) — the
    per-transition tuple key, so two pushes to the same ref produce
    one anchor upload per landed state.

The legacy entry points (record_push, issue_ref_certificate,
insert_ref_certificate) remain for callers that prefer a fresh UUID
per cert; they are #[allow(dead_code)] for the PR 3 cert/CLI compat
pass to decide whether to keep or remove.

STARTUP DRAIN
=============

crates/gitlawb-node/src/main.rs calls
durable_outbox::drain_pending_ref_transitions(state, 1000) ONCE
before serving, after migrations and after the existing peer /
quarantine prunes. Non-fatal: a transient drain failure logs and
leaves the rows for the next startup.

durable_outbox::drain_pending_ref_transitions reads every 'applied'
row, calls derive_one (which re-derives the three artifacts using the
persisted authentic pusher DID and signature header), then deletes
the row. A second drain pass is a no-op for both the artifacts
(idempotent inserts) and the row (gone after the first pass).

NEW END-TO-END TESTS
====================

crates/gitlawb-node/src/durable_outbox.rs adds three end-to-end
tests in drain_tests, complementing the eight DB-layer tests in
db::pending_ref_transition_tests:

  - drain_re_derives_all_three_artifacts_for_an_applied_row: the
    reviewer's first proof. Inserts a row in 'applied' state (the
    crash window), drains, asserts exactly one push event row,
    exactly one cert row carrying the original pusher DID (not a
    placeholder), and exactly one anchor job row. Asserts the
    deterministic cert id matches. Asserts a second drain pass is a
    no-op.
  - cancelled_row_produces_no_artifacts: the reviewer's second proof
    for the cancelled state. A row in 'cancelled' (receive_pack
    returned Err) is invisible to the drain.
  - prepared_row_produces_no_artifacts: the reviewer's second proof
    for the prepared state. A row in 'prepared' (handler crashed
    between insert_prepared and the post-Ok branch) is invisible to
    the drain.

Each test names the invariant it pins and the production line it
covers. Reverting that line turns the named assertion red.

Compiles clean, 1099 tests pass with 0 regressions, clippy clean
under -D warnings, fmt clean.

Cross-PR overlap (declared in the PR description):

  - Twigpine#134 (anchors auth): composes. The /arweave/anchors route
    already requires auth; this PR does not change the route.
  - Twigpine#285 (advisory-lock session affinity): composes. The durable
    intent is written inside the same handler that holds the lock
    from Twigpine#285; no changes to the lock layer.
  - Twigpine#306 (Content-Digest on signed requests): composes. PR 1
    persists the Content-Digest header that Twigpine#306 makes mandatory.
  - Twigpine#314 (small-order Ed25519): independent. PR 1's tests use strong
    keys.
  - Twigpine#324 (libp2p keypair persistence): independent. PR 1 does not
    touch p2p identity.
  - Twigpine#325 (gossip ref-update auth): independent. PR 1's signed
    envelope is the HTTP-side equivalent, not the gossip-side.
  - Twigpine#382 (replication withheld-subtree trees): independent. PR 1
    does not touch replication or pin selection.

* fix(node): address four reviewer findings on Twigpine#26 split 1/4

- P1-A: add startup reconcile step that promotes `prepared` rows to
  `applied` when the on-disk ref matches the row's `new_sha`. The
  recovery drain (which only reads `applied` rows) can now pick up
  a ref that landed when the live handler's
  `mark_pending_ref_transitions_applied` call errored or was
  interrupted. Strict SHA equality is the load-bearing check — a
  `prepared` row whose target did NOT actually land stays
  `prepared`.
- P1-B: route the live handler's cert issuance through
  `cert::issue_ref_certificate` (the upsert) instead of
  `issue_ref_certificate_idempotent` (DO NOTHING). A re-push to the
  same ref now updates the cert's `old_sha` / `new_sha` /
  `pusher_did` / `issued_at` / `signature` to the new transition
  while preserving the deterministic `cert_id`. The recovery drain
  keeps the idempotent variant; both paths collapse to one row.
- P2-A: refactor the drain into a `drain_pending_ref_transitions_with`
  testable seam that does per-row log-and-continue, and add
  `drain_pending_ref_transitions_all` that loops
  `DRAIN_PER_PASS_LIMIT=1000` rows for `DRAIN_MAX_PASSES=10` passes.
  A failing row no longer stalls the batch; a backlog above 1000
  rows is fully processed across passes.
- P2-B: add a `first_ref_name` column to `pending_ref_transitions`
  via migration v28. The live handler hoists a `first_ref_name`
  local and persists it on every row of the same `request_id`. The
  drain's `derive_one` keys the push event id on
  `row.first_ref_name` instead of `row.ref_name`, so live and
  recovery produce the same id and `ON CONFLICT (id) DO NOTHING`
  collapses a multi-ref push to one push event row (and one trust-
  score bump). Cert and anchor ids stay per-ref / per-transition.

* fix(node): address four reviewer findings on Twigpine#26 split 1/4 (round 2)

* fix(node): address reviewer round-3 findings on Twigpine#26 split 1/4

- P1: Delete outbox rows after live durable effects complete so they
  don't replay on every restart
- P1: Parse git report-status for per-ref ok/ng results; mark only
  proven rejections as cancelled, uncertain outcomes as recoverable
- P1: Introduce 'uncertain' state for receive-pack errors where some
  refs may have landed; reconcile checks these against disk at startup
- P2: Promote deletion transitions during reconcile (new_sha == ZERO_SHA
  with absent ref = successful deletion)
- P2: Loop reconcile across multiple passes so backlogs beyond the first
  page are processed in the same startup

Closes review round 3 findings from reviewer-1 and reviewer-2.

* fix(node): fix CI failures from round-3 changes

- Add COMMENT ON TABLE to v29 migration so migration_bodies_are_non_empty passes
- Return error on non-zero receive-pack exit (preserving backward compat
  with tests that expect Err(AppError::Git(_))) while still parsing
  report-status for outbox row handling

* fix(node): update inv22 gate tests for receive_pack_raw refactor

- F3 gate: scan for smart_http::receive_pack_raw( instead of receive_pack(
- U5 gate: scan for all_refs_ok instead of receive_result.is_ok()

* fix(node): require reflog proof before the reconcile promotes a row (Twigpine#26 split 1/4)

Closes the second half of the reviewer's round-3 [P1]: the startup
reconcile still promoted on `disk_refs.get(ref) == row.new_sha`, which
also matches a coincidental current tip (old=B, new=A while the ref is
already A). That is not an exotic case — it is the ordinary shape of a
REJECTED push, since git refuses an update whose expected old value does
not match the ref. Both the SHA check and the MAX_RECONCILE_AGE window
pass, the push never happened, and the drain then writes a push event, a
signed certificate and an anchor for it.

The live path proves landings from git's report-status body; that body is
gone by the time the reconcile runs, so the proof is re-derived from the
repository. `git::store::ref_reflog_entries` reads `logs/<ref>` and a row
is promoted only when an entry carries its exact `old -> new` pair,
stamped at or after the row was written (REFLOG_CLOCK_SKEW absorbs git's
whole-second truncation against the row's sub-second `created_at`). The
timestamp floor is what separates a landing from a later push that
re-introduced the same pair: proof must postdate the intent it proves.

Deletions stay exempt from the reflog half and keep the absence-plus-age
rule already in place — git removes a ref's reflog along with the ref, so
no other evidence can exist for one.

`init_bare` now sets `core.logAllRefUpdates`, which bare repos default
off; a repo with no reflog yields no proof and its rows stay put for
human-attended recovery rather than becoming accounting the node cannot
substantiate.

Tests: reconcile_refuses_a_coincidental_tip_with_no_reflog_proof,
reconcile_promotes_a_row_the_reflog_proves,
reconcile_leaves_a_row_prepared_when_the_repo_keeps_no_reflog,
reconcile_refuses_a_reflog_entry_older_than_the_row,
reconcile_still_promotes_a_landed_deletion_which_can_have_no_reflog,
init_bare_keeps_reflogs_so_a_landing_can_be_proven,
ref_reflog_entries_is_none_when_the_repo_kept_no_log.

No migrations added or renumbered.

* fix(node): walk the reconcile backlog on a keyset cursor (Twigpine#26 split 1/4)

The multi-pass reconcile looped but never advanced. Each pass re-issued
the same cursor-less `LIMIT n` query, and unlike the drain — which
deletes every row it finishes, so its next page is always new work — the
reconcile leaves every row it cannot promote exactly where it was. One
unprovable row at the head of the ordering therefore pinned page one on
every pass, and the backlog behind it was never examined at all, which
is the condition the multi-pass loop was added to remove. Rows keep
ageing toward MAX_RECONCILE_AGE while they wait, so "picked up next
restart" can mean "never recovered".

Pages now resume strictly after the `(created_at, id)` of the last row
EXAMINED, via `list_pending_ref_transitions_prepared_or_uncertain_after`.
A keyset cursor rather than an OFFSET because rows leave the set
underneath the walk when they are promoted: an absent row simply does
not appear on a later page, and it can never shift an unvisited row into
a page that was already read. Pass budget, per-pass limit, short-page
exit and the residual-backlog warning are unchanged.

This matters more with the reflog gate in place: a landing in a repo
that keeps no reflog is PERMANENTLY unprovable, so before this it would
jam page one on every startup rather than once.

Tests: reconcile_all_walks_the_backlog_past_the_first_page,
reconcile_all_advances_past_an_unprovable_row (whose blocker is a
no-reflog row, the class the gate introduces).

* fix(node): bound the reflog read to a recent tail (Twigpine#26 split 1/4)

`ref_reflog_entries` slurped the whole reflog. A reflog grows one line
(~150 bytes) per ref update and how often a ref is updated is
PUSHER-controlled, so that was an attacker-sized allocation, taken once
per stranded row, at startup — the moment the node can least absorb it.
Every other attacker-influenced read in this crate is bounded
(read_body_capped, MAX_SIGNATURE_ENTRIES, the pin/walk budgets); this one
should be too.

The bound costs nothing in proving power, which is why it is a ceiling
rather than a tradeoff: the landing gate only ever accepts an entry
stamped at or after `created_at - REFLOG_CLOCK_SKEW`, and reflogs are
append-ordered oldest-first, so every entry that could possibly qualify
is at the tail. The read now seeks to the last REFLOG_TAIL_BYTES (256KB,
~2000 recent updates to one ref) and `take`s that many bytes, so the
allocation stays bounded even if the file grows between the stat and the
read.

A window into the middle of a file starts mid-record, so the bytes before
its first newline are discarded: without that, a boundary landing inside
a record's old SHA leaves a suffix that still tokenizes and yields a
truncated SHA as a bogus `old -> new` pair — and landing proof is an
exact pair match, so a bogus pair is a fabricated proof. An entry older
than the window reads as absent, which is the safe direction: the caller
treats "no matching entry" as unproven and leaves the row where it is.
`Ok(None)` for a missing file, and the absence-of-evidence contract, are
unchanged.

Tests: ref_reflog_entries_reads_whole_records_from_the_recent_tail (lays
the file out so the boundary lands 20 bytes into a record's old SHA — the
alignment that would otherwise parse), an_entry_older_than_the_reflog_tail_reads_as_unproven.

* fix(node): read the report through both side-band frames

The report-status is framed twice whenever the client negotiates
side-band-64k, which `git push` over smart HTTP does: the outer
side-band envelope carries a stream that is itself pkt-line encoded.
One strip pass left the first line as `000eunpack ok`, so the unpack
check failed and parse_report_status returned None — read by the caller
as "no report", which keeps every declared ref. The per-ref landing
gate was therefore inert for exactly the pushes it exists to filter.

The regression test is a byte-for-byte capture from git 2.50.1
rejecting one ref of a two-ref push, because a hand-written
single-framed fixture parses under the old code and hides the case.

* fix(node): address round-4 reviewer findings on Twigpine#26 split 1/4

* fix(node): rustfmt round-4 reviewer fixes

* fix(node): remove empty line after doc comment

* fix(node): drop redundant reflog gate; implicit-ok on exit-zero no-report

* fix(node): address round-5 reviewer findings on Twigpine#26 split 1/4

* fix(db): v30 migration — add receive_pack_requests table and ordinal column

The reviewer's round-5 finding: the push event identity was encoded
into a mutable per-ref column (first_ref_name) whose correct value is
knowable only after git, and the correction was not committed
atomically with the per-ref outcomes. A crash between git updating
a later ref and the rewrite left the durable rows naming the
rejected ref, and derive_one's row.ref_name == row.first_ref_name
guard then meant no push event ever landed for the accepted child.

Per the state-transition model at
.gravirei/plans/state-model-durable-post-receive.md, this migration
introduces the request-level record that owns the push event and the
trust score, and extends the per-ref child with an ordinal column
the drain and the effect executor walk together. first_ref_name is
dropped; the push event id is keyed on
(request_id, accepted_ordinal) and lives on the request row.

The next commit (handler rewrite) wires the live path and the
recovery drain against the new tables.

BREAKING CHANGE: pending_ref_transitions.first_ref_name is dropped.
Callers that read or write that column must move to
receive_pack_requests.accepted_ordinal.

* fix(node): rewrite receive-pack handler against the request-level model (Twigpine#26 split 1/4 step 2)

The v30 migration added receive_pack_requests and dropped first_ref_name,
but no Rust code used either. The handler still ran a four-branch per-ref
state flip plus a first_ref_name rewrite after git returned, which is the
mixed-outcome bug a push with the first ref rejected exposes: the live
path and the drain computed different push-event ids.

- Insert a receive_pack_requests row in state `received` BEFORE git runs
  (insert_receive_pack_request), carrying the request bytes and the
  SHA-256 of the body. Crash between intent and git return is now
  recoverable via the reconcile step.
- Drop the first_ref_name rewrite at api/repos.rs:2454. The push event
  identity is now (request_id, accepted_ordinal); accepted_ordinal is
  computed once at the per-ref state flip.
- Re-key push_event_id_for and ref_cert_id_for on (request_id, ordinal).
  Anchor job id stays per-transition.
- Transition the request row to outcomes_committed (with parsed_report
  and accepted_ordinal) or rejected_at_git (no report + non-zero exit)
  in the same handler tail as the per-ref state flip. The drain (step 3)
  picks up outcomes_committed rows; today the live path also runs the
  per-ref effects inline.
- Stub the step-3 surface (mark_request_effects_pending, complete,
  list_receive_pack_requests_due, update_request_attempt) with
  #[allow(dead_code)] + contract-pin comments so the rewrite is
  bisectable: reverting this commit restores the pre-rewrite live path.

The U5 gate (replication tail spawn inside push_succeeded, before
guard.release) is preserved; inv22_replication_tail_spawns_at_the_
durability_boundary stays green.

* fix(node): use raw bytes for request_bytes_hash (Twigpine#26 split 1/4 step 2)

The v30 migration defined request_bytes_hash as BYTEA but the handler
was binding a hex-encoded String. Postgres rejected the insert with
"column request_bytes_hash is of type bytea but expression is of type
text" and the handler shed the push with a 503.

Switch the handler to bind the raw 32-byte digest and update the
ReceivePackRequest struct field type to Vec<u8> to match. The
drain-side test fixture already used Vec<u8> and needs no change.

Pinned by the bin test suite going from 24 failures (all the
receive-pack-cap tests that send a 4-byte body) to 0 regressions
on the new model.

* fix(node): factor out apply_request_effects; drain walks receive_pack_requests (Twigpine#26 split 1/4 step 3)

The step-2 commit moved the request row to be the unit of work but
left the per-ref effects fan-out inline in the handler and the
drain walking pending_ref_transitions per-ref. Step 3 factors the
effects fan-out into apply_request_effects(state, request_id) and
rewrites the drain to walk receive_pack_requests.

- apply_request_effects lives in durable_outbox.rs; the live handler
  in api/repos.rs and the drain both call it. Idempotent on
  (request_id, accepted_ordinal). Returns EffectsOutcome::{Done,
  Nothing, Retry}.
- Drain switches from list_pending_ref_transitions_applied +
  derive_one to list_receive_pack_requests_due +
  apply_request_effects. derive_one and the per-ref drain entry
  points are deleted.
- The step-3 stubs in db/mod.rs (mark_request_effects_pending,
  mark_request_complete, list_receive_pack_requests_due) lose
  their #[allow(dead_code)] annotations; every stub has a caller
  in this PR.
- Reconcile (reconcile_prepared_from_disk_all) is unchanged.
  The crash window between intent durable and outcomes commit
  still routes through per-ref reflog proof.
- Drain tests rewritten to stage receive_pack_requests rows; the
  per-ref fixtures (make_row, lookup_accepted_ordinal) are gone.
- New inv26_step3_live_and_drain_share_apply_request_effects
  assertion pins the live/drain sharing and the per-request
  drain walk.

* fix(node): bounded retirement purge for terminal request rows (Twigpine#26 split 1/4 step 4)

The v30 partial index idx_receive_pack_requests_completed_at
exists but no code reads it. Step 4 wires a periodic purge task
that deletes terminal `complete` and `rejected_at_git` rows
older than the retention window, along with their per-ref
children. `quarantined` (a step-5 state) is never purged by the
timer.

- `purge_completed_receive_pack_requests(older_than, limit)` —
  deletes parent requests using the v30 partial index.
- `purge_completed_pending_ref_transitions(older_than, limit)` —
  deletes children of purged parents, gated on the child's own
  applied_at / cancelled_at.
- `purge_request_queue(db, retention_days, limit)` — orchestrator
  in durable_outbox.rs that calls both and returns the totals.
  The deletion order (parents first, then children) is the
  contract; a crash mid-purge leaves orphaned children that the
  next pass will pick up.
- `spawn_queue_lifecycle_sweep` in main.rs — periodic task on
  the same detached pattern as `spawn_legacy_cid_sweep`, 24-hour
  interval, shutdown-aware. The interval matches the spec's "one
  per cluster per day" target.
- Config knobs `queue_retention_days` (default 7, range 1..=365)
  and `queue_purge_batch` (default 1000, matches
  DRAIN_PER_PASS_LIMIT).
- 3 new `durable_outbox::drain_tests::purge_*` tests pin the
  contract: only old terminal rows are deleted, the second pass
  is a no-op, and a row inside the retention window survives.
- New `inv26_step4_queue_lifecycle_purge_is_wired` gate asserts
  the wiring (main.rs calls the purge, the DB helpers exist, the
  Config knobs exist).

* fix(node): reference-transaction marker + quarantined state + failure matrix (Twigpine#26 split 1/4 step 5)

Steps 2-4 gave the request row the unit of work, the shared
executor, and the bounded retirement policy. Step 5 closes the
evidence gap: the reconcile now requires a per-request marker
ref (refs/gitlawb/requests/<id>) whose value matches
request_bytes_hash. A missing or mismatched marker quarantines
the request; an operator reclassifies it.

- v31 migration: adds `quarantined` to the state vocabulary and a
  partial index for operator queries.
- Handler writes the marker ref before git-receive-pack; the
  marker is causally bound by being in the same async task as
  the receive-pack call. The marker's value is content-addressed
  (git hash-object of the request bytes), so the gate compares
  consistent SHAs on both sides.
- git::store::read_ref reads a single ref's value, returning
  Ok(None) for absent refs. Used by the marker gate.
- git::store::marker_value_for computes the content-addressed
  marker value; both the live handler and the reconcile use it
  so the write and the read agree.
- Reconcile gains a marker gate between the age check and the
  reflog proof. Mismatch or absent ⇒ mark_request_quarantined +
  mark_children_rejected_for_quarantined_parent.
- effects_max_attempts bound (config knob, default 8) flips
  retry-stuck requests to `quarantined` after N attempts,
  closing the infinite-retry DoS window.
- New failure_matrix_tests submodule covers the spec's outcome ×
  ref-kind × exit-point × recovery-scenario matrix (6 cells).
- New inv26_step5_marker_quarantine_and_bound_are_wired gate
  asserts the marker gate, the bound check, the handler's
  pre-receive-pack ordering, and every load-bearing helper.
- Existing 7 reconcile tests updated to stage a marker ref via
  the new `stage_marker` test helper.

* fix(node): converge durable post-receive to singular request-level lifecycle

Unify producer, evidence, aggregate outcome, executor, retry, and
retirement on the request row: atomic intent and outcome commits,
synthetic normalized reports for implicit-ok/reconciled paths,
reconcile parent promotion with fail-closed deletions and
competing-claimant guard, retry progression from both executable
states with backoff/quarantine, terminal-parent-gated purge with
marker cleanup and hidden refs, minimal digest-only intent plus
request-level RFC9421 proof (v32).

* fix(node): harden request occurrence lifecycle, proof, retry, and retirement

Reject incomplete report framing and omitted refs as indeterminate,
preserve uncertain children on completion, terminalize all-rejected,
bind reconcile with landing history, durable proof with ack gate,
occurrence-keyed anchors, background due loop, deletion quarantine
with operator resolve, children-before-parent purge with marker
tombstones, recovery prereq upgrade check, and bounded marker runner.

* fix(node): keep recovery prereq upgrade best-effort on push path

Refusing pushes when git config upgrade fails broke fake-git and
non-repo disk-path tests with 503. Reconcile already fails closed
on missing reflogs, so downgrade to warn-and-proceed.

* fix(node): close reviewer round on outcome authority and lifecycle ownership

Gate effects on unpack_ok with cleared ordinal and terminal no-effects,
intersect executor with applied children, treat absent report-status as
indeterminate regardless of exit, require landing history before child
delete, claim due requests with recoverable leases, dedupe webhooks by
occurrence ledger, ack-gate proof retention with terminal disposition,
fair marker tombstones with dead-letter, kill_on_drop marker bounds,
and loud migration version collision guard.

* fix(node): load-bearing coverage for intent, guards, and delivery ledger

Handler intent test plus inv22 gate so disabling the durable insert
turns red; unpack-false, mixed ok/ng, divergent cancelled, partial
sibling, and proof-ack tests pin each executor guard; webhook
deliveries record sent_at with stale-pending reclaim; stale
parsed_report comment corrected.

* fix(node): centralize live retry, bound outcome commit, close retention orphans

Route live Retry/Err through schedule_request_retry_or_quarantine so
attempt progression matches the drain; retry post-git outcome commits
3x before restart repair; block terminal purge while non-terminal
children exist; add v27 upgrade-path test.

* fix(node): document attended-restart commit policy and pin executor retry arms

Track outcome-commit success, skip metrics/touch on failure, correct
the lease-expiry comment to startup reconcile; add restart-repair test
for stuck received parents plus RED tests for landing-history and
proof-ack failure paths.

* fix(node): correct marker prereq comment to warn-and-proceed

* fix(node): single partial-completion model with disposition gate

Split apply_request_effects into phase-1 bundle (push, cert, anchor,
history, webhook, proof ack before child deletion) and phase-2 gate
that retries while non-cancelled siblings remain; Nothing only when
nothing owed. Gate tail, Tigris, and effects on PostGitDisposition
with documented replication carve-out; add partial-sibling webhook
and disposition tests; fix stale comments.

* fix(node): close review round on outbox ordering, marker visibility, and repair reachability

Address the P1/P2 review findings: await the webhook delivery claim
before accepted-child deletion; exempt the marker namespace from the
fail-closed ref peeling guard so visibility walks tolerate blob-pointing
request markers; keep request marker deletion bounded in the purge path;
promote cross-page siblings via idempotent report superset; run
stuck-parent repair when the prepared queue is empty; bound the durable
intent write while admission permits are held; remove dead proof
verification scaffolding.

* fix(node): close review round on pre-git refusal lifecycle, namespace gate, and promotion merge

* fix(node): close review round on namespace gate, intent lifecycle, and promotion atomicity

Namespace gate hardening:
- Refuse non-UTF-8 ref-update lines (git accepts bytes ≥0x80; gate never sees them)
- Catch bare 'refs/gitlawb' name (starts_with missed it)
- Add deny test for both holes plus refusal wiring verification

Intent lifecycle guards:
- Add IntentDropGuard: terminalize stranded intents on dropped futures
- Bound refuse_receive_pack_before_git to 10s (no statement_timeout on pool)
- Guard mirror fetch: hideRefs blocks non-exempt refs/gitlawb/* from +refs/*:refs/*

Promotion merge atomicity:
- Take row under SELECT FOR UPDATE so merge is atomic with state read
- Prevents racing due worker from stranding newly applied children outside report

* fix(test): correct inv22 namespace gate assertions to match actual implementation

The test was looking for the wrong patterns:
- UTF-8 check: error message string, not pre-loop condition
- Namespace check: `*r == "refs/gitlawb"`, not `ref_name ==`
- Visibility exemption: `&&` logic (both false to exclude), not `||`

* chore: cargo fmt

* fix(node): phase-aware intent guard, mirror import prune, terminal-state merge

* fix(node): guard cleanup retry with fates, fail-closed mirror prune, refusal coverage

* fix(node): bound guard attempts, sibling-wait outcome, fail-closed prune enumeration

* fix(node): sibling-wait outcome, no-report path, bounded prune and guard retry

* fix(node): no-report durability, cert landing order, bounded reconcile git, flush checks

* fix(node): gate trust-score bump on a new push event row

* chore: update rustls 0.23.37 → 0.23.45 to resolve RUSTSEC-2026-0285

* fix(node): surface git's report-status when a stdin EPIPE follows child exit

* fix(node): cap receive-pack ref-update count before per-ref work and intent

* fix(node): cap ref-update parsing in-loop and dedupe identical commands

* docs(node): correct round-4 comment claims on client outcome and gate cost

* fix(node): single replication tail, proven marker taxonomy, claimed webhook delivery (Twigpine#558)

- One post_receive_replication_tail per push: the carve-out spawn and the
  durability-bound spawn are mutually exclusive positions on the same
  proven-landed ref set; inv22 now fails on any unfiltered or ungated spawn.
- Marker reconcile: bounded-read errors (git Err/deadline) stay prepared
  and retry instead of quarantining as 'missing marker ref'; only a
  successfully read marker proves missing or mismatch. Err-injection test
  pins no-quarantine on unproven reads.
- Claimed webhook delivery: the synchronous claim phase returns the
  claimed Webhook rows so the send task never re-lists; a list failure
  after claim can no longer drop claimed deliveries. Source gate pins it.

* docs(node): name claimed-hooks variant in fire_event_occurrence doc

* fix(node): order pending_ref_transitions create before outbox alters, fill v29 (Twigpine#558)

* fix(node): proven-missing marker taxonomy, v38 upgrade test, report-ok tail fixtures (Twigpine#558)

* fix(node): read marker refs via rev-parse so present refs return their oid (Twigpine#558)

---------

Co-authored-by: Gravirei <gravirei@users.noreply.github.com>
Co-authored-by: Kevin Codex <kevin@gitlawb.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An abandoned archive extraction can replace a repo directory under a later writer holding the advisory lock

5 participants