Coverage for src/keel/stepverifier.py: 100%

163 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 20:26 +0000

1"""Deterministic backbone step completion verification. 

2 

3Agentic ship steps may be performed by different runtimes, but advancing the 

4backbone must not depend on private prose. This module defines the shared 

5"done" contract for each step, the structured handoff shape between steps, and 

6the fail-closed transition check adapters can run before moving forward. 

7 

8:data:`HANDOFF_FIELDS` is the single declaration of the handoff schema. The producer 

9(:func:`build_handoff`), the published contract (:func:`contract_as_dict`) and the 

10verifier (:func:`_check_handoff_schema`) all read it, so the verifier cannot fall 

11behind the shape the renderer emits — which is exactly how it came to check two of 

12ten fields while the renderer emitted all ten (#1101). 

13""" 

14 

15from __future__ import annotations 

16 

17from dataclasses import dataclass 

18from typing import Any 

19 

20from . import artifacts, evidence, model, provenance 

21 

22SCHEMA_VERSION = "keel.step-verification.v1" 

23HANDOFF_SCHEMA_VERSION = "keel.step-handoff.v1" 

24HANDOFF_MARKER = artifacts.STEP_HANDOFF_MARKER 

25COMPLETE_STATUS = "complete" 

26 

27#: JSON type names the handoff schema uses, and the Python type each one is once the 

28#: document has been read back out of JSON. 

29_JSON_TYPES: dict[str, type] = {"string": str, "array": list, "object": dict} 

30 

31 

32@dataclass(frozen=True) 

33class HandoffField: 

34 """One field of the published ``keel.step-handoff.v1`` handoff object.""" 

35 

36 name: str 

37 json_type: str 

38 #: ``None`` is a value the canonical renderer legitimately emits for this field. 

39 nullable: bool = False 

40 #: An empty string or an empty array is legitimate for this field. 

41 may_be_blank: bool = False 

42 

43 def as_dict(self) -> dict[str, Any]: 

44 return {"name": self.name, "type": self.json_type, "nullable": self.nullable} 

45 

46 def problem(self, handoff: dict[str, Any]) -> str | None: 

47 """Name what is wrong with this field in ``handoff``, or return ``None``. 

48 

49 Every reason names the field, because "something is missing" is not a 

50 message an agent can act on and not one an operator can audit. 

51 """ 

52 if self.name not in handoff: 

53 return f"handoff field missing: {self.name}" 

54 value = handoff[self.name] 

55 if value is None: 

56 return None if self.nullable else f"handoff field null: {self.name}" 

57 if not isinstance(value, _JSON_TYPES[self.json_type]): 

58 return f"handoff field wrong type: {self.name}" 

59 if not self.may_be_blank and not _has_content(value): 

60 return f"handoff field empty: {self.name}" 

61 return None 

62 

63 

64#: The published handoff schema — the *one* declaration shared with the producer. 

65#: A field added here is emitted by :func:`build_handoff`, published by 

66#: :func:`contract_as_dict` and required by the verifier in the same commit. A second, 

67#: hand-kept list is how the verifier fell eight fields behind the renderer (#1101). 

68HANDOFF_FIELDS: tuple[HandoffField, ...] = ( 

69 HandoffField("schema_version", "string"), 

70 HandoffField("step_id", "string"), 

71 HandoffField("step_name", "string"), 

72 HandoffField("status", "string"), 

73 HandoffField("summary", "string"), 

74 HandoffField("evidence_ids", "array", may_be_blank=True), 

75 HandoffField("next_step", "string", nullable=True, may_be_blank=True), 

76 HandoffField("producer", "string", nullable=True, may_be_blank=True), 

77 HandoffField("provenance", "object"), 

78 HandoffField("rendered", "string"), 

79) 

80 

81 

82@dataclass(frozen=True) 

83class StepRequirement: 

84 """Required evidence for one backbone step.""" 

85 

86 step_id: str 

87 step_name: str 

88 required_evidence: tuple[str, ...] = () 

89 verifier: str = "keel.stepverifier.verify_step_completion" 

90 

91 def as_dict(self) -> dict[str, Any]: 

92 return { 

93 "step_id": self.step_id, 

94 "step_name": self.step_name, 

95 "required_evidence": list(self.required_evidence), 

96 "verifier": self.verifier, 

97 } 

