contracts.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. """Closed, deterministic contracts for AI-authored data processing assets."""
  2. from __future__ import annotations
  3. import copy
  4. import hashlib
  5. import json
  6. import re
  7. from typing import Any
  8. from app.core.common.identifiers import ensure_governance_uid
  9. from app.core.data_rules.expressions import parse_expression
  10. SCHEMA_VERSION = "1.0"
  11. RULE_SCHEMA_VERSION = "2.0"
  12. LEGACY_RULE_SCHEMA_VERSION = "1.0"
  13. RULE_OPS = {
  14. "aggregate",
  15. "assert",
  16. "cast",
  17. "deduplicate",
  18. "derive",
  19. "fill_null",
  20. "filter",
  21. "lookup_join",
  22. "map_values",
  23. "mask",
  24. "normalize_text",
  25. "regex_replace",
  26. }
  27. COMPONENT_TYPES = {"standard.enforce", "rule.apply", "quality.check"}
  28. STAGES = {"extract", "normalize", "transform", "quality_gate", "write", "publish"}
  29. SEVERITIES = {"info", "warning", "error", "critical"}
  30. FAILURE_ACTIONS = {"reject", "quarantine", "warn", "fail"}
  31. NULL_POLICIES = {"explicit", "preserve", "reject"}
  32. IDEMPOTENCY_STRATEGIES = {
  33. "partition_replace",
  34. "upsert",
  35. "deduplication_key",
  36. }
  37. SECRET_KEY_NAMES = {
  38. "apikey",
  39. "authorization",
  40. "connectionstring",
  41. "credential",
  42. "credentials",
  43. "dsn",
  44. "password",
  45. "secret",
  46. "token",
  47. }
  48. RULE_ROOT_KEYS = {
  49. "schema_version",
  50. "rule_uid",
  51. "name",
  52. "description",
  53. "input_schema_ref",
  54. "output_schema_ref",
  55. "steps",
  56. "null_policy",
  57. "timezone",
  58. }
  59. RULE_STEP_KEYS = {
  60. "id",
  61. "op",
  62. "column",
  63. "target",
  64. "to",
  65. "expression",
  66. "expression_text",
  67. "expression_ast",
  68. "pattern",
  69. "replacement",
  70. "value",
  71. "trim",
  72. "lowercase",
  73. "uppercase",
  74. "on_error",
  75. "on_failure",
  76. "severity",
  77. "keys",
  78. "keep",
  79. "order_by",
  80. "mapping",
  81. "group_by",
  82. "aggregations",
  83. "lookup",
  84. "policy",
  85. }
  86. STANDARD_ROOT_KEYS = {
  87. "schema_version",
  88. "standard_uid",
  89. "name",
  90. "description",
  91. "scope",
  92. "clauses",
  93. }
  94. STANDARD_SCOPE_KEYS = {"object_type", "schema_ref", "business_domain_uid"}
  95. STANDARD_CLAUSE_KEYS = {
  96. "id",
  97. "description",
  98. "severity",
  99. "rule_version_id",
  100. "exception_policy",
  101. }
  102. DATAFLOW_ROOT_KEYS = {
  103. "schema_version",
  104. "dataflow_uid",
  105. "name",
  106. "description",
  107. "input_schema_refs",
  108. "output_schema_ref",
  109. "components",
  110. "parameters",
  111. }
  112. COMPONENT_KEYS = {
  113. "id",
  114. "type",
  115. "rule_version_id",
  116. "standard_version_id",
  117. "stage",
  118. "order",
  119. "idempotency",
  120. }
  121. IDEMPOTENCY_KEYS = {"strategy", "key"}
  122. CANDIDATE_KEYS = {
  123. "schema_version",
  124. "candidate_type",
  125. "rule_spec",
  126. "standard_spec",
  127. "assumptions",
  128. "ambiguities",
  129. "confidence",
  130. "explanation",
  131. }
  132. RULE_SPEC_SCHEMA = {
  133. "$schema": "https://json-schema.org/draft/2020-12/schema",
  134. "$id": "https://dataops.local/schemas/rule-spec-2.0.json",
  135. "title": "DataOps RuleSpec",
  136. "type": "object",
  137. "additionalProperties": False,
  138. "required": sorted(RULE_ROOT_KEYS - {"description"}),
  139. "properties": {
  140. "schema_version": {"const": RULE_SCHEMA_VERSION},
  141. "rule_uid": {"type": "string", "format": "uuid"},
  142. "name": {"type": "string", "minLength": 1, "maxLength": 200},
  143. "description": {"type": "string", "maxLength": 2000},
  144. "input_schema_ref": {"type": "string", "minLength": 1, "maxLength": 500},
  145. "output_schema_ref": {"type": "string", "minLength": 1, "maxLength": 500},
  146. "steps": {"type": "array", "minItems": 1, "maxItems": 200},
  147. "null_policy": {"enum": sorted(NULL_POLICIES)},
  148. "timezone": {"type": "string", "minLength": 1, "maxLength": 100},
  149. },
  150. }
  151. STANDARD_SPEC_SCHEMA = {
  152. "$schema": "https://json-schema.org/draft/2020-12/schema",
  153. "$id": "https://dataops.local/schemas/data-standard-spec-1.0.json",
  154. "title": "DataOps DataStandardSpec",
  155. "type": "object",
  156. "additionalProperties": False,
  157. "required": ["schema_version", "standard_uid", "name", "scope", "clauses"],
  158. }
  159. DATAFLOW_SPEC_SCHEMA = {
  160. "$schema": "https://json-schema.org/draft/2020-12/schema",
  161. "$id": "https://dataops.local/schemas/dataflow-production-line-1.0.json",
  162. "title": "DataOps DataFlow Production Line",
  163. "type": "object",
  164. "additionalProperties": False,
  165. "required": [
  166. "schema_version",
  167. "dataflow_uid",
  168. "name",
  169. "input_schema_refs",
  170. "output_schema_ref",
  171. "components",
  172. "parameters",
  173. ],
  174. }
  175. RULE_CANDIDATE_SCHEMA = {
  176. "$schema": "https://json-schema.org/draft/2020-12/schema",
  177. "$id": "https://dataops.local/schemas/rule-candidate-1.0.json",
  178. "title": "DataOps AI Rule Candidate",
  179. "type": "object",
  180. "additionalProperties": False,
  181. "required": sorted(CANDIDATE_KEYS),
  182. }
  183. def _closed_object(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
  184. if not isinstance(value, dict):
  185. raise ValueError(f"{label} must be an object")
  186. unknown = sorted(set(value) - allowed)
  187. if unknown:
  188. raise ValueError(
  189. f"{label} contains unsupported fields: {', '.join(unknown)}"
  190. )
  191. return value
  192. def _required_string(value: Any, label: str, maximum: int = 500) -> str:
  193. if not isinstance(value, str) or not value.strip():
  194. raise ValueError(f"{label} is required")
  195. normalized = value.strip()
  196. if len(normalized) > maximum:
  197. raise ValueError(f"{label} exceeds {maximum} characters")
  198. return normalized
  199. def _optional_string(value: Any, label: str, maximum: int = 2000) -> str:
  200. if not isinstance(value, str):
  201. raise ValueError(f"{label} must be a string")
  202. normalized = value.strip()
  203. if len(normalized) > maximum:
  204. raise ValueError(f"{label} exceeds {maximum} characters")
  205. return normalized
  206. def _uid(value: Any, label: str) -> str:
  207. try:
  208. return ensure_governance_uid({"uid": str(value)})
  209. except ValueError as exc:
  210. raise ValueError(f"{label} must be a valid UUIDv7") from exc
  211. def _identifier(value: Any, label: str) -> str:
  212. result = _required_string(value, label, 100)
  213. if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,99}", result):
  214. raise ValueError(f"{label} contains unsupported characters")
  215. return result
  216. def _normalized_secret_key(value: Any) -> str:
  217. return re.sub(r"[^a-z0-9]", "", str(value).lower())
  218. def _reject_secret_material(value: Any, path: str = "$") -> None:
  219. if isinstance(value, dict):
  220. for key, item in value.items():
  221. if _normalized_secret_key(key) in SECRET_KEY_NAMES:
  222. raise ValueError(f"secret material is not allowed at {path}.{key}")
  223. _reject_secret_material(item, f"{path}.{key}")
  224. elif isinstance(value, list):
  225. for index, item in enumerate(value):
  226. _reject_secret_material(item, f"{path}[{index}]")
  227. def _bounded_strings(
  228. value: Any,
  229. label: str,
  230. *,
  231. maximum_items: int = 100,
  232. maximum_length: int = 1000,
  233. ) -> list[str]:
  234. if not isinstance(value, list) or len(value) > maximum_items:
  235. raise ValueError(f"{label} must be a bounded array")
  236. return [
  237. _required_string(item, f"{label} item", maximum_length) for item in value
  238. ]
  239. def _canonical_hash(value: dict[str, Any]) -> str:
  240. canonical = json.dumps(
  241. value,
  242. sort_keys=True,
  243. separators=(",", ":"),
  244. ensure_ascii=False,
  245. )
  246. return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  247. def _validate_rule_step(value: Any) -> dict[str, Any]:
  248. step = copy.deepcopy(_closed_object(value, RULE_STEP_KEYS, "rule step"))
  249. step["id"] = _identifier(step.get("id"), "rule step id")
  250. operation = _required_string(step.get("op"), "rule step operation", 100)
  251. if operation not in RULE_OPS:
  252. raise ValueError(f"unsupported rule operation: {operation}")
  253. step["op"] = operation
  254. for key in ("column", "target", "to", "pattern", "replacement"):
  255. if key in step:
  256. step[key] = _required_string(
  257. step[key], f"rule step {key}", 2000
  258. )
  259. has_source_expression = "expression" in step
  260. has_canonical_expression = (
  261. "expression_text" in step or "expression_ast" in step
  262. )
  263. if has_source_expression and has_canonical_expression:
  264. raise ValueError("rule step expression cannot mix source and canonical forms")
  265. if has_source_expression:
  266. expression_text = _required_string(
  267. step.pop("expression"), "rule step expression", 20_000
  268. )
  269. step["expression_text"] = expression_text
  270. step["expression_ast"] = parse_expression(expression_text)
  271. elif has_canonical_expression:
  272. expression_text = _required_string(
  273. step.get("expression_text"), "rule step expression_text", 20_000
  274. )
  275. if "expression_ast" not in step:
  276. raise ValueError("rule step expression_ast is required")
  277. parsed_expression = parse_expression(expression_text)
  278. if step["expression_ast"] != parsed_expression:
  279. raise ValueError("rule step expression_ast must match expression_text")
  280. step["expression_text"] = expression_text
  281. step["expression_ast"] = parsed_expression
  282. for key in ("keys", "order_by", "group_by"):
  283. if key in step:
  284. step[key] = _bounded_strings(
  285. step[key], f"rule step {key}", maximum_length=200
  286. )
  287. for key in ("mapping", "aggregations", "lookup"):
  288. if key in step and not isinstance(step[key], dict):
  289. raise ValueError(f"rule step {key} must be an object")
  290. for key in ("trim", "lowercase", "uppercase"):
  291. if key in step and not isinstance(step[key], bool):
  292. raise ValueError(f"rule step {key} must be a boolean")
  293. if "severity" in step and step["severity"] not in SEVERITIES:
  294. raise ValueError("unsupported rule step severity")
  295. for key in ("on_error", "on_failure"):
  296. if key in step and step[key] not in FAILURE_ACTIONS:
  297. raise ValueError(f"unsupported rule step {key}")
  298. if "keep" in step and step["keep"] not in {"first", "last"}:
  299. raise ValueError("deduplicate keep must be first or last")
  300. if operation == "assert":
  301. if "expression_ast" not in step:
  302. raise ValueError("assert expression is required")
  303. if step.get("on_failure") not in FAILURE_ACTIONS:
  304. raise ValueError("assert on_failure is required")
  305. if operation == "cast":
  306. _required_string(step.get("column"), "cast column", 200)
  307. _required_string(step.get("to"), "cast target type", 100)
  308. if operation in {"normalize_text", "regex_replace", "fill_null"}:
  309. _required_string(step.get("column"), f"{operation} column", 200)
  310. if operation == "deduplicate" and not step.get("keys"):
  311. raise ValueError("deduplicate keys are required")
  312. if operation == "mask":
  313. _required_string(step.get("policy"), "mask policy", 200)
  314. _reject_secret_material(step)
  315. return step
  316. def validate_rule_spec(value: Any) -> dict[str, Any]:
  317. spec = copy.deepcopy(_closed_object(value, RULE_ROOT_KEYS, "rule spec"))
  318. if spec.get("schema_version") not in {
  319. LEGACY_RULE_SCHEMA_VERSION,
  320. RULE_SCHEMA_VERSION,
  321. }:
  322. raise ValueError(
  323. "rule spec schema_version must be "
  324. f"{LEGACY_RULE_SCHEMA_VERSION} (read-only) or {RULE_SCHEMA_VERSION}"
  325. )
  326. # V1 is accepted solely for migration. All normalized RuleSpecs are V2,
  327. # where expression source text and the parsed AST are independently stored.
  328. spec["schema_version"] = RULE_SCHEMA_VERSION
  329. spec["rule_uid"] = _uid(spec.get("rule_uid"), "rule_uid")
  330. spec["name"] = _required_string(spec.get("name"), "rule name", 200)
  331. if "description" in spec:
  332. spec["description"] = _optional_string(
  333. spec["description"], "rule description"
  334. )
  335. spec["input_schema_ref"] = _required_string(
  336. spec.get("input_schema_ref"), "input_schema_ref"
  337. )
  338. spec["output_schema_ref"] = _required_string(
  339. spec.get("output_schema_ref"), "output_schema_ref"
  340. )
  341. raw_steps = spec.get("steps")
  342. if not isinstance(raw_steps, list) or not raw_steps or len(raw_steps) > 200:
  343. raise ValueError("rule steps must be a non-empty bounded array")
  344. spec["steps"] = [_validate_rule_step(item) for item in raw_steps]
  345. ids = [item["id"] for item in spec["steps"]]
  346. if len(ids) != len(set(ids)):
  347. raise ValueError("rule step ids must be unique")
  348. if spec.get("null_policy") not in NULL_POLICIES:
  349. raise ValueError("unsupported null_policy")
  350. spec["timezone"] = _required_string(
  351. spec.get("timezone"), "rule timezone", 100
  352. )
  353. _reject_secret_material(spec)
  354. return spec
  355. def rule_spec_hash(value: Any) -> str:
  356. return _canonical_hash(validate_rule_spec(value))
  357. def validate_standard_spec(value: Any) -> dict[str, Any]:
  358. spec = copy.deepcopy(
  359. _closed_object(value, STANDARD_ROOT_KEYS, "standard spec")
  360. )
  361. if spec.get("schema_version") != SCHEMA_VERSION:
  362. raise ValueError(f"standard spec schema_version must be {SCHEMA_VERSION}")
  363. spec["standard_uid"] = _uid(spec.get("standard_uid"), "standard_uid")
  364. spec["name"] = _required_string(spec.get("name"), "standard name", 200)
  365. if "description" in spec:
  366. spec["description"] = _optional_string(
  367. spec["description"], "standard description"
  368. )
  369. scope = copy.deepcopy(
  370. _closed_object(spec.get("scope"), STANDARD_SCOPE_KEYS, "standard scope")
  371. )
  372. scope["object_type"] = _required_string(
  373. scope.get("object_type"), "standard scope object_type", 100
  374. )
  375. scope["schema_ref"] = _required_string(
  376. scope.get("schema_ref"), "standard scope schema_ref"
  377. )
  378. if "business_domain_uid" in scope:
  379. scope["business_domain_uid"] = _uid(
  380. scope["business_domain_uid"], "standard scope business_domain_uid"
  381. )
  382. spec["scope"] = scope
  383. raw_clauses = spec.get("clauses")
  384. if (
  385. not isinstance(raw_clauses, list)
  386. or not raw_clauses
  387. or len(raw_clauses) > 200
  388. ):
  389. raise ValueError("standard clauses must be a non-empty bounded array")
  390. clauses = []
  391. for raw_clause in raw_clauses:
  392. clause = copy.deepcopy(
  393. _closed_object(
  394. raw_clause, STANDARD_CLAUSE_KEYS, "standard clause"
  395. )
  396. )
  397. clause["id"] = _identifier(clause.get("id"), "standard clause id")
  398. clause["description"] = _required_string(
  399. clause.get("description"), "standard clause description", 2000
  400. )
  401. if clause.get("severity") not in SEVERITIES:
  402. raise ValueError("unsupported standard clause severity")
  403. clause["rule_version_id"] = _uid(
  404. clause.get("rule_version_id"), "standard clause rule_version_id"
  405. )
  406. if clause.get("exception_policy") not in FAILURE_ACTIONS:
  407. raise ValueError("unsupported standard clause exception_policy")
  408. clauses.append(clause)
  409. clause_ids = [item["id"] for item in clauses]
  410. if len(clause_ids) != len(set(clause_ids)):
  411. raise ValueError("standard clause ids must be unique")
  412. spec["clauses"] = clauses
  413. _reject_secret_material(spec)
  414. return spec
  415. def standard_spec_hash(value: Any) -> str:
  416. return _canonical_hash(validate_standard_spec(value))
  417. def _validate_idempotency(value: Any) -> dict[str, Any]:
  418. item = copy.deepcopy(
  419. _closed_object(value, IDEMPOTENCY_KEYS, "component idempotency")
  420. )
  421. if item.get("strategy") not in IDEMPOTENCY_STRATEGIES:
  422. raise ValueError("unsupported component idempotency strategy")
  423. item["key"] = _required_string(
  424. item.get("key"), "component idempotency key", 500
  425. )
  426. return item
  427. def validate_dataflow_spec(value: Any) -> dict[str, Any]:
  428. spec = copy.deepcopy(
  429. _closed_object(value, DATAFLOW_ROOT_KEYS, "dataflow spec")
  430. )
  431. if spec.get("schema_version") != SCHEMA_VERSION:
  432. raise ValueError(f"dataflow spec schema_version must be {SCHEMA_VERSION}")
  433. spec["dataflow_uid"] = _uid(spec.get("dataflow_uid"), "dataflow_uid")
  434. spec["name"] = _required_string(spec.get("name"), "dataflow name", 200)
  435. if "description" in spec:
  436. spec["description"] = _optional_string(
  437. spec["description"], "dataflow description"
  438. )
  439. spec["input_schema_refs"] = _bounded_strings(
  440. spec.get("input_schema_refs"),
  441. "input_schema_refs",
  442. maximum_items=50,
  443. maximum_length=500,
  444. )
  445. if not spec["input_schema_refs"]:
  446. raise ValueError("input_schema_refs must not be empty")
  447. spec["output_schema_ref"] = _required_string(
  448. spec.get("output_schema_ref"), "output_schema_ref"
  449. )
  450. raw_components = spec.get("components")
  451. if (
  452. not isinstance(raw_components, list)
  453. or not raw_components
  454. or len(raw_components) > 500
  455. ):
  456. raise ValueError("dataflow components must be a non-empty bounded array")
  457. components = []
  458. for raw_component in raw_components:
  459. component = copy.deepcopy(
  460. _closed_object(
  461. raw_component, COMPONENT_KEYS, "dataflow component"
  462. )
  463. )
  464. component["id"] = _identifier(
  465. component.get("id"), "dataflow component id"
  466. )
  467. component_type = component.get("type")
  468. if component_type not in COMPONENT_TYPES:
  469. raise ValueError("unsupported dataflow component type")
  470. component["type"] = component_type
  471. if component.get("stage") not in STAGES:
  472. raise ValueError("unsupported dataflow component stage")
  473. order = component.get("order")
  474. if (
  475. isinstance(order, bool)
  476. or not isinstance(order, int)
  477. or order < 0
  478. or order > 1_000_000
  479. ):
  480. raise ValueError("dataflow component order must be a bounded integer")
  481. if component_type == "standard.enforce":
  482. component["standard_version_id"] = _uid(
  483. component.get("standard_version_id"),
  484. "component standard_version_id",
  485. )
  486. if "rule_version_id" in component or "idempotency" in component:
  487. raise ValueError(
  488. "standard.enforce cannot define rule or idempotency"
  489. )
  490. else:
  491. component["rule_version_id"] = _uid(
  492. component.get("rule_version_id"),
  493. "component rule_version_id",
  494. )
  495. if "standard_version_id" in component:
  496. raise ValueError(
  497. "rule component cannot define standard_version_id"
  498. )
  499. if component_type == "rule.apply":
  500. component["idempotency"] = _validate_idempotency(
  501. component.get("idempotency")
  502. )
  503. elif "idempotency" in component:
  504. raise ValueError("quality.check cannot define idempotency")
  505. components.append(component)
  506. component_ids = [item["id"] for item in components]
  507. if len(component_ids) != len(set(component_ids)):
  508. raise ValueError("dataflow component ids must be unique")
  509. spec["components"] = sorted(
  510. components, key=lambda item: (item["order"], item["id"])
  511. )
  512. parameters = spec.get("parameters")
  513. if not isinstance(parameters, dict) or len(parameters) > 100:
  514. raise ValueError("dataflow parameters must be a bounded object")
  515. spec["parameters"] = parameters
  516. _reject_secret_material(spec)
  517. return spec
  518. def dataflow_spec_hash(value: Any) -> str:
  519. return _canonical_hash(validate_dataflow_spec(value))
  520. def validate_rule_candidate(value: Any) -> dict[str, Any]:
  521. candidate = copy.deepcopy(
  522. _closed_object(value, CANDIDATE_KEYS, "rule candidate")
  523. )
  524. missing = sorted(CANDIDATE_KEYS - set(candidate))
  525. if missing:
  526. raise ValueError(
  527. f"rule candidate is missing fields: {', '.join(missing)}"
  528. )
  529. if candidate.get("schema_version") != SCHEMA_VERSION:
  530. raise ValueError(
  531. f"rule candidate schema_version must be {SCHEMA_VERSION}"
  532. )
  533. candidate_type = candidate.get("candidate_type")
  534. if candidate_type not in {"rule", "standard"}:
  535. raise ValueError("unsupported rule candidate_type")
  536. if candidate_type == "rule":
  537. candidate["rule_spec"] = validate_rule_spec(
  538. candidate.get("rule_spec")
  539. )
  540. if candidate.get("standard_spec") is not None:
  541. raise ValueError("rule candidate cannot contain standard_spec")
  542. else:
  543. candidate["standard_spec"] = validate_standard_spec(
  544. candidate.get("standard_spec")
  545. )
  546. if candidate.get("rule_spec") is not None:
  547. candidate["rule_spec"] = validate_rule_spec(
  548. candidate["rule_spec"]
  549. )
  550. candidate["assumptions"] = _bounded_strings(
  551. candidate.get("assumptions"), "candidate assumptions", maximum_items=50
  552. )
  553. candidate["ambiguities"] = _bounded_strings(
  554. candidate.get("ambiguities"), "candidate ambiguities", maximum_items=50
  555. )
  556. confidence = candidate.get("confidence")
  557. if (
  558. isinstance(confidence, bool)
  559. or not isinstance(confidence, (int, float))
  560. or confidence < 0
  561. or confidence > 1
  562. ):
  563. raise ValueError("candidate confidence must be between 0 and 1")
  564. candidate["confidence"] = float(confidence)
  565. candidate["explanation"] = _required_string(
  566. candidate.get("explanation"), "candidate explanation", 2000
  567. )
  568. _reject_secret_material(candidate)
  569. return candidate