arcgraph 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 (165) hide show
  1. arcgraph/__init__.py +41 -0
  2. arcgraph/_build_provenance.json +10 -0
  3. arcgraph/adapters/__init__.py +6 -0
  4. arcgraph/adapters/arq_adapter.py +369 -0
  5. arcgraph/adapters/celery_adapter.py +440 -0
  6. arcgraph/adapters/common.py +436 -0
  7. arcgraph/adapters/django_adapter.py +765 -0
  8. arcgraph/adapters/fastapi_adapter.py +415 -0
  9. arcgraph/adapters/logging_adapter.py +335 -0
  10. arcgraph/adapters/mcp_adapter.py +100 -0
  11. arcgraph/adapters/pydantic_adapter.py +290 -0
  12. arcgraph/adapters/pytest_adapter.py +221 -0
  13. arcgraph/adapters/pytest_collection.py +96 -0
  14. arcgraph/adapters/pytest_fixtures.py +371 -0
  15. arcgraph/adapters/pytest_types.py +251 -0
  16. arcgraph/adapters/registry.py +34 -0
  17. arcgraph/adapters/service_container_adapter.py +548 -0
  18. arcgraph/adapters/sqlalchemy_adapter.py +446 -0
  19. arcgraph/adapters/typer_click_adapter.py +292 -0
  20. arcgraph/analyzers/__init__.py +1 -0
  21. arcgraph/analyzers/bindings.py +782 -0
  22. arcgraph/analyzers/calls/__init__.py +6 -0
  23. arcgraph/analyzers/calls/analyzer.py +2921 -0
  24. arcgraph/analyzers/calls/constants.py +702 -0
  25. arcgraph/analyzers/calls/context.py +298 -0
  26. arcgraph/analyzers/calls/lexical.py +237 -0
  27. arcgraph/analyzers/coverage.py +826 -0
  28. arcgraph/analyzers/exports.py +46 -0
  29. arcgraph/analyzers/har_trace.py +323 -0
  30. arcgraph/analyzers/imports.py +190 -0
  31. arcgraph/analyzers/otel_trace.py +352 -0
  32. arcgraph/analyzers/precise_references.py +1199 -0
  33. arcgraph/analyzers/python_scopes.py +56 -0
  34. arcgraph/analyzers/runtime_trace.py +516 -0
  35. arcgraph/analyzers/similarity.py +430 -0
  36. arcgraph/analyzers/symbols.py +363 -0
  37. arcgraph/analyzers/type_unions.py +89 -0
  38. arcgraph/analyzers/types.py +2293 -0
  39. arcgraph/assets/__init__.py +1 -0
  40. arcgraph/assets/workbench/__init__.py +1 -0
  41. arcgraph/assets/workbench/index.html +2398 -0
  42. arcgraph/assets/workbench/vendor/LICENSE.d3.txt +15 -0
  43. arcgraph/assets/workbench/vendor/d3.v7.min.js +2 -0
  44. arcgraph/change/__init__.py +10 -0
  45. arcgraph/change/baseline.py +145 -0
  46. arcgraph/change/changed_regions.py +728 -0
  47. arcgraph/change/contracts.py +927 -0
  48. arcgraph/change/errors.py +88 -0
  49. arcgraph/change/evidence.py +289 -0
  50. arcgraph/change/graph_delta.py +1422 -0
  51. arcgraph/change/identities.py +1277 -0
  52. arcgraph/change/paths.py +234 -0
  53. arcgraph/change/pins.py +426 -0
  54. arcgraph/change/planner.py +504 -0
  55. arcgraph/change/protected_surfaces.py +328 -0
  56. arcgraph/change/provider.py +46 -0
  57. arcgraph/change/service.py +1030 -0
  58. arcgraph/change/source_provider.py +311 -0
  59. arcgraph/change/store.py +1138 -0
  60. arcgraph/change/trusted_runners.py +154 -0
  61. arcgraph/change/verdicts.py +141 -0
  62. arcgraph/change/verification_plan.py +113 -0
  63. arcgraph/change/verifier.py +579 -0
  64. arcgraph/core/__init__.py +1 -0
  65. arcgraph/core/assurance.py +343 -0
  66. arcgraph/core/binding_metrics.py +75 -0
  67. arcgraph/core/cleanup.py +701 -0
  68. arcgraph/core/confidence_profiles.py +108 -0
  69. arcgraph/core/evidence_manifest.py +862 -0
  70. arcgraph/core/expression_kinds.py +11 -0
  71. arcgraph/core/force_graph_export.py +1125 -0
  72. arcgraph/core/freshness.py +94 -0
  73. arcgraph/core/graph_store.py +1099 -0
  74. arcgraph/core/ids.py +206 -0
  75. arcgraph/core/language_tiers.py +150 -0
  76. arcgraph/core/merge.py +649 -0
  77. arcgraph/core/metadata.py +30 -0
  78. arcgraph/core/operation_lock.py +218 -0
  79. arcgraph/core/passes.py +142 -0
  80. arcgraph/core/payload_policy.py +708 -0
  81. arcgraph/core/precise_references.py +124 -0
  82. arcgraph/core/query_engine.py +3078 -0
  83. arcgraph/core/recovery.py +18 -0
  84. arcgraph/core/scanner.py +1454 -0
  85. arcgraph/core/schemas.py +700 -0
  86. arcgraph/core/semantic.py +263 -0
  87. arcgraph/core/semantic_metrics.py +619 -0
  88. arcgraph/core/sharing_retry.py +84 -0
  89. arcgraph/core/structural.py +260 -0
  90. arcgraph/core/target_resolver.py +486 -0
  91. arcgraph/core/type_metrics.py +67 -0
  92. arcgraph/core/unresolved_classification.py +471 -0
  93. arcgraph/core/utils.py +100 -0
  94. arcgraph/core/visual_contract.py +240 -0
  95. arcgraph/core/visual_slice_engine.py +933 -0
  96. arcgraph/interfaces/__init__.py +1 -0
  97. arcgraph/interfaces/agent_capabilities.py +493 -0
  98. arcgraph/interfaces/agent_help.py +313 -0
  99. arcgraph/interfaces/benchmark.py +548 -0
  100. arcgraph/interfaces/ci.py +1992 -0
  101. arcgraph/interfaces/cli.py +2799 -0
  102. arcgraph/interfaces/cli_change.py +690 -0
  103. arcgraph/interfaces/cli_feedback.py +176 -0
  104. arcgraph/interfaces/cli_ops.py +69 -0
  105. arcgraph/interfaces/cli_support.py +113 -0
  106. arcgraph/interfaces/cli_trial.py +42 -0
  107. arcgraph/interfaces/cli_visual.py +601 -0
  108. arcgraph/interfaces/cli_workspace.py +300 -0
  109. arcgraph/interfaces/client_setup.py +481 -0
  110. arcgraph/interfaces/docs.py +1255 -0
  111. arcgraph/interfaces/human_output.py +409 -0
  112. arcgraph/interfaces/local_state.py +577 -0
  113. arcgraph/interfaces/mcp_server.py +457 -0
  114. arcgraph/interfaces/mcp_tools.py +1209 -0
  115. arcgraph/interfaces/metrics.py +571 -0
  116. arcgraph/interfaces/ops.py +330 -0
  117. arcgraph/interfaces/precision.py +1670 -0
  118. arcgraph/interfaces/project_probe.py +75 -0
  119. arcgraph/interfaces/reports.py +1121 -0
  120. arcgraph/interfaces/stdio_encoding.py +29 -0
  121. arcgraph/interfaces/trace.py +592 -0
  122. arcgraph/interfaces/trial_feedback.py +519 -0
  123. arcgraph/interfaces/trial_setup.py +535 -0
  124. arcgraph/interfaces/visual_server.py +354 -0
  125. arcgraph/interfaces/visual_slice.py +168 -0
  126. arcgraph/interfaces/visual_smoke.py +666 -0
  127. arcgraph/interfaces/workbench.py +576 -0
  128. arcgraph/interfaces/workspace.py +1513 -0
  129. arcgraph/pipeline/__init__.py +1 -0
  130. arcgraph/pipeline/contracts.py +122 -0
  131. arcgraph/pipeline/external_frontend.py +451 -0
  132. arcgraph/pipeline/external_l3_frontends.py +368 -0
  133. arcgraph/pipeline/external_protocol.py +358 -0
  134. arcgraph/pipeline/frontends.py +437 -0
  135. arcgraph/pipeline/indexer.py +661 -0
  136. arcgraph/pipeline/openapi_frontend.py +708 -0
  137. arcgraph/pipeline/python_frontend.py +298 -0
  138. arcgraph/pipeline/reindexer.py +1333 -0
  139. arcgraph/pipeline/scip_frontend.py +726 -0
  140. arcgraph/pipeline/typescript_extractor/declarations.mjs +183 -0
  141. arcgraph/pipeline/typescript_extractor/edges.mjs +2794 -0
  142. arcgraph/pipeline/typescript_extractor/graph_primitives.mjs +189 -0
  143. arcgraph/pipeline/typescript_extractor/module_resolution.mjs +877 -0
  144. arcgraph/pipeline/typescript_extractor/runtime.mjs +56 -0
  145. arcgraph/pipeline/typescript_extractor/similarity.mjs +619 -0
  146. arcgraph/pipeline/typescript_extractor/similarity_runner.mjs +19 -0
  147. arcgraph/pipeline/typescript_extractor/sources.mjs +23 -0
  148. arcgraph/pipeline/typescript_extractor.mjs +177 -0
  149. arcgraph/pipeline/typescript_frameworks.py +947 -0
  150. arcgraph/pipeline/typescript_frontend.py +656 -0
  151. arcgraph/pipeline/typescript_references.mjs +167 -0
  152. arcgraph/providers/__init__.py +27 -0
  153. arcgraph/providers/context_provider.py +1258 -0
  154. arcgraph/providers/flow_payload.py +117 -0
  155. arcgraph/providers/memory_connector.py +216 -0
  156. arcgraph/providers/orchestrator.py +475 -0
  157. arcgraph/providers/relationship_payload.py +102 -0
  158. arcgraph/providers/risk_provider.py +843 -0
  159. arcgraph/providers/why_provider.py +349 -0
  160. arcgraph/version_info.py +246 -0
  161. arcgraph-0.1.0.dist-info/METADATA +676 -0
  162. arcgraph-0.1.0.dist-info/RECORD +165 -0
  163. arcgraph-0.1.0.dist-info/WHEEL +4 -0
  164. arcgraph-0.1.0.dist-info/entry_points.txt +2 -0
  165. arcgraph-0.1.0.dist-info/licenses/LICENSE +203 -0