98 

99 

100def contract_as_dict( 

101 review_contract: dict[str, Any], 

102 *, 

103 dry_run: bool = False, 

104 enforced: bool = True, 

105) -> dict[str, Any]: 

106 """Return the deterministic step-completion contract for ship-like flows.""" 

107 del dry_run # The contract describes the required done-state even for dry-run output. 

108 requirements = step_requirements(review_contract, dry_run=False, enforced=enforced) 

109 return { 

110 "schema_version": SCHEMA_VERSION, 

111 "consumer_neutral": True, 

112 "deterministic": True, 

113 "fail_closed": True, 

114 "dry_run_disables_runtime_gating": True, 

115 "source": "backbone_plan + evidence", 

116 "no_premature_termination": True, 

117 "handoff_schema": { 

118 "schema_version": HANDOFF_SCHEMA_VERSION, 

119 "required_fields": [field.name for field in HANDOFF_FIELDS], 

120 "fields": [field.as_dict() for field in HANDOFF_FIELDS], 

121 "renderer": "keel.artifacts.render_step_handoff", 

122 "marker": HANDOFF_MARKER, 

123 "rendered_body_matches_fields": True, 

124 "completed_step_claims_required_evidence": True, 

125 }, 

126 "completion_rule": ( 

127 "A step may transition as success only when its structured handoff has " 

128 "status=complete and every required evidence id for that step is ok." 

129 ), 

130 "steps": [requirement.as_dict() for requirement in requirements], 

131 } 

132 

133 

134def step_requirements( 

135 review_contract: dict[str, Any], 

136 *, 

137 dry_run: bool = False, 

138 enforced: bool = True, 

139) -> tuple[StepRequirement, ...]: 

140 """Map the public evidence contract onto the fixed backbone steps.""" 

141 evidence_ids = [ 

142 item.id 

143 for item in evidence.required_items( 

144 review_contract, 

145 dry_run=dry_run, 

146 enforced=enforced, 

147 ) 

148 ] 

149 by_step = { 

150 "s7": tuple(item for item in evidence_ids if item.startswith("review-verdict-")), 

151 "s8": tuple(item for item in evidence_ids if item == "jury-verdict"), 

152 "s12": tuple(item for item in evidence_ids if item.startswith("closure-comment-")), 

153 } 

154 return tuple( 

155 StepRequirement( 

156 step_id=step.id, 

157 step_name=step.name, 

158 required_evidence=by_step.get(step.id, ()), 

159 ) 

160 for step in model.BACKBONE 

161 ) 

162 

163 

164def build_handoff( 

165 *, 

166 step_id: str, 

167 status: str = COMPLETE_STATUS, 

168 summary: str | None = None, 

169 evidence_ids: tuple[str, ...] | list[str] = (), 

170 next_step: str | None = None, 

171 producer: str | None = None, 

172 vendor: str | None = None, 

173 model_name: str | None = None, 

174 allowed_capabilities: tuple[str, ...] | list[str] = (), 

175) -> dict[str, Any]: 

176 """Build a structured handoff object rendered through canonical artifacts.""" 

177 step = model.get_step(step_id) 

178 clean_evidence = tuple( 

179 item.strip() for item in evidence_ids if isinstance(item, str) and item.strip() 

180 ) 

181 rendered = artifacts.render_step_handoff( 

182 step_id=step.id, 

183 step_name=step.name, 

184 status=status, 

185 summary=summary, 

186 next_step=next_step, 

187 evidence_ids=clean_evidence, 

188 ) 

189 values = { 

190 "schema_version": HANDOFF_SCHEMA_VERSION, 

191 "step_id": step.id, 

192 "step_name": step.name, 

193 "status": status, 

194 # `.strip()` before the default: a whitespace-only summary is truthy, so the 

195 # bare `or` stored it as written and the verifier — which refuses a blank 

196 # field — rejected a document this very function had produced. A producer 

197 # that can emit what its own verifier refuses is the synchronisation this 

198 # change is about, pointing the other way. 

199 "summary": (summary or "").strip() or "No summary recorded.", 

200 "evidence_ids": list(clean_evidence), 

201 "next_step": next_step, 

202 "producer": producer, 

203 "provenance": provenance.source_tag( 

204 source_agent=producer, 

205 step_id=step.id, 

206 vendor=vendor, 

207 model=model_name, 

208 allowed_capabilities=allowed_capabilities, 

209 ), 

210 "rendered": rendered, 

211 } 

