atrib 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.
atrib/__init__.py ADDED
@@ -0,0 +1,237 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """atrib — verifiable agent actions, Python client SDK.
3
+
4
+ The first non-TypeScript implementation of the atrib record layer
5
+ (spec §1) and SDK contract (spec §5). Byte-identical to the TypeScript
6
+ implementation: identical inputs produce identical JCS canonical forms,
7
+ signatures, record hashes, and propagation tokens, verified against the
8
+ shared conformance corpora under ``spec/conformance/``.
9
+ """
10
+
11
+ from .canon import (
12
+ canonical_cross_attestation_input,
13
+ canonical_record,
14
+ canonical_signing_input,
15
+ jcs,
16
+ )
17
+ from .anchors import (
18
+ ANCHOR_CLAIM_PREFIX,
19
+ ANCHOR_TYPES,
20
+ BUILT_IN_DEFAULT_ANCHOR_SET,
21
+ AnchorDescriptor,
22
+ AnchorPostureResolution,
23
+ AnchorSetConfig,
24
+ anchor_claim_artifact,
25
+ resolve_anchor_posture,
26
+ resolve_effective_anchors,
27
+ )
28
+ from .attribution import (
29
+ ATTRIBUTION_EXTENSION_KEY,
30
+ ATTRIBUTION_LOG_SUBMISSION_STATUSES,
31
+ AttributionReceiptBlock,
32
+ AttributionReceiptConsistency,
33
+ AttributionReceiptVerification,
34
+ check_attribution_receipt_consistency,
35
+ parse_attribution_receipt_block,
36
+ verify_attribution_receipt,
37
+ )
38
+ from .chain import chain_root, genesis_chain_root, resolve_chain_root
39
+ from .client import AnchorSpec, AtribClient, AttestRef, AttestResult, RecallOutcome
40
+ from .evidence import (
41
+ ATRIB_PROFILE_BASE,
42
+ ATRIB_PROFILE_REGISTRY,
43
+ EVIDENCE_CONSTRAINT_STATUSES,
44
+ EVIDENCE_REF_KINDS,
45
+ EVIDENCE_TIERS,
46
+ FROZEN_LEGACY_PROTOCOLS,
47
+ LEGACY_PROTOCOL_TO_PROFILE,
48
+ EnvelopeReproducibility,
49
+ EnvelopeValidation,
50
+ EvidenceConstraint,
51
+ EvidenceEnvelope,
52
+ EvidencePayload,
53
+ EvidencePayloadRef,
54
+ EvidenceResult,
55
+ EvidenceVerifier,
56
+ ProfileClassification,
57
+ assess_reproducibility,
58
+ build_evidence_envelope,
59
+ classify_profile,
60
+ evidence_envelope_key,
61
+ evidence_tier_rank,
62
+ from_legacy_evidence_block,
63
+ is_relay_identity_swap,
64
+ is_valid_envelope,
65
+ jcs_sha256,
66
+ map_legacy_evidence_block,
67
+ order_envelope_instances,
68
+ raw_sha256,
69
+ render_envelope_opaque,
70
+ validate_envelope,
71
+ )
72
+ from .content_id import compute_content_id, normalize_server_url
73
+ from .encoding import base64url_decode, base64url_encode, hex_decode, hex_encode
74
+ from .hashes import (
75
+ derive_provenance_token,
76
+ record_hash_bytes,
77
+ record_hash_hex,
78
+ record_hash_ref,
79
+ sha256,
80
+ )
81
+ from .keys import ResolvedKey, resolve_key
82
+ from .mirror import (
83
+ MirrorLine,
84
+ append_mirror_line,
85
+ default_mirror_read_path,
86
+ default_mirror_write_path,
87
+ mirror_tail_hash_hex,
88
+ parse_mirror_line,
89
+ read_mirror,
90
+ read_mirror_tail,
91
+ )
92
+ from .records import (
93
+ SYNTHETIC_SERVER_URL,
94
+ build_and_sign_emit_record,
95
+ content_hash,
96
+ leaf_of_event_type_uri,
97
+ )
98
+ from .signing import (
99
+ get_public_key,
100
+ sign_record,
101
+ sign_transaction_attestation,
102
+ sign_transaction_record,
103
+ verify_record,
104
+ )
105
+ from .submission import DEFAULT_LOG_ENDPOINT, SubmissionQueue, normalize_log_endpoint
106
+ from .token import DecodedToken, decode_token, encode_token
107
+ from .types import (
108
+ EVENT_TYPE_ANNOTATION_URI,
109
+ EVENT_TYPE_DIRECTORY_ANCHOR_URI,
110
+ EVENT_TYPE_OBSERVATION_URI,
111
+ EVENT_TYPE_REVISION_URI,
112
+ EVENT_TYPE_TOOL_CALL_URI,
113
+ EVENT_TYPE_TRANSACTION_URI,
114
+ SPEC_VERSION,
115
+ AtribRecord,
116
+ SignerEntry,
117
+ event_type_uri_to_byte,
118
+ is_normative_event_type_uri,
119
+ is_valid_event_type_uri,
120
+ normalize_event_type,
121
+ )
122
+ from .validation import ValidationResult, validate_submission
123
+
124
+ __version__ = "0.1.0"
125
+
126
+ __all__ = [
127
+ "ANCHOR_CLAIM_PREFIX",
128
+ "ANCHOR_TYPES",
129
+ "ATRIB_PROFILE_BASE",
130
+ "ATRIB_PROFILE_REGISTRY",
131
+ "ATTRIBUTION_EXTENSION_KEY",
132
+ "ATTRIBUTION_LOG_SUBMISSION_STATUSES",
133
+ "AnchorDescriptor",
134
+ "AnchorPostureResolution",
135
+ "AnchorSetConfig",
136
+ "AnchorSpec",
137
+ "AttributionReceiptBlock",
138
+ "AttributionReceiptConsistency",
139
+ "AttributionReceiptVerification",
140
+ "BUILT_IN_DEFAULT_ANCHOR_SET",
141
+ "anchor_claim_artifact",
142
+ "check_attribution_receipt_consistency",
143
+ "parse_attribution_receipt_block",
144
+ "resolve_anchor_posture",
145
+ "resolve_effective_anchors",
146
+ "verify_attribution_receipt",
147
+ "AtribClient",
148
+ "AtribRecord",
149
+ "AttestRef",
150
+ "AttestResult",
151
+ "DecodedToken",
152
+ "DEFAULT_LOG_ENDPOINT",
153
+ "EVIDENCE_CONSTRAINT_STATUSES",
154
+ "EVIDENCE_REF_KINDS",
155
+ "EVIDENCE_TIERS",
156
+ "FROZEN_LEGACY_PROTOCOLS",
157
+ "LEGACY_PROTOCOL_TO_PROFILE",
158
+ "EnvelopeReproducibility",
159
+ "EnvelopeValidation",
160
+ "EvidenceConstraint",
161
+ "EvidenceEnvelope",
162
+ "EvidencePayload",
163
+ "EvidencePayloadRef",
164
+ "EvidenceResult",
165
+ "EvidenceVerifier",
166
+ "ProfileClassification",
167
+ "assess_reproducibility",
168
+ "build_evidence_envelope",
169
+ "classify_profile",
170
+ "evidence_envelope_key",
171
+ "evidence_tier_rank",
172
+ "from_legacy_evidence_block",
173
+ "is_relay_identity_swap",
174
+ "is_valid_envelope",
175
+ "jcs_sha256",
176
+ "map_legacy_evidence_block",
177
+ "order_envelope_instances",
178
+ "raw_sha256",
179
+ "render_envelope_opaque",
180
+ "validate_envelope",
181
+ "EVENT_TYPE_ANNOTATION_URI",
182
+ "EVENT_TYPE_DIRECTORY_ANCHOR_URI",
183
+ "EVENT_TYPE_OBSERVATION_URI",
184
+ "EVENT_TYPE_REVISION_URI",
185
+ "EVENT_TYPE_TOOL_CALL_URI",
186
+ "EVENT_TYPE_TRANSACTION_URI",
187
+ "MirrorLine",
188
+ "RecallOutcome",
189
+ "ResolvedKey",
190
+ "SPEC_VERSION",
191
+ "SignerEntry",
192
+ "SubmissionQueue",
193
+ "SYNTHETIC_SERVER_URL",
194
+ "ValidationResult",
195
+ "append_mirror_line",
196
+ "base64url_decode",
197
+ "base64url_encode",
198
+ "build_and_sign_emit_record",
199
+ "canonical_cross_attestation_input",
200
+ "canonical_record",
201
+ "canonical_signing_input",
202
+ "chain_root",
203
+ "compute_content_id",
204
+ "content_hash",
205
+ "decode_token",
206
+ "default_mirror_read_path",
207
+ "default_mirror_write_path",
208
+ "derive_provenance_token",
209
+ "encode_token",
210
+ "event_type_uri_to_byte",
211
+ "genesis_chain_root",
212
+ "get_public_key",
213
+ "hex_decode",
214
+ "hex_encode",
215
+ "is_normative_event_type_uri",
216
+ "is_valid_event_type_uri",
217
+ "jcs",
218
+ "leaf_of_event_type_uri",
219
+ "mirror_tail_hash_hex",
220
+ "normalize_event_type",
221
+ "normalize_log_endpoint",
222
+ "normalize_server_url",
223
+ "parse_mirror_line",
224
+ "read_mirror",
225
+ "read_mirror_tail",
226
+ "record_hash_bytes",
227
+ "record_hash_hex",
228
+ "record_hash_ref",
229
+ "resolve_chain_root",
230
+ "resolve_key",
231
+ "sha256",
232
+ "sign_record",
233
+ "sign_transaction_attestation",
234
+ "sign_transaction_record",
235
+ "validate_submission",
236
+ "verify_record",
237
+ ]
atrib/anchors.py ADDED
@@ -0,0 +1,200 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Producer-side anchor plurality (D138, spec §2.11.7-§2.11.13).
3
+
4
+ Python port of ``packages/mcp/src/anchors.ts``: the §2.11.8 anchor-type
5
+ registry, the typed anchor-set configuration, the §2.11.12 posture
6
+ resolution (``resolve_anchor_posture`` / ``resolve_effective_anchors``)
7
+ including the ``allow_single_anchor`` opt-in gate, and the §2.11.10
8
+ anchoring-claim artifact bytes (``anchor_claim_artifact``).
9
+
10
+ Submission fan-out lives in :mod:`atrib.client`: ``AtribClient`` builds one
11
+ :class:`atrib.submission.SubmissionQueue` per effective ``atrib-log``
12
+ anchor endpoint, so every leg keeps the §5.3.5 non-blocking contract.
13
+ Non-``atrib-log`` anchor types (``sigstore-rekor``, ``rfc3161-tsa``,
14
+ ``opentimestamps``) have no Python transport yet — the TypeScript
15
+ reference stubs them too — so the client skips those legs with an
16
+ ``atrib:`` warning naming the type.
17
+
18
+ Nothing here changes any signed byte: proof bundles are post-signing
19
+ artifacts (§2.8) and anchoring is permissionless and post-hoc (§2.11.7).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from collections.abc import Mapping
26
+ from dataclasses import dataclass
27
+ from typing import TypedDict
28
+
29
+ from typing_extensions import NotRequired
30
+
31
+ # ── Anchor type registry (§2.11.8, v1) ───────────────────────────────────
32
+
33
+ ANCHOR_TYPES = ("atrib-log", "sigstore-rekor", "rfc3161-tsa", "opentimestamps")
34
+
35
+ # §2.11.10 domain-separation prefix for the anchoring-claim artifact.
36
+ # JCS-canonical records begin with '{'; the prefix makes the separation
37
+ # between anchoring signatures and record signatures explicit.
38
+ ANCHOR_CLAIM_PREFIX = "atrib-anchor/v1:"
39
+
40
+ _RECORD_HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}\Z")
41
+
42
+
43
+ # ── Anchor-set configuration (§2.11.12) ──────────────────────────────────
44
+
45
+
46
+ class AnchorDescriptor(TypedDict, total=False):
47
+ """One anchor in a producer's anchor set. ``anchor_type`` absent (or
48
+ ``None``, mirroring the TS ``??`` coalescing) means ``atrib-log`` — the
49
+ same absence-defaulting rule the ``log_proofs`` discriminator uses
50
+ (§2.11.9 rule (a)). ``url`` wins over ``endpoint`` when both are set."""
51
+
52
+ anchor_type: NotRequired[str]
53
+ anchor_id: NotRequired[str]
54
+ url: NotRequired[str]
55
+ endpoint: NotRequired[str] # alias for `url`; `url` wins when both set
56
+ calendars: NotRequired[list[str]] # opentimestamps only
57
+ public_key_b64: NotRequired[str] # verifier trust material passthrough
58
+
59
+
60
+ class AnchorSetConfig(TypedDict, total=False):
61
+ """Producer anchor configuration per §2.11.12."""
62
+
63
+ anchors: NotRequired[list[AnchorDescriptor]]
64
+ # Opt-in acknowledgment that a sub-plurality anchor set is deliberate
65
+ # (§2.11.12 rule 3) — the single-anchor analog of a deliberate dangling
66
+ # informed_by claim per D113. Defaults to False.
67
+ allow_single_anchor: NotRequired[bool]
68
+
69
+
70
+ #: The SDK's built-in default anchor set (§2.11.12 rule 1): two independent
71
+ #: anchors so zero-config producers get plurality without opting in. Values
72
+ #: match ``BUILT_IN_DEFAULT_ANCHOR_SET`` in ``packages/mcp/src/anchors.ts``
73
+ #: exactly. The OpenTimestamps member has no transport in this SDK yet, so
74
+ #: zero-config fan-out submits to the atrib log exactly as today and the
75
+ #: client reports the OTS leg skipped.
76
+ BUILT_IN_DEFAULT_ANCHOR_SET: tuple[AnchorDescriptor, ...] = (
77
+ {
78
+ "anchor_type": "atrib-log",
79
+ "anchor_id": "log.atrib.dev",
80
+ "url": "https://log.atrib.dev/v1",
81
+ },
82
+ {
83
+ "anchor_type": "opentimestamps",
84
+ "anchor_id": "opentimestamps-calendars",
85
+ "calendars": ["https://a.pool.opentimestamps.org"],
86
+ },
87
+ )
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class AnchorPostureResolution:
92
+ """Result of resolving a producer anchor config per the §2.11.12
93
+ precedence rules. Field names match the conformance corpus
94
+ (``spec/conformance/2.11/anchors/cases/allow-single-anchor-config.json``)
95
+ and the TS ``AnchorPostureResolution`` exactly."""
96
+
97
+ effective_anchor_count: int
98
+ used_default_set: bool
99
+ warn: bool
100
+ # §5.9.3 sidecar degradation marker written when a sub-plurality config
101
+ # lacks allow_single_anchor (§2.11.12 rule 4), else None:
102
+ # {"configured": <n>, "allow_single_anchor": False}
103
+ sidecar_anchor_config: Mapping[str, object] | None
104
+
105
+
106
+ def resolve_anchor_posture(
107
+ config: Mapping[str, object] | None = None,
108
+ ) -> AnchorPostureResolution:
109
+ """Resolve a producer anchor config per §2.11.12, exact precedence:
110
+
111
+ 1. No anchor config at all ⇒ the built-in default set (two anchors).
112
+ 2. Explicit config with ≥ 2 entries ⇒ used as given.
113
+ 3. Explicit config with < 2 entries and ``allow_single_anchor: True``
114
+ ⇒ used as given, no warning.
115
+ 4. Explicit config with < 2 entries and no flag ⇒ ``warn=True`` plus
116
+ the sidecar degradation marker. The operation continues; this
117
+ function is PURE (no output, no raise) — the client emits the
118
+ ``atrib:``-prefixed warning so pure-function callers stay silent.
119
+
120
+ Never raises (§5.8). A malformed config resolves as if empty.
121
+ """
122
+ mapping: Mapping[str, object] = config if isinstance(config, Mapping) else {}
123
+ anchors = mapping.get("anchors")
124
+ if not isinstance(anchors, list):
125
+ return AnchorPostureResolution(
126
+ effective_anchor_count=len(BUILT_IN_DEFAULT_ANCHOR_SET),
127
+ used_default_set=True,
128
+ warn=False,
129
+ sidecar_anchor_config=None,
130
+ )
131
+ configured = len(anchors)
132
+ if configured >= 2 or mapping.get("allow_single_anchor") is True:
133
+ return AnchorPostureResolution(
134
+ effective_anchor_count=configured,
135
+ used_default_set=False,
136
+ warn=False,
137
+ sidecar_anchor_config=None,
138
+ )
139
+ return AnchorPostureResolution(
140
+ effective_anchor_count=configured,
141
+ used_default_set=False,
142
+ warn=True,
143
+ sidecar_anchor_config={"configured": configured, "allow_single_anchor": False},
144
+ )
145
+
146
+
147
+ def resolve_effective_anchors(
148
+ config: Mapping[str, object] | None = None,
149
+ ) -> list[AnchorDescriptor]:
150
+ """The effective anchor set for a config: the built-in default set when
151
+ no config was given (§2.11.12 rule 1), the caller's entries otherwise —
152
+ including deliberate or warned sub-plurality sets, which are used as
153
+ given (rules 3-4: warn, never block). Never raises."""
154
+ mapping: Mapping[str, object] = config if isinstance(config, Mapping) else {}
155
+ anchors = mapping.get("anchors")
156
+ if isinstance(anchors, list):
157
+ # Entries are returned as given (hostile members included) so the
158
+ # transport-build layer can skip them with a warning per §5.8.
159
+ return list(anchors)
160
+ return list(BUILT_IN_DEFAULT_ANCHOR_SET)
161
+
162
+
163
+ # ── §2.11.10 anchoring-claim artifact ────────────────────────────────────
164
+
165
+
166
+ def anchor_claim_artifact(record_hash: str) -> bytes:
167
+ """Build the §2.11.10 anchor-claim artifact bytes for a record hash:
168
+ the UTF-8 bytes of ``"atrib-anchor/v1:" + record_hash`` with
169
+ ``record_hash`` in canonical ``"sha256:" + 64-lowercase-hex`` form.
170
+ Deterministically reconstructible from ``record_hash`` alone; reveals
171
+ nothing beyond the commitment itself (§8.3 posture preserved).
172
+
173
+ Byte-identical to the TS ``anchorClaimArtifact``. Raises ``ValueError``
174
+ on a malformed record hash — this is a pure builder for programmer
175
+ input; fan-out paths catch everything per §5.8.
176
+ """
177
+ if not isinstance(record_hash, str) or not _RECORD_HASH_RE.match(record_hash):
178
+ raise ValueError(
179
+ "atrib: anchor claim requires a canonical "
180
+ f'"sha256:<64 lowercase hex>" record hash, got {record_hash!r:.90}'
181
+ )
182
+ return (ANCHOR_CLAIM_PREFIX + record_hash).encode("utf-8")
183
+
184
+
185
+ # ── Descriptor helpers (shared with the client fan-out) ──────────────────
186
+
187
+
188
+ def anchor_descriptor_type(descriptor: Mapping[str, object]) -> object:
189
+ """§2.11.9 rule (a) analog: absent (or ``None``, matching the TS ``??``)
190
+ ``anchor_type`` means ``atrib-log``. Non-string values pass through as
191
+ given so callers can name them in skip warnings."""
192
+ anchor_type = descriptor.get("anchor_type")
193
+ return "atrib-log" if anchor_type is None else anchor_type
194
+
195
+
196
+ def anchor_descriptor_endpoint(descriptor: Mapping[str, object]) -> object:
197
+ """Submission endpoint: ``url`` wins over ``endpoint`` (both spellings
198
+ accepted per §2.11.12; the spec sample uses ``url``)."""
199
+ url = descriptor.get("url")
200
+ return url if url is not None else descriptor.get("endpoint")
atrib/attribution.py ADDED
@@ -0,0 +1,229 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``dev.atrib/attribution`` extension receipts (accepted as D141).
3
+
4
+ Python parity for the TypeScript SDK's receipt surface: lenient parsing of
5
+ the extension block from a tool result's ``_meta``, and a consistency check
6
+ of a receipt's claims against the signed record it names. Receipts are
7
+ advisory — a mismatch never invalidates the tool result; it means the
8
+ receipt must not be trusted or cited. Conformance:
9
+ ``spec/conformance/mcp-extension/cases/receipt--*.json``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass, field
16
+ from typing import cast
17
+
18
+ from .encoding import base64url_encode, hex_encode
19
+ from .hashes import record_hash_hex, record_hash_ref
20
+ from .token import decode_token, encode_token
21
+ from .types import AtribRecord, normalize_event_type
22
+
23
+ ATTRIBUTION_EXTENSION_KEY = "dev.atrib/attribution"
24
+
25
+ _RECEIPT_STRING_FIELDS = (
26
+ "record_hash",
27
+ "creator_key",
28
+ "context_id",
29
+ "event_type",
30
+ "chain_root",
31
+ "log_submission",
32
+ )
33
+
34
+ ATTRIBUTION_LOG_SUBMISSION_STATUSES = ("queued", "submitted", "disabled", "failed")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class AttributionReceiptBlock:
39
+ token: str | None = None
40
+ receipt: Mapping[str, str] | None = None
41
+ record: AtribRecord | None = None
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class AttributionReceiptConsistency:
46
+ receipt_valid: bool
47
+ mismatched_fields: list[str] = field(default_factory=list)
48
+ attached_record_hash: str | None = None
49
+ claimed_record_hash: str | None = None
50
+
51
+
52
+ def parse_attribution_receipt_block(meta: object) -> AttributionReceiptBlock | None:
53
+ """Extract the extension block from a tool result's ``_meta``. Lenient
54
+ parse: anything malformed yields None, never a raise; wrong-typed
55
+ fields drop; an all-wrong-typed receipt reads as absent."""
56
+ if not isinstance(meta, Mapping):
57
+ return None
58
+ raw = meta.get(ATTRIBUTION_EXTENSION_KEY)
59
+ if not isinstance(raw, Mapping):
60
+ return None
61
+ token = raw.get("token")
62
+ parsed_token = token if isinstance(token, str) else None
63
+ parsed_receipt: dict[str, str] | None = None
64
+ raw_receipt = raw.get("receipt")
65
+ if isinstance(raw_receipt, Mapping):
66
+ kept = {
67
+ fld: value
68
+ for fld in _RECEIPT_STRING_FIELDS
69
+ if isinstance(value := raw_receipt.get(fld), str)
70
+ }
71
+ if kept:
72
+ parsed_receipt = kept
73
+ raw_record = raw.get("record")
74
+ parsed_record = (
75
+ cast(AtribRecord, dict(raw_record)) if isinstance(raw_record, Mapping) else None
76
+ )
77
+ if parsed_token is None and parsed_receipt is None and parsed_record is None:
78
+ return None
79
+ return AttributionReceiptBlock(
80
+ token=parsed_token, receipt=parsed_receipt, record=parsed_record
81
+ )
82
+
83
+
84
+ def check_attribution_receipt_consistency(
85
+ block: AttributionReceiptBlock,
86
+ record: AtribRecord | None = None,
87
+ ) -> AttributionReceiptConsistency:
88
+ """Check a receipt block's claims against the signed record they name
89
+ (the attached ``block.record`` or a caller-retrieved record). Absent
90
+ receipt fields are not mismatches. Never raises."""
91
+ attached = record if record is not None else block.record
92
+ receipt = block.receipt or {}
93
+ claimed = receipt.get("record_hash")
94
+ if attached is None:
95
+ return AttributionReceiptConsistency(
96
+ receipt_valid=False,
97
+ mismatched_fields=["record"],
98
+ claimed_record_hash=claimed,
99
+ )
100
+ try:
101
+ attached_hash = record_hash_ref(attached)
102
+ token = encode_token(attached)
103
+ except Exception: # noqa: BLE001 — unhashable record cannot back a receipt
104
+ return AttributionReceiptConsistency(
105
+ receipt_valid=False,
106
+ mismatched_fields=["record"],
107
+ claimed_record_hash=claimed,
108
+ )
109
+ mismatched: list[str] = []
110
+ if claimed is not None and claimed != attached_hash:
111
+ mismatched.append("record_hash")
112
+ if block.token is not None and block.token != token:
113
+ mismatched.append("token")
114
+ for fld in ("creator_key", "context_id", "chain_root"):
115
+ value = receipt.get(fld)
116
+ if value is not None and value != attached.get(fld):
117
+ mismatched.append(fld)
118
+ claimed_event = receipt.get("event_type")
119
+ attached_event = attached.get("event_type")
120
+ if claimed_event is not None and normalize_event_type(claimed_event) != (
121
+ normalize_event_type(attached_event) if isinstance(attached_event, str) else None
122
+ ):
123
+ mismatched.append("event_type")
124
+ return AttributionReceiptConsistency(
125
+ receipt_valid=not mismatched,
126
+ mismatched_fields=mismatched,
127
+ attached_record_hash=attached_hash,
128
+ claimed_record_hash=claimed,
129
+ )
130
+
131
+
132
+ # ── Receipt verification (extension spec §6.2, consumer side) ────────────
133
+
134
+ _RECEIPT_REQUIRED_STRING_FIELDS = _RECEIPT_STRING_FIELDS
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class AttributionReceiptVerification:
139
+ """Outcome of :func:`verify_attribution_receipt`. ``mismatched`` uses
140
+ the TS vocabulary exactly: ``'token'``, ``'record_hash'``,
141
+ ``'creator_key'``, ``'context_id'``, ``'chain_root'``,
142
+ ``'log_submission'``, or ``['malformed']`` when the block is not
143
+ structurally a v0.1 result block."""
144
+
145
+ valid: bool
146
+ mismatched: list[str] = field(default_factory=list)
147
+
148
+
149
+ def _is_structurally_receipt_block(block: object) -> bool:
150
+ if not isinstance(block, Mapping):
151
+ return False
152
+ if not isinstance(block.get("token"), str):
153
+ return False
154
+ receipt = block.get("receipt")
155
+ if not isinstance(receipt, Mapping):
156
+ return False
157
+ for fld in _RECEIPT_REQUIRED_STRING_FIELDS:
158
+ if not isinstance(receipt.get(fld), str):
159
+ return False
160
+ # TS parity: `record !== undefined && !isPlainObject(record)` — a
161
+ # present-but-null (None) record is malformed; an absent one is fine.
162
+ if "record" in block and not isinstance(block["record"], Mapping):
163
+ return False
164
+ return True
165
+
166
+
167
+ def verify_attribution_receipt(block: object) -> AttributionReceiptVerification:
168
+ """Consumer-side receipt integrity check (extension spec §6.2), the
169
+ exact port of the TS ``verifyAttributionReceipt``: the token,
170
+ ``record_hash``, and ``creator_key`` must agree with each other and —
171
+ when the full record body is attached — recompute from the record's
172
+ canonical bytes. A consumer that detects an internal inconsistency MUST
173
+ treat the receipt as invalid and discard it; receipt invalidity never
174
+ invalidates the tool result itself.
175
+
176
+ This checks internal consistency only; it is not a proof. Callers
177
+ wanting Tier-3 assurance additionally verify the attached record's
178
+ Ed25519 signature (:func:`atrib.verify_record`) and, where required,
179
+ log inclusion via an independently fetched proof bundle.
180
+
181
+ Never raises: hostile input yields ``valid=False`` with
182
+ ``mismatched=['malformed']``.
183
+ """
184
+ if not _is_structurally_receipt_block(block):
185
+ return AttributionReceiptVerification(valid=False, mismatched=["malformed"])
186
+ mblock = cast("Mapping[str, object]", block) # narrowed by the structural check
187
+ receipt = cast("Mapping[str, object]", mblock["receipt"])
188
+ token = cast(str, mblock["token"])
189
+ mismatched: list[str] = []
190
+ decoded = decode_token(token)
191
+ if decoded is None:
192
+ return AttributionReceiptVerification(valid=False, mismatched=["token"])
193
+ token_hash_hex = hex_encode(decoded.record_hash)
194
+ token_key = base64url_encode(decoded.creator_key)
195
+
196
+ if receipt["record_hash"] != f"sha256:{token_hash_hex}":
197
+ mismatched.append("record_hash")
198
+ if receipt["creator_key"] != token_key:
199
+ mismatched.append("creator_key")
200
+ if receipt["log_submission"] not in ATTRIBUTION_LOG_SUBMISSION_STATUSES:
201
+ mismatched.append("log_submission")
202
+
203
+ record = mblock.get("record")
204
+ if isinstance(record, Mapping):
205
+ try:
206
+ record_hex = record_hash_hex(record)
207
+ record_token = encode_token(record)
208
+ except Exception: # noqa: BLE001 — unhashable attached record
209
+ # The TS reference would propagate here; Python keeps the
210
+ # never-raise contract and reports the block as malformed.
211
+ return AttributionReceiptVerification(valid=False, mismatched=["malformed"])
212
+ if token != record_token and "token" not in mismatched:
213
+ mismatched.append("token")
214
+ if (
215
+ receipt["record_hash"] != f"sha256:{record_hex}"
216
+ and "record_hash" not in mismatched
217
+ ):
218
+ mismatched.append("record_hash")
219
+ if (
220
+ receipt["creator_key"] != record.get("creator_key")
221
+ and "creator_key" not in mismatched
222
+ ):
223
+ mismatched.append("creator_key")
224
+ if receipt["context_id"] != record.get("context_id"):
225
+ mismatched.append("context_id")
226
+ if receipt["chain_root"] != record.get("chain_root"):
227
+ mismatched.append("chain_root")
228
+
229
+ return AttributionReceiptVerification(valid=not mismatched, mismatched=mismatched)