gateway.py 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226
  1. """Application gateway used by all script-build Agent tools.
  2. Agent-visible functions never receive a build, root, task, attempt, or
  3. validation identity from model arguments. Those values are resolved from the
  4. framework's protected context and then checked against durable bindings.
  5. """
  6. from __future__ import annotations
  7. import re
  8. from collections.abc import Awaitable, Callable, Mapping, Sequence
  9. from dataclasses import asdict, dataclass
  10. from datetime import UTC, datetime
  11. from hashlib import sha256
  12. from typing import Any, Protocol, cast
  13. from unicodedata import normalize
  14. from uuid import uuid4
  15. from agent.orchestration import (
  16. ArtifactRef,
  17. AttemptSubmission,
  18. CriterionResult,
  19. EvidenceQuery,
  20. ValidationVerdict,
  21. )
  22. from script_build_host.agents.validation import (
  23. PHASE_TWO_KINDS,
  24. RETRIEVAL_KINDS,
  25. ROOT_DELIVERY_KIND,
  26. precheck_business_artifact,
  27. precheck_snapshot,
  28. task_kind,
  29. )
  30. from script_build_host.domain.artifacts import (
  31. ArtifactKind,
  32. DirectionArtifact,
  33. DirectionConstraint,
  34. DirectionGoal,
  35. DirectionPreference,
  36. EvidenceRecordV1,
  37. )
  38. from script_build_host.domain.errors import ArtifactNotFound, ProtocolViolation, ScriptBuildError
  39. from script_build_host.domain.ports import (
  40. InputSnapshotRepository,
  41. MissionBindingRepository,
  42. ScriptBusinessArtifactRepository,
  43. )
  44. from script_build_host.domain.task_contracts import stable_semantic_id
  45. from script_build_host.domain.workbench import (
  46. protect_model_value,
  47. semantic_handle,
  48. strategy_handle,
  49. strategy_ref,
  50. )
  51. from script_build_host.infrastructure.redaction import redact, redact_text
  52. from .contracts import RootDeliveryToolPort, ScriptCandidateToolPort, ScriptPlannerToolPort
  53. _DEFECT_SEVERITIES = frozenset({"warning", "hard", "critical"})
  54. _DEFECT_ACTIONS = frozenset(
  55. {"repair", "retry", "revise", "split", "retrieve", "replace", "compare", "block"}
  56. )
  57. _BUSINESS_ARTIFACT_KINDS = frozenset(item.value for item in ArtifactKind)
  58. _DEFECT_CODE = re.compile(r"^[A-Z][A-Z0-9_]{0,63}$")
  59. @dataclass(frozen=True, slots=True)
  60. class RetrievalResult:
  61. source_refs: tuple[str, ...]
  62. summary: str
  63. supports: tuple[str, ...] = ()
  64. confidence: str = "unknown"
  65. limitations: tuple[str, ...] = ()
  66. raw_artifact_ref: str | None = None
  67. metadata: Mapping[str, Any] | None = None
  68. class RetrievalAdapter(Protocol):
  69. async def retrieve(
  70. self,
  71. *,
  72. query: Mapping[str, Any],
  73. snapshot: Any,
  74. ) -> RetrievalResult: ...
  75. class DispatchGuard(Protocol):
  76. async def ensure_dispatch_allowed(self, script_build_id: int) -> None: ...
  77. async def execute_fenced(
  78. self, script_build_id: int, mutation: Callable[[], Awaitable[Any]]
  79. ) -> Any: ...
  80. class PhaseTwoBudgetGuard(Protocol):
  81. async def ensure_tool_budget(self, *, root_trace_id: str, task_id: str, entry: str) -> None: ...
  82. class LegacyScriptToolGateway:
  83. """Translate protected Agent tool calls to typed domain operations."""
  84. def __init__(
  85. self,
  86. *,
  87. bindings: MissionBindingRepository,
  88. snapshots: InputSnapshotRepository,
  89. artifacts: ScriptBusinessArtifactRepository,
  90. coordinator: Any,
  91. retrieval_adapters: Mapping[str, RetrievalAdapter] | None = None,
  92. dispatch_guard: DispatchGuard | None = None,
  93. planner_tools: ScriptPlannerToolPort | None = None,
  94. candidate_tools: ScriptCandidateToolPort | None = None,
  95. budget_guard: PhaseTwoBudgetGuard | None = None,
  96. root_tools: RootDeliveryToolPort | None = None,
  97. workbench: Any | None = None,
  98. ) -> None:
  99. self.bindings = bindings
  100. self.snapshots = snapshots
  101. self.artifacts = artifacts
  102. self.coordinator = coordinator
  103. self.retrieval_adapters = dict(retrieval_adapters or {})
  104. self.dispatch_guard = dispatch_guard
  105. self.planner_tools = planner_tools
  106. self.candidate_tools = candidate_tools
  107. self.budget_guard = budget_guard
  108. self.root_tools = root_tools
  109. self.workbench = workbench
  110. self._active_retrieval_attempts: set[tuple[str, str, str]] = set()
  111. async def ensure_dispatch_allowed(self, context: Mapping[str, Any]) -> None:
  112. binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
  113. if self.dispatch_guard is not None:
  114. await self.dispatch_guard.ensure_dispatch_allowed(binding.script_build_id)
  115. async def _execute_fenced(
  116. self, context: Mapping[str, Any], mutation: Callable[[], Awaitable[Any]]
  117. ) -> Any:
  118. binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
  119. if self.dispatch_guard is None:
  120. return await mutation()
  121. return await self.dispatch_guard.execute_fenced(binding.script_build_id, mutation)
  122. async def plan_script_tasks(
  123. self,
  124. *,
  125. contracts: Sequence[Mapping[str, Any]],
  126. parent_task_id: str | None,
  127. context: Mapping[str, Any],
  128. ) -> Mapping[str, Any]:
  129. return cast(
  130. Mapping[str, Any],
  131. await self._execute_fenced(
  132. context,
  133. lambda: self._planner().plan_script_tasks(
  134. contract_payloads=contracts,
  135. parent_task_id=parent_task_id,
  136. context=context,
  137. ),
  138. ),
  139. )
  140. async def decide_script_task(
  141. self,
  142. *,
  143. task_id: str,
  144. action: str,
  145. reason: str,
  146. validation_id: str | None,
  147. replacement_contract: Mapping[str, Any] | None,
  148. child_contracts: Sequence[Mapping[str, Any]],
  149. selected_decision_ids: Sequence[str],
  150. context: Mapping[str, Any],
  151. ) -> Mapping[str, Any]:
  152. return cast(
  153. Mapping[str, Any],
  154. await self._execute_fenced(
  155. context,
  156. lambda: self._planner().decide_script_task(
  157. task_id=task_id,
  158. action=action,
  159. reason=reason,
  160. validation_id=validation_id,
  161. replacement_contract=replacement_contract,
  162. child_contracts=child_contracts,
  163. selected_decision_ids=selected_decision_ids,
  164. context=context,
  165. ),
  166. ),
  167. )
  168. async def inspect_script_plan(self, *, context: Mapping[str, Any]) -> Mapping[str, Any]:
  169. return await self._planner().inspect_script_plan(context=context)
  170. async def search_mission_context(
  171. self,
  172. *,
  173. collection: str,
  174. query: str,
  175. goal_ids: Sequence[str],
  176. task_kinds: Sequence[str],
  177. source_types: Sequence[str],
  178. known_revision: str | None,
  179. cursor: str | None,
  180. page_size: int,
  181. context: Mapping[str, Any],
  182. ) -> Mapping[str, Any]:
  183. if self.workbench is None:
  184. raise ProtocolViolation("Context Broker is not configured")
  185. return cast(
  186. Mapping[str, Any],
  187. await self.workbench.search_mission_context(
  188. root_trace_id=_required(context, "root_trace_id"),
  189. role=str(context.get("role") or "agent"),
  190. task_id=cast(str | None, context.get("task_id")),
  191. collection=collection,
  192. query=query,
  193. goal_ids=goal_ids,
  194. task_kinds=task_kinds,
  195. source_types=source_types,
  196. known_revision=known_revision,
  197. cursor=cursor,
  198. page_size=page_size,
  199. ),
  200. )
  201. async def read_mission_context(
  202. self,
  203. *,
  204. handle: str,
  205. section: str,
  206. cursor: str | None,
  207. context: Mapping[str, Any],
  208. ) -> Mapping[str, Any]:
  209. if self.workbench is None:
  210. raise ProtocolViolation("Context Broker is not configured")
  211. return cast(
  212. Mapping[str, Any],
  213. await self.workbench.read_mission_context(
  214. root_trace_id=_required(context, "root_trace_id"),
  215. role=str(context.get("role") or "agent"),
  216. task_id=cast(str | None, context.get("task_id")),
  217. attempt_id=cast(str | None, context.get("attempt_id")),
  218. handle=handle,
  219. section=section,
  220. cursor=cursor,
  221. ),
  222. )
  223. async def dispatch_script_tasks(
  224. self, *, task_ids: Sequence[str], context: Mapping[str, Any]
  225. ) -> Sequence[Mapping[str, Any]]:
  226. # Dispatch waits for the durable Operation (Worker + Validator) to
  227. # finish. Holding the binding row lock across that wait deadlocks the
  228. # child submissions, which must independently pass the same fence.
  229. # The planning service performs a short owner/fence verification
  230. # immediately before it reserves the Operation.
  231. return await self._planner().dispatch_script_tasks(task_ids=task_ids, context=context)
  232. async def read_accepted_portfolio(self, context: Mapping[str, Any]) -> Mapping[str, Any]:
  233. return await self._root_tools().read_accepted_portfolio(context=context)
  234. async def legacy_projection_dry_run(
  235. self, build_summary: str, context: Mapping[str, Any]
  236. ) -> Mapping[str, Any]:
  237. return await self._root_tools().legacy_projection_dry_run(
  238. build_summary=build_summary, context=context
  239. )
  240. async def save_root_delivery_manifest(
  241. self,
  242. *,
  243. build_summary: str,
  244. blocking_defects: Sequence[str],
  245. context: Mapping[str, Any],
  246. ) -> Mapping[str, Any]:
  247. return cast(
  248. Mapping[str, Any],
  249. await self._execute_fenced(
  250. context,
  251. lambda: self._root_tools().save_root_delivery_manifest(
  252. build_summary=build_summary,
  253. blocking_defects=blocking_defects,
  254. context=context,
  255. ),
  256. ),
  257. )
  258. async def read_accepted_artifacts(self, context: Mapping[str, Any]) -> list[dict[str, Any]]:
  259. binding, _ = await self._scope(context)
  260. ledger = await self.coordinator.task_store.load(binding.root_trace_id)
  261. attempt_id = _required(context, "attempt_id")
  262. attempt = ledger.attempts.get(attempt_id)
  263. if attempt is None or attempt.task_id != _required(context, "task_id"):
  264. raise ProtocolViolation("attempt is outside the protected task context")
  265. decision_ids = attempt.accepted_child_decision_ids
  266. if decision_ids is None:
  267. raise ProtocolViolation("attempt has no frozen child decision binding")
  268. values: list[dict[str, Any]] = []
  269. for decision_id in decision_ids:
  270. decision = ledger.decisions.get(decision_id)
  271. if (
  272. decision is None
  273. or decision.action.value != "accept"
  274. or not decision.attempt_id
  275. or not decision.validation_id
  276. ):
  277. raise ProtocolViolation("accepted child decision binding is invalid")
  278. child_attempt = ledger.attempts[decision.attempt_id]
  279. child_task = ledger.tasks[child_attempt.task_id]
  280. validation = ledger.validations.get(decision.validation_id)
  281. child_kind = task_kind(child_task.current_spec.context_refs)
  282. if (
  283. child_task.parent_task_id != attempt.task_id
  284. or child_kind not in RETRIEVAL_KINDS
  285. or decision.task_id != child_task.task_id
  286. or validation is None
  287. or validation.attempt_id != child_attempt.attempt_id
  288. or validation.verdict is None
  289. or validation.verdict.value != "passed"
  290. or child_attempt.snapshot_id != validation.snapshot_id
  291. ):
  292. raise ProtocolViolation(
  293. "accepted decision is not closed over a passed direct-child snapshot"
  294. )
  295. if child_attempt.submission is None:
  296. raise ProtocolViolation("accepted child attempt has no submission")
  297. refs = [
  298. *child_attempt.submission.artifact_refs,
  299. *child_attempt.submission.evidence_refs,
  300. ]
  301. for ref in refs:
  302. if ref.kind != ArtifactKind.EVIDENCE.value:
  303. raise ProtocolViolation(
  304. "direction child decisions may expose only evidence artifacts"
  305. )
  306. version = await self.artifacts.read_by_ref(
  307. ref,
  308. script_build_id=binding.script_build_id,
  309. task_id=child_attempt.task_id,
  310. attempt_id=child_attempt.attempt_id,
  311. )
  312. values.append(
  313. {
  314. "decision_id": decision_id,
  315. "task_id": child_attempt.task_id,
  316. "attempt_id": child_attempt.attempt_id,
  317. "artifact_ref": _ref_dict(ref),
  318. "artifact": _json_values(asdict(version.artifact)),
  319. }
  320. )
  321. return values
  322. async def retrieve(
  323. self,
  324. source_type: str,
  325. tool_name: str,
  326. query: Mapping[str, Any],
  327. context: Mapping[str, Any],
  328. ) -> dict[str, Any]:
  329. await self.ensure_dispatch_allowed(context)
  330. if self.budget_guard is not None:
  331. await self.budget_guard.ensure_tool_budget(
  332. root_trace_id=_required(context, "root_trace_id"),
  333. task_id=_required(context, "task_id"),
  334. entry="retrieval",
  335. )
  336. root_trace_id = _required(context, "root_trace_id")
  337. task_id = _required(context, "task_id")
  338. attempt_id = _required(context, "attempt_id")
  339. retrieval_key = (root_trace_id, task_id, attempt_id)
  340. if retrieval_key in self._active_retrieval_attempts:
  341. raise ProtocolViolation("one Retrieval Attempt may execute only one external query")
  342. binding = await self.bindings.get_by_root(root_trace_id)
  343. try:
  344. await self.artifacts.get_by_attempt(
  345. script_build_id=binding.script_build_id,
  346. task_id=task_id,
  347. attempt_id=attempt_id,
  348. )
  349. except ArtifactNotFound:
  350. pass
  351. else:
  352. raise ProtocolViolation("one Retrieval Attempt may freeze only one Evidence artifact")
  353. self._active_retrieval_attempts.add(retrieval_key)
  354. try:
  355. return await self._retrieve_once(source_type, tool_name, query, context)
  356. finally:
  357. self._active_retrieval_attempts.discard(retrieval_key)
  358. async def _retrieve_once(
  359. self,
  360. source_type: str,
  361. tool_name: str,
  362. query: Mapping[str, Any],
  363. context: Mapping[str, Any],
  364. ) -> dict[str, Any]:
  365. binding, snapshot = await self._scope(context)
  366. if source_type == "decode" and query.get("account_name"):
  367. frozen_accounts = {
  368. str(snapshot.account.get(key) or "")
  369. for key in ("account_name", "resolved_account_name")
  370. }
  371. if query["account_name"] not in frozen_accounts:
  372. raise ProtocolViolation("decode account_name is outside the frozen account scope")
  373. adapter = self.retrieval_adapters.get(source_type)
  374. if adapter is None:
  375. raise ProtocolViolation(f"retrieval adapter is not configured: {source_type}")
  376. result = await adapter.retrieve(query=dict(query), snapshot=snapshot)
  377. frozen_query = redact(dict(query))
  378. if not isinstance(frozen_query, dict):
  379. raise ProtocolViolation("redacted retrieval query must remain an object")
  380. if result.metadata:
  381. safe_metadata = redact(dict(result.metadata))
  382. if not isinstance(safe_metadata, dict):
  383. raise ProtocolViolation("redacted retrieval metadata must remain an object")
  384. frozen_query["_retrieval_metadata"] = safe_metadata
  385. record = EvidenceRecordV1(
  386. evidence_id=str(uuid4()),
  387. source_type=source_type,
  388. tool_name=tool_name,
  389. query=frozen_query,
  390. source_refs=_safe_text_tuple(result.source_refs),
  391. raw_artifact_ref=(
  392. redact_text(result.raw_artifact_ref) if result.raw_artifact_ref else None
  393. ),
  394. summary=redact_text(result.summary),
  395. supports=_safe_text_tuple(result.supports),
  396. confidence=redact_text(result.confidence),
  397. limitations=_safe_text_tuple(result.limitations),
  398. content_sha256="",
  399. created_at=datetime.now(UTC),
  400. )
  401. version, ref = await self.artifacts.freeze(
  402. script_build_id=binding.script_build_id,
  403. task_id=_required(context, "task_id"),
  404. attempt_id=_required(context, "attempt_id"),
  405. spec_version=_required_int(context, "spec_version"),
  406. artifact=record,
  407. )
  408. return {
  409. "evidence_handle": semantic_handle(
  410. "evidence",
  411. _required(context, "root_trace_id"),
  412. _required(context, "task_id"),
  413. ref.digest or ref.version or "",
  414. ),
  415. "evidence": protect_model_value(
  416. _json_values(asdict(version.artifact)),
  417. namespace=_required(context, "attempt_id"),
  418. ),
  419. "source_count": len(result.source_refs),
  420. }
  421. async def view_frozen_images(
  422. self, image_handles: Sequence[str], context: Mapping[str, Any]
  423. ) -> Sequence[Mapping[str, Any]]:
  424. return await self._candidates().view_frozen_images(
  425. image_handles=image_handles,
  426. context=context,
  427. )
  428. async def candidate_command(
  429. self,
  430. operation: str,
  431. payload: Mapping[str, Any] | Sequence[Mapping[str, Any]],
  432. context: Mapping[str, Any],
  433. ) -> Mapping[str, Any]:
  434. await self.ensure_dispatch_allowed(context)
  435. tools = self._candidates()
  436. if operation == "save_script_paragraphs":
  437. batch = _mapping_payload(payload)
  438. return await tools.save_script_paragraphs(
  439. paragraphs=_sequence_payload(batch.get("paragraphs", ())),
  440. expected_state_revision=str(batch.get("expected_state_revision", "")),
  441. context=context,
  442. )
  443. if operation == "save_script_elements":
  444. batch = _mapping_payload(payload)
  445. return await tools.save_script_elements(
  446. elements=_sequence_payload(batch.get("elements", ())),
  447. links=_sequence_payload(batch.get("links", ())),
  448. expected_state_revision=str(batch.get("expected_state_revision", "")),
  449. context=context,
  450. )
  451. if operation == "save_comparison_candidate":
  452. return await tools.save_comparison_candidate(
  453. payload=_mapping_payload(payload), context=context
  454. )
  455. if operation == "save_candidate_portfolio":
  456. return await tools.save_candidate_portfolio(
  457. payload=_mapping_payload(payload), context=context
  458. )
  459. raise ProtocolViolation(f"unsupported candidate operation: {operation}")
  460. async def save_structured_script_candidate(
  461. self, acceptance_notes: Sequence[str], context: Mapping[str, Any]
  462. ) -> Mapping[str, Any]:
  463. await self.ensure_dispatch_allowed(context)
  464. notes = tuple(_bounded_text(item, "acceptance note", 500) for item in acceptance_notes)
  465. if len(notes) > 16:
  466. raise ProtocolViolation("a StructuredScript may contain at most 16 acceptance notes")
  467. return await self._candidates().save_structured_script_candidate(
  468. acceptance_notes=notes,
  469. context=context,
  470. )
  471. async def submit_current_attempt(self, context: Mapping[str, Any]) -> Mapping[str, Any]:
  472. await self.ensure_dispatch_allowed(context)
  473. if self.budget_guard is not None:
  474. await self.budget_guard.ensure_tool_budget(
  475. root_trace_id=_required(context, "root_trace_id"),
  476. task_id=_required(context, "task_id"),
  477. entry="submit",
  478. )
  479. root_trace_id = _required(context, "root_trace_id")
  480. task_id = _required(context, "task_id")
  481. attempt_id = _required(context, "attempt_id")
  482. ledger = await self.coordinator.task_store.load(root_trace_id)
  483. task = ledger.tasks.get(task_id)
  484. if task is None:
  485. raise ProtocolViolation("attempt task is outside the protected mission")
  486. if task_kind(task.current_spec.context_refs) == ROOT_DELIVERY_KIND:
  487. binding = await self.bindings.get_by_root(root_trace_id)
  488. root_version = await self.artifacts.get_by_attempt(
  489. script_build_id=binding.script_build_id,
  490. task_id=task_id,
  491. attempt_id=attempt_id,
  492. )
  493. if root_version.artifact_type is not ArtifactKind.ROOT_DELIVERY_MANIFEST:
  494. raise ProtocolViolation("Root Attempt does not own a delivery manifest")
  495. ref = ArtifactRef(
  496. uri=(f"script-build://artifact-versions/{root_version.artifact_version_id}"),
  497. kind=root_version.artifact_type.value,
  498. version=str(root_version.artifact_version_id),
  499. digest=root_version.canonical_sha256,
  500. )
  501. scope_ref = "script-build://scope/root"
  502. else:
  503. manifest = await self._candidates().resolve_attempt_manifest(context=context)
  504. ref = manifest.artifact_ref
  505. scope_ref = manifest.scope_ref
  506. if ref.kind not in _BUSINESS_ARTIFACT_KINDS or not ref.digest:
  507. raise ProtocolViolation("attempt manifest must contain one frozen business artifact")
  508. binding = await self.bindings.get_by_root(_required(context, "root_trace_id"))
  509. version = await self.artifacts.read_by_ref(
  510. ref,
  511. script_build_id=binding.script_build_id,
  512. task_id=_required(context, "task_id"),
  513. attempt_id=_required(context, "attempt_id"),
  514. )
  515. if (
  516. version.artifact_type.value != ref.kind
  517. or version.canonical_sha256 != ref.digest
  518. or version.state.value != "frozen"
  519. ):
  520. raise ProtocolViolation("attempt artifact ownership or frozen digest is invalid")
  521. safe_ref = ArtifactRef(
  522. uri=ref.uri,
  523. kind=ref.kind,
  524. version=ref.version,
  525. digest=ref.digest,
  526. )
  527. summary = _attempt_summary(safe_ref, scope_ref)
  528. submission = AttemptSubmission(
  529. summary=summary,
  530. artifact_refs=([] if safe_ref.kind == ArtifactKind.EVIDENCE.value else [safe_ref]),
  531. evidence_refs=([safe_ref] if safe_ref.kind == ArtifactKind.EVIDENCE.value else []),
  532. )
  533. return cast(
  534. dict[str, Any],
  535. await self._execute_fenced(
  536. context,
  537. lambda: self.coordinator.submit_attempt(dict(context), submission),
  538. ),
  539. )
  540. async def submit_structured_validation(
  541. self,
  542. *,
  543. verdict: str,
  544. criterion_results: Sequence[Mapping[str, Any]],
  545. defects: Sequence[Mapping[str, Any]],
  546. recommendation: str,
  547. context: Mapping[str, Any],
  548. ) -> Mapping[str, Any]:
  549. root_trace_id = _required(context, "root_trace_id")
  550. task_id = _required(context, "task_id")
  551. snapshot_id = _required(context, "snapshot_id")
  552. ledger = await self.coordinator.task_store.load(root_trace_id)
  553. task = ledger.tasks.get(task_id)
  554. if task is None:
  555. raise ProtocolViolation("validation task is outside the protected mission")
  556. snapshot = await self.coordinator.artifact_store.get(root_trace_id, snapshot_id)
  557. allowed_values = [*snapshot.artifact_refs, *snapshot.evidence_refs]
  558. kind = task_kind(task.current_spec.context_refs)
  559. if kind in PHASE_TWO_KINDS:
  560. evidence_reader = getattr(self._candidates(), "validation_evidence_refs", None)
  561. if callable(evidence_reader):
  562. allowed_values.extend(await evidence_reader(context=context))
  563. elif kind == ROOT_DELIVERY_KIND:
  564. evidence_reader = getattr(self._root_tools(), "validation_evidence_refs", None)
  565. if callable(evidence_reader):
  566. allowed_values.extend(await evidence_reader(context=context))
  567. allowed_refs = {(ref.uri, ref.version, ref.digest, ref.kind): ref for ref in allowed_values}
  568. refs_by_handle = {_evidence_handle(ref): ref for ref in allowed_refs.values()}
  569. normalized_defects = _normalize_defects(
  570. [_expand_validation_handles(item, refs_by_handle) for item in defects],
  571. allowed_refs,
  572. )
  573. expected_ids = {item.criterion_id for item in task.current_spec.acceptance_criteria}
  574. if any(item["criterion_id"] not in expected_ids for item in normalized_defects):
  575. raise ProtocolViolation("validation defect references an unknown Task criterion")
  576. result_by_id: dict[str, CriterionResult] = {}
  577. for raw in criterion_results:
  578. criterion_id = _bounded_text(raw.get("criterion_id"), "criterion_id", 128)
  579. if criterion_id not in expected_ids or criterion_id in result_by_id:
  580. raise ProtocolViolation("validation criterion identity is unknown or duplicated")
  581. criterion_verdict = ValidationVerdict(str(raw.get("verdict", "")))
  582. reason = _bounded_text(raw.get("reason"), "criterion reason", 300)
  583. defect_codes = tuple(
  584. item["defect_code"]
  585. for item in normalized_defects
  586. if item["criterion_id"] == criterion_id
  587. )
  588. if defect_codes:
  589. reason = f"{reason}; defect_codes={','.join(defect_codes)}"[:500]
  590. evidence_handles = raw.get("evidence_handle_ids", ())
  591. if not isinstance(evidence_handles, list) or len(evidence_handles) > 64:
  592. raise ProtocolViolation("criterion evidence handles are invalid")
  593. evidence = _normalize_evidence_refs(
  594. [
  595. _ref_dict(_required_evidence_handle(value, refs_by_handle))
  596. for value in evidence_handles
  597. ],
  598. allowed_refs,
  599. label="criterion evidence_handle_ids",
  600. )
  601. if criterion_verdict is ValidationVerdict.PASSED and not evidence:
  602. raise ProtocolViolation("passed criterion requires frozen evidence_refs")
  603. result_by_id[criterion_id] = CriterionResult(
  604. criterion_id=criterion_id,
  605. verdict=criterion_verdict,
  606. reason=reason,
  607. evidence_refs=list(evidence),
  608. )
  609. if set(result_by_id) != expected_ids:
  610. raise ProtocolViolation("validation must report every Task criterion exactly once")
  611. overall = ValidationVerdict(verdict)
  612. if overall is ValidationVerdict.PASSED and self.workbench is not None:
  613. try:
  614. await self.workbench.verify_context_exhausted(
  615. root_trace_id=root_trace_id,
  616. task_id=task_id,
  617. attempt_id=_required(context, "attempt_id"),
  618. )
  619. except Exception as exc:
  620. if getattr(exc, "code", None) != "CONTEXT_NOT_EXHAUSTED":
  621. raise
  622. raise ScriptBuildError("CONTEXT_NOT_EXHAUSTED", str(exc)) from exc
  623. blocking = [item for item in normalized_defects if item["severity"] in {"hard", "critical"}]
  624. if overall is ValidationVerdict.PASSED and blocking:
  625. raise ProtocolViolation("passed validation cannot contain hard or critical defects")
  626. criterion_verdicts = {item.verdict for item in result_by_id.values()}
  627. every_criterion_passed = criterion_verdicts == {ValidationVerdict.PASSED}
  628. if (overall is ValidationVerdict.PASSED) != every_criterion_passed:
  629. raise ProtocolViolation("overall validation verdict must match all criterion verdicts")
  630. from agent.orchestration.validation_policy import ValidationContext
  631. attempt = ledger.attempts.get(_required(context, "attempt_id"))
  632. if attempt is None or attempt.task_id != task_id:
  633. raise ProtocolViolation("validation Attempt is outside the protected Task")
  634. validation_context = ValidationContext(
  635. root_trace_id=root_trace_id,
  636. task=task,
  637. attempt=attempt,
  638. snapshot=snapshot,
  639. prior_validation_count=max(0, len(task.validation_ids) - 1),
  640. )
  641. deterministic = precheck_snapshot(validation_context)
  642. if overall is ValidationVerdict.PASSED and any(
  643. item.verdict is not ValidationVerdict.PASSED for item in deterministic
  644. ):
  645. raise ProtocolViolation("passed validation conflicts with deterministic precheck")
  646. if (
  647. overall is ValidationVerdict.PASSED
  648. and task_kind(task.current_spec.context_refs) in PHASE_TWO_KINDS
  649. ):
  650. binding = await self.bindings.get_by_root(root_trace_id)
  651. for ref in snapshot.artifact_refs:
  652. version = await self.artifacts.read_by_ref(
  653. ref,
  654. script_build_id=binding.script_build_id,
  655. task_id=task_id,
  656. attempt_id=attempt.attempt_id,
  657. )
  658. if precheck_business_artifact(version.artifact):
  659. raise ProtocolViolation(
  660. "passed validation conflicts with unrealized placeholder content"
  661. )
  662. business_rules = await self._candidates().deterministic_precheck(context=context)
  663. if any(item.get("verdict") != "passed" for item in business_rules):
  664. raise ProtocolViolation(
  665. "passed validation conflicts with phase-two deterministic precheck"
  666. )
  667. if (
  668. overall is ValidationVerdict.PASSED
  669. and task_kind(task.current_spec.context_refs) == ROOT_DELIVERY_KIND
  670. ):
  671. root_rules = await self._root_tools().deterministic_precheck(context=context)
  672. if any(item.get("verdict") != "passed" for item in root_rules):
  673. raise ProtocolViolation(
  674. "passed validation conflicts with Root deterministic precheck"
  675. )
  676. if overall is not ValidationVerdict.PASSED and not normalized_defects:
  677. raise ProtocolViolation("failed or inconclusive validation must report a defect")
  678. if any(
  679. result_by_id[item["criterion_id"]].verdict is ValidationVerdict.PASSED
  680. for item in blocking
  681. ):
  682. raise ProtocolViolation("blocking defect conflicts with a passed criterion")
  683. codes = tuple(dict.fromkeys(item["defect_code"] for item in normalized_defects))
  684. scopes = tuple(dict.fromkeys(item["scope_ref"] for item in normalized_defects))
  685. action_classes = tuple(
  686. dict.fromkeys(item["recommended_action_class"] for item in normalized_defects)
  687. )
  688. summary = (
  689. f"validation={overall.value};criteria={len(result_by_id)};"
  690. f"defects={len(normalized_defects)};codes={','.join(codes) or 'none'};"
  691. f"scopes={','.join(scopes) or 'none'};"
  692. f"actions={','.join(action_classes) or 'none'}"
  693. )[:500]
  694. evidence_refs = _dedupe_refs(
  695. ref for item in normalized_defects for ref in item["evidence_refs"]
  696. )
  697. bounded_recommendation = _bounded_text(
  698. recommendation or (action_classes[0] if action_classes else "accept"),
  699. "recommendation",
  700. 200,
  701. )
  702. return cast(
  703. dict[str, Any],
  704. await self._execute_fenced(
  705. context,
  706. lambda: self.coordinator.submit_validation(
  707. dict(context),
  708. overall,
  709. tuple(result_by_id.values()),
  710. summary,
  711. evidence_refs,
  712. (),
  713. codes[:50],
  714. bounded_recommendation,
  715. ),
  716. ),
  717. )
  718. async def save_direction_candidate(
  719. self,
  720. *,
  721. goals: Sequence[Mapping[str, Any]],
  722. constraints: Sequence[Mapping[str, Any]],
  723. preferences: Sequence[Mapping[str, Any]],
  724. strategy_handles: Sequence[str],
  725. evidence_decision_ids: Sequence[str],
  726. context: Mapping[str, Any],
  727. ) -> dict[str, Any]:
  728. await self.ensure_dispatch_allowed(context)
  729. binding, snapshot = await self._scope(context)
  730. accepted = await self.read_accepted_artifacts(context)
  731. evidence_by_decision = {
  732. str(item["decision_id"]): str(item["artifact_ref"]["uri"]) for item in accepted
  733. }
  734. if len(set(evidence_decision_ids)) != len(evidence_decision_ids) or any(
  735. item not in evidence_by_decision for item in evidence_decision_ids
  736. ):
  737. raise ProtocolViolation(
  738. "direction references evidence outside frozen child ACCEPT decisions"
  739. )
  740. task_id = _required(context, "task_id")
  741. direction_goals, goal_ids_by_client_key = _direction_goals(task_id, goals)
  742. strategy_by_handle = {
  743. strategy_handle(snapshot.snapshot_id, index, item): strategy_ref(
  744. snapshot.snapshot_id, index, item
  745. )
  746. for index, item in enumerate(snapshot.strategies)
  747. }
  748. if len(set(strategy_handles)) != len(strategy_handles) or any(
  749. item not in strategy_by_handle for item in strategy_handles
  750. ):
  751. raise ProtocolViolation("direction contains an unknown strategy handle")
  752. artifact = DirectionArtifact(
  753. goals=direction_goals,
  754. constraints=tuple(
  755. DirectionConstraint(
  756. constraint_id=stable_semantic_id(
  757. task_id,
  758. "constraint",
  759. _normalized_client_key(item.get("client_key"), "constraint client_key"),
  760. ),
  761. statement=redact_text(str(item["statement"])),
  762. rationale=redact_text(str(item.get("rationale", ""))),
  763. )
  764. for item in constraints
  765. ),
  766. preferences=tuple(
  767. DirectionPreference(
  768. preference_id=stable_semantic_id(
  769. task_id,
  770. "preference",
  771. _normalized_client_key(item.get("client_key"), "preference client_key"),
  772. ),
  773. statement=redact_text(str(item["statement"])),
  774. rationale=redact_text(str(item.get("rationale", ""))),
  775. priority=(int(item["priority"]) if item.get("priority") is not None else None),
  776. )
  777. for item in preferences
  778. ),
  779. topic_refs=(f"script-build://inputs/{snapshot.snapshot_id}/topic",),
  780. persona_refs=(f"script-build://inputs/{snapshot.snapshot_id}/persona",),
  781. strategy_refs=tuple(strategy_by_handle[item] for item in strategy_handles),
  782. evidence_refs=tuple(evidence_by_decision[item] for item in evidence_decision_ids),
  783. )
  784. version, ref = await self.artifacts.freeze(
  785. script_build_id=binding.script_build_id,
  786. task_id=_required(context, "task_id"),
  787. attempt_id=_required(context, "attempt_id"),
  788. spec_version=_required_int(context, "spec_version"),
  789. artifact=artifact,
  790. )
  791. return {
  792. "direction_handle": semantic_handle(
  793. "direction",
  794. _required(context, "root_trace_id"),
  795. _required(context, "task_id"),
  796. ref.digest or ref.version or "",
  797. ),
  798. "direction": protect_model_value(
  799. _json_values(version.artifact.content_payload()),
  800. namespace=_required(context, "attempt_id"),
  801. ),
  802. "goal_ids_by_client_key": goal_ids_by_client_key,
  803. }
  804. async def query_validation_evidence(
  805. self,
  806. query: str,
  807. limit: int,
  808. context: Mapping[str, Any],
  809. ) -> dict[str, Any]:
  810. request = EvidenceQuery(
  811. root_trace_id=_required(context, "root_trace_id"),
  812. task_id=_required(context, "task_id"),
  813. attempt_id=_required(context, "attempt_id"),
  814. snapshot_id=_required(context, "snapshot_id"),
  815. validation_id=_required(context, "validation_id"),
  816. query=query,
  817. limit=limit,
  818. )
  819. response = await self.coordinator.query_evidence(dict(context), request)
  820. payload = cast(dict[str, Any], _json_values(asdict(response)))
  821. refs = tuple(response.evidence_refs)
  822. payload.pop("evidence_refs", None)
  823. payload["evidence_handles"] = [
  824. {
  825. "evidence_handle": _evidence_handle(ref),
  826. "kind": ref.kind,
  827. "summary": ref.summary,
  828. }
  829. for ref in refs
  830. ]
  831. payload["items"] = protect_model_value(
  832. payload.get("items", []),
  833. namespace=f"validation:{request.validation_id}",
  834. )
  835. return payload
  836. async def deterministic_precheck(self, context: Mapping[str, Any]) -> dict[str, Any]:
  837. from agent.orchestration.validation_policy import ValidationContext
  838. from script_build_host.agents.validation import (
  839. PHASE_TWO_KINDS,
  840. precheck_snapshot,
  841. task_kind,
  842. )
  843. root_trace_id = _required(context, "root_trace_id")
  844. ledger = await self.coordinator.task_store.load(root_trace_id)
  845. task = ledger.tasks[_required(context, "task_id")]
  846. attempt = ledger.attempts[_required(context, "attempt_id")]
  847. snapshot = await self.coordinator.artifact_store.get(
  848. root_trace_id, _required(context, "snapshot_id")
  849. )
  850. validation_context = ValidationContext(
  851. root_trace_id=root_trace_id,
  852. task=task,
  853. attempt=attempt,
  854. snapshot=snapshot,
  855. prior_validation_count=max(0, len(task.validation_ids) - 1),
  856. )
  857. rules: list[Mapping[str, Any]] = [
  858. item.to_dict() for item in precheck_snapshot(validation_context)
  859. ]
  860. kind = task_kind(task.current_spec.context_refs)
  861. if kind in PHASE_TWO_KINDS:
  862. rules.extend(await self._candidates().deterministic_precheck(context=context))
  863. elif kind == "root-delivery":
  864. rules.extend(await self._root_tools().deterministic_precheck(context=context))
  865. return {"rule_results": rules}
  866. async def _scope(self, context: Mapping[str, Any]) -> tuple[Any, Any]:
  867. root_trace_id = _required(context, "root_trace_id")
  868. binding = await self.bindings.get_by_root(root_trace_id)
  869. snapshot = await self.snapshots.get(
  870. str(binding.input_snapshot_id),
  871. script_build_id=binding.script_build_id,
  872. )
  873. return binding, snapshot
  874. def _planner(self) -> ScriptPlannerToolPort:
  875. if self.planner_tools is None:
  876. raise ProtocolViolation("script planner tools are not configured")
  877. return self.planner_tools
  878. def _candidates(self) -> ScriptCandidateToolPort:
  879. if self.candidate_tools is None:
  880. raise ProtocolViolation("script candidate tools are not configured")
  881. return self.candidate_tools
  882. def _root_tools(self) -> RootDeliveryToolPort:
  883. if self.root_tools is None:
  884. raise ProtocolViolation("script Root delivery tools are not configured")
  885. return self.root_tools
  886. def _required(context: Mapping[str, Any], key: str) -> str:
  887. value = context.get(key)
  888. if not isinstance(value, str) or not value.strip():
  889. raise ProtocolViolation(f"protected context is missing {key}")
  890. return value
  891. def _required_int(context: Mapping[str, Any], key: str) -> int:
  892. value = context.get(key)
  893. if isinstance(value, bool) or not isinstance(value, int) or value < 1:
  894. raise ProtocolViolation(f"protected context is missing {key}")
  895. return value
  896. def _ref_dict(ref: ArtifactRef) -> dict[str, Any]:
  897. return {
  898. "uri": ref.uri,
  899. "kind": ref.kind,
  900. "version": ref.version,
  901. "digest": ref.digest,
  902. "summary": ref.summary,
  903. "metadata": ref.metadata,
  904. }
  905. def _json_values(value: Any) -> Any:
  906. if isinstance(value, datetime):
  907. return value.isoformat()
  908. if isinstance(value, Mapping):
  909. return {str(key): _json_values(item) for key, item in value.items()}
  910. if isinstance(value, (list, tuple)):
  911. return [_json_values(item) for item in value]
  912. if hasattr(value, "value"):
  913. return value.value
  914. return value
  915. def _safe_text_tuple(values: Sequence[str]) -> tuple[str, ...]:
  916. normalized: list[str] = []
  917. seen: set[str] = set()
  918. for value in values:
  919. safe_value = redact_text(str(value))
  920. if safe_value in seen:
  921. continue
  922. seen.add(safe_value)
  923. normalized.append(safe_value)
  924. return tuple(normalized)
  925. def _mapping_payload(
  926. value: Mapping[str, Any] | Sequence[Mapping[str, Any]],
  927. ) -> Mapping[str, Any]:
  928. if not isinstance(value, Mapping):
  929. raise ProtocolViolation("candidate command expects an object payload")
  930. return value
  931. def _sequence_payload(
  932. value: Mapping[str, Any] | Sequence[Mapping[str, Any]],
  933. ) -> Sequence[Mapping[str, Any]]:
  934. if isinstance(value, Mapping) or isinstance(value, (str, bytes)):
  935. raise ProtocolViolation("candidate batch command expects an array payload")
  936. if any(not isinstance(item, Mapping) for item in value):
  937. raise ProtocolViolation("candidate batch entries must be objects")
  938. return value
  939. def _bounded_text(value: Any, label: str, maximum: int) -> str:
  940. if not isinstance(value, str) or not value.strip() or len(value) > maximum:
  941. raise ProtocolViolation(f"{label} must contain between 1 and {maximum} characters")
  942. return redact_text(value.strip())
  943. def _normalized_client_key(value: Any, label: str) -> str:
  944. text = _bounded_text(value, label, 128)
  945. normalized = " ".join(normalize("NFKC", text).split()).casefold()
  946. if not normalized or len(normalized) > 128:
  947. raise ProtocolViolation(f"{label} must contain between 1 and 128 characters")
  948. return normalized
  949. def _direction_goals(
  950. task_id: str, goals: Sequence[Mapping[str, Any]]
  951. ) -> tuple[tuple[DirectionGoal, ...], dict[str, str]]:
  952. if not 1 <= len(goals) <= 30:
  953. raise ValueError("direction requires between one and thirty Goal nodes")
  954. normalized: list[tuple[str, str | None, Mapping[str, Any]]] = []
  955. for item in goals:
  956. client_key = _normalized_client_key(item.get("client_key"), "goal client_key")
  957. parent_key = (
  958. _normalized_client_key(item.get("parent_client_key"), "goal parent_client_key")
  959. if item.get("parent_client_key") is not None
  960. else None
  961. )
  962. normalized.append((client_key, parent_key, item))
  963. keys = [item[0] for item in normalized]
  964. if len(set(keys)) != len(keys):
  965. raise ValueError("goal client_key values must be unique")
  966. key_set = set(keys)
  967. for client_key, parent_key, _ in normalized:
  968. if parent_key == client_key:
  969. raise ValueError("a goal cannot parent itself")
  970. if parent_key is not None and parent_key not in key_set:
  971. raise ValueError(f"goal parent_client_key does not exist: {parent_key}")
  972. parent_by_key = {key: parent for key, parent, _ in normalized}
  973. if any(
  974. parent is not None and parent_by_key.get(parent) is not None for _, parent, _ in normalized
  975. ):
  976. raise ValueError("direction goal hierarchy may contain only two levels")
  977. goal_ids = {
  978. key: "goal-" + sha256(f"{task_id}\0{key}".encode()).hexdigest()[:16] for key in keys
  979. }
  980. if len(set(goal_ids.values())) != len(goal_ids):
  981. raise ValueError("generated goal identity collision")
  982. return (
  983. tuple(
  984. DirectionGoal(
  985. goal_id=goal_ids[client_key],
  986. statement=redact_text(str(item["statement"])),
  987. rationale=redact_text(str(item["rationale"])),
  988. parent_goal_id=goal_ids[parent_key] if parent_key is not None else None,
  989. success_criteria=tuple(
  990. redact_text(str(value)) for value in item["success_criteria"]
  991. ),
  992. )
  993. for client_key, parent_key, item in normalized
  994. ),
  995. goal_ids,
  996. )
  997. def _attempt_summary(ref: ArtifactRef, scope_ref: str) -> str:
  998. scope = _bounded_text(scope_ref, "scope_ref", 300)
  999. if not scope.startswith("script-build://"):
  1000. raise ProtocolViolation("scope_ref must use the script-build namespace")
  1001. digest = _bounded_text(ref.digest, "artifact digest", 71)
  1002. return f"kind={ref.kind};scope={scope};digest={digest};status=frozen"[:500]
  1003. def _evidence_handle(ref: ArtifactRef) -> str:
  1004. return semantic_handle("src", ref.uri, ref.digest or ref.version or "")
  1005. def _required_evidence_handle(
  1006. value: object, refs_by_handle: Mapping[str, ArtifactRef]
  1007. ) -> ArtifactRef:
  1008. handle = str(value or "").strip()
  1009. ref = refs_by_handle.get(handle)
  1010. if ref is None:
  1011. raise ProtocolViolation("validation references an unknown evidence handle")
  1012. return ref
  1013. def _expand_validation_handles(
  1014. value: Mapping[str, Any], refs_by_handle: Mapping[str, ArtifactRef]
  1015. ) -> dict[str, Any]:
  1016. unknown = set(value) - {
  1017. "defect_code",
  1018. "criterion_id",
  1019. "scope_selector",
  1020. "observed_excerpt",
  1021. "evidence_handle_ids",
  1022. "severity",
  1023. "invalidated_inputs",
  1024. "recommended_action_class",
  1025. }
  1026. if unknown:
  1027. raise ProtocolViolation("validation defect contains unsupported fields")
  1028. selector = value.get("scope_selector")
  1029. if not isinstance(selector, Mapping) or selector.get("anchor") != "mission":
  1030. raise ProtocolViolation("validation defect scope_selector is invalid")
  1031. path = selector.get("path")
  1032. if (
  1033. not isinstance(path, list)
  1034. or not 1 <= len(path) <= 4
  1035. or any(not isinstance(item, str) or not item.strip() for item in path)
  1036. ):
  1037. raise ProtocolViolation("validation defect scope_selector path is invalid")
  1038. handles = value.get("evidence_handle_ids", ())
  1039. if not isinstance(handles, list) or len(handles) > 64:
  1040. raise ProtocolViolation("validation defect evidence handles are invalid")
  1041. return {
  1042. **{
  1043. key: item
  1044. for key, item in value.items()
  1045. if key not in {"scope_selector", "evidence_handle_ids"}
  1046. },
  1047. "scope_ref": "script-build://scopes/" + "/".join(path),
  1048. "evidence_refs": [
  1049. _ref_dict(_required_evidence_handle(item, refs_by_handle)) for item in handles
  1050. ],
  1051. }
  1052. def _normalize_defects(
  1053. defects: Sequence[Mapping[str, Any]],
  1054. allowed_refs: Mapping[tuple[Any, Any, Any, Any], ArtifactRef],
  1055. ) -> tuple[dict[str, Any], ...]:
  1056. if len(defects) > 50:
  1057. raise ProtocolViolation("a validation may report at most 50 defects")
  1058. values: list[dict[str, Any]] = []
  1059. for raw in defects:
  1060. if not isinstance(raw, Mapping):
  1061. raise ProtocolViolation("each validation defect must be an object")
  1062. code = _bounded_text(raw.get("defect_code"), "defect_code", 64)
  1063. if not _DEFECT_CODE.fullmatch(code):
  1064. raise ProtocolViolation("defect_code must be an uppercase stable identifier")
  1065. criterion_id = _bounded_text(raw.get("criterion_id"), "criterion_id", 128)
  1066. scope_ref = _bounded_text(raw.get("scope_ref"), "scope_ref", 500)
  1067. if not scope_ref.startswith("script-build://"):
  1068. raise ProtocolViolation("defect scope_ref must use script-build://")
  1069. excerpt = _bounded_text(raw.get("observed_excerpt"), "observed_excerpt", 500)
  1070. severity = str(raw.get("severity", ""))
  1071. if severity not in _DEFECT_SEVERITIES:
  1072. raise ProtocolViolation("defect severity must be warning, hard, or critical")
  1073. action = str(raw.get("recommended_action_class", ""))
  1074. if action not in _DEFECT_ACTIONS:
  1075. raise ProtocolViolation("defect recommended action class is unsupported")
  1076. invalidated = raw.get("invalidated_inputs", [])
  1077. if (
  1078. not isinstance(invalidated, list)
  1079. or len(invalidated) > 64
  1080. or any(not isinstance(item, str) or not item.strip() for item in invalidated)
  1081. ):
  1082. raise ProtocolViolation("invalidated_inputs must be a bounded string array")
  1083. refs = _normalize_evidence_refs(
  1084. raw.get("evidence_refs"), allowed_refs, label="defect evidence_refs"
  1085. )
  1086. values.append(
  1087. {
  1088. "defect_code": code,
  1089. "criterion_id": criterion_id,
  1090. "scope_ref": scope_ref,
  1091. "observed_excerpt": excerpt,
  1092. "evidence_refs": refs,
  1093. "severity": severity,
  1094. "invalidated_inputs": tuple(str(item) for item in invalidated),
  1095. "recommended_action_class": action,
  1096. }
  1097. )
  1098. return tuple(values)
  1099. def _normalize_evidence_refs(
  1100. value: Any,
  1101. allowed_refs: Mapping[tuple[Any, Any, Any, Any], ArtifactRef],
  1102. *,
  1103. label: str,
  1104. ) -> tuple[ArtifactRef, ...]:
  1105. if not isinstance(value, list) or len(value) > 64:
  1106. raise ProtocolViolation(f"{label} must be a bounded array")
  1107. refs: list[ArtifactRef] = []
  1108. for item in value:
  1109. if not isinstance(item, Mapping):
  1110. raise ProtocolViolation(f"{label} entries must be objects")
  1111. candidate = ArtifactRef.from_dict(dict(item))
  1112. key = (candidate.uri, candidate.version, candidate.digest, candidate.kind)
  1113. frozen = allowed_refs.get(key)
  1114. if frozen is None:
  1115. raise ProtocolViolation(f"{label} must come from the protected validation closure")
  1116. refs.append(frozen)
  1117. return _dedupe_refs(refs)
  1118. def _dedupe_refs(values: Any) -> tuple[ArtifactRef, ...]:
  1119. result: list[ArtifactRef] = []
  1120. seen: set[tuple[Any, Any, Any, Any]] = set()
  1121. for ref in values:
  1122. key = (ref.uri, ref.version, ref.digest, ref.kind)
  1123. if key in seen:
  1124. continue
  1125. seen.add(key)
  1126. result.append(
  1127. ArtifactRef(
  1128. uri=ref.uri,
  1129. kind=ref.kind,
  1130. version=ref.version,
  1131. digest=ref.digest,
  1132. )
  1133. )
  1134. return tuple(result)
  1135. __all__ = [
  1136. "DispatchGuard",
  1137. "LegacyScriptToolGateway",
  1138. "RetrievalAdapter",
  1139. "RetrievalResult",
  1140. "ScriptCandidateToolPort",
  1141. "ScriptPlannerToolPort",
  1142. ]