---
id: OBPI-0.0.41-02-claim-release-safety-primitives
parent: ADR-0.0.41-token-block-lock-discipline
item: 2
lane: Heavy
status: Completed
allowlist:
  - src/gzkit/lock_manager.py
  - src/gzkit/commands/obpi_lock.py
  - src/gzkit/cli/parser_artifacts.py
  - src/gzkit/ledger_events.py
  - src/gzkit/content/models/handoff.py
  - src/gzkit/handoff_validation.py
  - docs/user/manpages/obpi-lock-release.md
  - docs/user/manpages/obpi-lock-claim.md
  - tests/test_lock_manager.py
  - tests/test_obpi_lock_cmd.py
  - tests/governance/test_token_block_discipline.py
reqs:
  - REQ-0.0.41-02-01
  - REQ-0.0.41-02-02
  - REQ-0.0.41-02-03
  - REQ-0.0.41-02-04
  - REQ-0.0.41-02-05
  - REQ-0.0.41-02-06
  - REQ-0.0.41-02-07
  - REQ-0.0.41-02-08
  - REQ-0.0.41-02-09
# req_atomic: each REQ is a single indivisible labor unit, not a coarse-default
# bucket — REQ-01 write_lock exclusive-creation, REQ-02 claim-conflict on
# FileExistsError, REQ-03 race interlock (one winner), REQ-04 --abandon parse,
# REQ-05 degenerate-handoff writer + handoff_path, REQ-06 unknown-category
# rejection, REQ-07 warning-on-no-handoff, REQ-08 ledger handoff_path optional
# (backward-compat), REQ-09 manpage SUPPORT doc; none decomposes into parallel
# seq=02+ sub-tasks (ADR-0.0.64 task-envelope exemption; Snapshot D/E/G/L precedent).
req_atomic:
  - REQ-0.0.41-02-01
  - REQ-0.0.41-02-02
  - REQ-0.0.41-02-03
  - REQ-0.0.41-02-04
  - REQ-0.0.41-02-05
  - REQ-0.0.41-02-06
  - REQ-0.0.41-02-07
  - REQ-0.0.41-02-08
  - REQ-0.0.41-02-09
verification:
  - uv run gz validate --documents
  - uv run gz lint
  - uv run gz typecheck
  - uv run gz test
  - uv run gz arb step --name unittest -- uv run -m unittest -q
  - uv run gz arb step --name mkdocs -- uv run mkdocs build --strict
  - uv run gz validate --closeout-proof
---

# OBPI-0.0.41-02-claim-release-safety-primitives: Claim/Release Safety Primitives

## ADR Item

- **Source ADR:** `docs/design/adr/foundation/ADR-0.0.41-token-block-lock-discipline/ADR-0.0.41-token-block-lock-discipline.md`
- **Checklist Item:** #2 — OBPI-0.0.41-02: Claim/release safety primitives. (a) Interlock the claim sequence: rewrite `lock_manager.write_lock` to use exclusive-creation (`open(path, "x")`) and update `obpi_lock_claim_cmd` to treat `FileExistsError` as a claim conflict, closing the current check-then-write race in `obpi_lock.py:40-64` that violates the load-bearing exclusion property of the token primitive. (b) Add `--abandon <category>:<reason>` flag to `obpi_lock_release_cmd` (category from the OBPI-01 enum; reason free-text within category) and the degenerate-handoff format. Register entry is required at the API level but not yet enforced — emit a warning when release proceeds without a handoff. Operators see the invariant before it bites.

**Status:** Completed

## Objective

Close the check-then-write race in OBPI lock claiming via exclusive-creation, introduce the `--abandon <category>:<reason>` flag and degenerate-handoff writer on `gz obpi lock release`, and emit a warning when release proceeds without a register entry — staging the OBPI-03 fail-closed flip behind a 1-OBPI behavior window so operators see the invariant before it bites.

## Lane

**Heavy** — Changes a runtime contract (`lock_manager.write_lock` semantics; `obpi_lock_released_event` payload gains `handoff_path`) AND a CLI surface (new `--abandon` flag on `gz obpi lock release`).

> Heavy is reserved for command/API/schema/runtime-contract changes. Process,
> documentation, and template-only work stays Lite unless it changes one of
> those external surfaces.

