[verified] Handle delivery acknowledgements and read-only recovery

This commit is contained in:
2026-10-13 06:26:49 +00:00
parent 3759fcbde3
commit dbd381a41b
5 changed files with 439 additions and 0 deletions
@@ -26,6 +26,12 @@ Baseline/current inventory를 생성·전달·채택할 때 `check-tests 이전`
실제 소비 함수에서도 [runtime consumption 정본](../../../../operations/repository-cache.md#delegation-adoption-runtime-consumption)의 `require_adoption`을 통과한다. main은 위임 근거 비사용을 `{"mode":"none"}`, 사용을 `mode=delegated` exact 선언으로 명시하며 누락이나 이전 PASS receipt를 허용하지 않는다. 판정/render, delivery artifact, native publication마다 새 검증을 수행하고 소비 source/stage에 결속한다. 최초 구현 delivery는 `gitea-issue-implementation/scripts/implementation_delivery.py`의 `deliver_implementation(adapter, head=final_head, declaration=main_declaration)`에서 main-owned push/PR adapter를 호출한다. Adapter는 push/read_remote_head/create_or_reuse_pr/read_pr를 제공하고 기존 인증·repository/Issue scope·non-force explicit refspec·remote readback 계약을 지킨다. 새 진입점을 쓰지 않는 직접 push/PR 생성은 이 workflow를 우회하는 것으로 금지한다. Gate 밖 직접 Git/API 실행을 sandbox로 차단한다고 주장하지 않는다. 기존 semantic/source/identity/lease 검증과 외부 readback은 그대로 유지한다.
## Delivery adapter 응답과 read-only reconciliation
Main-owned REST adapter는 `scripts/delivery_response.py`의 응답 검증을 사용하고, 확인된 endpoint별 성공 status를 명시한다. `POST이면 201, 나머지는 200`이나 모든 2xx 수용으로 계약을 추측하지 않는다. 이 helper는 인증·origin/TLS·권한·payload 계약을 대신하지 않으며 mutation을 자동 재시도하지 않는다. Credential mechanism은 `git_credential_helper`, 검증 script는 `evidence_script`처럼 별도 변수로 유지한다.
응답 status·JSON shape 검사 또는 연결 오류가 write 이후 발생하면 acknowledgement가 불확실할 수 있다. 인증 거부·redirect를 성공으로 완화하지 않는다. 이미 승인된 의도 상태가 반영됐는지 fresh readback으로 확인한 뒤에만 `scripts/implementation_delivery.py`의 `reconcile_implementation(adapter, head=final_head, number=known_pr_number, declaration=main_declaration)`를 명시적으로 호출한다. 이 경로는 push/create/patch를 호출하지 않는다. Main-owned read-only adapter의 `verify_reconciliation(head, number)`는 fresh identity·repository/base/head/state·exact intended body·보호된 Issue snapshot과 전체 changed-file/commit collection을 검증하고 literal `True`만 반환한다. 불일치·미완료 pagination·확인 불가이면 완료하지 않으며 별도 승인 없이 재write하지 않는다. Trusted callback 계약이지 transport sandbox나 stale receipt의 자동 채택 경로가 아니다.
## Managed-lock broker gate
Issue workflow는 bootstrap 이후 반드시 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 하나의 role-neutral managed-lock broker를 사용한다. `serve`에 canonical Issue identity, role, workflow run-id와 TTL을 전달해 broker-generated runtime을 만들고, ready JSON의 `handle`/`socket` 경로만 사용한다. 경로는 `<issue>/runtime/<workflow-run-id>/broker.handle`과 `broker.sock`이며 verification stage run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. Repository lock은 같은 handle의 `acquire_repository`/`release_repository` request로만 중첩하고, TTL 전에 `renew`한다. 정상·오류·cancel 모두 `release_repository`(보유 시) → `release` → broker wait/reap → `probe`의 `free=true` 확인 순서를 완료하기 전에는 완료·로그·보고하지 않는다. Raw `flock ... sleep`과 lock-file unlink는 금지한다.
@@ -0,0 +1,80 @@
"""Fail-closed decoding for one Gitea API delivery attempt.
Unexpected/malformed acknowledgements can follow a committed mutation: callers
must not blindly retry. They may perform caller-initiated read-only reconciliation.
"""
import json
import ssl
from urllib.error import HTTPError, URLError
MAX_RESPONSE_BYTES = 1024 * 1024
class DeliveryResponseError(Exception):
"""Sanitized response failure; message never includes server content."""
class InvalidExpectationError(DeliveryResponseError, ValueError):
"""The caller supplied an invalid endpoint-specific status expectation."""
class RejectedResponseError(DeliveryResponseError):
"""Known auth/redirect or other non-success rejection; do not retry blindly."""
class AcknowledgementUncertainError(DeliveryResponseError):
"""Mutation may have committed; do not retry; reconcile read-only if needed."""
def _validate_expected_status(expected_status):
if (isinstance(expected_status, bool) or not isinstance(expected_status, int)
or expected_status < 200 or expected_status > 299):
raise InvalidExpectationError("expected_status must be an explicit success status integer")
def _reject_constant(_value):
raise ValueError("non-standard JSON constant")
def decode_response(status, body, *, expected_status):
"""Require the exact expected status and a bounded JSON object body."""
_validate_expected_status(expected_status)
if isinstance(status, bool) or not isinstance(status, int):
raise AcknowledgementUncertainError("invalid response status; do not retry; reconcile read-only")
if 300 <= status <= 399 or status in (401, 403):
raise RejectedResponseError("request rejected or redirected; do not retry blindly")
if status != expected_status:
raise AcknowledgementUncertainError("unexpected response status; do not retry; reconcile read-only")
if not isinstance(body, (bytes, bytearray)) or len(body) > MAX_RESPONSE_BYTES:
raise AcknowledgementUncertainError("invalid or oversized response; do not retry; reconcile read-only")
try:
value = json.loads(bytes(body).decode("utf-8"), parse_constant=_reject_constant)
except (UnicodeError, ValueError, RecursionError):
raise AcknowledgementUncertainError("invalid JSON response; do not retry; reconcile read-only") from None
if not isinstance(value, dict):
raise AcknowledgementUncertainError("response JSON must be an object; do not retry; reconcile read-only")
return value
def deliver_once(send, *, expected_status):
"""Validate expectation, invoke injected sender exactly once, decode its (status, bytes)."""
_validate_expected_status(expected_status)
try:
result = send()
except (RejectedResponseError, ssl.SSLCertVerificationError):
raise RejectedResponseError("transport security rejected; do not retry blindly") from None
except HTTPError as error:
if 300 <= error.code <= 399 or error.code in (401, 403):
raise RejectedResponseError("request rejected or redirected; do not retry blindly") from None
raise AcknowledgementUncertainError("HTTP acknowledgement uncertain; do not retry; reconcile read-only") from None
except URLError as error:
if isinstance(error.reason, ssl.SSLCertVerificationError):
raise RejectedResponseError("TLS verification rejected; do not retry blindly") from None
raise AcknowledgementUncertainError("transport failure; do not retry; reconcile read-only") from None
except Exception:
raise AcknowledgementUncertainError("transport failure; do not retry; reconcile read-only") from None
try:
status, body = result
except Exception:
raise AcknowledgementUncertainError("invalid transport response; do not retry; reconcile read-only") from None
return decode_response(status, body, expected_status=expected_status)
@@ -88,3 +88,42 @@ def deliver_implementation(adapter: Any, *, head: str, declaration: Any) -> dict
readback = adapter.read_pr(pr_number)
bounded_pr = _validate_readback(readback, pr_number, head)
return {"remote_head": remote_head, "pr": bounded_pr}
def reconcile_implementation(adapter: Any, *, head: str, number: int,
declaration: Any) -> dict[str, Any]:
"""Explicit read-only recovery after uncertain write acknowledgement.
The trusted main-owned adapter must freshly verify identity/scope, exact
intended body, protected Issue snapshot, and complete files/commits via
verify_reconciliation(head, number). Only literal True accepts that proof.
These callbacks MUST be read-only. This is not a transport sandbox, an
automatic exception handler, or permission to retry a write.
"""
_validate_head(head)
_validate_pr_number(number)
for name in ("read_remote_head", "read_pr", "verify_reconciliation"):
callback = None
try:
callback = getattr(adapter, name)
except Exception:
_reject("trusted reconciliation adapter required")
if not callable(callback):
_reject("trusted reconciliation adapter required")
remote_head = _delivery_adoption.deliver(
adapter.read_remote_head, head=head, declaration=declaration
)
if type(remote_head) is not str or remote_head != head:
_reject("remote head mismatch")
readback = _delivery_adoption.deliver(
lambda: adapter.read_pr(number), head=head, declaration=declaration
)
bounded_pr = _validate_readback(readback, number, head)
verified = _delivery_adoption.deliver(
lambda: adapter.verify_reconciliation(head, number),
head=head, declaration=declaration
)
if verified is not True:
_reject("reconciliation intent or protected state mismatch")
return {"remote_head": remote_head, "pr": bounded_pr}