Skip to content

fix: serialize issue-ledger transitions + validate ForceID (iss-71) - #32

Merged
REPPL merged 3 commits into
mainfrom
auto/iss-71-capture-concurrency
Jul 12, 2026
Merged

fix: serialize issue-ledger transitions + validate ForceID (iss-71)#32
REPPL merged 3 commits into
mainfrom
auto/iss-71-capture-concurrency

Conversation

@REPPL

@REPPL REPPL commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Resolves iss-71 (native ledger) — two ledger-mutation-safety fixes found by the clean-slate sweep.

The bugs

  • C4 — split-brain (FIX-FIRST). abcd capture resolve/wontfix ran their find→read→move with no ledger lock (only id allocation locked). Two concurrent conflicting transitions on one issue — a resolve and a wontfix — could both pass commitTransition's checksum re-read before either removed the source, landing the issue in two status directories at once. From then on findIssue always returns ErrDuplicateIssueID and the issue can never be transitioned again.
  • P13 — ForceID traversal (FIX-FIRST). reservePath built a path from and O_EXCL-created a placeholder for a migrator-supplied ForceID before validating it. A traversal id (../../evil) escaped the ledger open/ dir (swept only on the later validation failure).

Fixes (internal/core/capture)

  • Refactor first (separate commit): extract withLedgerLock from reservePath — the flock-acquire/release primitive — so a second mutator can share it (reuse-before-reinvent; behaviour-preserving, suite stays green).
  • C4: transition's find→read→commit critical section now runs inside withLedgerLock, the same lock allocation takes. The second of two racing transitions acquires the lock only after the first has moved the issue out of open/, so it sees status != open and conflicts cleanly.
  • P13: ForceID is validated against ^iss-[0-9]+$ before any filesystem op.

Tests (TDD, each watched fail→pass)

  • TestTransitionSerializesOnLedgerLock — with the ledger lock held externally, Resolve returns ErrAllocatorContention and leaves the issue in open/ (deterministic; a short lockTimeout override). Fails on revert (lock-free Resolve succeeds).
  • TestReservePathRejectsUnsafeForceID — traversal/space/newline/non-id ForceIDs all rejected before any fs op; no escaped file. Fails on revert.

make preflight + make record-lint green; capture package green under -race. Reviewed: ruthless-reviewer SHIP, security-reviewer PASS (both attacked every interleaving and the anchor-bypass classes).

Not in scope (noted by review)

The capture content write happens outside the lock (only the placeholder reservation is locked). A transition racing a mid-capture zero-byte placeholder reads empty content and fails parseFrontmatterAndBody → errors out safely, so it cannot move an empty file. Pre-existing and fail-safe; this change neither regresses nor fixes it.

Assisted-by: Claude:claude-opus-4-8

REPPL added 3 commits July 12, 2026 10:02
Lift the flock-acquire/release primitive (ensure dirs, open lock with the
symlink/regular-file guards, acquire within lockTimeout, release) out of
reservePath into a reusable withLedgerLock helper so a second ledger mutator
(status transitions) can serialize on the same lock without a duplicate copy.
Behaviour-preserving; the capture suite stays green.

Assisted-by: Claude:claude-opus-4-8
Two ledger-mutation-safety fixes:

- C4: abcd capture resolve/wontfix ran their find->read->move with no ledger
  lock (only id allocation locked). Two concurrent conflicting transitions on
  one issue (a resolve and a wontfix) could both pass commitTransition's
  checksum re-read before either removed the source, landing the issue in TWO
  status dirs — permanent split-brain (findIssue then always ErrDuplicateIssueID).
  The find->move critical section now runs under withLedgerLock, the same lock
  allocation takes, so the second transition sees the issue already moved and
  conflicts cleanly. Deterministic test: with the lock held, Resolve returns
  ErrAllocatorContention and leaves the issue in open/.

- P13: reservePath built a path from and O_EXCL-created a placeholder for a
  migrator-supplied ForceID before validating it. A traversal id (../../evil)
  escaped the ledger open/ dir (swept only on the later validation failure).
  ForceID is now validated against ^iss-[0-9]+$ before any filesystem op.

Assisted-by: Claude:claude-opus-4-8
Ledger move trailing the fix in this branch, so resolution lands atomically on
merge (no separate chore PR).

Assisted-by: Claude:claude-opus-4-8
@REPPL
REPPL merged commit 71196a0 into main Jul 12, 2026
12 checks passed
@REPPL
REPPL deleted the auto/iss-71-capture-concurrency branch July 12, 2026 10:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant