fix(author): unify the bootstrap and lock_issue issue-lock contract (Closes #953)

gitea_bootstrap_author_issue_worktree wrote a lock no downstream author
operation accepts, then directed the author straight to implementation. Once
the branch carried commits, heartbeat, re-lock, exact-owner renewal, and the
#447 create-PR guard all refused simultaneously and no sanctioned recovery
path remained eligible.

Each of those gates is individually correct. The defect was that two writers
disagreed about what a lock is.

- Add author_lock_contract as the single canonical definition: claimant,
  work_lease, lock_provenance, generation, and an explicit expiration state.
  Both gitea_lock_issue and bootstrap now build through it.
- Promote issue_lock_store.lock_claimant to the one shared claimant reader and
  use it in the ownership check, so a claimant recorded at the lock top level
  is read rather than refused. The values are still compared against
  server-resolved identity and profile, so no legacy placement grants anything
  the canonical placement would not.
- Represent missing expiration explicitly. An absent expires_at previously read
  as "not yet expired", leaving a malformed lock permanently non-expiring and
  permanently ineligible for #760 renewal.
- Bootstrap reads its lock back and verifies it structurally before reporting
  success. A partial lock fails closed while the worktree is still
  base-equivalent, names the missing fields, and never reports
  implementation_allowed. Its exact_next_action now matches the state returned.
- Add gitea_recover_incomplete_bootstrap_lock for locks already written by the
  old bootstrap, including those whose branches carry pushed commits. It never
  moves, resets, or rewinds a branch, never requires base-equivalence, never
  pushes or opens a PR, and touches only the target lock. It proves repository,
  issue, claimant username and profile, branch, worktree, registration, and
  head before writing, refuses healthy foreign-owned locks, and mints
  provenance and authorization server-side.
- Add gitea_inspect_issue_lock_contract, a strictly read-only surface.
- Document the required ordering and the recovery path.

The #447 provenance guard is unchanged and the sanctioned source set was not
widened: bootstrap now satisfies the guard rather than the guard being relaxed
to admit bootstrap.

Tests: 61 new cases covering the canonical schema, immediate heartbeat,
renewal before and after commits, the create_pr guard, executable next actions,
partial and malformed and missing-expiration and expired and same-owner and
foreign-owner and legacy locks, recovery isolation, read-only inspection, and
the full bootstrap-implement-commit-push-create_pr regression against isolated
fixtures.

Full suite at head: 28 failed, 5753 passed, 6 skipped, 1042 subtests.
Full suite at base 82d71b77: 28 failed, 5692 passed, 6 skipped, 1042 subtests.
Failing test-ID sets are identical, so there are zero regressions; the +61
passes are this issue's new suite.

Issue #949 was preserved and not recovered: its branch remains at
92615f474b and its worktree, lock, and PR state
were not touched. The #949-shaped regression uses isolated fixtures only.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-28 00:35:12 -04:00
co-authored by Claude Opus 4.8
parent 82d71b7702
commit cdf0daefa9
8 changed files with 2390 additions and 58 deletions
+35 -9
View File
@@ -299,9 +299,13 @@ def _ownership_refusals(
f"lock worktree '{lock.get('worktree_path')}' does not match "
f"'{worktree_path}'"
)
lease = lock.get("work_lease") if isinstance(lock, dict) else None
claimant = lease.get("claimant") if isinstance(lease, dict) else None
claimant = claimant if isinstance(claimant, dict) else {}
# #953 AC2/AC13/AC14: read through the shared claimant reader so a lock
# written by bootstrap — which records the claimant at the top level — is
# not refused for "not recording a claimant" when it plainly records one.
# This is not a widening: the values are still compared against the
# server-resolved identity and profile immediately below, so a legacy
# placement grants nothing that the canonical placement would not.
claimant = lock_claimant(lock) if isinstance(lock, dict) else {}
recorded_identity = str(claimant.get("username") or "").strip()
recorded_profile = str(claimant.get("profile") or "").strip()
if not recorded_identity or not recorded_profile:
@@ -1112,21 +1116,43 @@ def assess_same_issue_lease_conflict(
)
def _lock_claimant(lock: dict[str, Any] | None) -> dict[str, str]:
def lock_claimant(lock: dict[str, Any] | None) -> dict[str, str]:
"""Read the claimant from either canonical or legacy placement (#953 AC13/AC14).
``work_lease.claimant`` is the canonical placement and is preferred; a
top-level ``claimant`` is the legacy/bootstrap placement and is accepted as
a fallback. This is the single definition. Before #953 the readers
disagreed: this module, ``issue_lock_renewal``, and ``issue_lock_recovery``
tolerated both placements, while ``_ownership_refusals`` looked only in
``work_lease`` — which is what made a bootstrap-written lock
un-heartbeatable.
Preferring ``work_lease`` over the top level is deliberate: once a legacy
lock is upgraded, the canonical placement is authoritative and a stale
top-level copy must never win.
This decides *where to look*, never whether ownership is proven — every
caller still compares these values against server-resolved identity and
profile.
"""
if not isinstance(lock, dict):
return {}
claimant = lock.get("claimant")
lease = lock.get("work_lease")
claimant = lease.get("claimant") if isinstance(lease, dict) else None
if not isinstance(claimant, dict):
lease = lock.get("work_lease")
claimant = lease.get("claimant") if isinstance(lease, dict) else None
claimant = lock.get("claimant")
if not isinstance(claimant, dict):
return {}
return {
"username": str(claimant.get("username") or ""),
"profile": str(claimant.get("profile") or ""),
"username": str(claimant.get("username") or "").strip(),
"profile": str(claimant.get("profile") or "").strip(),
}
#: Back-compatible alias for the pre-#953 private name.
_lock_claimant = lock_claimant
def assess_foreign_lock_overwrite(
existing_lock: dict[str, Any] | None,
incoming_lock: dict[str, Any],