nexus-certify 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. nexus_certify-0.1.0.dist-info/METADATA +128 -0
  2. nexus_certify-0.1.0.dist-info/RECORD +46 -0
  3. nexus_certify-0.1.0.dist-info/WHEEL +5 -0
  4. nexus_certify-0.1.0.dist-info/entry_points.txt +2 -0
  5. nexus_certify-0.1.0.dist-info/top_level.txt +1 -0
  6. product/__init__.py +1 -0
  7. product/acquisition/__init__.py +29 -0
  8. product/acquisition/github.py +400 -0
  9. product/adapters/__init__.py +1 -0
  10. product/adapters/changeset_certification_v2.py +1006 -0
  11. product/adapters/generic_verification.py +429 -0
  12. product/adapters/github.py +274 -0
  13. product/adapters/legacy.py +34 -0
  14. product/adapters/trusted.py +593 -0
  15. product/benchmark/__init__.py +1500 -0
  16. product/benchmark/tg7_shadow.py +70 -0
  17. product/benchmark/tg7_shadow_kernel.py +999 -0
  18. product/benchmark/tg9_value.py +1217 -0
  19. product/certification/__init__.py +83 -0
  20. product/certification/receipt.py +520 -0
  21. product/clients/__init__.py +15 -0
  22. product/clients/cli.py +421 -0
  23. product/clients/github_action.py +312 -0
  24. product/clients/local_golden_path.py +835 -0
  25. product/clients/mcp.py +159 -0
  26. product/completion/__init__.py +550 -0
  27. product/evidence/__init__.py +909 -0
  28. product/evidence/code_integrity.py +824 -0
  29. product/evidence/ingestion.py +2472 -0
  30. product/execution/__init__.py +39 -0
  31. product/execution/profiles/__init__.py +1 -0
  32. product/execution/profiles/python-oci-pytest-v1.json +1 -0
  33. product/execution/profiles/python-oci-pytest-v1.lock +1 -0
  34. product/execution/python_runner.py +732 -0
  35. product/kernel/__init__.py +176 -0
  36. product/ledger.py +1068 -0
  37. product/protocol/__init__.py +17 -0
  38. product/protocol/compatibility_gate.py +163 -0
  39. product/protocol/compatibility_gate_kernel.py +1205 -0
  40. product/protocol/generic_verification.py +866 -0
  41. product/runtime/__init__.py +61 -0
  42. product/runtime/auth.py +205 -0
  43. product/runtime/http.py +379 -0
  44. product/runtime/schemas.py +374 -0
  45. product/runtime/service.py +763 -0
  46. product/verification/__init__.py +207 -0
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: nexus-certify
3
+ Version: 0.1.0
4
+ Summary: Nexus Evidence Trust Core and Completion Core
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: aiohttp>=3.9.0
9
+ Requires-Dist: PyGithub>=2.1.0
10
+
11
+ # Nexus Core
12
+
13
+ Nexus Core is the standalone Evidence-to-Claim Completion Certification implementation extracted from Nexus-new.
14
+
15
+ Its truth authorities are Evidence Trust Core and Completion Core.
16
+
17
+ ## Scope
18
+
19
+ - **Evidence Trust Core**: Ingestion, normalization, verification, and tamper detection of execution evidence.
20
+ - **Completion Core**: ChangeSet certification, deterministic verification reduction, and disposition enforcement.
21
+
22
+ It provides the standalone `nexus-certify` CLI and HTTP/deterministic runtime interfaces.
23
+
24
+ ## Local Golden Path
25
+
26
+ An external Python Git repository can verify a committed or dirty change without
27
+ running the HTTP service or hand-authoring protocol JSON:
28
+
29
+ The public distribution identity is `nexus-certify`. Until the release gate
30
+ publishes a versioned artifact, install the candidate source or locally built wheel
31
+ in a development environment. After an authorized public release, the supported
32
+ registry install command is `python -m pip install nexus-certify`.
33
+
34
+ ```bash
35
+ nexus-certify init --base-ref main --allow 'src/**' --allow 'tests/**' \
36
+ --verifier python -m pytest -q
37
+ nexus-certify doctor
38
+ nexus-certify check
39
+ ```
40
+
41
+ The local shell derives real Git object identities and the canonical manifest,
42
+ runs the configured verifier, delegates the verdict to the existing generic Core
43
+ adapter, and persists a verification receipt under `.nexus-core/receipts/`.
44
+ `VERIFIED` remains distinct from `CERTIFIED`; the local receipt is not a
45
+ Certification receipt or merge/release authority.
46
+
47
+ See [Local Golden Path Contract](docs/LOCAL_GOLDEN_PATH.md) for the exact config,
48
+ negative controls, receipt validation contract, and the not-yet-executed G4
49
+ fresh-environment canary contract.
50
+
51
+ For trusted same-repository GitHub `push` and `pull_request` events on self-hosted
52
+ runners, see the [G2 GitHub Repository Golden Path](docs/GITHUB_REPOSITORY_CHECK.md).
53
+ Fork PRs and GitHub-hosted runners are explicitly unsupported by that path.
54
+
55
+ ## Generic ChangeSet Verification (experimental)
56
+
57
+ The loopback HTTP runtime also exposes a transport-neutral, non-GitHub verification seam for bounded consumers such as DevSpace or Open SWE:
58
+
59
+ - `GET /v1/protocol/generic-verification` — authenticated protocol descriptor, JSON schemas, canonicalization rules, schema-bundle hash, and cross-language conformance vectors.
60
+ - `POST /v1/changesets/verify` — deterministic `AcceptanceContract + ChangeSet + VerificationPlan + EvidenceBundle` verification. Certification is optional and occurs only when the caller supplies explicit policy facts.
61
+
62
+ Generic revision identities are typed as `git-commit:<40-lowercase-hex>` or `git-tree:<40-lowercase-hex>`. An uncommitted managed change may therefore be verified against a deterministic Git tree without creating a commit first. The generic `diff_hash` binds a canonical source-tree-to-target-tree manifest instead of pretty-patch formatting.
63
+
64
+ `verification=VERIFIED` does not imply `CERTIFIED`, Candidate acceptance, merge authorization, release, deployment, or production readiness. This interface does not select execution lanes, workers, models, routes, or workspaces.
65
+
66
+ ## Completion Evidence Applicability and Freshness Contract
67
+
68
+ `product.completion` is an additive Completion Core capability that binds a
69
+ completion claim to the exact source/artifact state it claims, then decides
70
+ whether verification evidence actually applies to that final state. It is
71
+ deterministic, model-independent, and advisory: the completion claim's
72
+ `asserted_by` identity never confers authority, and certification remains with
73
+ `product.kernel.certify`.
74
+
75
+ It derives three explicit semantic states:
76
+
77
+ - `CLAIMS_COMPLETE` — a completion claim is present, revision-bound to the
78
+ change set's `target_revision`, and covers every changed non-deleted path
79
+ with a claimed final content hash.
80
+ - `VERIFICATION_APPLIES` — every required verifier has an evidence observation
81
+ that is bound to a changed path, observes the claimed final content hash
82
+ (freshness by content-hash comparison), and reports `PASS`.
83
+ - `CLAIMS_VERIFIED` — the conjunction of the two states above with the existing
84
+ deterministic `verify()` reduction. A claim assertion can never substitute
85
+ for a physical observation.
86
+
87
+ Per-verifier `EvidenceDisposition` values (`ACCEPTED`, `REJECTED_STALE`,
88
+ `REJECTED_IRRELEVANT`, `REJECTED_MISSING`, `REJECTED_CONTRADICTORY`,
89
+ `REJECTED_FAILED`) let Completion Core explain exactly which evidence was
90
+ accepted, rejected, stale, missing, or contradictory. Original API:
91
+
92
+ ```python
93
+ from product.completion import analyze_completion_evidence, validate_completion_evidence_analysis
94
+
95
+ analysis = analyze_completion_evidence(claim, contract, change_set, plan, evidence)
96
+ assert analysis.claims_verified # all three states
97
+ assert validate_completion_evidence_analysis(analysis, claim, contract, change_set, plan, evidence)
98
+ ```
99
+
100
+ Freshness and execution ordering are derived from content hash comparison
101
+ rather than wall-clock timestamps: an observation whose artifact content hash
102
+ differs from the claimed final content hash predates a later mutation and is
103
+ stale unless the hashes are equal (equivalence proven by the trusted hash
104
+ machinery). See `tests/product/test_completion_evidence_applicability.py` for
105
+ the `modify -> test PASS -> modify again` and irrelevant-verification
106
+ regressions.
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ uv sync
112
+ uv run pytest -q tests/product
113
+ uv run pytest -q tests/benchmark
114
+ uv run ruff check product tests
115
+ uv run nexus-certify --help
116
+ ```
117
+
118
+ ## Compatibility and Coexistence Boundary
119
+
120
+ `nexus-core` and the current `nexus-legacy` package in `Nexus-new` have distinct package and console-script ownership:
121
+
122
+ - Nexus Core is distributed as `nexus-certify` while continuing to own the
123
+ internal `product` Python package and `nexus-certify` console script.
124
+ - `nexus-legacy` owns the `nexus` and `scripts` packages and the `nexus` console script.
125
+
126
+ The current package definitions therefore no longer have the historical `product` namespace / `nexus-certify` console-script collision described by the previous README.
127
+
128
+ Use separate virtual environments for normal development and testing because the repositories have different dependency sets and operational roles. That isolation is development hygiene, not a requirement caused by the retired namespace/script collision.
@@ -0,0 +1,46 @@
1
+ product/__init__.py,sha256=xvXwzejqwfCdfJTb_oGKMg2Ypn7RwXDfT0f-jtqwOSI,52
2
+ product/ledger.py,sha256=yIcHlyjIlJxV3apS_s8HASr3FhbGs20rVyUYo_8O4xM,40419
3
+ product/acquisition/__init__.py,sha256=jrzd7v51bgT6RAE2HblvT1E_eKYGh9H5FWADXZKxrUY,842
4
+ product/acquisition/github.py,sha256=E3R9P0KGCF47eSo84unWebv2pGiSZMltE-2O8naS3Vo,15655
5
+ product/adapters/__init__.py,sha256=1DkEAIRjlVpe_Fmxu9ErQ2I_anEzCVChFuWL5mQnKtM,39
6
+ product/adapters/changeset_certification_v2.py,sha256=dRsTXxnwj6-8LaC0oI0RtEkv1aLLX5O87dgMwZk4ugM,36207
7
+ product/adapters/generic_verification.py,sha256=lheYMpkFG1h9BpZSglaApvkZeXWMinxBkZgb02Y4YTc,16249
8
+ product/adapters/github.py,sha256=J-8DrnrGXVIwvTkvHZEQ3Zx2xi3Pw1QuNJJc8IqpVv4,10347
9
+ product/adapters/legacy.py,sha256=QPZL4vC7f6kSGZtDSjRdo3cheXikDrRxcJY4jrKRUn8,1171
10
+ product/adapters/trusted.py,sha256=GsrcoY-6fgsScgnoM4o4kQJV2v6-dqA5Rsngr2rfQDA,21442
11
+ product/benchmark/__init__.py,sha256=wHHb44gGX-f8EPOta1kf8vaElE1v58n_ZxzIHXvKH6Q,53846
12
+ product/benchmark/tg7_shadow.py,sha256=gOZsEx1HKEQ1OrjgtGnvE9NHgZ2iCM0KB5wTtCETmMM,2103
13
+ product/benchmark/tg7_shadow_kernel.py,sha256=MHEhcqRuxjLMPMjFoQDdyEGkofULYtasrAXGVvAVB2U,41400
14
+ product/benchmark/tg9_value.py,sha256=BuBj91yUTE4g-8aeE0eOTPWKfW47r4A2pn0ykTznykg,45949
15
+ product/certification/__init__.py,sha256=Yw0_X06jZCb1tZqw5DrppvkBmseAJl--tDPm5xhjGhY,2867
16
+ product/certification/receipt.py,sha256=w4XabPii3lp_WnpPmZW_HWNvIF19icV2Uux1x7MmsxQ,19369
17
+ product/clients/__init__.py,sha256=6W9lqbdUiKzHIxy0CiIWA6dkZg9IYVM_vR9F3Mg0oCs,566
18
+ product/clients/cli.py,sha256=yB5G44JMO36aMEJQNSs4gewlvp1qDRfoqsm1vVNeIDg,14650
19
+ product/clients/github_action.py,sha256=Z-kSnn5wVmLwEFbWB8uI94w-omwdxgI6ijRWubxnP78,11621
20
+ product/clients/local_golden_path.py,sha256=s0s1_whFeSpRobJdwSmlMwy-5Amc78m9lvwUYx4NOdE,30499
21
+ product/clients/mcp.py,sha256=RF5SiXgeTKelLv8Fj-oPUVkINR-ia1yoZfGHB29uS3o,5494
22
+ product/completion/__init__.py,sha256=MVnB9S7AYfV8QXQ5Fb0ciLIQuv-sg3Ha7mTnKRw-_TU,23118
23
+ product/evidence/__init__.py,sha256=sC0WF34FAH80ZTPAbTp9v-xTPk3lzjCZdEVVwoHUL_s,32660
24
+ product/evidence/code_integrity.py,sha256=i_ThdcTonfpAAKaJRWXiwFneVEkYndRuxxRGpMSEkts,27894
25
+ product/evidence/ingestion.py,sha256=0QkQrnpXDCftGysp8nSBew0cpnXgHQAc_Iyo2QoRoDU,96210
26
+ product/execution/__init__.py,sha256=FNLS3FhyNqz5WCUSbp3J0iqwX2nsCxypdnRyA9-DzP0,1015
27
+ product/execution/python_runner.py,sha256=D7VOqvvhL8KhkhIANk9S2TnQE2tQXUrXBzPv5pHLCMA,28918
28
+ product/execution/profiles/__init__.py,sha256=ocOHYa4W4wgIYxU4QYizCVBohSb2IDHvhaqrcQim1mc,57
29
+ product/execution/profiles/python-oci-pytest-v1.json,sha256=qj49XuuZ4SJy6Cwi5vLGeFA3Bsrp-RdYMHAwf5e-jaY,517
30
+ product/execution/profiles/python-oci-pytest-v1.lock,sha256=5gPRsKszIciB84lrKUb2Rq2-EmL1uWSL2VV-cmILXZ8,1513
31
+ product/kernel/__init__.py,sha256=zzX4Sy-6xuScQ1VKk6yfrT6nwgDMTb9h574gHYiVZiE,5336
32
+ product/protocol/__init__.py,sha256=-t-ooUzNHtCXoosaR_WflCHVihdbfXIvsxsi8Ldl3qI,696
33
+ product/protocol/compatibility_gate.py,sha256=8hLjxR0f2e9Ft5W2kQKEJPXZ3nnAOURagY7Sva627no,5579
34
+ product/protocol/compatibility_gate_kernel.py,sha256=W-lKXq5kVoiJii4a7UmJItA4ictKgHglxYrdmnxgHtI,40525
35
+ product/protocol/generic_verification.py,sha256=dCZfMpcGGNK99Vj8oH5G7WiEZi_TEvnctGJVR-T2III,31271
36
+ product/runtime/__init__.py,sha256=UWP4zFdcVpvmr8fDs_uAV_qnOjnyWgsOFYLbOcnSslo,1512
37
+ product/runtime/auth.py,sha256=yjXe0qyn7nT23q8lwFZCT31KOfnsb3hkLrHnAuGijKc,6349
38
+ product/runtime/http.py,sha256=KM7XO80tyUMRvSGPEEpUk18chJsapcep-Q6hbpC2m4s,12947
39
+ product/runtime/schemas.py,sha256=p8XCWMSBXzVJYXGortyMj14tnhc9XaTSh2sdpq636bo,12913
40
+ product/runtime/service.py,sha256=Wj-xZYAVXwzyGP7Z-bTnKSHZR2eClbJC54K2qVYmgC4,30570
41
+ product/verification/__init__.py,sha256=Bwle0QbMHveecHwjCjyFsOzOdypC9N5vWuvWQjA_fv8,8125
42
+ nexus_certify-0.1.0.dist-info/METADATA,sha256=tyCv7OZwhWbQDxEmXPbsJk76IJWSZvaM5dMaaoZWqZ8,6579
43
+ nexus_certify-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
44
+ nexus_certify-0.1.0.dist-info/entry_points.txt,sha256=QxDwcmncZyvXBHYhJKpnA-Ki0Xbf6s0hxYXW6mbJva0,59
45
+ nexus_certify-0.1.0.dist-info/top_level.txt,sha256=FrxW_86o3e_ivgRQnhtrp6bWvCG39Mq7aKsxCtzKFTQ,8
46
+ nexus_certify-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nexus-certify = product.clients.cli:main
@@ -0,0 +1 @@
1
+ product
product/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Pure completion-certification product kernel."""
@@ -0,0 +1,29 @@
1
+ """Credential-free acquisition ports for externally owned source systems.
2
+
3
+ Acquisition is deliberately separate from :mod:`product.adapters`: ports are
4
+ injected by the controller and return already-authorized, read-only data.
5
+ """
6
+
7
+ from .github import (
8
+ AcquisitionError,
9
+ GitHubAcquisitionSnapshot,
10
+ GitHubPullRequestAcquisition,
11
+ GitHubPullRequestLocator,
12
+ GitHubReadPort,
13
+ acquire_github_pull_request,
14
+ acquire_pull_request,
15
+ load_github_acquisition_snapshot,
16
+ serialize_github_acquisition_snapshot,
17
+ )
18
+
19
+ __all__ = [
20
+ "AcquisitionError",
21
+ "GitHubAcquisitionSnapshot",
22
+ "GitHubPullRequestAcquisition",
23
+ "GitHubPullRequestLocator",
24
+ "GitHubReadPort",
25
+ "acquire_github_pull_request",
26
+ "acquire_pull_request",
27
+ "load_github_acquisition_snapshot",
28
+ "serialize_github_acquisition_snapshot",
29
+ ]
@@ -0,0 +1,400 @@
1
+ """Injected, read-only GitHub pull-request acquisition.
2
+
3
+ The port intentionally has no credential parameter and this module has no
4
+ network client. A controller-owned implementation performs authentication
5
+ outside the product package and supplies one complete response per read.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from typing import Mapping, Protocol, runtime_checkable
16
+
17
+ _SHA40 = re.compile(r"[0-9a-f]{40}\Z")
18
+ _SHA256 = re.compile(r"sha256:[0-9a-f]{64}\Z")
19
+ _NAME = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})?\Z")
20
+ _UTC_RFC3339 = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\Z")
21
+ _REQUIRED = frozenset({
22
+ "repository_owner",
23
+ "repository_name",
24
+ "pr_number",
25
+ "base_sha",
26
+ "head_sha",
27
+ "base_tree_sha",
28
+ "head_tree_sha",
29
+ "merge_base_policy",
30
+ "diff_bytes",
31
+ "diff_hash",
32
+ "changed_paths",
33
+ "deleted_paths",
34
+ "checks",
35
+ "pagination_complete",
36
+ "observed_at",
37
+ "freshness_cas",
38
+ })
39
+
40
+
41
+ class AcquisitionError(ValueError):
42
+ """A response cannot be admitted as an immutable acquisition snapshot."""
43
+
44
+
45
+ class PermissionDenied(AcquisitionError):
46
+ """The injected port could not read the requested PR."""
47
+
48
+
49
+ class AcquisitionDriftError(AcquisitionError):
50
+ """The two independent reads do not describe the same PR identity."""
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class GitHubPullRequestLocator:
55
+ repository_owner: str
56
+ repository_name: str
57
+ pr_number: int
58
+
59
+ def __post_init__(self) -> None:
60
+ for field in ("repository_owner", "repository_name"):
61
+ value = getattr(self, field)
62
+ if (
63
+ type(value) is not str
64
+ or not value
65
+ or value != value.strip()
66
+ or _NAME.fullmatch(value) is None
67
+ ):
68
+ raise AcquisitionError(f"{field} is not a normalized GitHub name")
69
+ if type(self.pr_number) is not int or self.pr_number <= 0:
70
+ raise AcquisitionError("pr_number must be a positive exact int")
71
+
72
+ @property
73
+ def locator_hash(self) -> str:
74
+ value = [self.repository_owner.lower(), self.repository_name.lower(), self.pr_number]
75
+ return (
76
+ "sha256:"
77
+ + hashlib.sha256(json.dumps(value, separators=(",", ":")).encode()).hexdigest()
78
+ )
79
+
80
+
81
+ @runtime_checkable
82
+ class GitHubReadPort(Protocol):
83
+ """Controller-provided authenticated read seam; credentials never cross it."""
84
+
85
+ def read_pull_request(self, locator: GitHubPullRequestLocator) -> Mapping[str, object]: ...
86
+
87
+
88
+ def _sha(value: object, field: str) -> str:
89
+ if type(value) is not str or _SHA40.fullmatch(value) is None:
90
+ raise AcquisitionError(f"{field} must be lowercase 40-hex SHA")
91
+ return value
92
+
93
+
94
+ def _hash(value: object, field: str) -> str:
95
+ if type(value) is not str or _SHA256.fullmatch(value) is None:
96
+ raise AcquisitionError(f"{field} must be sha256:<64 lowercase hex>")
97
+ return value
98
+
99
+
100
+ def _paths(value: object, field: str, *, allow_empty: bool = True) -> tuple[str, ...]:
101
+ if not isinstance(value, (list, tuple)):
102
+ raise AcquisitionError(f"{field} must be a path list")
103
+ result = tuple(str(x) for x in value)
104
+ if not allow_empty and not result:
105
+ raise AcquisitionError(f"{field} must be non-empty")
106
+ if len(result) != len(set(result)):
107
+ raise AcquisitionError(f"{field} contains duplicate paths")
108
+ for path in result:
109
+ if (
110
+ type(path) is not str
111
+ or not path
112
+ or path != path.strip()
113
+ or path.startswith("/")
114
+ or "\\" in path
115
+ or any(part in {"", ".", ".."} for part in path.split("/"))
116
+ ):
117
+ raise AcquisitionError(f"{field} contains an invalid relative path")
118
+ return tuple(sorted(result))
119
+
120
+
121
+ def _canonical(value: object) -> str:
122
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
123
+
124
+
125
+ def _freshness_cas_for(
126
+ owner: str,
127
+ repository: str,
128
+ pr_number: int,
129
+ base_sha: str,
130
+ head_sha: str,
131
+ base_tree_sha: str,
132
+ head_tree_sha: str,
133
+ merge_base_policy: str,
134
+ diff_hash: str,
135
+ changed_paths: tuple[str, ...],
136
+ deleted_paths: tuple[str, ...],
137
+ checks: tuple[tuple[str, str], ...],
138
+ ) -> str:
139
+ subject = {
140
+ "repository_owner": owner.lower(),
141
+ "repository_name": repository.lower(),
142
+ "pr_number": pr_number,
143
+ "base_sha": base_sha,
144
+ "head_sha": head_sha,
145
+ "base_tree_sha": base_tree_sha,
146
+ "head_tree_sha": head_tree_sha,
147
+ "merge_base_policy": merge_base_policy,
148
+ "diff_hash": diff_hash,
149
+ "changed_paths": list(changed_paths),
150
+ "deleted_paths": list(deleted_paths),
151
+ "checks": [list(item) for item in checks],
152
+ }
153
+ return "sha256:" + hashlib.sha256(_canonical(subject).encode()).hexdigest()
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class GitHubAcquisitionSnapshot:
158
+ repository_owner: str
159
+ repository_name: str
160
+ pr_number: int
161
+ base_sha: str
162
+ head_sha: str
163
+ base_tree_sha: str
164
+ head_tree_sha: str
165
+ merge_base_policy: str
166
+ diff_bytes: bytes
167
+ diff_hash: str
168
+ changed_paths: tuple[str, ...]
169
+ deleted_paths: tuple[str, ...]
170
+ checks: tuple[tuple[str, str], ...]
171
+ pagination_complete: bool
172
+ observed_at: str
173
+ freshness_cas: str
174
+ locator_hash: str
175
+
176
+ def __post_init__(self) -> None:
177
+ locator = GitHubPullRequestLocator(
178
+ self.repository_owner, self.repository_name, self.pr_number
179
+ )
180
+ for field in ("base_sha", "head_sha", "base_tree_sha", "head_tree_sha"):
181
+ _sha(getattr(self, field), field)
182
+ if self.base_sha == self.head_sha:
183
+ raise AcquisitionError("base_sha and head_sha must differ")
184
+ if self.merge_base_policy != "base_sha_exact":
185
+ raise AcquisitionError("merge_base_policy must be base_sha_exact")
186
+ if type(self.diff_bytes) is not bytes:
187
+ raise AcquisitionError("diff_bytes must be immutable bytes")
188
+ expected = "sha256:" + hashlib.sha256(self.diff_bytes).hexdigest()
189
+ if self.diff_hash != expected:
190
+ raise AcquisitionError("diff_hash does not match diff_bytes")
191
+ _hash(self.diff_hash, "diff_hash")
192
+ if self.changed_paths != tuple(sorted(self.changed_paths)):
193
+ raise AcquisitionError("changed_paths must be sorted")
194
+ if self.deleted_paths != tuple(sorted(self.deleted_paths)):
195
+ raise AcquisitionError("deleted_paths must be sorted")
196
+ _paths(self.changed_paths, "changed_paths", allow_empty=False)
197
+ _paths(self.deleted_paths, "deleted_paths")
198
+ if not set(self.deleted_paths).issubset(set(self.changed_paths)):
199
+ raise AcquisitionError("deleted_paths must be a subset of changed_paths")
200
+ if type(self.checks) is not tuple or any(
201
+ type(x) is not tuple or len(x) != 2 for x in self.checks
202
+ ):
203
+ raise AcquisitionError("checks must be (identity, digest) pairs")
204
+ if self.checks != tuple(sorted(self.checks)) or len({x[0] for x in self.checks}) != len(
205
+ self.checks
206
+ ):
207
+ raise AcquisitionError("checks must be sorted and have unique identities")
208
+ for identity, digest in self.checks:
209
+ if (
210
+ type(identity) is not str
211
+ or not identity
212
+ or identity != identity.strip()
213
+ or "\x00" in identity
214
+ ):
215
+ raise AcquisitionError("check identities must be normalized")
216
+ _hash(digest, "check digest")
217
+ if type(self.pagination_complete) is not bool or not self.pagination_complete:
218
+ raise AcquisitionError("pagination_complete must be true")
219
+ if type(self.observed_at) is not str or _UTC_RFC3339.fullmatch(self.observed_at) is None:
220
+ raise AcquisitionError("observed_at must be canonical UTC RFC3339 ending in Z")
221
+ try:
222
+ parsed = datetime.fromisoformat(self.observed_at[:-1])
223
+ except ValueError as exc:
224
+ raise AcquisitionError("observed_at must be a valid UTC RFC3339 timestamp") from exc
225
+ if parsed.tzinfo is not None:
226
+ raise AcquisitionError("observed_at must use canonical Z timezone")
227
+ expected_cas = _freshness_cas_for(
228
+ self.repository_owner,
229
+ self.repository_name,
230
+ self.pr_number,
231
+ self.base_sha,
232
+ self.head_sha,
233
+ self.base_tree_sha,
234
+ self.head_tree_sha,
235
+ self.merge_base_policy,
236
+ self.diff_hash,
237
+ self.changed_paths,
238
+ self.deleted_paths,
239
+ self.checks,
240
+ )
241
+ if self.freshness_cas != expected_cas:
242
+ raise AcquisitionError("freshness_cas does not match snapshot subject")
243
+ if self.locator_hash != locator.locator_hash:
244
+ raise AcquisitionError("locator_hash does not match locator")
245
+
246
+ def to_dict(self) -> dict[str, object]:
247
+ return {
248
+ "repository_owner": self.repository_owner,
249
+ "repository_name": self.repository_name,
250
+ "pr_number": self.pr_number,
251
+ "base_sha": self.base_sha,
252
+ "head_sha": self.head_sha,
253
+ "base_tree_sha": self.base_tree_sha,
254
+ "head_tree_sha": self.head_tree_sha,
255
+ "merge_base_policy": self.merge_base_policy,
256
+ "diff_bytes": self.diff_bytes.hex(),
257
+ "diff_hash": self.diff_hash,
258
+ "changed_paths": list(self.changed_paths),
259
+ "deleted_paths": list(self.deleted_paths),
260
+ "checks": [list(x) for x in self.checks],
261
+ "pagination_complete": self.pagination_complete,
262
+ "observed_at": self.observed_at,
263
+ "freshness_cas": self.freshness_cas,
264
+ "locator_hash": self.locator_hash,
265
+ }
266
+
267
+
268
+ def _parse(
269
+ raw: Mapping[str, object], locator: GitHubPullRequestLocator
270
+ ) -> GitHubAcquisitionSnapshot:
271
+ if not isinstance(raw, Mapping) or set(raw) != _REQUIRED:
272
+ raise AcquisitionError("response has an incomplete or substituted schema")
273
+ values = dict(raw)
274
+ if (
275
+ values["repository_owner"] != locator.repository_owner
276
+ or values["repository_name"] != locator.repository_name
277
+ or values["pr_number"] != locator.pr_number
278
+ ):
279
+ raise AcquisitionDriftError("response locator differs from requested locator")
280
+ diff_bytes = values["diff_bytes"]
281
+ if not isinstance(diff_bytes, bytes):
282
+ raise AcquisitionError("diff_bytes must be bytes from the read port")
283
+ checks = values["checks"]
284
+ if not isinstance(checks, (list, tuple)):
285
+ raise AcquisitionError("checks must be a list")
286
+ checks_tuple: tuple[tuple[str, str], ...] = tuple(
287
+ (str(x[0]), str(x[1]))
288
+ for x in checks
289
+ if isinstance(x, (list, tuple)) and len(x) == 2
290
+ )
291
+ changed_paths = values["changed_paths"]
292
+ changed_tuple = tuple(str(x) for x in changed_paths) if isinstance(changed_paths, (list, tuple)) else ()
293
+ deleted_paths = values["deleted_paths"]
294
+ deleted_tuple = tuple(str(x) for x in deleted_paths) if isinstance(deleted_paths, (list, tuple)) else ()
295
+ raw_pr = values["pr_number"]
296
+ pr_num = raw_pr if isinstance(raw_pr, int) else int(str(raw_pr))
297
+ return GitHubAcquisitionSnapshot(
298
+ repository_owner=str(values["repository_owner"]),
299
+ repository_name=str(values["repository_name"]),
300
+ pr_number=pr_num,
301
+ base_sha=str(values["base_sha"]),
302
+ head_sha=str(values["head_sha"]),
303
+ base_tree_sha=str(values["base_tree_sha"]),
304
+ head_tree_sha=str(values["head_tree_sha"]),
305
+ merge_base_policy=str(values["merge_base_policy"]),
306
+ diff_bytes=diff_bytes,
307
+ diff_hash=str(values["diff_hash"]),
308
+ changed_paths=changed_tuple,
309
+ deleted_paths=deleted_tuple,
310
+ checks=checks_tuple,
311
+ pagination_complete=bool(values["pagination_complete"]),
312
+ observed_at=str(values["observed_at"]),
313
+ freshness_cas=str(values["freshness_cas"]),
314
+ locator_hash=locator.locator_hash,
315
+ )
316
+
317
+
318
+ def acquire_github_pull_request(
319
+ port: GitHubReadPort, locator: GitHubPullRequestLocator
320
+ ) -> GitHubAcquisitionSnapshot:
321
+ """Read twice and admit only convergent, complete, immutable identity."""
322
+ if not isinstance(locator, GitHubPullRequestLocator):
323
+ raise TypeError("locator must be GitHubPullRequestLocator")
324
+ try:
325
+ first = port.read_pull_request(locator)
326
+ second = port.read_pull_request(locator)
327
+ except PermissionError as exc:
328
+ raise PermissionDenied("GitHub read permission denied") from exc
329
+ left = _parse(first, locator)
330
+ right = _parse(second, locator)
331
+ if left != right:
332
+ raise AcquisitionDriftError("independent GitHub reads disagree")
333
+ return left
334
+
335
+
336
+ def serialize_github_acquisition_snapshot(snapshot: GitHubAcquisitionSnapshot) -> dict[str, object]:
337
+ if type(snapshot) is not GitHubAcquisitionSnapshot:
338
+ raise TypeError("snapshot must be GitHubAcquisitionSnapshot")
339
+ return snapshot.to_dict()
340
+
341
+
342
+ def load_github_acquisition_snapshot(payload: Mapping[str, object]) -> GitHubAcquisitionSnapshot:
343
+ if not isinstance(payload, Mapping):
344
+ raise TypeError("payload must be a mapping")
345
+ values = dict(payload)
346
+ try:
347
+ raw_diff = values["diff_bytes"]
348
+ diff_bytes = bytes.fromhex(str(raw_diff))
349
+ raw_checks = values["checks"]
350
+ checks_tuple: tuple[tuple[str, str], ...] = tuple(
351
+ (str(x[0]), str(x[1]))
352
+ for x in raw_checks
353
+ if isinstance(x, (list, tuple)) and len(x) == 2
354
+ ) if isinstance(raw_checks, (list, tuple)) else ()
355
+ raw_changed = values["changed_paths"]
356
+ changed_tuple = tuple(str(x) for x in raw_changed) if isinstance(raw_changed, (list, tuple)) else ()
357
+ raw_deleted = values["deleted_paths"]
358
+ deleted_tuple = tuple(str(x) for x in raw_deleted) if isinstance(raw_deleted, (list, tuple)) else ()
359
+ raw_pr = values["pr_number"]
360
+ pr_num = raw_pr if isinstance(raw_pr, int) else int(str(raw_pr))
361
+ return GitHubAcquisitionSnapshot(
362
+ repository_owner=str(values["repository_owner"]),
363
+ repository_name=str(values["repository_name"]),
364
+ pr_number=pr_num,
365
+ base_sha=str(values["base_sha"]),
366
+ head_sha=str(values["head_sha"]),
367
+ base_tree_sha=str(values["base_tree_sha"]),
368
+ head_tree_sha=str(values["head_tree_sha"]),
369
+ merge_base_policy=str(values["merge_base_policy"]),
370
+ diff_bytes=diff_bytes,
371
+ diff_hash=str(values["diff_hash"]),
372
+ changed_paths=changed_tuple,
373
+ deleted_paths=deleted_tuple,
374
+ checks=checks_tuple,
375
+ pagination_complete=bool(values["pagination_complete"]),
376
+ observed_at=str(values["observed_at"]),
377
+ freshness_cas=str(values["freshness_cas"]),
378
+ locator_hash=str(values["locator_hash"]),
379
+ )
380
+ except (KeyError, TypeError, ValueError) as exc:
381
+ raise AcquisitionError("malformed serialized acquisition snapshot") from exc
382
+
383
+
384
+ GitHubPullRequestAcquisition = GitHubAcquisitionSnapshot
385
+ acquire_pull_request = acquire_github_pull_request
386
+
387
+
388
+ __all__ = [
389
+ "AcquisitionError",
390
+ "AcquisitionDriftError",
391
+ "GitHubAcquisitionSnapshot",
392
+ "GitHubPullRequestAcquisition",
393
+ "GitHubPullRequestLocator",
394
+ "GitHubReadPort",
395
+ "PermissionDenied",
396
+ "acquire_github_pull_request",
397
+ "acquire_pull_request",
398
+ "load_github_acquisition_snapshot",
399
+ "serialize_github_acquisition_snapshot",
400
+ ]
@@ -0,0 +1 @@
1
+ """External compatibility adapters."""