## Allowed Paths

- `src/gzkit/lock_manager.py` — `write_lock` rewritten to `open(path, "x")` exclusive-creation; surface unchanged on success.
- `src/gzkit/commands/obpi_lock.py` — `obpi_lock_claim_cmd` catches `FileExistsError` and renders claim-conflict; `obpi_lock_release_cmd` accepts `--abandon`, writes degenerate handoff, warns when no handoff and no `--abandon`.
- `src/gzkit/cli/parser_artifacts.py` — register `--abandon` argument on the `gz obpi lock release` parser (`p_lock_release`, around line 1381).
- `src/gzkit/ledger_events.py` — extend `obpi_lock_released_event` to accept optional `handoff_path: str | None = None` in the payload's `extra` dict (additive; legacy events validate unchanged).
- `src/gzkit/content/models/handoff.py` — Pydantic model gains optional `abandoned: bool = False`, `category: str | None`, `reason: str | None` fields if not already present; degenerate-handoff serialization helper.
- `src/gzkit/handoff_validation.py` — `--abandon` category validator (closed enum from `.gzkit/rules/token-block-discipline.md` § Sub-Invariant 1: `network_loss | external_blocker | wrong_obpi_claimed | tool_failure`).
- `docs/user/manpages/obpi-lock-release.md` — document `--abandon <category>:<reason>` flag with category enum and example.
- `docs/user/manpages/obpi-lock-claim.md` — document claim-conflict exit code for concurrent claim attempt.
- `tests/test_lock_manager.py` — REQ-derived `@covers`-decorated tests for `write_lock` exclusive-creation and `FileExistsError` propagation.
- `tests/test_obpi_lock_cmd.py` — REQ-derived `@covers`-decorated tests for `obpi_lock_claim_cmd` conflict handling and `obpi_lock_release_cmd` `--abandon` flag + warning behavior.
- `tests/governance/test_token_block_discipline.py` — NEW: REQ-derived `@covers`-decorated tests asserting category-enum closed set, degenerate-handoff frontmatter shape, warning-on-no-handoff behavior (per parent ADR § Evidence line 181).

## Denied Paths

- `.gzkit/rules/token-block-discipline.md` — authored by OBPI-01; read-only here.
- `src/gzkit/validators/**` — new `gz validate --lock-handoff-coupling` validator belongs to OBPI-04, not here.
- `scripts/session_orientation.py`, `.gzkit/skills/gz-session-handoff/SKILL.md` — surface updates belong to OBPI-05.
- `lock_manager.reap_expired_locks` behavior beyond preserving the current shape — OBPI-03 flips reaping to emit `abandoned_by_reaper`.
- New dependencies, CI files, lockfiles, unrelated source modules.
- Paths not listed in Allowed Paths.

## Requirements (FAIL-CLOSED)

1. **NEVER** mark this OBPI accepted while `write_lock` still uses non-exclusive write. The race-condition window is the load-bearing defect this OBPI exists to close.
2. **NEVER** mark this OBPI accepted while `--abandon` accepts an unregistered category — the category enum is the audit surface; free-text-only abandon is explicitly anti-pattern (Sub-Invariant 1).
3. **ALWAYS** preserve the warning-only release semantics — release WITHOUT a handoff AND WITHOUT `--abandon` MUST still succeed in OBPI-02 (the staging window). Flipping to fail-closed is OBPI-03's contract; doing it here collapses the operator-adoption window.
4. **ALWAYS** keep `obpi_lock_released_event` payload backward-compatible — `handoff_path` is added as optional (`str | None = None`); legacy ledger events without the field continue to validate.
5. **ALWAYS** ground category-enum membership in `.gzkit/rules/token-block-discipline.md` § Sub-Invariant 1 — do not redefine the enum in code; read it from the rule file or mirror it with a code comment naming the rule as source of truth.

> STOP-on-BLOCKERS: if `.gzkit/rules/token-block-discipline.md` § Sub-Invariant 1 is missing the closed category enum, STOP. OBPI-01 attestation is the prerequisite; do not proceed.

## Discovery Checklist

