causal-continuity-engine 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 (124) hide show
  1. causal_continuity_engine/__init__.py +21 -0
  2. causal_continuity_engine/api.py +1264 -0
  3. causal_continuity_engine/capabilities.py +548 -0
  4. causal_continuity_engine/capsule.py +996 -0
  5. causal_continuity_engine/cli.py +1338 -0
  6. causal_continuity_engine/core.py +468 -0
  7. causal_continuity_engine/engine.py +4354 -0
  8. causal_continuity_engine/evidence.py +971 -0
  9. causal_continuity_engine/extraction.py +263 -0
  10. causal_continuity_engine/github.py +657 -0
  11. causal_continuity_engine/graph.py +809 -0
  12. causal_continuity_engine/invalidation.py +1040 -0
  13. causal_continuity_engine/lamport.py +306 -0
  14. causal_continuity_engine/learning.py +466 -0
  15. causal_continuity_engine/memory.py +610 -0
  16. causal_continuity_engine/ontology.py +108 -0
  17. causal_continuity_engine/partial.py +309 -0
  18. causal_continuity_engine/policy.py +940 -0
  19. causal_continuity_engine/proof.py +920 -0
  20. causal_continuity_engine/py.typed +1 -0
  21. causal_continuity_engine/redaction.py +94 -0
  22. causal_continuity_engine/resume.py +653 -0
  23. causal_continuity_engine/store.py +1174 -0
  24. causal_continuity_engine/verifiers.py +1006 -0
  25. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/SPEC.md +515 -0
  26. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/__init__.py +0 -0
  27. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/__init__.py +0 -0
  28. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/run.py +146 -0
  29. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/scenarios.py +544 -0
  30. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.anchor.v1.json +62 -0
  31. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.capsule.v1.json +112 -0
  32. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.continuity-receipt.v1.json +315 -0
  33. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.event.v1.json +73 -0
  34. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.proof-predicate.v1.json +35 -0
  35. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.proof.v1.json +242 -0
  36. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.recovery.v1.json +109 -0
  37. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.resume.v1.json +332 -0
  38. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/__init__.py +1 -0
  39. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/schema_validation.py +61 -0
  40. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_api_contract.py +1051 -0
  41. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_cli.py +664 -0
  42. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_conformance.py +758 -0
  43. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_distribution_environment.py +26 -0
  44. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_engine_e2e.py +286 -0
  45. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_github_redaction.py +193 -0
  46. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_instrument_validation.py +535 -0
  47. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_invalidation_resume.py +205 -0
  48. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_learning_partial_api.py +380 -0
  49. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_memory_extraction.py +151 -0
  50. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions.py +406 -0
  51. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round10_release.py +842 -0
  52. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round11_deep_integrity.py +831 -0
  53. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round12_release_blockers.py +206 -0
  54. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round13_jcs.py +212 -0
  55. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round14_state_integrity.py +1056 -0
  56. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_capsule_invalidation_github.py +251 -0
  57. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_public_boundaries.py +561 -0
  58. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_storage_boundaries.py +707 -0
  59. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round16_cli_api_core_capabilities.py +285 -0
  60. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round2.py +388 -0
  61. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round3.py +490 -0
  62. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round4.py +557 -0
  63. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round5.py +583 -0
  64. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round6.py +559 -0
  65. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round7.py +579 -0
  66. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_boundaries.py +558 -0
  67. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_engine.py +843 -0
  68. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_portability.py +2250 -0
  69. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_state.py +705 -0
  70. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round9_integrity.py +885 -0
  71. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round9_scope_freshness.py +179 -0
  72. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_release_boundary_validation.py +676 -0
  73. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_release_controls.py +840 -0
  74. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_sqlite_lifecycle.py +52 -0
  75. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_store_graph.py +172 -0
  76. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_trust.py +398 -0
  77. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/action_intent_wrong_type.json +170 -0
  78. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/actor_wrong_type.json +170 -0
  79. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/created_at_invalid_calendar_date.json +172 -0
  80. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/created_at_noncanonical_offset.json +172 -0
  81. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_authoritative_failure.json +114 -0
  82. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_authoritative_pass.json +107 -0
  83. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_no_results.json +99 -0
  84. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_self_asserted_pass.json +105 -0
  85. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/generate.py +498 -0
  86. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/incomplete_failed_check.json +168 -0
  87. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/index.json +240 -0
  88. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/input_item_wrong_type.json +163 -0
  89. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/invalid_result_value.json +170 -0
  90. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_fingerprint_spoof.json +1458 -0
  91. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_no_registry.json +1454 -0
  92. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_truncated_key.json +1454 -0
  93. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/missing_required_field.json +157 -0
  94. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/non_binary64_integer.json +174 -0
  95. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/policy_decision_wrong_type.json +146 -0
  96. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/retry_cannot_launder_a_failure.json +197 -0
  97. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/self_asserted_cannot_satisfy.json +173 -0
  98. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/self_assertion_cannot_poison_authoritative_pass.json +194 -0
  99. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/status_contradicts_contents.json +173 -0
  100. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/summary_contradicts_contents.json +170 -0
  101. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/tamper_statement.json +172 -0
  102. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/tamper_statement_resealed.json +172 -0
  103. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/task_id_under_unrelated_field.json +172 -0
  104. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unbound_task.json +165 -0
  105. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_action_intent_field.json +173 -0
  106. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_algorithm.json +172 -0
  107. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_schema_version.json +170 -0
  108. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_top_level_field.json +175 -0
  109. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unverified_forged_body_no_key.json +170 -0
  110. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unverified_hmac_no_key.json +170 -0
  111. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac.json +172 -0
  112. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac_jcs_edges.json +186 -0
  113. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac_unicode.json +172 -0
  114. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_lamport.json +1458 -0
  115. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/wrong_project.json +173 -0
  116. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/verifiers/__init__.py +0 -0
  117. causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/verifiers/verify_proof.py +1109 -0
  118. causal_continuity_engine-0.1.0.dist-info/METADATA +720 -0
  119. causal_continuity_engine-0.1.0.dist-info/RECORD +124 -0
  120. causal_continuity_engine-0.1.0.dist-info/WHEEL +5 -0
  121. causal_continuity_engine-0.1.0.dist-info/entry_points.txt +2 -0
  122. causal_continuity_engine-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
  123. causal_continuity_engine-0.1.0.dist-info/licenses/NOTICE +6 -0
  124. causal_continuity_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1264 @@
