feat(forge): report unreadable repositories as unreadable (STATE-WP-0084-T01)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s

A private repository failed derivation the same way a broken one did, so
"cannot read" and "does not exist" were indistinguishable from outside.
They authorise opposite things: only the second can justify retiring a
record.

- ForgeUnreadableError (a ForgeDeriveError, so old callers still catch it)
  for permission-shaped clone failures, including Forgejo's 404 for an
  unauthenticated private repo — indistinguishable here, and the safe
  reading of an ambiguous answer cannot destroy a record.
- GIT_TERMINAL_PROMPT=0: an unattended pass must fail, not block on a
  username prompt. Failing is what makes the case observable.
- DerivedProjection.retirement_eligible separates "no records found" from
  "no records exist". A checkout with no workplans/ directory cannot
  evidence an absence — the empty-clone path that would have proposed
  every record in a repository for retirement.
- Retirement from an ineligible source is refused even when acknowledged.
- Fleet keeps unreadable out of the error bucket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3377672@bnt-lap001
Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
This commit is contained in:
tegwick 2026-08-26 21:45:03 +02:00
parent 8b38f815e5
commit 85181cd3e4
3 changed files with 294 additions and 6 deletions

View file

@ -54,7 +54,7 @@ wait on `MASON-WP-0003-T02`.
```task
id: STATE-WP-0084-T01
status: todo
status: done
priority: high
state_hub_task_id: "e955067d-2152-59f7-b672-12755ceb26b0"
```
@ -81,6 +81,33 @@ Acceptance: an unreadable repository is reported as unreadable; a clone that
succeeds but returns nothing produces no retirement proposals; both cases are
covered by tests that fail if the retirement path is reachable from either.
**Done 2026-08-26.** `api/services/forge_projection.py`:
- `ForgeUnreadableError` subclasses `ForgeDeriveError`, so existing callers
keep catching it while new ones can tell the cases apart. A permission-shaped
git failure is classified into it; a genuine fault stays a plain error.
Forgejo's 404 for an unauthenticated private repository classifies as
unreadable, because the two are indistinguishable at this layer and the safe
reading of an ambiguous answer is the one that cannot destroy a record.
- `_run_git` runs with `GIT_TERMINAL_PROMPT=0`. Without it an unattended pass
blocks on a username prompt instead of failing, and an unreadable repository
is only observable if it fails.
- `DerivedProjection.records_source_present` / `.retirement_eligible` separate
"no records found" from "no records exist". A checkout with no `workplans/`
directory can no longer evidence an absence.
- `diff_against_hub` withholds `stale` rather than computing and filtering it,
and says so in `stale_withheld`. `would_remove` is 0 for such a source.
- `reset_repository_projection` returns `status="unreadable"` for a repository
it may not read, and refuses retirement from an ineligible source **even when
`acknowledge_retirements=True`** — consenting to a conclusion is not the same
as the evidence for it existing.
- `reset_fleet_projection` keeps unreadable repositories out of `errors`, in
their own bucket with its own count. Nine repositories in an error bucket
read as nine broken repositories; they were nine we were not allowed to read.
13 tests in `TestUnreadableIsNotMissing` (`tests/test_forge_projection.py`);
58 pass across the forge/projection/backfill suites.
## Deliver the credential to the pod
```task