212 # Emit the schema, not a dict that happens to resemble it: a field declared in 

213 # HANDOFF_FIELDS and not built above raises here rather than shipping a handoff 

214 # the verifier will refuse, and one built above and not declared cannot leak into 

215 # the document at all. 

216 return {field.name: values[field.name] for field in HANDOFF_FIELDS} 

217 

218 

219def verify_step_completion( 

220 *, 

221 step_id: str, 

222 handoff: dict[str, Any] | None, 

223 evidence_report: dict[str, Any] | None, 

224 review_contract: dict[str, Any], 

225 dry_run: bool = False, 

226 enforced: bool = True, 

227) -> dict[str, Any]: 

228 """Verify that one step can be marked complete without trusting prose.""" 

229 requirement = _requirement_for( 

230 step_id, 

231 review_contract, 

232 dry_run=dry_run, 

233 enforced=enforced, 

234 ) 

235 checks = [ 

236 _check_handoff_schema(step_id, handoff), 

237 _check_handoff_status(handoff), 

238 _check_handoff_marker(handoff), 

239 _check_handoff_provenance(step_id, handoff), 

240 _check_handoff_evidence(requirement, handoff), 

241 _check_required_evidence(requirement, evidence_report), 

242 ] 

243 missing = [reason for check in checks if not check["ok"] for reason in check["missing"]] 

244 return { 

245 "schema_version": SCHEMA_VERSION, 

246 "step_id": step_id, 

247 "status": "pass" if not missing else "fail", 

248 "no_premature_termination": True, 

249 "required_evidence": list(requirement.required_evidence), 

250 "missing": missing, 

251 "checks": checks, 

252 } 

253 

254 

255def _requirement_for( 

256 step_id: str, 

257 review_contract: dict[str, Any], 

258 *, 

259 dry_run: bool, 

260 enforced: bool, 

261) -> StepRequirement: 

262 requirements = { 

263 requirement.step_id: requirement 

264 for requirement in step_requirements( 

265 review_contract, 

266 dry_run=dry_run, 

267 enforced=enforced, 

268 ) 

269 } 

270 if step_id not in requirements: 

271 raise KeyError(f"unknown backbone step: {step_id}") 

272 return requirements[step_id] 

273 

274 

275def _check_handoff_schema(step_id: str, handoff: dict[str, Any] | None) -> dict[str, Any]: 

276 """Check the whole published schema, naming every field that is absent or wrong.""" 

277 if not isinstance(handoff, dict): 

278 return _check("handoff_schema", False, "handoff missing") 

279 problems = [field.problem(handoff) for field in HANDOFF_FIELDS] 

280 named = [problem for problem in problems if problem] 

281 if named: 

282 return _check("handoff_schema", False, *named) 

283 if handoff["schema_version"] != HANDOFF_SCHEMA_VERSION: 

284 return _check("handoff_schema", False, "handoff schema mismatch") 

285 if handoff["step_id"] != step_id: 

286 return _check("handoff_schema", False, "handoff step mismatch") 

287 # The backbone names its own steps, so a handoff that names one wrong was not 

288 # written from the step it claims to be reporting. 

289 if handoff["step_name"] != model.get_step(step_id).name: 

290 return _check("handoff_schema", False, "handoff step name mismatch") 

291 return _check("handoff_schema", True) 

292 

293 

294def _check_handoff_status(handoff: dict[str, Any] | None) -> dict[str, Any]: 

295 if not isinstance(handoff, dict): 

296 return _check("handoff_status", False, "handoff missing") 

297 if handoff.get("status") != COMPLETE_STATUS: 

298 return _check("handoff_status", False, "handoff not complete") 

299 return _check("handoff_status", True) 

300 

301 

302def _check_handoff_marker(handoff: dict[str, Any] | None) -> dict[str, Any]: 

303 """Check the rendered body against the renderer, not against a substring. 

304 

305 A marker can be typed; the canonical rendering of *these* fields cannot be typed 

306 into disagreement with them. Re-rendering the handoff's own structured fields and 

307 comparing catches a body pasted from another step, or one whose prose says 

308 something the fields do not. 

309 """ 