1
+ """Authenticated, fail-closed HTTP API for the PLT-001 endpoint contracts.
2
+
3
+ The production caveat remains explicit: ``http.server`` provides only basic
4
+ HTTP handling and is not a hosted-service boundary. The local entry point
5
+ binds an IP loopback literal, requires a minimum-length bearer token, verifies
6
+ GitHub's HMAC over the untouched request bytes, bounds request size, socket I/O
7
+ timeout, and worker count, and never accepts executable verifier definitions.
8
+ It does not provide TLS, OS-user isolation, distributed rate limiting, or a
9
+ browser security boundary; a hosted service still needs a real gateway
10
+ (PLT-001).
11
+
12
+ ``make_handler`` requires a syntactically valid, minimum-length API token even
13
+ when it is used as an embedding seam. ``serve`` additionally requires a
14
+ minimum-length webhook secret. Route metadata below is also the sole source for
15
+ the checked public API document.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hmac
21
+ import json
22
+ import math
23
+ import re
24
+ import threading
25
+ from dataclasses import dataclass
26
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
27
+ from urllib.parse import parse_qs, urlsplit
28
+
29
+ from .capsule import CapsuleError
30
+ from .core import strict_json_loads, validate_public_identifier
31
+ from .engine import AttestationInputError, GitHubDeliveryError
32
+ from .github import (
33
+ WebhookError,
34
+ WebhookPayloadError,
35
+ validate_webhook_secret,
36
+ verify_signature,
37
+ )
38
+ from .invalidation import ResolutionInputError
39
+ from .store import PayloadMismatchError
40
+
41
+ _MAX_BODY_BYTES = 1024 * 1024
42
+ _MAX_CONFIGURED_BODY_BYTES = 25 * 1024 * 1024
43
+ _MAX_TOKEN_BUDGET = 100_000
44
+ _DEFAULT_REQUEST_TIMEOUT_SECONDS = 15.0
45
+ _MAX_REQUEST_TIMEOUT_SECONDS = 300.0
46
+ _DEFAULT_MAX_WORKERS = 8
47
+ _MAX_WORKERS = 64
48
+ _MIN_SECRET_BYTES = 32
49
+ _MAX_SECRET_BYTES = 4096
50
+ _BEARER_TOKEN = re.compile(r"[A-Za-z0-9\-._~+/]+=*\Z")
51
+ _GITHUB_EVENT_HEADER = re.compile(r"[a-z][a-z_]{0,63}\Z", re.ASCII)
52
+ _ASSUMPTION_STATUSES = frozenset({
53
+ "active", "supported", "uncertain", "resolved", "invalidated", "superseded",
54
+ })
55
+ _CONTINUITY_RELATIONS = frozenset({
56
+ "task_ids", "requirement_ids", "decision_ids", "assumption_ids",
57
+ "artifact_ids", "evidence_ids", "action_ids",
58
+ })
59
+ _VERIFICATION_RESULTS = frozenset({
60
+ "passed", "failed", "skipped", "missing", "inconclusive", "stale",
61
+ })
62
+ _MISSING = object()
63
+
64
+
65
+ class RequestValidationError(Exception):
66
+ """A caller-controlled request failed an explicit API contract check."""
67
+
68
+ def __init__(self, field: str, message: str, *, code: str = "invalid_request"):
69
+ super().__init__(message)
70
+ self.field = field
71
+ self.code = code
72
+ self.message = message
73
+
74
+
75
+ class UnsupportedMediaTypeError(RequestValidationError):
76
+ def __init__(self):
77
+ super().__init__(
78
+ "Content-Type", "Content-Type must be application/json",
79
+ code="unsupported_media_type")
80
+
81
+
82
+ class APIResourceNotFound(Exception):
83
+ """A deliberately scoped lookup found neither local nor disclosable state."""
84
+
85
+ def __init__(self, resource: str):
86
+ super().__init__(resource)
87
+ self.resource = resource
88
+
89
+
90
+ class APIForbidden(Exception):
91
+ """A request failed an API-owned tenant, project, or identity boundary."""
92
+
93
+
94
+ class RequestTooLarge(Exception):
95
+ def __init__(self, limit: int):
96
+ super().__init__(f"request body exceeds {limit}-byte limit")
97
+ self.limit = limit
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class RouteSpec:
102
+ method: str
103
+ template: str
104
+ pattern: re.Pattern[str]
105
+ name: str
106
+ auth: str
107
+ request: str
108
+ response: str
109
+ success: int
110
+
111
+
112
+ def _route(method: str, template: str, pattern: str, name: str, auth: str,
113
+ request: str, response: str, success: int) -> RouteSpec:
114
+ return RouteSpec(
115
+ method, template, re.compile(pattern), name, auth, request, response, success)
116
+
117
+
118
+ API_ROUTES = (
119
+ _route("GET", "/v1/projects/{project_id}/assumptions",
120
+ r"^/v1/projects/([^/]+)/assumptions$", "assumptions", "bearer",
121
+ "Optional status query: comma-separated documented assumption statuses.",
122
+ "Array of assumption summaries.", 200),
123
+ _route("GET", "/v1/projects/{project_id}/invalidations",
124
+ r"^/v1/projects/([^/]+)/invalidations$", "invalidations", "bearer",
125
+ "No body.", "Array of open invalidation summaries.", 200),
126
+ _route("GET", "/v1/evaluations", r"^/v1/evaluations$", "evaluations", "bearer",
127
+ "No body.", "Array of evaluation summaries for the bound project.", 200),
128
+ _route("GET", "/v1/health", r"^/v1/health$", "health", "public",
129
+ "No body.", "Object with status=ok. This is the only unauthenticated route.", 200),
130
+ _route("POST", "/v1/events:ingest", r"^/v1/events:ingest$", "events_ingest",
131
+ "webhook", "Raw GitHub JSON payload; HMAC is checked before JSON parsing.",
132
+ "Ingestion report, duplicate status, or ping health response.", 202),
133
+ _route("POST", "/v1/traces:ingest", r"^/v1/traces:ingest$", "traces_ingest",
134
+ "bearer", "Object: span_id; optional project_id, session_id, payload object.",
135
+ "Ingestion report or duplicate status.", 202),
136
+ _route("POST", "/v1/projects/{project_id}/resume-packets:compose",
137
+ r"^/v1/projects/([^/]+)/resume-packets:compose$", "compose", "bearer",
138
+ "Object: optional token_budget integer and target object.",
139
+ "cce.resume.v1 object.", 200),
140
+ _route("POST", "/v1/assumptions/{assumption_id}:resolve",
141
+ r"^/v1/assumptions/([^/]+):resolve$", "resolve", "bearer",
142
+ "Object selecting direct resolution or a typed invalidation that "
143
+ "targets/affects the path resource.",
144
+ "Resolved node or invalidation summary.", 200),
145
+ _route("POST", "/v1/actions:attest", r"^/v1/actions:attest$", "attest", "bearer",
146
+ "Object: intent_type plus optional statement, actor, action_type, "
147
+ "verifications, continuity.",
148
+ "cce.proof.v1 object.", 200),
149
+ _route("POST", "/v1/verifications:run", r"^/v1/verifications:run$",
150
+ "verifications_run", "bearer",
151
+ "Object: optional intent fields and continuity; executable definitions are forbidden.",
152
+ "cce.proof.v1 object.", 200),
153
+ _route("POST", "/v1/projects/{project_id}/continuity-receipts:verify",
154
+ r"^/v1/projects/([^/]+)/continuity-receipts:verify$",
155
+ "continuity_receipt_verify", "bearer", "Object containing receipt object.",
156
+ "CURRENT, AUTHENTIC_HISTORICAL, or INVALID verdict object.", 200),
157
+ _route("POST", "/v1/migrations:prepare", r"^/v1/migrations:prepare$",
158
+ "migrations_prepare", "bearer",
159
+ "Object with optional scoped session and non-empty source/target strings.",
160
+ "cce.capsule.v1 object.", 200),
161
+ _route("POST", "/v1/migrations:validate", r"^/v1/migrations:validate$",
162
+ "migrations_validate", "bearer",
163
+ "Object containing capsule plus optional non-empty target model/runtime.",
164
+ "Imported session, challenge, and validation object.", 200),
165
+ _route("POST", "/v1/replays", r"^/v1/replays$", "replays", "bearer",
166
+ "Object: from_event_id plus optional project_id and object-valued "
167
+ "captured_inputs, mocks, fork.",
168
+ "Replay node and fidelity object.", 201),
169
+ )
170
+
171
+
172
+ def render_api_document() -> str:
173
+ """Render the complete public API contract from the live route registry."""
174
+ auth_labels = {
175
+ "public": "Public health check",
176
+ "bearer": "Bearer token",
177
+ "webhook": "GitHub HMAC",
178
+ }
179
+ lines = [
180
+ "# Local HTTP API",
181
+ "",
182
+ "> Generated from `causal_continuity_engine.api.API_ROUTES`. Do not edit by hand.",
183
+ "> Run `python .github/scripts/render_api_docs.py --write` after changing routes.",
184
+ "",
185
+ "CCE's built-in HTTP server is a loopback integration endpoint. It binds only",
186
+ "`127.0.0.1`; it does not provide TLS, OS-user isolation, a browser security",
187
+ "boundary, or distributed rate limiting. Put a deployment-grade authenticated",
188
+ "gateway in front of it before any non-loopback exposure.",
189
+ "",
190
+ "## Authentication and requests",
191
+ "",
192
+ "Every route except `GET /v1/health` requires authentication. Ordinary routes",
193
+ "use `Authorization: Bearer <api.token>`. `POST /v1/events:ingest` instead",
194
+ "checks `X-Hub-Signature-256` over the untouched request bytes before parsing",
195
+ "JSON, then requires `X-GitHub-Event` and `X-GitHub-Delivery`.",
196
+ "",
197
+ "Every POST requires `Content-Type: application/json` (parameters such as",
198
+ "`charset=utf-8` are allowed). JSON must be an object, have no duplicate keys or",
199
+ "non-finite numbers, and satisfy the route's typed field contract. The default",
200
+ f"body limit is {_MAX_BODY_BYTES} bytes; configured limits must be between 1 and",
201
+ f"{_MAX_CONFIGURED_BODY_BYTES} bytes. The socket I/O timeout must be finite,",
202
+ "positive,",
203
+ f"and at most {_MAX_REQUEST_TIMEOUT_SECONDS:g} seconds. Worker count is bounded",
204
+ f"between 1 and {_MAX_WORKERS}.",
205
+ "",
206
+ "Every public resource identifier is one URI path segment: 1–128 ASCII",
207
+ "unreserved characters, beginning with a letter or digit and continuing with",
208
+ "letters, digits, `.`, `_`, `~`, or `-`. Slash, percent escapes, whitespace,",
209
+ "controls, Unicode, dot segments, and longer values are rejected as",
210
+ "`invalid_identifier`; clients must not encode an otherwise-invalid identity.",
211
+ "",
212
+ "GitHub sends `ping` when a webhook is created. A valid signed repository ping",
213
+ "must carry `hook`, positive `hook_id`, `zen`, and the bound positive numeric",
214
+ "`repository.id`; it returns 200 without persisting an event. GitHub's documented",
215
+ "ping object does not declare `installation`, so absence does not fail setup. If",
216
+ "installation is present it is checked, and every operational event still must",
217
+ "match a configured installation id. See GitHub's [webhook payload reference]",
218
+ "[github-webhook-payloads].",
219
+ "",
220
+ "## Routes",
221
+ "",
222
+ "| Method | Path | Authentication | Request | Success response | Status |",
223
+ "|---|---|---|---|---|---:|",
224
+ ]
225
+ for route in API_ROUTES:
226
+ lines.append(
227
+ f"| {route.method} | `{route.template}` | {auth_labels[route.auth]} | "
228
+ f"{route.request} | {route.response} | {route.success} |")
229
+ lines.extend([
230
+ "",
231
+ "## Errors and method handling",
232
+ "",
233
+ "Every error uses the same JSON shape:",
234
+ "",
235
+ "```json",
236
+ '{"error":{"code":"invalid_request","message":"...","field":"token_budget"}}',
237
+ "```",
238
+ "",
239
+ "`field` is omitted when no request field applies. Explicit validation, including",
240
+ "an `invalid_identifier`, is 400;",
241
+ "missing/invalid bearer credentials are 401 with `WWW-Authenticate: Bearer`;",
242
+ "project-scope denial is 403; an explicitly scoped missing resource is 404; a",
243
+ "known route with the wrong method is 405 with exact `Allow`; idempotency-key",
244
+ "payload conflict is 409; oversized input is 413; wrong media type is 415; and an",
245
+ "authenticated unsupported GitHub event is 422. Unexpected implementation errors",
246
+ "are generic 500 responses and disclose no exception detail.",
247
+ "",
248
+ "Unknown paths return JSON 404. GET, POST, HEAD, PUT, PATCH, DELETE, OPTIONS,",
249
+ "TRACE, and CONNECT all pass through the same dispatcher; the standard-library",
250
+ "HTML error pages and Python version banner are not part of this API.",
251
+ "",
252
+ "[github-webhook-payloads]: "
253
+ "https://docs.github.com/en/webhooks/webhook-events-and-payloads#ping",
254
+ ])
255
+ return "\n".join(lines) + "\n"
256
+
257
+
258
+ def _error_body(code: str, message: str, field: str | None = None) -> dict:
259
+ error = {"code": code, "message": message}
260
+ if field is not None:
261
+ error["field"] = field
262
+ return {"error": error}
263
+
264
+
265
+ def _reject_unknown(body: dict, allowed) -> None:
266
+ unknown = sorted(set(body) - set(allowed))
267
+ if unknown:
268
+ raise RequestValidationError(
269
+ "body", f"unknown request field(s): {', '.join(unknown)}",
270
+ code="unknown_field")
271
+
272
+
273
+ def _required_string(body: dict, field: str, *, allow_empty: bool = False) -> str:
274
+ value = body.get(field, _MISSING)
275
+ if value is _MISSING:
276
+ raise RequestValidationError(field, f"{field} is required", code="missing_field")
277
+ if not isinstance(value, str) or (not allow_empty and not value.strip()):
278
+ qualifier = "a string" if allow_empty else "a non-empty string"
279
+ raise RequestValidationError(field, f"{field} must be {qualifier}")
280
+ return value
281
+
282
+
283
+ def _optional_string(
284
+ body: dict,
285
+ field: str,
286
+ default: str | None = None,
287
+ *,
288
+ allow_none: bool = False,
289
+ allow_empty: bool = False,
290
+ ) -> str | None:
291
+ if field not in body:
292
+ return default
293
+ if body[field] is None and allow_none:
294
+ return None
295
+ return _required_string(body, field, allow_empty=allow_empty)
296
+
297
+
298
+ def _identifier(value, field: str) -> str:
299
+ try:
300
+ return validate_public_identifier(value, field=field)
301
+ except ValueError as exc:
302
+ raise RequestValidationError(
303
+ field, str(exc), code="invalid_identifier") from None
304
+
305
+
306
+ def _required_identifier(body: dict, field: str) -> str:
307
+ return _identifier(_required_string(body, field), field)
308
+
309
+
310
+ def _optional_identifier(
311
+ body: dict,
312
+ field: str,
313
+ default: str | None = None,
314
+ *,
315
+ allow_none: bool = False,
316
+ ) -> str | None:
317
+ value = _optional_string(
318
+ body, field, default, allow_none=allow_none)
319
+ if value is None:
320
+ return None
321
+ return _identifier(value, field)
322
+
323
+
324
+ def _required_object(body: dict, field: str) -> dict:
325
+ value = body.get(field, _MISSING)
326
+ if value is _MISSING:
327
+ raise RequestValidationError(field, f"{field} is required", code="missing_field")
328
+ if not isinstance(value, dict):
329
+ raise RequestValidationError(field, f"{field} must be a JSON object")
330
+ return value
331
+
332
+
333
+ def _optional_object(
334
+ body: dict,
335
+ field: str,
336
+ default=None,
337
+ *,
338
+ allow_none: bool = False,
339
+ ) -> dict | None:
340
+ if field not in body:
341
+ return default
342
+ if body[field] is None and allow_none:
343
+ return None
344
+ return _required_object(body, field)
345
+
346
+
347
+ def _required_list(body: dict, field: str) -> list:
348
+ value = body.get(field, _MISSING)
349
+ if value is _MISSING:
350
+ raise RequestValidationError(field, f"{field} is required", code="missing_field")
351
+ if not isinstance(value, list):
352
+ raise RequestValidationError(field, f"{field} must be a JSON array")
353
+ return value
354
+
355
+
356
+ def _optional_list(body: dict, field: str, default=None, *, allow_none: bool = False):
357
+ if field not in body:
358
+ return default
359
+ if body[field] is None and allow_none:
360
+ return None
361
+ return _required_list(body, field)
362
+
363
+
364
+ def _required_bool(body: dict, field: str) -> bool:
365
+ value = body.get(field, _MISSING)
366
+ if value is _MISSING:
367
+ raise RequestValidationError(field, f"{field} is required", code="missing_field")
368
+ if not isinstance(value, bool):
369
+ raise RequestValidationError(field, f"{field} must be a boolean")
370
+ return value
371
+
372
+
373
+ def _optional_bool(body: dict, field: str, default: bool | None = None) -> bool | None:
374
+ if field not in body:
375
+ return default
376
+ return _required_bool(body, field)
377
+
378
+
379
+ def _required_int(
380
+ body: dict,
381
+ field: str,
382
+ *,
383
+ minimum: int | None = None,
384
+ maximum: int | None = None,
385
+ ) -> int:
386
+ value = body.get(field, _MISSING)
387
+ if value is _MISSING:
388
+ raise RequestValidationError(field, f"{field} is required", code="missing_field")
389
+ if isinstance(value, bool) or not isinstance(value, int):
390
+ raise RequestValidationError(field, f"{field} must be an integer")
391
+ if ((minimum is not None and value < minimum)
392
+ or (maximum is not None and value > maximum)):
393
+ if minimum is not None and maximum is not None:
394
+ message = f"{field} must be between {minimum} and {maximum}"
395
+ elif minimum is not None:
396
+ message = f"{field} must be at least {minimum}"
397
+ else:
398
+ message = f"{field} must be at most {maximum}"
399
+ raise RequestValidationError(field, message)
400
+ return value
401
+
402
+
403
+ def _optional_int(
404
+ body: dict,
405
+ field: str,
406
+ default: int | None = None,
407
+ *,
408
+ minimum: int | None = None,
409
+ maximum: int | None = None,
410
+ ) -> int | None:
411
+ if field not in body:
412
+ return default
413
+ return _required_int(body, field, minimum=minimum, maximum=maximum)
414
+
415
+
416
+ def _validate_continuity(body: dict, field: str = "continuity") -> dict | None:
417
+ continuity = _optional_object(body, field)
418
+ if continuity is None:
419
+ return None
420
+ unknown = sorted(set(continuity) - _CONTINUITY_RELATIONS)
421
+ if unknown:
422
+ raise RequestValidationError(
423
+ field, f"{field} has unknown relation(s): {', '.join(unknown)}")
424
+ for relation, targets in continuity.items():
425
+ if (not isinstance(targets, list)
426
+ or any(not isinstance(target, str) or not target for target in targets)):
427
+ raise RequestValidationError(
428
+ f"{field}.{relation}",
429
+ f"{field}.{relation} must be an array of non-empty node ids")
430
+ for index, target in enumerate(targets):
431
+ _identifier(target, f"{field}.{relation}[{index}]")
432
+ if len(targets) != len(set(targets)):
433
+ raise RequestValidationError(
434
+ f"{field}.{relation}", f"{field}.{relation} must not contain duplicates")
435
+ return continuity
436
+
437
+
438
+ def _validate_verifications(body: dict) -> list[dict] | None:
439
+ values = _optional_list(body, "verifications")
440
+ if values is None:
441
+ return None
442
+ for index, value in enumerate(values):
443
+ field = f"verifications[{index}]"
444
+ if not isinstance(value, dict):
445
+ raise RequestValidationError(field, f"{field} must be a JSON object")
446
+ verifier = value.get("verifier")
447
+ if not isinstance(verifier, str) or not verifier.strip():
448
+ raise RequestValidationError(
449
+ f"{field}.verifier", f"{field}.verifier must be a non-empty string")
450
+ result = value.get("result")
451
+ if (not isinstance(result, str)
452
+ or result not in _VERIFICATION_RESULTS):
453
+ raise RequestValidationError(
454
+ f"{field}.result", f"{field}.result is not recognized")
455
+ return values
456
+
457
+
458
+ def _bounded_int(name: str, value, minimum: int, maximum: int) -> int:
459
+ if isinstance(value, bool) or not isinstance(value, int):
460
+ raise ValueError(f"{name} must be an integer")
461
+ if not minimum <= value <= maximum:
462
+ raise ValueError(f"{name} must be between {minimum} and {maximum}")
463
+ return value
464
+
465
+
466
+ def _validated_timeout(value) -> float:
467
+ if (isinstance(value, bool) or not isinstance(value, (int, float))
468
+ or not math.isfinite(value) or value <= 0
469
+ or value > _MAX_REQUEST_TIMEOUT_SECONDS):
470
+ raise ValueError(
471
+ "request_timeout_seconds must be finite and between 0 and "
472
+ f"{_MAX_REQUEST_TIMEOUT_SECONDS}")
473
+ return float(value)
474
+
475
+
476
+ def _validated_project_id(project_id) -> str:
477
+ return validate_public_identifier(project_id, field="project_id")
478
+
479
+
480
+ def _validated_api_token(api_token) -> str:
481
+ if not isinstance(api_token, str):
482
+ raise ValueError("api_token must be an ASCII bearer-token string")
483
+ try:
484
+ encoded = api_token.encode("ascii")
485
+ except UnicodeEncodeError:
486
+ raise ValueError("api_token must contain only ASCII characters") from None
487
+ if not _MIN_SECRET_BYTES <= len(encoded) <= _MAX_SECRET_BYTES:
488
+ raise ValueError(
489
+ f"api_token must be between {_MIN_SECRET_BYTES} and "
490
+ f"{_MAX_SECRET_BYTES} ASCII bytes")
491
+ if not _BEARER_TOKEN.fullmatch(api_token):
492
+ raise ValueError("api_token contains characters forbidden in a bearer token")
493
+ return api_token
494
+
495
+
496
+ def _validated_webhook_secret(webhook_secret, *, required: bool) -> bytes | None:
497
+ return validate_webhook_secret(webhook_secret, required=required)
498
+
499
+
500
+ class BoundedThreadingHTTPServer(ThreadingHTTPServer):
501
+ """ThreadingHTTPServer with a fixed in-flight request ceiling."""
502
+
503
+ daemon_threads = True
504
+ request_queue_size = 16
505
+
506
+ def __init__(
507
+ self, server_address, handler, *, max_workers: int = _DEFAULT_MAX_WORKERS):
508
+ max_workers = _bounded_int("max_workers", max_workers, 1, _MAX_WORKERS)
509
+ self._request_slots = threading.BoundedSemaphore(max_workers)
510
+ super().__init__(server_address, handler)
511
+
512
+ def process_request(self, request, client_address):
513
+ self._request_slots.acquire()
514
+ try:
515
+ super().process_request(request, client_address)
516
+ except BaseException:
517
+ self._request_slots.release()
518
+ raise
519
+
520
+ def process_request_thread(self, request, client_address):
521
+ try:
522
+ super().process_request_thread(request, client_address)
523
+ finally:
524
+ self._request_slots.release()
525
+
526
+
527
+ def make_handler(
528
+ engine,
529
+ project_id: str,
530
+ *,
531
+ api_token: str,
532
+ webhook_secret: bytes | str | None = None,
533
+ max_body_bytes: int = _MAX_BODY_BYTES,
534
+ request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS,
535
+ ):
536
+ project_id = _validated_project_id(project_id)
537
+ api_token = _validated_api_token(api_token)
538
+ webhook_secret = _validated_webhook_secret(webhook_secret, required=False)
539
+ max_body_bytes = _bounded_int(
540
+ "max_body_bytes", max_body_bytes, 1, _MAX_CONFIGURED_BODY_BYTES)
541
+ request_timeout_seconds = _validated_timeout(request_timeout_seconds)
542
+
543
+ class Handler(BaseHTTPRequestHandler):
544
+ server_version = "CCE"
545
+ sys_version = ""
546
+
547
+ def setup(self):
548
+ super().setup()
549
+ self.request.settimeout(request_timeout_seconds)
550
+
551
+ def log_message(self, *a):
552
+ pass
553
+
554
+ def version_string(self):
555
+ return self.server_version
556
+
557
+ def _send(self, code: int, obj, *, headers: dict | None = None):
558
+ body = json.dumps(
559
+ obj, allow_nan=False, ensure_ascii=False,
560
+ separators=(",", ":")).encode("utf-8")
561
+ self.send_response(code)
562
+ self.send_header("Content-Type", "application/json; charset=utf-8")
563
+ self.send_header("Content-Length", str(len(body)))
564
+ self.send_header("Cache-Control", "no-store")
565
+ self.send_header("X-Content-Type-Options", "nosniff")
566
+ for name, value in (headers or {}).items():
567
+ self.send_header(name, value)
568
+ self.end_headers()
569
+ if self.command != "HEAD":
570
+ self.wfile.write(body)
571
+
572
+ def _send_error(
573
+ self, status: int, code: str, message: str, *,
574
+ field: str | None = None, headers: dict | None = None):
575
+ self._send(
576
+ status, _error_body(code, message, field), headers=headers)
577
+
578
+ def send_error(self, code, message=None, explain=None):
579
+ # BaseHTTPRequestHandler otherwise emits an HTML page containing
580
+ # its Python version. Keep even parser/fallback failures in the
581
+ # same non-cacheable JSON contract.
582
+ status = int(code)
583
+ error_code = "unsupported_method" if status == 501 else "http_error"
584
+ public = "unsupported HTTP method" if status == 501 else "HTTP request rejected"
585
+ self._send_error(status, error_code, public)
586
+
587
+ def _single_header(
588
+ self, name: str, *, required: bool = False) -> str | None:
589
+ values = self.headers.get_all(name) or []
590
+ if len(values) > 1:
591
+ raise RequestValidationError(
592
+ name, f"{name} must appear at most once")
593
+ if not values:
594
+ if required:
595
+ raise RequestValidationError(
596
+ name, f"{name} header is required",
597
+ code="missing_field")
598
+ return None
599
+ return values[0]
600
+
601
+ def _require_json_media_type(self):
602
+ media_type = (
603
+ self._single_header("Content-Type") or "").split(";", 1)[0]
604
+ if media_type.strip().lower() != "application/json":
605
+ raise UnsupportedMediaTypeError()
606
+
607
+ @staticmethod
608
+ def _parse_object(raw: bytes, *, field: str = "body") -> dict:
609
+ try:
610
+ parsed = strict_json_loads(raw)
611
+ except (ValueError, RecursionError):
612
+ raise RequestValidationError(
613
+ field, "request body must be valid unambiguous JSON",
614
+ code="invalid_json") from None
615
+ if not isinstance(parsed, dict):
616
+ raise RequestValidationError(
617
+ field, "JSON request body must be an object")
618
+ return parsed
619
+
620
+ def _body(self, *, parse_json: bool = True) -> dict | bytes:
621
+ if self.headers.get_all("Transfer-Encoding"):
622
+ self.close_connection = True
623
+ raise RequestValidationError(
624
+ "Transfer-Encoding",
625
+ "transfer encoding is unsupported; send Content-Length")
626
+ lengths = self.headers.get_all("Content-Length") or []
627
+ if len(lengths) != 1:
628
+ self.close_connection = True
629
+ raise RequestValidationError(
630
+ "Content-Length",
631
+ "POST requires exactly one Content-Length header")
632
+ encoded_length = lengths[0]
633
+ if re.fullmatch(r"(?:0|[1-9][0-9]*)", encoded_length) is None:
634
+ self.close_connection = True
635
+ raise RequestValidationError(
636
+ "Content-Length",
637
+ "Content-Length must be a canonical non-negative integer")
638
+ encoded_limit = str(max_body_bytes)
639
+ if (len(encoded_length) > len(encoded_limit)
640
+ or (len(encoded_length) == len(encoded_limit)
641
+ and encoded_length > encoded_limit)):
642
+ # Compare the canonical decimal strings before int(). Python
643
+ # deliberately rejects extremely long conversions; allowing
644
+ # that interpreter guard to fire turned caller input into a
645
+ # 500 instead of the documented request-size response.
646
+ self.close_connection = True
647
+ raise RequestTooLarge(max_body_bytes)
648
+ length = int(encoded_length)
649
+ if not length:
650
+ self._raw_request_body = b""
651
+ raise RequestValidationError(
652
+ "body", "request body must be a valid JSON object",
653
+ code="invalid_json")
654
+ raw = self.rfile.read(length)
655
+ if len(raw) != length:
656
+ raise RequestValidationError(
657
+ "body", "request body ended before Content-Length")
658
+ self._raw_request_body = raw
659
+ if not parse_json:
660
+ return raw
661
+ return self._parse_object(raw)
662
+
663
+ def _authorized(self) -> bool:
664
+ header = self._single_header("Authorization") or ""
665
+ scheme, _, supplied = header.partition(" ")
666
+ return (
667
+ scheme.lower() == "bearer"
668
+ and _MIN_SECRET_BYTES <= len(supplied) <= _MAX_SECRET_BYTES
669
+ and supplied.isascii()
670
+ and _BEARER_TOKEN.fullmatch(supplied) is not None
671
+ and hmac.compare_digest(supplied, api_token)
672
+ )
673
+
674
+ def _require_auth(self) -> bool:
675
+ try:
676
+ if self._authorized():
677
+ return True
678
+ except RequestValidationError as exc:
679
+ self._send_error(
680
+ 400, exc.code, exc.message, field=exc.field)
681
+ return False
682
+ self._send_error(
683
+ 401, "unauthorized", "valid bearer token required",
684
+ headers={"WWW-Authenticate": "Bearer"})
685
+ return False
686
+
687
+ @staticmethod
688
+ def _project(pid: str) -> str:
689
+ pid = _identifier(pid, "project_id")
690
+ if pid != project_id:
691
+ raise APIForbidden(
692
+ f"API is bound to project {project_id}, not {pid}")
693
+ return pid
694
+
695
+ @staticmethod
696
+ def _require_session_in_project(session_id: str | None):
697
+ """Validate a local session without probing outside API scope."""
698
+ if session_id is None:
699
+ return
700
+ session_id = _identifier(session_id, "session_id")
701
+ try:
702
+ engine.graph.get(
703
+ session_id, tenant_id=engine.tenant_id,
704
+ project_id=project_id, entity_type="session")
705
+ except KeyError:
706
+ raise RequestValidationError(
707
+ "session_id",
708
+ "session_id is not a session in the bound project") from None
709
+
710
+ @staticmethod
711
+ def _parse_request_target(raw_target: str):
712
+ message = (
713
+ "request target must be an origin-form path without an "
714
+ "authority or fragment")
715
+ if (not isinstance(raw_target, str)
716
+ or not raw_target.startswith("/")
717
+ or "#" in raw_target
718
+ or any(ord(char) < 0x20 or ord(char) == 0x7F
719
+ for char in raw_target)):
720
+ raise RequestValidationError(
721
+ "request_target", message)
722
+ try:
723
+ parsed = urlsplit(raw_target)
724
+ except ValueError:
725
+ raise RequestValidationError(
726
+ "request_target", message) from None
727
+ if (parsed.scheme or parsed.netloc or parsed.fragment
728
+ or not parsed.path.startswith("/")):
729
+ raise RequestValidationError(
730
+ "request_target", message)
731
+ return parsed
732
+
733
+ def _dispatch(self):
734
+ try:
735
+ target = self._parse_request_target(self.path)
736
+ self._parsed_request_target = target
737
+ path = target.path
738
+ matches = [
739
+ (route, match)
740
+ for route in API_ROUTES
741
+ if (match := route.pattern.fullmatch(path)) is not None
742
+ ]
743
+ if not matches:
744
+ return self._send_error(
745
+ 404, "not_found", "route not found")
746
+ method_matches = [
747
+ item for item in matches if item[0].method == self.command]
748
+ if not method_matches:
749
+ allow = ", ".join(sorted({
750
+ route.method for route, _ in matches}))
751
+ return self._send_error(
752
+ 405, "method_not_allowed",
753
+ "method is not allowed for this route",
754
+ headers={"Allow": allow})
755
+
756
+ route, match = method_matches[0]
757
+ signed_webhook = (
758
+ route.auth == "webhook" and webhook_secret is not None)
759
+ if (route.auth == "bearer"
760
+ or route.auth == "webhook" and not signed_webhook):
761
+ if not self._require_auth():
762
+ return None
763
+ if target.query and route.name != "assumptions":
764
+ raise RequestValidationError(
765
+ "query", "query parameters are not supported for this route",
766
+ code="unknown_field")
767
+ if route.method == "POST":
768
+ self._require_json_media_type()
769
+ body = self._body(parse_json=not signed_webhook)
770
+ return getattr(self, route.name)(body, *match.groups())
771
+ return getattr(self, route.name)(*match.groups())
772
+ except UnsupportedMediaTypeError as exc:
773
+ return self._send_error(
774
+ 415, exc.code, exc.message, field=exc.field)
775
+ except RequestTooLarge as exc:
776
+ return self._send_error(
777
+ 413, "request_too_large",
778
+ f"request body exceeds {exc.limit}-byte limit", field="body")
779
+ except RequestValidationError as exc:
780
+ return self._send_error(
781
+ 400, exc.code, exc.message, field=exc.field)
782
+ except APIResourceNotFound as exc:
783
+ return self._send_error(
784
+ 404, "not_found", f"{exc.resource} was not found")
785
+ except PayloadMismatchError:
786
+ return self._send_error(
787
+ 409, "idempotency_conflict",
788
+ "idempotency key was reused with different content")
789
+ except WebhookPayloadError as exc:
790
+ return self._send_error(
791
+ 400, "invalid_webhook_payload", str(exc), field="body")
792
+ except WebhookError as exc:
793
+ return self._send_error(
794
+ 422, "unsupported_webhook_event", str(exc), field="X-GitHub-Event")
795
+ except CapsuleError as exc:
796
+ return self._send_error(
797
+ 400, "invalid_capsule", str(exc), field="capsule")
798
+ except AttestationInputError as exc:
799
+ return self._send_error(
800
+ 400, "invalid_attestation", str(exc), field="attestation")
801
+ except ResolutionInputError as exc:
802
+ return self._send_error(
803
+ 400, "invalid_resolution", str(exc), field="resolution")
804
+ except (APIForbidden, GitHubDeliveryError):
805
+ return self._send_error(
806
+ 403, "forbidden", "request is outside the authorized project scope")
807
+ except Exception:
808
+ return self._send_error(
809
+ 500, "internal_error", "internal server error")
810
+
811
+ def do_GET(self):
812
+ return self._dispatch()
813
+
814
+ def do_POST(self):
815
+ return self._dispatch()
816
+
817
+ def do_HEAD(self):
818
+ return self._dispatch()
819
+
820
+ def do_PUT(self):
821
+ return self._dispatch()
822
+
823
+ def do_PATCH(self):
824
+ return self._dispatch()
825
+
826
+ def do_DELETE(self):
827
+ return self._dispatch()
828
+
829
+ def do_OPTIONS(self):
830
+ return self._dispatch()
831
+
832
+ def do_TRACE(self):
833
+ return self._dispatch()
834
+
835
+ def do_CONNECT(self):
836
+ return self._dispatch()
837
+
838
+ # ------------------------------------------------------------- GET
839
+
840
+ def health(self):
841
+ self._send(200, {"status": "ok"})
842
+
843
+ def assumptions(self, pid):
844
+ pid = self._project(pid)
845
+ try:
846
+ query = parse_qs(
847
+ self._parsed_request_target.query,
848
+ keep_blank_values=True, strict_parsing=True,
849
+ max_num_fields=2, separator="&")
850
+ except ValueError:
851
+ raise RequestValidationError(
852
+ "query", "query string is malformed") from None
853
+ unknown = sorted(set(query) - {"status"})
854
+ if unknown:
855
+ raise RequestValidationError(
856
+ "query", f"unknown query parameter(s): {', '.join(unknown)}",
857
+ code="unknown_field")
858
+ statuses = None
859
+ if "status" in query:
860
+ if len(query["status"]) != 1:
861
+ raise RequestValidationError(
862
+ "status", "status may be supplied only once")
863
+ statuses = query["status"][0].split(",")
864
+ if (not statuses or any(not status for status in statuses)
865
+ or len(statuses) != len(set(statuses))
866
+ or any(status not in _ASSUMPTION_STATUSES
867
+ for status in statuses)):
868
+ raise RequestValidationError(
869
+ "status", "status must be a comma-separated list of: "
870
+ + ", ".join(sorted(_ASSUMPTION_STATUSES)))
871
+ nodes = engine.graph.current(
872
+ pid, "assumption", status=statuses,
873
+ tenant_id=engine.tenant_id)
874
+ self._send(200, [
875
+ {"node_id": n["node_id"], "status": n["status"],
876
+ "criticality": n["criticality"], "confidence": n["confidence"],
877
+ "statement": n["data"].get("statement")} for n in nodes])
878
+
879
+ def invalidations(self, pid):
880
+ pid = self._project(pid)
881
+ invs = engine.invalidation.open_invalidations(pid)
882
+ self._send(200, [
883
+ {"node_id": i["node_id"], "status": i["status"], **i["data"]}
884
+ for i in invs])
885
+
886
+ def evaluations(self):
887
+ evals = engine.graph.current(
888
+ project_id, "evaluation", tenant_id=engine.tenant_id)
889
+ self._send(200, [
890
+ {"node_id": e["node_id"], "status": e["status"],
891
+ "kind": e["data"].get("kind")} for e in evals])
892
+
893
+ # ------------------------------------------------------------ POST
894
+
895
+ @staticmethod
896
+ def _validate_ping(payload: dict):
897
+ # GitHub's ping schema has repository for repository-scoped hooks,
898
+ # but does not declare installation. Require the immutable repo id;
899
+ # if an installation is present, bind it too. Operational events
900
+ # still require an installation whenever project policy pins one.
901
+ _required_object(payload, "hook")
902
+ _required_int(payload, "hook_id", minimum=1)
903
+ _required_string(payload, "zen")
904
+ repository = _required_object(payload, "repository")
905
+ delivered_id = _required_int(repository, "id", minimum=1)
906
+ try:
907
+ project = engine.graph.get(
908
+ project_id, tenant_id=engine.tenant_id,
909
+ project_id=project_id, entity_type="project")
910
+ except KeyError:
911
+ raise APIForbidden("bound project does not exist") from None
912
+ configured_id = project["data"].get("repository_id")
913
+ if (isinstance(configured_id, bool)
914
+ or not isinstance(configured_id, int)
915
+ or configured_id <= 0 or delivered_id != configured_id):
916
+ raise APIForbidden("ping repository does not match the bound project")
917
+ installation = payload.get("installation")
918
+ configured_installation = project["data"].get(
919
+ "github_installation_id")
920
+ if (configured_installation is not None
921
+ and (isinstance(configured_installation, bool)
922
+ or not isinstance(configured_installation, int)
923
+ or configured_installation <= 0)):
924
+ raise APIForbidden(
925
+ "bound project has an invalid installation identity")
926
+ if installation is not None:
927
+ if not isinstance(installation, dict):
928
+ raise RequestValidationError(
929
+ "installation", "installation must be a JSON object")
930
+ delivered_installation = _required_int(
931
+ installation, "id", minimum=1)
932
+ if (configured_installation is not None
933
+ and delivered_installation != configured_installation):
934
+ raise APIForbidden(
935
+ "ping installation does not match the bound project")
936
+
937
+ def events_ingest(self, body):
938
+ if webhook_secret is not None:
939
+ raw = body
940
+ signature = self._single_header("X-Hub-Signature-256")
941
+ if (signature is None or not verify_signature(
942
+ webhook_secret, raw, signature)):
943
+ raise APIForbidden("GitHub webhook signature invalid")
944
+ event_name = self._single_header(
945
+ "X-GitHub-Event", required=True)
946
+ delivery_id = self._single_header(
947
+ "X-GitHub-Delivery", required=True)
948
+ if (_GITHUB_EVENT_HEADER.fullmatch(event_name or "") is None):
949
+ raise RequestValidationError(
950
+ "X-GitHub-Event",
951
+ "X-GitHub-Event must be a bounded lowercase event name")
952
+ try:
953
+ validate_public_identifier(
954
+ delivery_id, field="X-GitHub-Delivery")
955
+ except ValueError as exc:
956
+ raise RequestValidationError(
957
+ "X-GitHub-Delivery", str(exc)) from None
958
+ payload = self._parse_object(raw)
959
+ pid = project_id
960
+ if event_name == "ping":
961
+ self._validate_ping(payload)
962
+ return self._send(200, {"status": "ok", "event": "ping"})
963
+ else:
964
+ # Bearer-authenticated embedding compatibility. The shipped
965
+ # server always configures a webhook secret.
966
+ _reject_unknown(
967
+ body, {"project_id", "event_name", "delivery_id", "payload"})
968
+ pid = self._project(
969
+ _optional_string(body, "project_id", project_id))
970
+ event_name = _required_string(body, "event_name")
971
+ delivery_id = _required_identifier(body, "delivery_id")
972
+ payload = _required_object(body, "payload")
973
+ signed_arguments = (
974
+ {
975
+ "raw_body": raw,
976
+ "signature_header": signature,
977
+ "webhook_secret": webhook_secret,
978
+ }
979
+ if webhook_secret is not None else {}
980
+ )
981
+ report = engine.ingest_github(
982
+ pid, event_name, delivery_id, payload, **signed_arguments)
983
+ self._send(202, report or {"status": "duplicate"})
984
+
985
+ def traces_ingest(self, body):
986
+ _reject_unknown(body, {"project_id", "session_id", "span_id", "payload"})
987
+ pid = self._project(_optional_identifier(
988
+ body, "project_id", project_id))
989
+ session_id = _optional_identifier(
990
+ body, "session_id", allow_none=True)
991
+ span_id = _required_identifier(body, "span_id")
992
+ payload = _optional_object(body, "payload", {})
993
+ if "message" in payload and not isinstance(payload["message"], str):
994
+ raise RequestValidationError(
995
+ "payload.message", "payload.message must be a string")
996
+ self._require_session_in_project(session_id)
997
+ report = engine.ingest_agent_trace(
998
+ pid, session_id=session_id, span_id=span_id, payload=payload)
999
+ self._send(202, report or {"status": "duplicate"})
1000
+
1001
+ def compose(self, body, pid):
1002
+ _reject_unknown(body, {"token_budget", "target"})
1003
+ pid = self._project(pid)
1004
+ budget = _optional_int(
1005
+ body, "token_budget", 4000,
1006
+ minimum=1, maximum=_MAX_TOKEN_BUDGET)
1007
+ target = _optional_object(body, "target")
1008
+ self._send(200, engine.resume_packet(
1009
+ pid, target=target, token_budget=budget))
1010
+
1011
+ def resolve(self, body, assumption_id):
1012
+ assumption_id = _identifier(assumption_id, "assumption_id")
1013
+ try:
1014
+ node = engine.graph.get(
1015
+ assumption_id, tenant_id=engine.tenant_id,
1016
+ project_id=project_id)
1017
+ except KeyError:
1018
+ raise APIResourceNotFound("assumption") from None
1019
+ if node["project_id"] != project_id:
1020
+ raise APIForbidden("assumption belongs to another project")
1021
+ if node["entity_type"] not in ("assumption", "claim", "requirement",
1022
+ "constraint", "decision"):
1023
+ raise RequestValidationError(
1024
+ "assumption_id",
1025
+ f"cannot resolve a {node['entity_type']} through this endpoint",
1026
+ code="invalid_resource_type")
1027
+ if "invalidation_id" in body:
1028
+ _reject_unknown(body, {
1029
+ "invalidation_id", "mode", "actor", "replacement_node_id",
1030
+ "narrowed_scope", "note",
1031
+ })
1032
+ inv_id = _required_identifier(body, "invalidation_id")
1033
+ mode = _optional_string(
1034
+ body, "mode", "replacement_evidence")
1035
+ if mode not in {
1036
+ "replacement_evidence", "narrowed_scope",
1037
+ "superseding_decision"}:
1038
+ raise RequestValidationError(
1039
+ "mode", "mode must be replacement_evidence, "
1040
+ "narrowed_scope, or superseding_decision")
1041
+ actor = _optional_string(body, "actor", "api")
1042
+ replacement = _optional_identifier(
1043
+ body, "replacement_node_id", allow_none=True)
1044
+ narrowed_scope = _optional_object(
1045
+ body, "narrowed_scope", allow_none=True)
1046
+ note = _optional_string(
1047
+ body, "note", "", allow_empty=True)
1048
+ try:
1049
+ invalidation = engine.graph.get(
1050
+ inv_id, tenant_id=engine.tenant_id,
1051
+ project_id=project_id, entity_type="invalidation")
1052
+ except KeyError:
1053
+ raise APIResourceNotFound("invalidation") from None
1054
+ affected_ids = {
1055
+ affected.get("node_id")
1056
+ for affected in invalidation["data"].get("affected", [])
1057
+ if isinstance(affected, dict)
1058
+ }
1059
+ if (invalidation["data"].get("target_node_id") != assumption_id
1060
+ and assumption_id not in affected_ids):
1061
+ raise RequestValidationError(
1062
+ "invalidation_id",
1063
+ "invalidation_id does not target or affect assumption_id",
1064
+ code="resource_mismatch")
1065
+ if replacement is not None:
1066
+ try:
1067
+ engine.graph.get(
1068
+ replacement, tenant_id=engine.tenant_id,
1069
+ project_id=project_id)
1070
+ except KeyError:
1071
+ raise APIResourceNotFound("replacement node") from None
1072
+ out = engine.invalidation.resolve(
1073
+ inv_id, mode=mode, actor=actor,
1074
+ replacement_node_id=replacement,
1075
+ narrowed_scope=narrowed_scope, note=note)
1076
+ return self._send(200, {"invalidation": out["node_id"],
1077
+ "status": out["status"]})
1078
+ _reject_unknown(body, {"action", "data"})
1079
+ # Scope the mutation. Without these two checks the endpoint
1080
+ # rewrites any node of any type in any project — a task, a proof
1081
+ # record, a project — because it reads the target's own
1082
+ # entity_type and never compares its project (ADR-052).
1083
+ action = _optional_string(body, "action", "accept")
1084
+ statuses = {"accept": "resolved", "reject": "invalidated",
1085
+ "narrow": "active", "supersede": "superseded"}
1086
+ if action not in statuses:
1087
+ raise RequestValidationError(
1088
+ "action",
1089
+ "action must be accept, reject, narrow, or supersede")
1090
+ status = statuses[action]
1091
+ patch = _optional_object(body, "data")
1092
+ data = dict(node["data"])
1093
+ if patch is not None:
1094
+ data.update(patch)
1095
+ out = engine.graph.put_node(
1096
+ entity_type=node["entity_type"], tenant_id=node["tenant_id"],
1097
+ project_id=node["project_id"], node_id=assumption_id,
1098
+ data=data, status=status,
1099
+ authority="human_decision")
1100
+ self._send(200, {"node_id": out["node_id"], "status": out["status"]})
1101
+
1102
+ def attest(self, body):
1103
+ _reject_unknown(body, {
1104
+ "project_id", "intent_type", "intent_statement", "actor",
1105
+ "action_type", "verifications", "continuity",
1106
+ })
1107
+ pid = self._project(
1108
+ _optional_identifier(body, "project_id", project_id))
1109
+ intent_type = _required_string(body, "intent_type")
1110
+ intent_statement = _optional_string(
1111
+ body, "intent_statement", "", allow_empty=True)
1112
+ actor = _optional_object(body, "actor", {"agent": "api"})
1113
+ action_type = _optional_string(
1114
+ body, "action_type", "run_verifier")
1115
+ verifications = _validate_verifications(body)
1116
+ continuity = _validate_continuity(body)
1117
+ proof = engine.attest_action(
1118
+ pid, intent_type=intent_type,
1119
+ intent_statement=intent_statement, actor=actor,
1120
+ action_type=action_type,
1121
+ verification_outcomes=verifications,
1122
+ continuity=continuity)
1123
+ self._send(200, proof)
1124
+
1125
+ def verifications_run(self, body):
1126
+ if "verifiers" in body or "command" in body:
1127
+ raise RequestValidationError(
1128
+ "verifiers",
1129
+ "caller-supplied verifier definitions are forbidden; "
1130
+ "configure pinned required_verifiers in project policy",
1131
+ code="caller_verifier_forbidden")
1132
+ _reject_unknown(body, {
1133
+ "project_id", "intent_type", "intent_statement", "continuity",
1134
+ })
1135
+ pid = self._project(
1136
+ _optional_identifier(body, "project_id", project_id))
1137
+ intent_type = _optional_string(
1138
+ body, "intent_type", "verification_run")
1139
+ intent_statement = _optional_string(
1140
+ body, "intent_statement", "run policy-owned verifiers",
1141
+ allow_empty=True)
1142
+ continuity = _validate_continuity(body)
1143
+ proof = engine.attest_action(
1144
+ pid, intent_type=intent_type,
1145
+ intent_statement=intent_statement,
1146
+ actor={"agent": "api", "model": "n/a"},
1147
+ action_type="run_verifier",
1148
+ continuity=continuity)
1149
+ self._send(200, proof)
1150
+
1151
+ def continuity_receipt_verify(self, body, pid):
1152
+ _reject_unknown(body, {"receipt"})
1153
+ pid = self._project(pid)
1154
+ receipt = _required_object(body, "receipt")
1155
+ self._send(
1156
+ 200, engine.verify_continuity_receipt(pid, receipt))
1157
+
1158
+ def migrations_prepare(self, body):
1159
+ _reject_unknown(body, {
1160
+ "project_id", "session_id", "source_model",
1161
+ "source_runtime", "target_adapter",
1162
+ })
1163
+ pid = self._project(
1164
+ _optional_identifier(body, "project_id", project_id))
1165
+ session_id = _optional_identifier(
1166
+ body, "session_id", allow_none=True)
1167
+ source_model = _optional_string(body, "source_model", "unknown")
1168
+ source_runtime = _optional_string(
1169
+ body, "source_runtime", "unknown")
1170
+ target_adapter = _optional_string(
1171
+ body, "target_adapter", "generic")
1172
+ self._require_session_in_project(session_id)
1173
+ capsule = engine.capsules.export(
1174
+ tenant_id=engine.tenant_id,
1175
+ project_id=pid, session_id=session_id,
1176
+ source_model=source_model,
1177
+ source_runtime=source_runtime,
1178
+ target_adapter=target_adapter,
1179
+ signer=engine.signer)
1180
+ self._send(200, capsule)
1181
+
1182
+ def migrations_validate(self, body):
1183
+ _reject_unknown(body, {"capsule", "target_model", "target_runtime"})
1184
+ capsule = _required_object(body, "capsule")
1185
+ capsule_tenant = _identifier(
1186
+ _required_string(capsule, "tenant_id"), "capsule.tenant_id")
1187
+ capsule_project = _identifier(
1188
+ _required_string(capsule, "project_id"), "capsule.project_id")
1189
+ target_model = _optional_string(body, "target_model", "generic")
1190
+ target_runtime = _optional_string(
1191
+ body, "target_runtime", "generic")
1192
+ if capsule_tenant != engine.tenant_id:
1193
+ raise APIForbidden("migration capsule belongs to another tenant")
1194
+ self._project(capsule_project)
1195
+ result = engine.capsules.import_capsule(
1196
+ capsule, signer=engine.signer,
1197
+ target_model=target_model,
1198
+ target_runtime=target_runtime,
1199
+ expected_tenant_id=engine.tenant_id,
1200
+ expected_project_id=project_id)
1201
+ self._send(200, {"session_id": result["session"]["node_id"],
1202
+ "challenge": result["challenge"],
1203
+ "validation": result["validation"]})
1204
+
1205
+ def replays(self, body):
1206
+ _reject_unknown(body, {
1207
+ "project_id", "from_event_id", "captured_inputs", "mocks", "fork",
1208
+ })
1209
+ pid = self._project(
1210
+ _optional_identifier(body, "project_id", project_id))
1211
+ from_event_id = _required_identifier(body, "from_event_id")
1212
+ captured_inputs = _optional_object(
1213
+ body, "captured_inputs", allow_none=True)
1214
+ mocks = _optional_object(body, "mocks", allow_none=True)
1215
+ fork = _optional_object(body, "fork", allow_none=True)
1216
+ try:
1217
+ engine.store.get_event(
1218
+ from_event_id, tenant_id=engine.tenant_id, project_id=pid)
1219
+ except KeyError:
1220
+ raise APIForbidden(
1221
+ "replay origin is outside the authorized project scope") from None
1222
+ node = engine.replay.start(
1223
+ tenant_id=engine.tenant_id,
1224
+ project_id=pid, from_event_id=from_event_id,
1225
+ captured_inputs=captured_inputs,
1226
+ mocks=mocks, fork=fork)
1227
+ self._send(201, {"replay_node": node["node_id"],
1228
+ "fidelity": node["data"]["fidelity"]})
1229
+
1230
+ return Handler
1231
+
1232
+
1233
+ def serve(
1234
+ engine,
1235
+ project_id: str,
1236
+ port: int = 8199,
1237
+ *,
1238
+ api_token: str,
1239
+ webhook_secret: bytes | str,
1240
+ max_body_bytes: int = _MAX_BODY_BYTES,
1241
+ max_workers: int = _DEFAULT_MAX_WORKERS,
1242
+ request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS,
1243
+ ):
1244
+ project_id = _validated_project_id(project_id)
1245
+ port = _bounded_int("port", port, 1, 65_535)
1246
+ api_token = _validated_api_token(api_token)
1247
+ webhook_secret = _validated_webhook_secret(webhook_secret, required=True)
1248
+ max_body_bytes = _bounded_int(
1249
+ "max_body_bytes", max_body_bytes, 1, _MAX_CONFIGURED_BODY_BYTES)
1250
+ max_workers = _bounded_int("max_workers", max_workers, 1, _MAX_WORKERS)
1251
+ request_timeout_seconds = _validated_timeout(request_timeout_seconds)
1252
+ server = BoundedThreadingHTTPServer(
1253
+ ("127.0.0.1", port),
1254
+ make_handler(
1255
+ engine, project_id, api_token=api_token,
1256
+ webhook_secret=webhook_secret, max_body_bytes=max_body_bytes,
1257
+ request_timeout_seconds=request_timeout_seconds),
1258
+ max_workers=max_workers)
1259
+ try:
1260
+ server.serve_forever()
1261
+ except KeyboardInterrupt:
1262
+ pass
1263
+ finally:
1264
+ server.server_close()