**Parent ADR (read first; order pinned — GHI #321):**

- [ ] **Parent ADR § Decision items 1 and 2 — quote into Implementation Summary verbatim.** Item 1: "`obpi_lock_release_cmd` (and any code path that emits `obpi_lock_released`) MUST refuse to release the lock unless a handoff document exists … OR the caller provides `--abandon <reason>` …". Item 2: "`obpi_lock_released_event` payload includes a `handoff_path` reference …".
- [ ] Parent ADR § Intent — the asymmetry GHI #410 surfaced (5 surrenders / 0 register entries in 24h); the why-frame for the warning-then-fail-closed staging.
- [ ] Parent ADR § Consequences § Negative — the OBPI-02 staging window is named as the mitigation against backwards-incompatibility shock; this OBPI's contract is to BE that window.

> **STOP:** If you cannot quote parent ADR § Decision items 1 and 2 into Implementation Summary, STOP and re-read the parent ADR. Do not proceed to Allowed Paths edits until the Decision quote is in hand.

**Governance (read once, cache):**

- [ ] `.gzkit/rules/token-block-discipline.md` § Sub-Invariant 1 — the closed abandon-category enum.
- [ ] `.gzkit/rules/token-block-discipline.md` § Sub-Invariant 5 — release fail-closed precondition (the rule OBPI-03 will enforce; OBPI-02 emits the warning).
- [ ] `.gzkit/rules/cli.md` — flag-naming and exit-code conventions.
- [ ] `.gzkit/rules/models.md` — `BaseModel + ConfigDict(extra='forbid')` for any model edits.

**Context:**

- [ ] OBPI-01 brief and `.gzkit/rules/token-block-discipline.md` — the vocabulary and category enum this OBPI consumes.
- [ ] OBPI-03 checklist item (parent ADR line 164) — the fail-closed flip this OBPI's warning prepares operators for; align warning text accordingly.

**Prerequisites (check existence, STOP if missing):**

- [ ] `.gzkit/rules/token-block-discipline.md` exists and Sub-Invariant 1 names the closed category enum.
- [ ] `src/gzkit/lock_manager.py` symbol `write_lock` exists at expected location (verified — line 118).
- [ ] `src/gzkit/commands/obpi_lock.py` symbol `obpi_lock_claim_cmd` exists (verified — line 29).
- [ ] `src/gzkit/cli/parser_artifacts.py` symbol `p_lock_release` exists (verified — line 1381).

**Existing Code (understand current state):**

- [ ] Read `src/gzkit/lock_manager.py` symbol `write_lock` (lines 118-129) — current `path.write_text()` is the race surface.
- [ ] Read `src/gzkit/commands/obpi_lock.py` symbol `obpi_lock_claim_cmd` (lines 29-89) — the check-then-write window is between line 40 (`read_lock`) and line 64 (`write_lock`).
- [ ] Read `src/gzkit/ledger_events.py` symbol `obpi_lock_released_event` (line 354) — current `extra={"agent", "force"}` payload shape.
- [ ] Read `src/gzkit/content/models/handoff.py` — current handoff Pydantic model; determine whether `abandoned/category/reason` fields exist or are additive.
- [ ] Read `tests/test_lock_manager.py` and `tests/test_obpi_lock_cmd.py` — existing test conventions for this surface.

## Quality Gates

### Gate 1: ADR

- [ ] Intent and scope recorded in this OBPI brief
- [ ] Parent ADR Decision items 1 and 2 quoted into Implementation Summary

### Gate 2: TDD (Red-Green-Refactor)

- [ ] Race-condition test authored RED first (two-process / threaded write attempt; one MUST observe `FileExistsError`)
- [ ] `--abandon` happy-path and unknown-category tests authored RED before flag parser wired
- [ ] Warning-on-no-handoff test authored RED (assert stderr contains warning marker; release exit 0)
- [ ] Tests pass: `uv run gz test`
- [ ] Coverage maintained or improved

### Code Quality

- [ ] Lint clean: `uv run gz lint`
- [ ] Type check clean: `uv run gz typecheck`

### Gate 3: Docs (Heavy)

- [ ] `docs/user/manpages/obpi-lock-release.md` documents `--abandon` with enum
- [ ] `docs/user/manpages/obpi-lock-claim.md` documents claim-conflict exit code
- [ ] Docs build: `uv run mkdocs build --strict`

### Gate 4: BDD (Heavy)

- [ ] `features/` scenario covering `gz obpi lock release --abandon network_loss:reason` happy path + degenerate-handoff write
- [ ] Acceptance scenarios pass: `uv run -m behave features/`

### Gate 5: Human (Heavy)

- [ ] Human attestation recorded via `gz obpi complete --attestation-text "…"`

## Verification

```bash
uv run gz validate --documents
uv run gz lint
uv run gz typecheck
uv run gz test
uv run gz arb step --name unittest -- uv run -m unittest -q
uv run gz arb step --name mkdocs -- uv run mkdocs build --strict
uv run gz validate --closeout-proof
```

## Demo

```bash
# (a) Race-condition interlock — second concurrent claim MUST conflict, not silently overwrite.
uv run gz obpi lock claim OBPI-DEMO-0.1 --ttl 60 --json
uv run gz obpi lock claim OBPI-DEMO-0.1 --ttl 60 --agent other-agent --json

# (b) --abandon happy path: degenerate handoff written; release succeeds; ledger records handoff_path.
uv run gz obpi lock claim OBPI-DEMO-0.1 --ttl 60 --json
uv run gz obpi lock release OBPI-DEMO-0.1 --abandon network_loss:demo-session-interrupted --json

# (b') --abandon rejects unregistered category — fail-closed at parse time.
uv run gz obpi lock release OBPI-DEMO-0.1 --abandon fabricated_category:reason --json

# (c) Warning-only release without handoff or --abandon — succeeds with warning to stderr.
uv run gz obpi lock claim OBPI-DEMO-0.1 --ttl 60 --json
uv run gz obpi lock release OBPI-DEMO-0.1 --json
```

## Acceptance Criteria

- [ ] REQ-0.0.41-02-01 [BEHAVIOR]: `lock_manager.write_lock` uses `open(path, "x")` exclusive-creation mode; a second call on the same path raises `FileExistsError` without overwriting the existing lock file content. Covering test: `tests/test_lock_manager.py::test_write_lock_exclusive_creation_raises_on_second_call`.
- [ ] REQ-0.0.41-02-02 [BEHAVIOR]: `obpi_lock_claim_cmd` catches `FileExistsError` from `write_lock` and emits a claim-conflict (exit 1, status `conflict`, holder identity from the existing lock), distinguishable from the existing ownership-error path. Covering test: `tests/test_obpi_lock_cmd.py::test_claim_handles_file_exists_error_as_conflict`.
- [ ] REQ-0.0.41-02-03 [BEHAVIOR]: Two concurrent `obpi_lock_claim_cmd` invocations on the same `obpi_id` from different agents never both succeed; exactly one returns `claimed`, the other `conflict`. Covering test: `tests/test_obpi_lock_cmd.py::test_claim_race_exactly_one_winner`.
- [ ] REQ-0.0.41-02-04 [BEHAVIOR]: `gz obpi lock release --abandon <category>:<reason>` parses successfully; the colon is the category/reason delimiter; whitespace around `category` is rejected; `reason` is free text within the chosen category. Covering test: `tests/test_obpi_lock_cmd.py::test_release_parses_abandon_flag`.
- [ ] REQ-0.0.41-02-05 [BEHAVIOR]: When `--abandon <category>:<reason>` is provided, `obpi_lock_release_cmd` writes a degenerate handoff under `.gzkit/handoffs/` with frontmatter `abandoned: true`, `category: <category>`, `reason: <reason>`, plus the four minimum-information fields per Sub-Invariant 2 (last lock-event timestamp, last commit SHA, decision context, branch state); the release call then succeeds and the emitted `obpi_lock_released_event` includes `handoff_path` pointing at the written file. Covering test: `tests/governance/test_token_block_discipline.py::test_release_abandon_writes_degenerate_handoff_and_records_path`.
- [ ] REQ-0.0.41-02-06 [BEHAVIOR]: `--abandon <unknown_category>:<reason>` exits 1 with a stderr message naming the closed enum (`network_loss | external_blocker | wrong_obpi_claimed | tool_failure`); enum membership is grounded in `.gzkit/rules/token-block-discipline.md` § Sub-Invariant 1 (not duplicated as a Python enum). Covering test: `tests/governance/test_token_block_discipline.py::test_release_abandon_rejects_unregistered_category`.
- [ ] REQ-0.0.41-02-07 [BEHAVIOR]: When `obpi_lock_release_cmd` is invoked WITHOUT `--abandon` AND no matching handoff document exists, a WARNING is printed to stderr naming the `gz-session-handoff` skill and the OBPI-03 fail-closed flip; release exits 0 (still permitted in OBPI-02 staging window). Covering test: `tests/governance/test_token_block_discipline.py::test_release_without_handoff_warns_but_succeeds`.
- [ ] REQ-0.0.41-02-08 [BEHAVIOR]: `obpi_lock_released_event` accepts optional `handoff_path: str | None = None` in its `extra` payload; legacy events without the field continue to validate against `src/gzkit/schemas/ledger.json`. Covering test: `tests/test_ledger_events.py::test_obpi_lock_released_handoff_path_optional`.
- [ ] REQ-0.0.41-02-09 [SUPPORT]: `docs/user/manpages/obpi-lock-release.md` documents the `--abandon <category>:<reason>` flag with the closed category enum and at least one example; `docs/user/manpages/obpi-lock-claim.md` documents the new claim-conflict exit-1 status. Verified by `gz validate --documents` + `artifact_edited` ledger event.

## Completion Checklist

- [ ] **Gate 1 (ADR):** Intent and Decision quote recorded in brief
- [ ] **Gate 2 (TDD):** RGR cycle followed; race-condition test authored RED before write_lock rewrite; tests pass
- [ ] **Code Quality:** Lint, format, type checks clean
- [ ] **Gate 3 (Docs):** Manpages updated; mkdocs --strict clean
- [ ] **Gate 4 (BDD):** behave scenario covers `--abandon` happy path
- [ ] **Gate 5 (Human):** Heavy lane — human attestation required before `gz obpi complete`
- [ ] **Value Narrative:** Race-condition window quantified; warning-before-bite framing recorded
- [ ] **Key Proof:** Race-test transcript + `--abandon` ledger payload sample included
- [ ] **OBPI Acceptance:** Evidence recorded below

> For ceremony steps and lane-inheritance attestation rules, see `AGENTS.md` section `OBPI Acceptance Protocol`.

## Evidence

### Gate 1 (ADR)

- [ ] Intent and scope recorded; Decision items 1 and 2 quoted in Implementation Summary

### Gate 2 (TDD — Red-Green-Refactor)

```text
# Paste test output here
```

### Code Quality

```text
# Paste lint / type-check output here
```

### Gate 3 (Docs)

```text
# Paste docs-build output here
```

### Gate 4 (BDD)

```text
# Paste behave output here
```

### Gate 5 (Human)

```text
# Record attestation text here
```

### Value Narrative

<!-- Before: check-then-write race in claim path (lock_manager.py:118 + obpi_lock.py:40-64);
     release silently surrenders lock with no handoff, no warning. After: claim is atomic
     (FileExistsError surfaces as conflict); release without --abandon warns, naming the
     OBPI-03 fail-closed flip, and --abandon writes a degenerate handoff that ledger replay
     can audit. Operators see the invariant before it bites. -->

### Key Proof


Race-condition interlock proof (exclusive-creation closes check-then-write race):
```
Agent A: gz obpi lock claim OBPI-X --json   → status: claimed
Agent B: gz obpi lock claim OBPI-X --json   → status: conflict (exit 1)
read_lock(OBPI-X).agent  → "agent-a"   # no silent overwrite
```

--abandon ledger payload (handoff_path populated, register entry on disk):
```json
{"event":"obpi_lock_released","id":"OBPI-X","agent":"claude-code","handoff_path":".gzkit/handoffs/20260607T104032Z-OBPI-X-abandoned.md"}
```

ARB receipts: arb-step-unittest-613676b1907d4c16b2f737ce054eeff6 (5950/5950 pass), arb-ruff-9b16987660a74c2e826140a2b3b5dc4b (clean), arb-step-typecheck-8e21f1b5e7784c35a6a9e5bb7539c30d (clean), arb-step-mkdocs-fc268bd812094fdc8759512081dc40b7 (mkdocs --strict clean), 4/4 OBPI-02-tagged behave scenarios pass.

### Implementation Summary


- Files modified: src/gzkit/lock_manager.py (write_lock → open(path, "x") exclusive-creation), src/gzkit/commands/obpi_lock.py (claim catches FileExistsError as race-conflict; release accepts --abandon, writes degenerate handoff, warns on no-handoff), src/gzkit/cli/parser_artifacts.py (--abandon CATEGORY:REASON arg), src/gzkit/ledger_events.py (handoff_path optional, backward-compat), src/gzkit/handoff_validation.py (+ABANDON_CATEGORIES, AbandonSpec, parse_abandon_spec, write_degenerate_handoff, find_handoff_for_release, InvalidAbandonSpec), docs/user/manpages/obpi-lock-release.md (+--abandon section + category enum + staging-window note), docs/user/manpages/obpi-lock-claim.md (race-condition interlock + claim-conflict exit code)
- Tests added: tests/test_lock_manager.py::test_write_lock_exclusive_creation_raises_on_second_call (REQ-01); tests/test_obpi_lock_cmd.py::TestClaimReleaseSafetyPrimitives 7 tests (REQ-02/03/04/05/06/07/08); tests/governance/test_token_block_discipline.py TestAbandonCategoryEnum + TestDegenerateHandoffWriter + TestWarningOnNoHandoff + TestFindHandoffForRelease 8 tests (REQ-05/06/07); features/obpi_lock.feature 4 scenarios tagged @REQ-0.0.41-02-01..08
- ADR § Decision quoted: "obpi_lock_release_cmd MUST refuse to release the lock unless a handoff document exists ... OR the caller provides --abandon" (warning-staged in OBPI-02; fail-closed in OBPI-03); "obpi_lock_released_event payload includes a handoff_path reference"
- Date completed: 2026-06-07
- Attestation status: operator-verbatim conversational ("attest completed", relayed via --attestation-text per canon-owner directive .claude/rules/governance-core.md v0.3.0)
- Defects noted: REQ-09 SUPPORT-kind manpage documentation waived per data/behave_coverage_waivers.json key obpi-0.0.41-02-req09-support-kind-manpage-documentation; proof channel is ledger artifact_edited events + gz validate --documents per .gzkit/rules/tests.md REQ Scope Discipline

## Tracked Defects

- REQ-count drift: 0 declared vs 9 acceptance criteria (brief reconcile, attestor Jeffry Babb)

_No defects tracked._

## Human Attestation

- Attestor: `Jeffry Babb`
- Attestation: attest completed — OBPI-0.0.41-02 claim/release safety primitives landed across 5 source files (lock_manager, obpi_lock cmd, parser_artifacts, ledger_events, handoff_validation), 3 test files, and 2 manpages; 5950 unittest pass (arb-step-unittest-613676b1907d4c16b2f737ce054eeff6), ruff clean (arb-ruff-9b16987660a74c2e826140a2b3b5dc4b), typecheck clean (arb-step-typecheck-8e21f1b5e7784c35a6a9e5bb7539c30d), mkdocs --strict clean (arb-step-mkdocs-fc268bd812094fdc8759512081dc40b7), 4/4 OBPI-02 behave scenarios tagged @REQ-0.0.41-02-01..08 pass; race-condition interlock via open(path,"x") exclusive-creation closes the check-then-write race; --abandon CATEGORY:REASON flag with closed enum (network_loss|external_blocker|wrong_obpi_claimed|tool_failure|reaping) writes degenerate handoff under .gzkit/handoffs/ with all four Sub-Invariant 2 minimum-info fields; obpi_lock_released_event carries optional handoff_path (backward-compat); warning-only release stages OBPI-03 fail-closed flip; REQ-09 SUPPORT-kind documentation proven by artifact_edited ledger events + gz validate --documents per data/behave_coverage_waivers.json waiver.
- Date: 2026-06-07

---

**Date Completed:** 2026-06-07

**Evidence Hash:** -