310 if not isinstance(handoff, dict): 

311 return _check("handoff_renderer", False, "handoff missing") 

312 rendered = handoff.get("rendered") 

313 if not isinstance(rendered, str) or HANDOFF_MARKER not in rendered: 

314 return _check("handoff_renderer", False, "canonical handoff renderer missing") 

315 if rendered != _render_from(handoff): 

316 return _check("handoff_renderer", False, "handoff body does not match its own fields") 

317 return _check("handoff_renderer", True) 

318 

319 

320def _render_from(handoff: dict[str, Any]) -> str: 

321 evidence_ids = handoff.get("evidence_ids") 

322 return artifacts.render_step_handoff( 

323 step_id=handoff.get("step_id"), 

324 step_name=handoff.get("step_name"), 

325 status=handoff.get("status"), 

326 summary=handoff.get("summary"), 

327 next_step=handoff.get("next_step"), 

328 evidence_ids=evidence_ids if isinstance(evidence_ids, list) else [], 

329 ) 

330 

331 

332def _canonical_provenance_keys() -> tuple[frozenset[str], frozenset[str], frozenset[str]]: 

333 """The key sets ``provenance.source_tag`` emits: tag, ``source``, ``capability_scope``. 

334 

335 Derived from the producer rather than retyped, for the same reason 

336 :data:`HANDOFF_FIELDS` is: a field added to the tag must not be able to go 

337 unchecked here. The *keys* are required, not their values — ``vendor`` and 

338 ``model`` are legitimately ``None`` when the caller knows neither. 

339 """ 

340 tag = provenance.source_tag(source_agent="a", step_id="s") 

341 return ( 

342 frozenset(tag), 

343 frozenset(tag["source"]), 

344 frozenset(tag["capability_scope"]), 

345 ) 

346 

347 

348def _check_handoff_provenance(step_id: str, handoff: dict[str, Any] | None) -> dict[str, Any]: 

349 """Check that the provenance tag is a canonical one, bound to this step. 

350 

351 The evidence chain downstream reads this tag. A handoff carrying an arbitrary 

352 object under ``provenance`` has answered "who says so?" with nothing. 

353 """ 

354 if not isinstance(handoff, dict): 

355 return _check("handoff_provenance", False, "handoff missing") 

356 tag = handoff.get("provenance") 

357 if not isinstance(tag, dict): 

358 return _check("handoff_provenance", False, "handoff field wrong type: provenance") 

359 if tag.get("schema_version") != provenance.SCHEMA_VERSION: 

360 return _check("handoff_provenance", False, "handoff provenance schema mismatch") 

361 if ( 

362 tag.get("role") != provenance.UNTRUSTED_ROLE 

363 or tag.get("trusted_as_instructions") is not False 

364 ): 

365 return _check("handoff_provenance", False, "handoff provenance is not untrusted output") 

366 scope = tag.get("capability_scope") 

367 if not isinstance(scope, dict) or scope.get("can_expand_capabilities") is not False: 

368 return _check("handoff_provenance", False, "handoff provenance expands capabilities") 

369 source = tag.get("source") 

370 if not isinstance(source, dict): 

371 return _check("handoff_provenance", False, "handoff provenance names no source") 

372 if source.get("step_id") != step_id: 

373 return _check("handoff_provenance", False, "handoff provenance step mismatch") 

374 # Required unconditionally, not only when ``producer`` names someone. ``source_tag`` 

375 # always writes this key — ``unknown-agent`` when the caller passes no agent — so a 

376 # tag that omits it was not built by the renderer, and the one field answering "who 

377 # says so?" is exactly the field a fabricated tag has no reason to fill in. Gating it 

378 # on a named ``producer`` let a handoff with ``producer: null`` clear the check by 

379 # naming nobody, which is the shape of defect this whole verifier exists to refuse. 

380 agent_id = source.get("agent_id") 

381 if not (isinstance(agent_id, str) and agent_id.strip()): 

382 return _check("handoff_provenance", False, "handoff provenance names no agent") 

383 producer = handoff.get("producer") 

384 named = producer.strip() if isinstance(producer, str) else "" 

385 if named and agent_id != named: 

386 return _check("handoff_provenance", False, "handoff provenance producer mismatch") 

387 # Last, so a specific violation is still reported specifically. "Canonical" has to 