@@ -0,0 +1,88 @@
1
+ """Typed, fail-closed errors for Surgical Change Safety."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ChangeSafetyError(RuntimeError):
7
+ """Base error with a stable machine-readable failure code."""
8
+
9
+ code = "CHANGE_SAFETY_ERROR"
10
+
11
+ def __init__(self, message: str, *, code: str | None = None) -> None:
12
+ self.code = code or self.code
13
+ super().__init__(f"{self.code}: {message}")
14
+
15
+ def to_payload(self) -> dict[str, str]:
16
+ return {"code": self.code, "message": str(self).split(": ", 1)[-1]}
17
+
18
+
19
+ class ChangeContractVersionUnsupported(ChangeSafetyError):
20
+ code = "CHANGE_CONTRACT_VERSION_UNSUPPORTED"
21
+
22
+
23
+ class ChangeContractSchemaUnsupported(ChangeSafetyError):
24
+ code = "CHANGE_SCHEMA_VERSION_UNSUPPORTED"
25
+
26
+
27
+ class ChangeContractDigestMismatch(ChangeSafetyError):
28
+ code = "PLAN_CONTENT_DIGEST_MISMATCH"
29
+
30
+
31
+ class RepositoryPathError(ChangeSafetyError):
32
+ code = "REPOSITORY_PATH_INVALID"
33
+
34
+
35
+ class OutputContainmentError(ChangeSafetyError):
36
+ code = "OUTPUT_PATH_OUTSIDE_CONTAINMENT"
37
+
38
+
39
+ class ChangeStoreCorrupt(ChangeSafetyError):
40
+ code = "CHANGE_STORE_CORRUPT"
41
+
42
+
43
+ class ChangeStoreNotFound(ChangeSafetyError):
44
+ code = "CHANGE_STORE_RECORD_NOT_FOUND"
45
+
46
+
47
+ class BaselineIntegrityMismatch(ChangeSafetyError):
48
+ code = "BASELINE_INDEX_INTEGRITY_MISMATCH"
49
+
50
+
51
+ class BaselineSourceUnavailable(ChangeSafetyError):
52
+ code = "BASELINE_SOURCE_UNAVAILABLE"
53
+
54
+
55
+ class BaselineGitObjectMissing(ChangeSafetyError):
56
+ code = "BASELINE_GIT_OBJECT_MISSING"
57
+
58
+
59
+ class BaselineSourceIdentityMismatch(ChangeSafetyError):
60
+ code = "BASELINE_SOURCE_IDENTITY_MISMATCH"
61
+
62
+
63
+ class CurrentCodeIdentityMismatch(ChangeSafetyError):
64
+ code = "CURRENT_CODE_IDENTITY_MISMATCH"
65
+
66
+
67
+ class CurrentBuildCodeIdentityMismatch(ChangeSafetyError):
68
+ code = "CURRENT_BUILD_CODE_IDENTITY_MISMATCH"
69
+
70
+
71
+ class ChangePlanStateError(ChangeSafetyError):
72
+ code = "CHANGE_PLAN_STATE_INVALID"
73
+
74
+
75
+ class ChangeEvidenceError(ChangeSafetyError):
76
+ code = "CHANGE_EVIDENCE_INVALID"
77
+
78
+
79
+ class TrustedRunnerError(ChangeSafetyError):
80
+ code = "TRUSTED_RUNNER_UNAVAILABLE"
81
+
82
+
83
+ class GraphDeltaIncomplete(ChangeSafetyError):
84
+ code = "GRAPH_DELTA_INCOMPLETE"
85
+
86
+
87
+ class StableIdentityCollision(ChangeSafetyError):
88
+ code = "STABLE_IDENTITY_COLLISION"
@@ -0,0 +1,289 @@
1
+ """Exact evidence ingress validation and dynamic material availability summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import json
8
+ from pathlib import Path
9
+ import re
10
+ from typing import Iterable
11
+
12
+ from arcgraph.change.contracts import (
13
+ ChangePlanRevision,
14
+ VerificationEvidence,
15
+ VerificationRequirement,
16
+ canonical_digest,
17
+ )
18
+ from arcgraph.change.errors import ChangeEvidenceError
19
+ from arcgraph.change.identities import (
20
+ assert_code_identity_matches,
21
+ assert_current_build_matches_code,
22
+ capture_build_identity,
23
+ capture_code_identity,
24
+ )
25
+ from arcgraph.change.trusted_runners import (
26
+ TrustedRunnerRegistry,
27
+ attestation_meets_requirement,
28
+ )
29
+ from arcgraph.core.graph_store import GraphStoreReader
30
+
31
+ _SENSITIVE_METADATA_KEY = re.compile(
32
+ r"(?:authorization|cookie|credential|password|secret|token|api[_-]?key|"
33
+ r"private[_-]?key|access[_-]?key|connection[_-]?(?:string|uri))",
34
+ re.IGNORECASE,
35
+ )
36
+ _SENSITIVE_TEXT_PATTERNS: tuple[re.Pattern[str], ...] = (
37
+ re.compile(
38
+ r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
39
+ re.IGNORECASE | re.DOTALL,
40
+ ),
41
+ re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE),
42
+ re.compile(r"\bsk-[A-Za-z0-9_-]{12,}\b", re.IGNORECASE),
43
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b", re.IGNORECASE),
44
+ re.compile(r"\bAKIA[A-Z0-9]{16}\b", re.IGNORECASE),
45
+ re.compile(
46
+ r"\b[A-Za-z0-9_-]*(?:password|passwd|secret|token|api[_-]?key|"
47
+ r"private[_-]?key)[A-Za-z0-9_-]*"
48
+ r"\s*(?::|=|\bis\b|\bwas\b)\s*[^\s,;]+",
49
+ re.IGNORECASE,
50
+ ),
51
+ )
52
+ # Dotted identifiers are common graph identities, not evidence of a secret.
53
+ # Recognize compact tokens by their encoded JSON object header instead of length.
54
+ _COMPACT_TOKEN = re.compile(
55
+ r"(?<![A-Za-z0-9_-])(?=(?P<token>(?P<header>[A-Za-z0-9_-]+)\."
56
+ r"[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*(?![A-Za-z0-9_-])))"
57
+ )
58
+ # Consume leading nonletters so punctuation/digit-prefixed URI text retains
59
+ # its prior redaction, without retrying every suffix of a long scheme token.
60
+ _URI_CREDENTIALS = re.compile(
61
+ r"(?<![A-Za-z0-9+.-])(?P<prefix>[0-9+.-]*[A-Za-z][A-Za-z0-9+.-]*://)"
62
+ r"[^/\s:@]+:[^/\s@]+@"
63
+ )
64
+ _WINDOWS_USER_PATH = re.compile(r"(?i)\b[A-Z]:\\Users\\[^\\\s\"']+(?:\\[^\s\"'<>]*)?")
65
+ _POSIX_USER_PATH = re.compile(r"/(?:home|Users)/[^/\s\"']+(?:/[^\s\"'<>]*)?")
66
+
67
+
68
+ class EvidenceIngressValidator:
69
+ """Validate evidence against the exact revision and current code/build state."""
70
+
71
+ def __init__(
72
+ self,
73
+ repo_root: Path,
74
+ output_dir: Path,
75
+ *,
76
+ repo_id: str,
77
+ trusted_runner_registry: TrustedRunnerRegistry | None = None,
78
+ ) -> None:
79
+ self.repo_root = repo_root.resolve()
80
+ self.output_dir = output_dir.resolve()
81
+ self.repo_id = repo_id
82
+ self.trusted_runner_registry = trusted_runner_registry or TrustedRunnerRegistry(
83
+ None,
84
+ repo_root=self.repo_root,
85
+ )
86
+
87
+ def validate(
88
+ self,
89
+ evidence: VerificationEvidence,
90
+ revision: ChangePlanRevision,
91
+ *,
92
+ material: dict[str, object] | None = None,
93
+ ) -> VerificationEvidence:
94
+ if evidence.repo_id != self.repo_id or revision.repo_id != self.repo_id:
95
+ raise ChangeEvidenceError(
96
+ "evidence and revision must use the configured repo_id",
97
+ code="EVIDENCE_REPOSITORY_MISMATCH",
98
+ )
99
+ if (
100
+ evidence.plan_id != revision.plan_id
101
+ or evidence.plan_revision != revision.revision
102
+ or evidence.plan_content_digest != revision.plan_content_digest
103
+ ):
104
+ raise ChangeEvidenceError(
105
+ "evidence does not bind the exact plan revision digest",
106
+ code="EVIDENCE_BINDING_MISMATCH",
107
+ )
108
+ requirement = _requirement_for(revision, evidence.verification_requirement_id)
109
+ current_build = capture_build_identity(
110
+ self.repo_root,
111
+ self.output_dir,
112
+ repo_id=self.repo_id,
113
+ )
114
+ source_paths = [
115
+ record.path
116
+ for record in GraphStoreReader.from_current(self.output_dir).iter_files()
117
+ ]
118
+ actual_code = capture_code_identity(
119
+ self.repo_root,
120
+ repo_id=self.repo_id,
121
+ build_identity=current_build,
122
+ source_paths=source_paths,
123
+ )
124
+ assert_current_build_matches_code(current_build, actual_code)
125
+ assert_code_identity_matches(evidence.current_code_identity, actual_code)
126
+ if not attestation_meets_requirement(evidence, requirement):
127
+ raise ChangeEvidenceError(
128
+ "evidence attestation level is below the requirement",
129
+ code="EVIDENCE_ATTESTATION_INSUFFICIENT",
130
+ )
131
+ self.trusted_runner_registry.validate(evidence, requirement)
132
+ if (
133
+ not evidence.producer_identity.strip()
134
+ or not evidence.artifact_digest.strip()
135
+ ):
136
+ raise ChangeEvidenceError(
137
+ "evidence producer identity and artifact digest are required",
138
+ code="EVIDENCE_IDENTITY_INCOMPLETE",
139
+ )
140
+ if evidence.attestation_level in {
141
+ "artifact_backed",
142
+ "trusted_runner",
143
+ } and evidence.artifact_digest != canonical_digest(material or {}):
144
+ raise ChangeEvidenceError(
145
+ "artifact digest does not bind the submitted evidence material",
146
+ code="EVIDENCE_ARTIFACT_DIGEST_MISMATCH",
147
+ )
148
+ return redact_evidence_metadata(evidence)
149
+
150
+
151
+ def evidence_summary(
152
+ evidence: Iterable[VerificationEvidence],
153
+ availability: dict[str, str],
154
+ ) -> dict[str, object]:
155
+ """Return a dynamic availability projection without mutating evidence records."""
156
+
157
+ records = list(evidence)
158
+ by_result: dict[str, int] = {}
159
+ by_availability: dict[str, int] = {}
160
+ for record in records:
161
+ by_result[record.result] = by_result.get(record.result, 0) + 1
162
+ state = availability.get(record.evidence_id, "missing")
163
+ by_availability[state] = by_availability.get(state, 0) + 1
164
+ return {
165
+ "total": len(records),
166
+ "by_result": dict(sorted(by_result.items())),
167
+ "by_availability": dict(sorted(by_availability.items())),
168
+ }
169
+
170
+
171
+ def redact_evidence_metadata(evidence: VerificationEvidence) -> VerificationEvidence:
172
+ """Redact sensitive runner metadata before an immutable record is written."""
173
+
174
+ metadata, changed = _redact_mapping(evidence.runner_metadata)
175
+ if not changed:
176
+ return evidence
177
+ payload = evidence.model_dump(mode="python")
178
+ payload["runner_metadata"] = metadata
179
+ payload["redaction_status"] = "redacted"
180
+ payload["payload_digest"] = ""
181
+ return VerificationEvidence.model_validate(payload)
182
+
183
+
184
+ def redact_evidence_material(material: dict[str, object] | None) -> dict[str, object]:
185
+ """Return an evidence-material projection safe for the local state store."""
186
+
187
+ if material is None:
188
+ return {}
189
+ redacted, _changed = _redact_mapping(material)
190
+ return redacted
191
+
192
+
193
+ def redact_sensitive_text(
194
+ value: str,
195
+ *,
196
+ redact_paths: bool = True,
197
+ ) -> tuple[str, bool]:
198
+ """Redact well-known secret values and host-user paths in free text."""
199
+
200
+ redacted = _redact_compact_tokens(value)
201
+ for pattern in _SENSITIVE_TEXT_PATTERNS:
202
+ redacted = pattern.sub("<redacted>", redacted)
203
+ redacted = _URI_CREDENTIALS.sub(r"\g<prefix><redacted>@", redacted)
204
+ if redact_paths:
205
+ redacted = _WINDOWS_USER_PATH.sub("<redacted-user-path>", redacted)
206
+ redacted = _POSIX_USER_PATH.sub("<redacted-user-path>", redacted)
207
+ return redacted, redacted != value
208
+
209
+
210
+ def _redact_compact_tokens(value: str) -> str:
211
+ # Lookahead permits overlapping candidates: an invalid `session.header.payload`
212
+ # must not consume the start of the valid `header.payload.signature` token.
213
+ spans: list[tuple[int, int]] = []
214
+ for match in _COMPACT_TOKEN.finditer(value):
215
+ if not _is_compact_header(match.group("header")):
216
+ continue
217
+ start, end = match.span("token")
218
+ if spans and start <= spans[-1][1]:
219
+ spans[-1] = (spans[-1][0], max(end, spans[-1][1]))
220
+ else:
221
+ spans.append((start, end))
222
+ parts: list[str] = []
223
+ cursor = 0
224
+ for start, end in spans:
225
+ parts.extend((value[cursor:start], "<redacted>"))
226
+ cursor = end
227
+ parts.append(value[cursor:])
228
+ return "".join(parts)
229
+
230
+
231
+ def _is_compact_header(header: str) -> bool:
232
+ try:
233
+ decoded = base64.b64decode(
234
+ header + "=" * (-len(header) % 4), altchars=b"-_", validate=True
235
+ ).decode("utf-8")
236
+ parsed = json.loads(decoded)
237
+ except (ValueError, binascii.Error):
238
+ return False
239
+ # Redaction is conservative recognition, not signature or claim validation.
240
+ return isinstance(parsed, dict)
241
+
242
+
243
+ def _requirement_for(
244
+ revision: ChangePlanRevision,
245
+ requirement_id: str,
246
+ ) -> VerificationRequirement:
247
+ for requirement in revision.verification_plan.requirements:
248
+ if requirement.requirement_id == requirement_id:
249
+ return requirement
250
+ raise ChangeEvidenceError(
251
+ "evidence requirement does not exist in the revision",
252
+ code="EVIDENCE_REQUIREMENT_INVALID",
253
+ )
254
+
255
+
256
+ def _redact_mapping(value: object) -> tuple[object, bool]:
257
+ if isinstance(value, dict):
258
+ result: dict[str, object] = {}
259
+ changed = False
260
+ for key, child in value.items():
261
+ if not isinstance(key, str):
262
+ raise ChangeEvidenceError(
263
+ "runner metadata and material keys must be strings",
264
+ code="EVIDENCE_REDACTION_INVALID",
265
+ )
266
+ if _SENSITIVE_METADATA_KEY.search(key):
267
+ result[key] = "<redacted>"
268
+ changed = True
269
+ continue
270
+ redacted, child_changed = _redact_mapping(child)
271
+ result[key] = redacted
272
+ changed = changed or child_changed
273
+ return result, changed
274
+ if isinstance(value, list):
275
+ result: list[object] = []
276
+ changed = False
277
+ for child in value:
278
+ redacted, child_changed = _redact_mapping(child)
279
+ result.append(redacted)
280
+ changed = changed or child_changed
281
+ return result, changed
282
+ if isinstance(value, str):
283
+ return redact_sensitive_text(value)
284
+ if isinstance(value, (int, float, bool)) or value is None:
285
+ return value, False
286
+ raise ChangeEvidenceError(
287
+ "runner metadata and material must contain JSON-compatible values",
288
+ code="EVIDENCE_REDACTION_INVALID",
289
+ )