probity-verify 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.
@@ -0,0 +1,5 @@
1
+ """Consumer-side claim adjudication. No network access or implicit trust roots."""
2
+
3
+ from .core import CaseError, adjudicate
4
+
5
+ __all__ = ["CaseError", "adjudicate"]
@@ -0,0 +1,110 @@
1
+ """Official-SDK client for the optional Verify A2A service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+ from pathlib import Path
9
+ from urllib.parse import urlsplit
10
+ from uuid import uuid4
11
+
12
+ import httpx
13
+ from a2a.client import A2ACardResolver, ClientConfig, ClientFactory
14
+ from a2a.types import (
15
+ GetTaskRequest, Message, Part, Role, SendMessageConfiguration,
16
+ SendMessageRequest, TaskState,
17
+ )
18
+ from a2a.utils.errors import A2AError
19
+ from google.protobuf.json_format import MessageToDict
20
+
21
+ from .a2a_server import (
22
+ CASE_MIME, DECISION_MIME, LAB_QUERY_MIME, LAB_RECORD_MIME, MAX_CASE_BYTES,
23
+ digest, parse_raw, read_bounded,
24
+ )
25
+ from .core import CaseError, canonical_json
26
+
27
+
28
+ async def request(base_url: str, raw: bytes, mime: str, pin: str) -> tuple[bytes, str]:
29
+ if not raw or len(raw) > MAX_CASE_BYTES:
30
+ raise CaseError("request bytes exceed the input limit")
31
+ expected_mime = DECISION_MIME if mime == CASE_MIME else LAB_RECORD_MIME
32
+ pin_name = "policy_sha256" if mime == CASE_MIME else "register_sha256"
33
+ origin = urlsplit(base_url)
34
+ if (origin.scheme not in {"http", "https"} or not origin.netloc or origin.path not in {"", "/"}
35
+ or origin.username or origin.password or origin.query or origin.fragment):
36
+ raise CaseError("base URL must be an HTTP origin")
37
+ use_environment = origin.hostname not in {"127.0.0.1", "::1", "localhost"}
38
+ async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=use_environment) as http:
39
+ card = await A2ACardResolver(http, base_url).get_agent_card()
40
+ interfaces = [i for i in card.supported_interfaces
41
+ if i.protocol_binding == "JSONRPC" and i.protocol_version == "1.0"]
42
+ if (len(interfaces) != 1 or urlsplit(interfaces[0].url).scheme != origin.scheme
43
+ or urlsplit(interfaces[0].url).netloc != origin.netloc
44
+ or urlsplit(interfaces[0].url).path != "/a2a/jsonrpc"):
45
+ raise CaseError("agent card must name its own A2A 1.0 JSON-RPC route")
46
+ client = ClientFactory(ClientConfig(streaming=False, httpx_client=http,
47
+ supported_protocol_bindings=["JSONRPC"])).create(card)
48
+ message = Message(message_id=str(uuid4()), role=Role.ROLE_USER,
49
+ parts=[Part(raw=raw, media_type=mime, metadata={
50
+ "length": len(raw), "sha256": digest(raw), pin_name: pin})])
51
+ task = None
52
+ try:
53
+ async for response in client.send_message(SendMessageRequest(
54
+ message=message, configuration=SendMessageConfiguration(return_immediately=False))):
55
+ if response.HasField("task"):
56
+ task = response.task
57
+ if task is None or task.status.state != TaskState.TASK_STATE_COMPLETED:
58
+ raise CaseError("A2A request did not complete")
59
+ retained = await client.get_task(GetTaskRequest(id=task.id))
60
+ if retained != task:
61
+ raise CaseError("retained task differs from the completed response")
62
+ parts = [part for artifact in task.artifacts for part in artifact.parts
63
+ if part.media_type == expected_mime]
64
+ if len(parts) != 1 or parts[0].WhichOneof("content") != "raw":
65
+ raise CaseError("expected one raw result artifact")
66
+ result = parts[0].raw
67
+ metadata = MessageToDict(parts[0].metadata)
68
+ if (metadata.get("sha256") != digest(result) or metadata.get("length") != len(result)
69
+ or metadata.get(pin_name) != pin):
70
+ raise CaseError("result binding differs")
71
+ if mime == CASE_MIME and metadata.get("case_sha256") != digest(raw):
72
+ raise CaseError("result case binding differs")
73
+ parse_raw(result)
74
+ return result, task.id
75
+ finally:
76
+ await client.close()
77
+
78
+
79
+ def main(argv: list[str] | None = None) -> int:
80
+ parser = argparse.ArgumentParser(prog="probity-a2a-client")
81
+ parser.add_argument("--url", default="http://127.0.0.1:41241")
82
+ commands = parser.add_subparsers(dest="command", required=True)
83
+ verify = commands.add_parser("verify")
84
+ verify.add_argument("case", type=Path)
85
+ verify.add_argument("--policy", type=Path, required=True)
86
+ lab = commands.add_parser("lab-record")
87
+ lab.add_argument("record_id")
88
+ lab.add_argument("--register-sha256", required=True)
89
+ args = parser.parse_args(argv)
90
+ try:
91
+ if args.command == "verify":
92
+ raw = read_bounded(args.case, MAX_CASE_BYTES)
93
+ policy = parse_raw(read_bounded(args.policy, 4 * 1024 * 1024))
94
+ pin = digest(canonical_json(policy))
95
+ mime = CASE_MIME
96
+ else:
97
+ raw = canonical_json({"record_id": args.record_id})
98
+ pin = args.register_sha256
99
+ mime = LAB_QUERY_MIME
100
+ result, _ = asyncio.run(request(args.url, raw, mime, pin))
101
+ except (CaseError, ValueError, OSError, RecursionError, httpx.InvalidURL,
102
+ httpx.HTTPError, A2AError) as exc:
103
+ print(f"probity-a2a-client: {exc}", file=sys.stderr)
104
+ return 2
105
+ sys.stdout.buffer.write(result)
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())
@@ -0,0 +1,283 @@
1
+ """Optional A2A 1.0 transport for operator-configured Verify and Lab reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import copy
8
+ import hashlib
9
+ import ipaddress
10
+ import sys
11
+ from collections import OrderedDict
12
+ from pathlib import Path
13
+ from urllib.parse import urlsplit
14
+
15
+ from a2a.server.agent_execution import AgentExecutor, RequestContext
16
+ from a2a.server.events import EventQueue
17
+ from a2a.server.request_handlers import DefaultRequestHandler
18
+ from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
19
+ from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
20
+ from a2a.types import (
21
+ AgentCapabilities, AgentCard, AgentInterface, AgentSkill, Part,
22
+ Task, TaskState, TaskStatus,
23
+ )
24
+ from a2a.utils.errors import InvalidParamsError, UnsupportedOperationError
25
+ from google.protobuf.json_format import MessageToDict
26
+ from starlette.applications import Starlette
27
+ from starlette.responses import JSONResponse, PlainTextResponse
28
+ from starlette.routing import Route
29
+
30
+ from .common import _read_input
31
+ from .json_input import parse_json
32
+ from .core import CaseError, adjudicate, canonical_json, render_packet
33
+
34
+ CASE_MIME = "application/vnd.probity.case+json"
35
+ DECISION_MIME = "application/vnd.probity.decision+json"
36
+ LAB_QUERY_MIME = "application/vnd.probity.lab-query+json"
37
+ LAB_RECORD_MIME = "application/vnd.probity.lab-record+json"
38
+ MAX_CASE_BYTES = 256 * 1024
39
+ MAX_REQUEST_BYTES = 384 * 1024
40
+ MAX_RETAINED_TASKS = 128
41
+
42
+
43
+ def digest(raw: bytes) -> str:
44
+ return hashlib.sha256(raw).hexdigest()
45
+
46
+
47
+ def parse_raw(raw: bytes) -> object:
48
+ """Use bounded duplicate-key and non-JSON-number admission rules."""
49
+ return parse_json(raw, max_bytes=16 * 1024 * 1024, max_depth=32, max_nodes=32768)
50
+
51
+
52
+ def read_bounded(path: Path, limit: int) -> bytes:
53
+ return _read_input(path, limit, "configured input")
54
+
55
+
56
+ class BoundedTaskStore(InMemoryTaskStore):
57
+ """Retain the latest completed tasks through the SDK's public store API."""
58
+
59
+ def __init__(self, limit: int = MAX_RETAINED_TASKS) -> None:
60
+ super().__init__()
61
+ if limit < 1:
62
+ raise ValueError("task retention limit must be positive")
63
+ self.limit = limit
64
+ self.completed = OrderedDict()
65
+ self.retention_lock = asyncio.Lock()
66
+
67
+ async def save(self, task, context) -> None:
68
+ async with self.retention_lock:
69
+ await super().save(task, context)
70
+ if task.status.state == TaskState.TASK_STATE_COMPLETED:
71
+ self.completed[task.id] = context
72
+ self.completed.move_to_end(task.id)
73
+ while len(self.completed) > self.limit:
74
+ expired, owner = self.completed.popitem(last=False)
75
+ await super().delete(expired, owner)
76
+
77
+
78
+ class VerifyExecutor(AgentExecutor):
79
+ def __init__(self, policy: bytes, artifact_root: Path,
80
+ lab_register: bytes | None = None,
81
+ lab_register_sha256: str | None = None) -> None:
82
+ self.policy = parse_raw(policy)
83
+ if not isinstance(self.policy, dict):
84
+ raise CaseError("consumer policy must be a JSON object")
85
+ self.policy_sha256 = digest(canonical_json(self.policy))
86
+ self.policy_source_sha256 = digest(policy)
87
+ self.artifact_root = artifact_root.absolute()
88
+ if not self.artifact_root.is_dir():
89
+ raise CaseError("artifact root must be a directory")
90
+ self.records: dict[str, dict] = {}
91
+ self.lab_register_sha256 = None
92
+ if lab_register is not None:
93
+ if digest(lab_register) != lab_register_sha256:
94
+ raise CaseError("Lab register digest differs from the operator pin")
95
+ register = parse_raw(lab_register)
96
+ if not isinstance(register, dict) or not isinstance(register.get("records"), list):
97
+ raise CaseError("Lab register must contain records")
98
+ for record in register["records"]:
99
+ if (not isinstance(record, dict) or not isinstance(record.get("id"), str)
100
+ or not record["id"] or record["id"] in self.records):
101
+ raise CaseError("Lab record IDs must be distinct non-empty strings")
102
+ self.records[record["id"]] = record
103
+ self.lab_register_sha256 = lab_register_sha256
104
+ elif lab_register_sha256 is not None:
105
+ raise CaseError("Lab register pin needs a configured register")
106
+ self.capacity = asyncio.Semaphore(4)
107
+
108
+ def assess(self, part: Part) -> tuple[bytes, str, dict, str | None]:
109
+ if part.WhichOneof("content") != "raw" or not part.raw or len(part.raw) > MAX_CASE_BYTES:
110
+ raise CaseError("supply one bounded raw JSON part")
111
+ metadata = MessageToDict(part.metadata)
112
+ raw_hash = digest(part.raw)
113
+ expected = {"sha256", "length", "policy_sha256" if part.media_type == CASE_MIME
114
+ else "register_sha256"}
115
+ if (set(metadata) != expected or metadata.get("sha256") != raw_hash
116
+ or isinstance(metadata.get("length"), bool)
117
+ or metadata.get("length") != len(part.raw)):
118
+ raise CaseError("raw part binding is missing or differs")
119
+ value = parse_raw(part.raw)
120
+ if part.media_type == CASE_MIME:
121
+ if metadata["policy_sha256"] != self.policy_sha256:
122
+ raise CaseError("consumer policy digest differs")
123
+ result = adjudicate(value, copy.deepcopy(self.policy), self.artifact_root)
124
+ bindings = {"case_sha256": raw_hash, "policy_sha256": self.policy_sha256,
125
+ "policy_source_sha256": self.policy_source_sha256}
126
+ return canonical_json(result), DECISION_MIME, bindings, render_packet(result)
127
+ if part.media_type == LAB_QUERY_MIME:
128
+ if (not self.lab_register_sha256
129
+ or metadata["register_sha256"] != self.lab_register_sha256):
130
+ raise CaseError("Lab register digest differs")
131
+ if (not isinstance(value, dict) or set(value) != {"record_id"}
132
+ or not isinstance(value["record_id"], str)
133
+ or value["record_id"] not in self.records):
134
+ raise CaseError("unknown Lab record ID")
135
+ result = {"schema_version": "probity-lab-record/v1",
136
+ "register_sha256": self.lab_register_sha256,
137
+ "record": self.records[value["record_id"]]}
138
+ return canonical_json(result), LAB_RECORD_MIME, {
139
+ "register_sha256": self.lab_register_sha256}, None
140
+ raise CaseError("unsupported input media type")
141
+
142
+ async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
143
+ message = context.message
144
+ if (not message or len(message.parts) != 1
145
+ or not context.task_id or not context.context_id):
146
+ raise InvalidParamsError(message="Supply a new request with one raw JSON part")
147
+ try:
148
+ async with self.capacity:
149
+ raw, mime, bindings, packet = await asyncio.to_thread(self.assess, message.parts[0])
150
+ except (CaseError, ValueError, OSError, RecursionError) as exc:
151
+ raise InvalidParamsError(message=str(exc)) from exc
152
+ await event_queue.enqueue_event(Task(
153
+ id=context.task_id, context_id=context.context_id,
154
+ status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), history=[message]))
155
+ updater = TaskUpdater(event_queue, context.task_id, context.context_id)
156
+ parts = [Part(raw=raw, media_type=mime,
157
+ metadata={**bindings, "sha256": digest(raw), "length": len(raw)})]
158
+ if packet is not None:
159
+ parts.append(Part(text=packet, media_type="text/plain"))
160
+ await updater.add_artifact(parts, name="probity-result", last_chunk=True)
161
+ await updater.complete()
162
+
163
+ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
164
+ raise UnsupportedOperationError(message="Assessments complete in one request")
165
+
166
+
167
+ class BoundedJSON:
168
+ """Admit bounded raw HTTP JSON before the SDK's protocol parsing."""
169
+
170
+ def __init__(self, app: Starlette) -> None:
171
+ self.app = app
172
+
173
+ async def __call__(self, scope, receive, send) -> None:
174
+ if scope["type"] != "http" or scope["method"] != "POST":
175
+ await self.app(scope, receive, send)
176
+ return
177
+ body = bytearray()
178
+ while True:
179
+ event = await receive()
180
+ if event["type"] == "http.disconnect":
181
+ return
182
+ body.extend(event.get("body", b""))
183
+ if len(body) > MAX_REQUEST_BYTES:
184
+ response = JSONResponse({"error": "request exceeds the byte limit"}, status_code=413)
185
+ await response(scope, receive, send)
186
+ return
187
+ if not event.get("more_body", False):
188
+ break
189
+ try:
190
+ value = parse_raw(bytes(body))
191
+ except (CaseError, ValueError, RecursionError) as exc:
192
+ response = JSONResponse({"jsonrpc": "2.0", "id": None,
193
+ "error": {"code": -32600, "message": str(exc)}})
194
+ await response(scope, receive, send)
195
+ return
196
+ if isinstance(value, dict) and isinstance(value.get("params"), dict):
197
+ message = value["params"].get("message")
198
+ if isinstance(message, dict) and (message.get("taskId") or message.get("task_id")):
199
+ response = JSONResponse({"jsonrpc": "2.0", "id": value.get("id"),
200
+ "error": {"code": -32602,
201
+ "message": "Supply a new assessment request"}})
202
+ await response(scope, receive, send)
203
+ return
204
+
205
+ async def replay():
206
+ return {"type": "http.request", "body": bytes(body), "more_body": False}
207
+
208
+ await self.app(scope, replay, send)
209
+
210
+
211
+ def create_app(policy: bytes, artifact_root: Path, base_url: str,
212
+ lab_register: bytes | None = None,
213
+ lab_register_sha256: str | None = None):
214
+ url = urlsplit(base_url)
215
+ if url.scheme not in {"http", "https"} or not url.netloc or url.path not in {"", "/"}:
216
+ raise CaseError("base URL must be an HTTP origin")
217
+ if url.username or url.password or url.query or url.fragment:
218
+ raise CaseError("base URL must be an HTTP origin")
219
+ base_url = base_url.rstrip("/")
220
+ executor = VerifyExecutor(policy, artifact_root, lab_register, lab_register_sha256)
221
+ skills = [AgentSkill(id="verify_case", name="Verify a pinned case",
222
+ description="Assess raw case bytes against operator-held policy and artifacts.",
223
+ tags=["evidence", "verification"], input_modes=[CASE_MIME],
224
+ output_modes=[DECISION_MIME, "text/plain"])]
225
+ if executor.lab_register_sha256:
226
+ skills.append(AgentSkill(id="read_lab_record", name="Read a pinned Lab record",
227
+ description="Return one retained publisher record from the configured register.",
228
+ tags=["evidence", "research"], input_modes=[LAB_QUERY_MIME],
229
+ output_modes=[LAB_RECORD_MIME]))
230
+ card = AgentCard(name="Probity Verify", version="0.1.0",
231
+ description="Consumer-policy assessments and pinned Lab record reads.",
232
+ documentation_url="https://github.com/probityai/probity-verify/blob/main/docs/a2a.md",
233
+ capabilities=AgentCapabilities(streaming=False, push_notifications=False),
234
+ default_input_modes=[skill.input_modes[0] for skill in skills],
235
+ default_output_modes=list(dict.fromkeys(mode for skill in skills
236
+ for mode in skill.output_modes)), skills=skills,
237
+ supported_interfaces=[AgentInterface(protocol_binding="JSONRPC", protocol_version="1.0",
238
+ url=base_url + "/a2a/jsonrpc")])
239
+ handler = DefaultRequestHandler(executor, BoundedTaskStore(), card)
240
+ discovery = ("# Probity Verify\n\nA2A 1.0 JSON-RPC.\n"
241
+ f"Agent card: {base_url}/.well-known/agent-card.json\n"
242
+ f"POST: {base_url}/a2a/jsonrpc\n"
243
+ f"Consumer policy SHA-256: {executor.policy_sha256}\n"
244
+ f"Policy source SHA-256: {executor.policy_source_sha256}\n"
245
+ "Send raw JSON case bytes with length, SHA-256 and consumer policy digest.\n"
246
+ "Results are byte-bound Verify decision packets. The latest 128 tasks last for this process.\n")
247
+ if executor.lab_register_sha256:
248
+ discovery += f"Lab register SHA-256: {executor.lab_register_sha256}\n"
249
+
250
+ async def llms(request):
251
+ return PlainTextResponse(discovery)
252
+
253
+ routes = create_agent_card_routes(card) + create_jsonrpc_routes(handler, "/a2a/jsonrpc")
254
+ routes.append(Route("/llms.txt", llms))
255
+ return BoundedJSON(Starlette(routes=routes))
256
+
257
+
258
+ def main(argv: list[str] | None = None) -> int:
259
+ parser = argparse.ArgumentParser(prog="probity-a2a")
260
+ parser.add_argument("--policy", type=Path, required=True)
261
+ parser.add_argument("--artifact-root", type=Path, required=True)
262
+ parser.add_argument("--lab-register", type=Path)
263
+ parser.add_argument("--lab-register-sha256")
264
+ parser.add_argument("--host", default="127.0.0.1")
265
+ parser.add_argument("--port", type=int, default=41241)
266
+ args = parser.parse_args(argv)
267
+ try:
268
+ if not ipaddress.ip_address(args.host).is_loopback or not 1 <= args.port <= 65535:
269
+ raise CaseError("bind to a loopback address and a valid port")
270
+ origin = f"http://[{args.host}]:{args.port}" if ":" in args.host else f"http://{args.host}:{args.port}"
271
+ app = create_app(read_bounded(args.policy, 4 * 1024 * 1024), args.artifact_root, origin,
272
+ read_bounded(args.lab_register, 16 * 1024 * 1024) if args.lab_register else None,
273
+ args.lab_register_sha256)
274
+ except (CaseError, ValueError, OSError, RecursionError) as exc:
275
+ print(f"probity-a2a: {exc}", file=sys.stderr)
276
+ return 2
277
+ import uvicorn
278
+ uvicorn.run(app, host=args.host, port=args.port, access_log=False, limit_concurrency=16)
279
+ return 0
280
+
281
+
282
+ if __name__ == "__main__":
283
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Versioned claim adapters. Each returns the common decision envelope."""
@@ -0,0 +1,148 @@
1
+ """Match an identity claim against a consumer-pinned authority capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import urlsplit
9
+
10
+ from ..common import CaseError, _artifact, _digest, _instant, _object, _string
11
+
12
+ CLAIM_TYPE = "authority_anchor/v1"
13
+
14
+
15
+ def _https_url(value: Any, label: str) -> str:
16
+ url = _string(value, label)
17
+ try:
18
+ parsed = urlsplit(url)
19
+ if (parsed.scheme != "https" or not parsed.hostname or parsed.username or
20
+ parsed.password or parsed.fragment):
21
+ raise ValueError()
22
+ parsed.port
23
+ except ValueError as exc:
24
+ raise CaseError(f"{label}: expected HTTPS URL without credentials or fragment") from exc
25
+ return url
26
+
27
+
28
+ def _capture(data: bytes) -> dict:
29
+ """Read a bound JSON capture without accepting ambiguous object keys."""
30
+ def unique(pairs: list[tuple[str, Any]]) -> dict:
31
+ value: dict = {}
32
+ for key, item in pairs:
33
+ if key in value:
34
+ raise ValueError("duplicate JSON key")
35
+ value[key] = item
36
+ return value
37
+
38
+ try:
39
+ value = json.loads(data.decode("utf-8"), object_pairs_hook=unique,
40
+ parse_constant=lambda _: (_ for _ in ()).throw(ValueError()))
41
+ except (UnicodeDecodeError, ValueError) as exc:
42
+ raise CaseError("capture is not unambiguous UTF-8 JSON") from exc
43
+ return _object(value, "capture", {"schema_version", "request_url", "captured_at",
44
+ "status", "body"})
45
+
46
+
47
+ def _pointer(body: Any, pointer: str) -> str | None:
48
+ """Resolve an RFC 6901 pointer. A missing or non-string field is unavailable."""
49
+ current = body
50
+ for part in pointer.split("/")[1:]:
51
+ key = part.replace("~1", "/").replace("~0", "~")
52
+ if isinstance(current, dict):
53
+ if key not in current:
54
+ return None
55
+ current = current[key]
56
+ elif (isinstance(current, list) and key.isascii() and key.isdecimal() and
57
+ len(key) <= 19 and (key == "0" or not key.startswith("0")) and
58
+ int(key) < len(current)):
59
+ current = current[int(key)]
60
+ else:
61
+ return None
62
+ return current if isinstance(current, str) else None
63
+
64
+
65
+ def adjudicate(case: Any, policy: Any, artifact_root: Path) -> dict:
66
+ """Decide an exact JSON field in a bound capture, within a policy time window.
67
+
68
+ The consumer must establish the capture's provenance outside this program.
69
+ The program neither contacts the authority nor authenticates its transport.
70
+ """
71
+ case = _object(case, "case", {"schema_version", "case_id", "artifacts"})
72
+ policy = _object(policy, "policy", {"schema_version", "witnesses", "assessments"})
73
+ if case["schema_version"] != "probity-case/v1" or policy["schema_version"] != "probity-policy/v1":
74
+ raise CaseError("unsupported schema_version")
75
+ case_id = _string(case["case_id"], "case_id")
76
+ artifacts = case["artifacts"]
77
+ witnesses = policy["witnesses"]
78
+ assessments = policy["assessments"]
79
+ if not isinstance(artifacts, dict) or not isinstance(witnesses, dict) or not isinstance(assessments, dict):
80
+ raise CaseError("artifacts, witnesses, and assessments: expected objects")
81
+ if case_id not in assessments:
82
+ raise CaseError("case_id has no consumer assessment")
83
+ assessment = _object(assessments[case_id], "assessment", {
84
+ "claim_type", "capture_witness", "request_url", "json_pointer",
85
+ "expected_value", "capture_window"
86
+ })
87
+ if assessment["claim_type"] != CLAIM_TYPE:
88
+ raise CaseError("unsupported claim_type")
89
+ witness_id = _string(assessment["capture_witness"], "capture_witness")
90
+ request_url = _https_url(assessment["request_url"], "request_url")
91
+ expected = _string(assessment["expected_value"], "expected_value")
92
+ pointer = _string(assessment["json_pointer"], "json_pointer")
93
+ if not pointer.startswith("/") or any(
94
+ pointer[i + 1:i + 2] not in {"0", "1"}
95
+ for i, char in enumerate(pointer) if char == "~"
96
+ ):
97
+ raise CaseError("json_pointer: expected nonempty RFC 6901 pointer")
98
+ window = _object(assessment["capture_window"], "capture_window", {"start", "end"})
99
+ start = _instant(window["start"], "capture_window.start")
100
+ end = _instant(window["end"], "capture_window.end")
101
+ if end < start:
102
+ raise CaseError("capture_window.end precedes start")
103
+ if witness_id not in witnesses:
104
+ raise CaseError("capture_witness has no consumer pin")
105
+ witness = _object(witnesses[witness_id], "witness", {"artifact", "sha256", "authority"})
106
+ artifact_id = _string(witness["artifact"], "witness.artifact")
107
+ pinned = _digest(witness["sha256"], "witness.sha256")
108
+ authority = _string(witness["authority"], "witness.authority")
109
+ if artifact_id not in artifacts:
110
+ raise CaseError("capture_witness names no case artifact")
111
+ data, binding = _artifact(artifact_root, artifacts[artifact_id], f"artifacts.{artifact_id}")
112
+ scope = {
113
+ "claim_type": CLAIM_TYPE, "request_url": request_url,
114
+ "capture_window": window, "json_pointer": pointer, "expected_value": expected,
115
+ "witness": {"id": witness_id, "authority": authority,
116
+ "consumer_pinned_sha256": pinned, "artifact": binding},
117
+ "limit": "The consumer pinned these capture bytes. This program does not authenticate the authority, transport, or capture time, or infer that a class-level detection stamp proves this particular observation.",
118
+ }
119
+ result = {"schema_version": "probity-decision/v1", "case_id": case_id,
120
+ "claim_type": CLAIM_TYPE, "scope": scope, "checks": []}
121
+ if data is None or binding["sha256"] != pinned:
122
+ return {**result, "decision": "not_established", "reason": "witness_unavailable_or_unbound"}
123
+ try:
124
+ capture = _capture(data)
125
+ if capture["schema_version"] != "probity-http-capture/v1":
126
+ raise CaseError("unsupported capture schema_version")
127
+ observed_at = _instant(capture["captured_at"], "capture.captured_at")
128
+ if type(capture["status"]) is not int or not 100 <= capture["status"] <= 599:
129
+ raise CaseError("capture.status: expected HTTP status")
130
+ _https_url(capture["request_url"], "capture.request_url")
131
+ except CaseError:
132
+ return {**result, "decision": "not_established", "reason": "capture_unreadable"}
133
+ scope["witness"]["captured_at"] = capture["captured_at"]
134
+ scope["witness"]["status"] = capture["status"]
135
+ if capture["request_url"] != request_url:
136
+ return {**result, "decision": "not_established", "reason": "capture_target_mismatch"}
137
+ if not start <= observed_at <= end:
138
+ return {**result, "decision": "not_established", "reason": "capture_outside_window"}
139
+ if capture["status"] != 200:
140
+ return {**result, "decision": "not_established", "reason": "authority_response_unavailable"}
141
+ observed = _pointer(capture["body"], pointer)
142
+ result["checks"] = [{"id": "anchor", "status": "unavailable" if observed is None else
143
+ "met" if observed == expected else "failed", "observed": observed}]
144
+ if observed is None:
145
+ return {**result, "decision": "not_established", "reason": "field_unavailable"}
146
+ if observed != expected:
147
+ return {**result, "decision": "contradicted", "reason": "anchor_mismatch"}
148
+ return {**result, "decision": "supported", "reason": "anchor_matches"}