388 # mean the shape the renderer emits, or the word is doing no work: a tag carrying 

389 # only the members the checks above happen to read is not the tag `source_tag` 

390 # builds, and accepting it claims a check that was never made. 

391 tag_keys, source_keys, scope_keys = _canonical_provenance_keys() 

392 for present, required, where in ( 

393 (tag, tag_keys, "provenance"), 

394 (source, source_keys, "provenance.source"), 

395 (scope, scope_keys, "provenance.capability_scope"), 

396 ): 

397 missing = sorted(required - set(present)) 

398 if missing: 

399 return _check( 

400 "handoff_provenance", 

401 False, 

402 f"handoff provenance is not canonical, {where} missing: {', '.join(missing)}", 

403 ) 

404 # Types, not values. `vendor` and `model` are free-form strings the producer 

405 # copies from the run, so there is nothing here to compare them against; what can 

406 # be said is that a list is a list. Extra keys are deliberately allowed — a tag 

407 # carrying a member this version has not heard of is a newer producer, not a 

408 # forgery, and refusing it would make the verifier the reason a handoff cannot 

409 # cross a version boundary. 

410 for key in ("vendor", "model"): 

411 if source[key] is not None and not isinstance(source[key], str): 

412 return _check( 

413 "handoff_provenance", False, f"handoff provenance source.{key} is not a string" 

414 ) 

415 for key in ("allowed_capabilities", "unknown_capabilities"): 

416 if not isinstance(scope[key], list): 

417 return _check( 

418 "handoff_provenance", 

419 False, 

420 f"handoff provenance capability_scope.{key} is not a list", 

421 ) 

422 return _check("handoff_provenance", True) 

423 

424 

425def _check_handoff_evidence( 

426 requirement: StepRequirement, 

427 handoff: dict[str, Any] | None, 

428) -> dict[str, Any]: 

429 """Check that a completed handoff claims the evidence its step owes. 

430 

431 The required-evidence check below reads a *separately supplied* report. This one 

432 binds the handoff to it: a step that reports completed work without naming the 

433 evidence for it is the fabrication this verifier exists to refuse. 

434 """ 

435 if not isinstance(handoff, dict): 

436 return _check("handoff_evidence", False, "handoff missing") 

437 claimed = handoff.get("evidence_ids") 

438 if not isinstance(claimed, list): 

439 return _check("handoff_evidence", False, "handoff field wrong type: evidence_ids") 

440 if any(not (isinstance(item, str) and item.strip()) for item in claimed): 

441 return _check("handoff_evidence", False, "handoff evidence ids are not all named") 

442 if handoff.get("status") != COMPLETE_STATUS: 

443 return _check("handoff_evidence", True) 

444 ids = {item.strip() for item in claimed} 

445 unclaimed = [item for item in requirement.required_evidence if item not in ids] 

446 return _check( 

447 "handoff_evidence", 

448 not unclaimed, 

449 *(f"handoff claims no evidence id: {item}" for item in unclaimed), 

450 ) 

451 

452 

453def _check_required_evidence( 

454 requirement: StepRequirement, 

455 evidence_report: dict[str, Any] | None, 

456) -> dict[str, Any]: 

457 if not requirement.required_evidence: 

458 return _check("required_evidence", True) 

459 ok_ids = { 

460 result["id"] 

461 for result in _evidence_results(evidence_report) 

462 if result.get("ok") is True and isinstance(result.get("id"), str) 

463 } 

464 missing = [ 

465 evidence_id for evidence_id in requirement.required_evidence if evidence_id not in ok_ids 

466 ] 

467 return _check("required_evidence", not missing, *missing) 

468 

469 

470def _evidence_results(evidence_report: dict[str, Any] | None) -> tuple[dict[str, Any], ...]: 

471 if not isinstance(evidence_report, dict): 

472 return () 

473 results = evidence_report.get("results") 

474 if not isinstance(results, list): 

475 return () 

476 return tuple(item for item in results if isinstance(item, dict)) 

477 

478 

479def _has_content(value: Any) -> bool: 

480 return bool(value.strip()) if isinstance(value, str) else bool(value) 

481 

482 

483def _check(name: str, ok: bool, *missing: str) -> dict[str, Any]: 

484 return { 

485 "name": name, 

486 "ok": ok, 

487 "missing": list(missing), 

488 }