autonomy-smoke-tests phase2-pr76 PASS evidence_sha256=c16c68f86ee0c4e8cfd0af3f76078be45441ee151395fbde5800145c08e189b5
autonomy-unit-tests phase2-pr76 PASS evidence_sha256=15bc76da47bdf8218f18fc8a7da65fafd841677d5f754b71a0c9f56e07d84c1b
campaign-approval-gate phase2-pr76 PASS evidence_sha256=ea7ed0516efe6736a8eaee426685e40a2393fbf3ae84104a743f394204c2ec33
git-diff-check phase2-pr76 PASS evidence_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
py-compile phase2-pr76 PASS evidence_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
reconciliation-contract phase2-pr76 PASS evidence_sha256=211444cb0c146f5d4a57e13b780cfe7ffb406e8a8bc2e11c02a3a4728f66b0b2
release-state-machine phase2-pr76 PASS evidence_sha256=6cea88752b3e58ae5cee56ff3b1b13eab34117918d6bad9661194c031e2dc157
secret-scan phase2-pr76 PASS evidence_sha256=7cb68dd604dc6cd4904246e16333072d512dba14c096301e08c5a2b78a45bd55
signer-validator-contract phase2-pr76 PASS evidence_sha256=775b7aa27d0c17f48b3feff20c836f14b6ed8ebcb90ec7e0b76d2a13c579268a
aios.owner-final-merge-authorization-v2 real v2 contract tests passed
528 lines
20 KiB
Python
528 lines
20 KiB
Python
"""Owner-final, structured authorization for one governed pull-request merge.
|
|
|
|
The Owner approval is the policy decision. Signing and public verification
|
|
prove that decision but cannot change its repository, pull request, CAS base,
|
|
technical-gate contract, or lifetime. This module deliberately exposes no
|
|
generic sign-bytes API and does not publish or alter technical check results.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import sqlite3
|
|
import time
|
|
from typing import Any, Callable, Mapping, Protocol
|
|
|
|
from aios_single_host_authority import (
|
|
OWNER_NAMESPACE,
|
|
LocalFileSigner,
|
|
SignedArtifact,
|
|
canonical_json_bytes,
|
|
sha256_json,
|
|
)
|
|
|
|
|
|
POLICY_SCHEMA = "aios.owner-merge-approval-policy.v2"
|
|
APPROVAL_SCHEMA = "aios.owner-merge-approval.v2"
|
|
RECEIPT_SCHEMA = "aios.owner-merge-admission-receipt.v2"
|
|
OPERATION = "merge.pull-request"
|
|
|
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
REPOSITORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,255}$")
|
|
CHECK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
|
TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{11,255}$")
|
|
|
|
|
|
class OwnerMergeApprovalError(RuntimeError):
|
|
"""Stable fail-closed error for the Owner merge approval contract."""
|
|
|
|
def __init__(self, code: str):
|
|
super().__init__(code)
|
|
self.code = code
|
|
|
|
|
|
def _fail(code: str) -> None:
|
|
raise OwnerMergeApprovalError(code)
|
|
|
|
|
|
def _require_oid(value: object, code: str) -> str:
|
|
if not isinstance(value, str) or OID_RE.fullmatch(value) is None:
|
|
_fail(code)
|
|
return value
|
|
|
|
|
|
def _require_digest(value: object, code: str) -> str:
|
|
if not isinstance(value, str) or DIGEST_RE.fullmatch(value) is None:
|
|
_fail(code)
|
|
return value
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OwnerMergeApprovalPolicy:
|
|
"""Immutable policy input for one exact protected merge approval."""
|
|
|
|
repository: str
|
|
pr_number: int
|
|
pr_head_oid: str
|
|
expected_main_oid: str
|
|
operation: str
|
|
required_check_contract_digest: str
|
|
required_checks: tuple[str, ...]
|
|
owner_identity: str
|
|
audit_lineage_digest: str
|
|
maximum_ttl_seconds: int
|
|
policy_version: str = "v2"
|
|
schema: str = POLICY_SCHEMA
|
|
|
|
def validate(self) -> None:
|
|
if (
|
|
self.schema != POLICY_SCHEMA
|
|
or self.policy_version != "v2"
|
|
or not isinstance(self.repository, str)
|
|
or REPOSITORY_RE.fullmatch(self.repository) is None
|
|
or type(self.pr_number) is not int
|
|
or not 1 <= self.pr_number < 2**31
|
|
or self.operation != OPERATION
|
|
or not isinstance(self.owner_identity, str)
|
|
or IDENTITY_RE.fullmatch(self.owner_identity) is None
|
|
or type(self.maximum_ttl_seconds) is not int
|
|
or not 60 <= self.maximum_ttl_seconds <= 900
|
|
):
|
|
_fail("OWNER_MERGE_POLICY_INVALID")
|
|
_require_oid(self.pr_head_oid, "OWNER_MERGE_POLICY_HEAD_INVALID")
|
|
_require_oid(self.expected_main_oid, "OWNER_MERGE_POLICY_BASE_INVALID")
|
|
_require_digest(
|
|
self.required_check_contract_digest,
|
|
"OWNER_MERGE_POLICY_CHECK_CONTRACT_INVALID",
|
|
)
|
|
_require_digest(self.audit_lineage_digest, "OWNER_MERGE_POLICY_LINEAGE_INVALID")
|
|
if (
|
|
not isinstance(self.required_checks, tuple)
|
|
or not self.required_checks
|
|
or len(set(self.required_checks)) != len(self.required_checks)
|
|
or any(not isinstance(item, str) or CHECK_RE.fullmatch(item) is None for item in self.required_checks)
|
|
):
|
|
_fail("OWNER_MERGE_POLICY_REQUIRED_CHECKS_INVALID")
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
self.validate()
|
|
value = asdict(self)
|
|
value["required_checks"] = list(self.required_checks)
|
|
return value
|
|
|
|
@property
|
|
def digest(self) -> str:
|
|
return sha256_json(self.as_dict())
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OwnerMergeLiveState:
|
|
"""Read-only live evidence; this object has no status publication method."""
|
|
|
|
repository: str
|
|
pr_number: int
|
|
pr_head_oid: str
|
|
main_oid: str
|
|
required_check_contract_digest: str
|
|
check_statuses: Mapping[str, bool]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OwnerMergeOidReadback:
|
|
authority_main_oid: str
|
|
gitea_main_oid: str
|
|
api_main_oid: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OwnerMergeAdmissionReceipt:
|
|
approval_id: str
|
|
nonce: str
|
|
owner_identity: str
|
|
policy_digest: str
|
|
proof_path: str
|
|
merge_oid: str
|
|
schema: str = RECEIPT_SCHEMA
|
|
|
|
|
|
class ApprovalProofVerifier(Protocol):
|
|
"""Public proof verifier; it is intentionally unable to issue approvals."""
|
|
|
|
def verify(
|
|
self,
|
|
*,
|
|
namespace: str,
|
|
payload: bytes,
|
|
signature: str,
|
|
key_id: str,
|
|
) -> bool:
|
|
...
|
|
|
|
|
|
class GovernedMergeExecutor(Protocol):
|
|
def execute(self, approval: Mapping[str, object]) -> str:
|
|
...
|
|
|
|
|
|
class MergeOidReconciler(Protocol):
|
|
def readback(self, merge_oid: str) -> OwnerMergeOidReadback:
|
|
...
|
|
|
|
|
|
APPROVAL_FIELDS = frozenset(
|
|
{
|
|
"schema",
|
|
"approval_id",
|
|
"nonce",
|
|
"repository",
|
|
"pr_number",
|
|
"pr_head_oid",
|
|
"expected_main_oid",
|
|
"operation",
|
|
"required_check_contract_digest",
|
|
"required_checks",
|
|
"owner_identity",
|
|
"audit_lineage_digest",
|
|
"policy_version",
|
|
"policy_digest",
|
|
"issued_at",
|
|
"not_before",
|
|
"expires_at",
|
|
"signature_namespace",
|
|
"signature_key_id",
|
|
"canonical_payload_digest",
|
|
}
|
|
)
|
|
|
|
|
|
class OwnerMergeApprovalAuthority:
|
|
"""Structured Owner signer for an immutable merge policy.
|
|
|
|
``issue`` has no caller-controlled payload parameter. The immutable policy
|
|
selects the complete authorization scope; only approval IDs and nonces are
|
|
generated inside the authority.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
key_path: Path,
|
|
policy: OwnerMergeApprovalPolicy,
|
|
*,
|
|
require_root_owner: bool = True,
|
|
clock: Callable[[], float] = time.time,
|
|
token_urlsafe: Callable[[int], str] = secrets.token_urlsafe,
|
|
) -> None:
|
|
policy.validate()
|
|
self.policy = policy
|
|
self.signer = LocalFileSigner(
|
|
Path(key_path),
|
|
signer_id="local-owner-authority",
|
|
namespace=OWNER_NAMESPACE,
|
|
require_root_owner=require_root_owner,
|
|
)
|
|
self.clock = clock
|
|
self.token_urlsafe = token_urlsafe
|
|
|
|
def _token(self, prefix: str) -> str:
|
|
value = prefix + self.token_urlsafe(24)
|
|
if TOKEN_RE.fullmatch(value) is None:
|
|
_fail("OWNER_MERGE_AUTHORITY_GENERATED_TOKEN_INVALID")
|
|
return value
|
|
|
|
def issue(self) -> SignedArtifact:
|
|
now = int(self.clock())
|
|
approval_id = self._token("owner-merge-approval-")
|
|
nonce = self._token("owner-merge-nonce-")
|
|
policy = self.policy
|
|
payload: dict[str, Any] = {
|
|
"schema": APPROVAL_SCHEMA,
|
|
"approval_id": approval_id,
|
|
"nonce": nonce,
|
|
"repository": policy.repository,
|
|
"pr_number": policy.pr_number,
|
|
"pr_head_oid": policy.pr_head_oid,
|
|
"expected_main_oid": policy.expected_main_oid,
|
|
"operation": policy.operation,
|
|
"required_check_contract_digest": policy.required_check_contract_digest,
|
|
"required_checks": list(policy.required_checks),
|
|
"owner_identity": policy.owner_identity,
|
|
"audit_lineage_digest": policy.audit_lineage_digest,
|
|
"policy_version": policy.policy_version,
|
|
"policy_digest": policy.digest,
|
|
"issued_at": now,
|
|
"not_before": max(0, now - 5),
|
|
"expires_at": now + policy.maximum_ttl_seconds,
|
|
"signature_namespace": OWNER_NAMESPACE,
|
|
"signature_key_id": self.signer.fingerprint,
|
|
}
|
|
payload["canonical_payload_digest"] = sha256_json(payload)
|
|
return self.signer._sign_canonical(payload)
|
|
|
|
|
|
class OwnerMergeApprovalVerifier:
|
|
"""Verify Owner proof, preferring normal verification then recovery proof.
|
|
|
|
The recovery verifier validates a pre-existing approval with the registered
|
|
public Owner root. It cannot issue an approval and is therefore not a
|
|
second policy principal.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
primary: ApprovalProofVerifier,
|
|
recovery: ApprovalProofVerifier | None = None,
|
|
clock: Callable[[], float] = time.time,
|
|
) -> None:
|
|
self.primary = primary
|
|
self.recovery = recovery
|
|
self.clock = clock
|
|
|
|
@staticmethod
|
|
def _artifact(artifact: SignedArtifact | Mapping[str, Any]) -> tuple[dict[str, Any], str, str, str]:
|
|
if isinstance(artifact, SignedArtifact):
|
|
return dict(artifact.payload), artifact.signature, artifact.key_id, artifact.namespace
|
|
if not isinstance(artifact, Mapping) or set(artifact) != {"payload", "signature", "key_id", "namespace"}:
|
|
_fail("OWNER_MERGE_APPROVAL_ARTIFACT_INVALID")
|
|
payload = artifact.get("payload")
|
|
signature = artifact.get("signature")
|
|
key_id = artifact.get("key_id")
|
|
namespace = artifact.get("namespace")
|
|
if not isinstance(payload, Mapping) or not all(isinstance(item, str) for item in (signature, key_id, namespace)):
|
|
_fail("OWNER_MERGE_APPROVAL_ARTIFACT_INVALID")
|
|
return dict(payload), signature, key_id, namespace
|
|
|
|
@staticmethod
|
|
def _validate_scope(payload: Mapping[str, Any], policy: OwnerMergeApprovalPolicy, *, now: int, key_id: str, namespace: str) -> None:
|
|
if set(payload) != APPROVAL_FIELDS:
|
|
_fail("OWNER_MERGE_APPROVAL_FIELDS_INVALID")
|
|
expected = {
|
|
"schema": APPROVAL_SCHEMA,
|
|
"repository": policy.repository,
|
|
"pr_number": policy.pr_number,
|
|
"pr_head_oid": policy.pr_head_oid,
|
|
"expected_main_oid": policy.expected_main_oid,
|
|
"operation": policy.operation,
|
|
"required_check_contract_digest": policy.required_check_contract_digest,
|
|
"required_checks": list(policy.required_checks),
|
|
"owner_identity": policy.owner_identity,
|
|
"audit_lineage_digest": policy.audit_lineage_digest,
|
|
"policy_version": policy.policy_version,
|
|
"policy_digest": policy.digest,
|
|
"signature_namespace": OWNER_NAMESPACE,
|
|
"signature_key_id": key_id,
|
|
}
|
|
if payload.get("pr_head_oid") != policy.pr_head_oid:
|
|
_fail("OWNER_MERGE_APPROVAL_HEAD_BINDING_INVALID")
|
|
if payload.get("expected_main_oid") != policy.expected_main_oid:
|
|
_fail("OWNER_MERGE_APPROVAL_BASE_BINDING_INVALID")
|
|
if payload.get("policy_digest") != policy.digest:
|
|
_fail("OWNER_MERGE_POLICY_DIGEST_DRIFT")
|
|
if namespace != OWNER_NAMESPACE or any(payload.get(key) != value for key, value in expected.items()):
|
|
_fail("OWNER_MERGE_APPROVAL_SCOPE_INVALID")
|
|
for field in ("approval_id", "nonce"):
|
|
if not isinstance(payload.get(field), str) or TOKEN_RE.fullmatch(payload[field]) is None:
|
|
_fail("OWNER_MERGE_APPROVAL_TOKEN_INVALID")
|
|
for field in ("issued_at", "not_before", "expires_at"):
|
|
if type(payload.get(field)) is not int:
|
|
_fail("OWNER_MERGE_APPROVAL_TIME_INVALID")
|
|
if not payload["not_before"] <= now < payload["expires_at"]:
|
|
_fail("OWNER_MERGE_APPROVAL_EXPIRED")
|
|
if payload["issued_at"] > now + 5 or payload["expires_at"] - payload["issued_at"] != policy.maximum_ttl_seconds:
|
|
_fail("OWNER_MERGE_APPROVAL_TIME_INVALID")
|
|
supplied = payload.get("canonical_payload_digest")
|
|
body = dict(payload)
|
|
body.pop("canonical_payload_digest", None)
|
|
if supplied != sha256_json(body):
|
|
_fail("OWNER_MERGE_APPROVAL_CANONICAL_DIGEST_INVALID")
|
|
|
|
def verify(
|
|
self,
|
|
artifact: SignedArtifact | Mapping[str, Any],
|
|
policy: OwnerMergeApprovalPolicy,
|
|
) -> tuple[dict[str, Any], str]:
|
|
policy.validate()
|
|
payload, signature, key_id, namespace = self._artifact(artifact)
|
|
canonical = canonical_json_bytes(payload)
|
|
proof_path: str | None = None
|
|
for path, verifier in (("primary", self.primary), ("recovery", self.recovery)):
|
|
if verifier is None:
|
|
continue
|
|
try:
|
|
verified = verifier.verify(
|
|
namespace=namespace,
|
|
payload=canonical,
|
|
signature=signature,
|
|
key_id=key_id,
|
|
)
|
|
except Exception:
|
|
verified = False
|
|
if verified is True:
|
|
proof_path = path
|
|
break
|
|
if proof_path is None:
|
|
_fail("OWNER_MERGE_APPROVAL_SIGNATURE_INVALID")
|
|
self._validate_scope(payload, policy, now=int(self.clock()), key_id=key_id, namespace=namespace)
|
|
return payload, proof_path
|
|
|
|
|
|
class SQLiteOwnerMergeNonceLedger:
|
|
"""Durable one-shot fence. Unknown effects are intentionally not retried."""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
self.path = Path(path)
|
|
|
|
def _connection(self) -> sqlite3.Connection:
|
|
if self.path.is_symlink() or self.path.parent.is_symlink():
|
|
_fail("OWNER_MERGE_NONCE_LEDGER_PATH_INVALID")
|
|
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
|
connection.execute(
|
|
"CREATE TABLE IF NOT EXISTS owner_merge_nonce ("
|
|
"nonce TEXT PRIMARY KEY, approval_digest TEXT NOT NULL, state TEXT NOT NULL, claimed_at INTEGER NOT NULL)"
|
|
)
|
|
return connection
|
|
|
|
def claim(self, *, nonce: str, approval_digest: str, now: int) -> None:
|
|
connection = self._connection()
|
|
try:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
existing = connection.execute(
|
|
"SELECT state FROM owner_merge_nonce WHERE nonce=?", (nonce,)
|
|
).fetchone()
|
|
if existing is not None:
|
|
connection.rollback()
|
|
_fail("OWNER_MERGE_NONCE_REPLAY")
|
|
connection.execute(
|
|
"INSERT INTO owner_merge_nonce(nonce,approval_digest,state,claimed_at) VALUES(?,?,?,?)",
|
|
(nonce, approval_digest, "PENDING", now),
|
|
)
|
|
connection.commit()
|
|
except OwnerMergeApprovalError:
|
|
raise
|
|
except sqlite3.Error as exc:
|
|
try:
|
|
connection.rollback()
|
|
except sqlite3.Error:
|
|
pass
|
|
raise OwnerMergeApprovalError("OWNER_MERGE_NONCE_LEDGER_UNAVAILABLE") from exc
|
|
finally:
|
|
connection.close()
|
|
|
|
def mark(self, *, nonce: str, state: str) -> None:
|
|
if state not in {"COMPLETED", "UNKNOWN"}:
|
|
_fail("OWNER_MERGE_NONCE_STATE_INVALID")
|
|
connection = self._connection()
|
|
try:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
updated = connection.execute(
|
|
"UPDATE owner_merge_nonce SET state=? WHERE nonce=? AND state='PENDING'",
|
|
(state, nonce),
|
|
).rowcount
|
|
if updated != 1:
|
|
connection.rollback()
|
|
_fail("OWNER_MERGE_NONCE_STATE_CONFLICT")
|
|
connection.commit()
|
|
except OwnerMergeApprovalError:
|
|
raise
|
|
except sqlite3.Error as exc:
|
|
try:
|
|
connection.rollback()
|
|
except sqlite3.Error:
|
|
pass
|
|
raise OwnerMergeApprovalError("OWNER_MERGE_NONCE_LEDGER_UNAVAILABLE") from exc
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
class GovernedOwnerMergeAdmission:
|
|
"""Admit one protected merge without owning any policy or technical gate."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
policy: OwnerMergeApprovalPolicy,
|
|
verifier: OwnerMergeApprovalVerifier,
|
|
live_state_reader: Callable[[], OwnerMergeLiveState],
|
|
executor: GovernedMergeExecutor,
|
|
reconciler: MergeOidReconciler,
|
|
nonce_ledger: SQLiteOwnerMergeNonceLedger,
|
|
clock: Callable[[], float] = time.time,
|
|
) -> None:
|
|
policy.validate()
|
|
self.policy = policy
|
|
self.verifier = verifier
|
|
self.live_state_reader = live_state_reader
|
|
self.executor = executor
|
|
self.reconciler = reconciler
|
|
self.nonce_ledger = nonce_ledger
|
|
self.clock = clock
|
|
|
|
def _validate_live_state(self, live: OwnerMergeLiveState) -> None:
|
|
if not isinstance(live, OwnerMergeLiveState):
|
|
_fail("OWNER_MERGE_LIVE_STATE_INVALID")
|
|
policy = self.policy
|
|
if live.repository != policy.repository:
|
|
_fail("OWNER_MERGE_REPOSITORY_DRIFT")
|
|
if live.pr_number != policy.pr_number:
|
|
_fail("OWNER_MERGE_PR_BINDING_DRIFT")
|
|
if live.pr_head_oid != policy.pr_head_oid:
|
|
_fail("OWNER_MERGE_HEAD_DRIFT")
|
|
if live.main_oid != policy.expected_main_oid:
|
|
_fail("OWNER_MERGE_BASE_DRIFT")
|
|
if live.required_check_contract_digest != policy.required_check_contract_digest:
|
|
_fail("OWNER_MERGE_REQUIRED_CHECK_CONTRACT_DRIFT")
|
|
if not isinstance(live.check_statuses, Mapping) or set(live.check_statuses) != set(policy.required_checks):
|
|
_fail("OWNER_MERGE_REQUIRED_CHECK_CONTRACT_DRIFT")
|
|
if any(value is not True for value in live.check_statuses.values()):
|
|
_fail("OWNER_MERGE_REQUIRED_CHECKS_FAILED")
|
|
|
|
def admit(self, artifact: SignedArtifact | Mapping[str, Any]) -> OwnerMergeAdmissionReceipt:
|
|
approval, proof_path = self.verifier.verify(artifact, self.policy)
|
|
self._validate_live_state(self.live_state_reader())
|
|
nonce = str(approval["nonce"])
|
|
approval_digest = hashlib.sha256(canonical_json_bytes(approval)).hexdigest()
|
|
self.nonce_ledger.claim(nonce=nonce, approval_digest=approval_digest, now=int(self.clock()))
|
|
try:
|
|
merge_oid = self.executor.execute(dict(approval))
|
|
_require_oid(merge_oid, "OWNER_MERGE_EXECUTOR_RESULT_INVALID")
|
|
readback = self.reconciler.readback(merge_oid)
|
|
if (
|
|
not isinstance(readback, OwnerMergeOidReadback)
|
|
or readback.authority_main_oid != merge_oid
|
|
or readback.gitea_main_oid != merge_oid
|
|
or readback.api_main_oid != merge_oid
|
|
):
|
|
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
|
_fail("OWNER_MERGE_OID_RECONCILIATION_REQUIRED")
|
|
except OwnerMergeApprovalError:
|
|
# Every failure after a durable claim represents an operation that
|
|
# is either known failed or needs reconciliation. Preserve the
|
|
# one-shot fence rather than exposing a retryable PENDING state.
|
|
try:
|
|
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
|
except OwnerMergeApprovalError:
|
|
pass
|
|
raise
|
|
except Exception as exc:
|
|
try:
|
|
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
|
except OwnerMergeApprovalError:
|
|
pass
|
|
raise OwnerMergeApprovalError("OWNER_MERGE_EXECUTION_UNKNOWN") from exc
|
|
self.nonce_ledger.mark(nonce=nonce, state="COMPLETED")
|
|
return OwnerMergeAdmissionReceipt(
|
|
approval_id=str(approval["approval_id"]),
|
|
nonce=nonce,
|
|
owner_identity=str(approval["owner_identity"]),
|
|
policy_digest=str(approval["policy_digest"]),
|
|
proof_path=proof_path,
|
|
merge_oid=merge_oid,
|
|
)
|