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

91 statements  

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

1"""Pure consent-boundary reconciliation: observed PR side effects vs approved scopes. 

2 

3keel's consent scopes (see :mod:`keel.consent`) gate the CLI *contract* that an 

4agent renders before a live run — they do not gate the side effects themselves. 

5Every real mutation (git push, ``gh pr create``/``comment``/``merge``, label 

6writes) is executed by the agent directly and never passes a consent check, and 

7the consent ``status``/``scopes`` recorded on the ledger are whatever the agent 

8passed. There is no deterministic process that checks the side effects actually 

9*observed* on a PR against the scopes that were *approved*. 

10 

11This module is that process, in its lowest-friction core-pure form: a post-hoc 

12reconcile. Given the side effects observed on a PR (the PR exists, comments were 

13posted, it was merged, labels were written) and the approved consent scopes from 

14the ledger's consent record, it maps each observed effect to its required scopes 

15(reusing :func:`keel.consent.side_effect_scopes` — no parallel vocabulary) and 

16flags any observed mutation not covered by an approved scope. 

17 

18Two verdicts, fail-closed only on a real boundary breach: 

19 

20* **advisory** — no consent record exists to reconcile against (a pre-consent or 

21 agent-self-reported PR). Back-compat: nothing to check, so nothing fails. 

22* **pass** / **fail** — a consent record exists. ``fail`` when an observed effect 

23 requires a scope the record never approved; ``pass`` otherwise. 

24 

25Pure data in / structured report out: no network, subprocess, clock, or random. 

26The CLI does the I/O (transport observation of PR state, comments, merged, 

27labels; ledger consent record) and feeds the booleans/scopes here. 

28""" 

29 

30from __future__ import annotations 

31 

32from dataclasses import dataclass 

33from typing import Any 

34 

35from . import consent, ledger 

36 

37SCHEMA_VERSION = "keel.consent-verify.v1" 

38 

39VERDICT_ADVISORY = "advisory" 

40VERDICT_PASS = "pass" 

41VERDICT_FAIL = "fail" 

42 

43#: Where a pull request's consent was read from: the ship run that opened it, or the 

44#: consent a live ``swarm-run`` delegated to the worker that opened it (#1400). 

45CONSENT_SOURCE_SHIP_RUN = ledger.RECORD_TYPE_SHIP_RUN 

46CONSENT_SOURCE_DELEGATION = ledger.RECORD_TYPE_CONSENT_DELEGATION 

47#: The record kinds consent-verify reads. 

48LEDGER_KINDS: tuple[str, ...] = (CONSENT_SOURCE_SHIP_RUN, CONSENT_SOURCE_DELEGATION) 

49 

50# Observed-effect flag name -> the consent.side_effect vocabulary entry it maps 

51# to. Each side-effect resolves through ``consent.side_effect_scopes`` so the 

52# required-scope set always tracks the canonical consent vocabulary. 

53_EFFECT_SIDE_EFFECTS: dict[str, str] = { 

54 # The PR existing at all means a branch was pushed and a PR opened: git push 

55 # (scope ``git``) plus the gh pr create (scope ``github``). 

56 "pr_exists": "git_push", 

57 "pr_created": "pull_request", 

58 "comment": "comments", 

59 "merged": "merge", 

60 "label": "labels", 

61} 

62 

63# ``pr_exists`` expands to two side effects (push + open) because a PR's mere 

64# existence implies both a ``git`` push and a ``github`` create. 

65_EFFECT_EXTRA_SIDE_EFFECTS: dict[str, tuple[str, ...]] = { 

66 "pr_exists": ("pull_request",), 

67} 

68 

69OBSERVED_EFFECT_KINDS: tuple[str, ...] = tuple(_EFFECT_SIDE_EFFECTS) 

70 

71 

72@dataclass(frozen=True) 

73class ObservedEffects: 

74 """The mutating side effects observed on one PR (each defaults to absent). 

75 

76 ``pr_exists`` is the baseline: a PR that exists implies a branch push and a 

77 ``gh pr create``. ``merged``/``commented``/``labeled`` layer on the heavier 

78 mutations. All offline-supplyable so tests are deterministic. 

79 """ 

80 

81 pr_exists: bool = False 

82 commented: bool = False 

83 merged: bool = False 

84 labeled: bool = False 

85 #: The pull request's current head commit, when known. Not an effect: a swarm 

86 #: cluster's delegated consent covers the commit its worker pushed and nothing else, so 

87 #: it applies only while the head is still that commit (#1400). 

88 head_sha: str | None = None 

89 

90 def as_kinds(self) -> tuple[str, ...]: 

91 """Return the observed effect-kind names in a stable order.""" 

92 kinds: list[str] = [] 

93 if self.pr_exists: 

94 kinds.append("pr_exists") 

95 if self.commented: 

96 kinds.append("comment") 

97 if self.merged: 

98 kinds.append("merged") 

99 if self.labeled: 

100 kinds.append("label") 

101 return tuple(kinds) 

102 

103 

104def required_scopes_for_effect(effect_kind: str) -> tuple[str, ...]: 

105 """Return the consent scopes an observed ``effect_kind`` requires. 

106 

107 Resolves through :func:`keel.consent.side_effect_scopes`, so the mapping 

108 reuses the canonical scope vocabulary rather than inventing a parallel one. 

109 Raises ``ValueError`` for an unknown effect kind so a typo can never silently 

110 map to "no scopes required" (which would wrongly pass reconciliation). 

111 """ 

112 side_effect = _EFFECT_SIDE_EFFECTS.get(effect_kind) 

113 if side_effect is None: 

114 raise ValueError( 

115 f"unknown observed effect {effect_kind!r}; valid: {', '.join(OBSERVED_EFFECT_KINDS)}" 

116 ) 

117 side_effects = (side_effect, *_EFFECT_EXTRA_SIDE_EFFECTS.get(effect_kind, ())) 

118 return consent.side_effect_scopes(side_effects) 

119 

120 

121def scope_effect_table() -> dict[str, list[str]]: 

122 """Return the deterministic observed-effect -> required-scope mapping table. 

123 

124 Surfaced in the command contract and docs so operators can audit exactly how 

125 each observed mutation is scored without reading the code. 

126 """ 

127 return {kind: list(required_scopes_for_effect(kind)) for kind in OBSERVED_EFFECT_KINDS} 

128 

129 

130def reconcile( 

131 observed: ObservedEffects, 

132 approved_scopes: list[str] | tuple[str, ...] | None, 

133 *, 

134 has_consent_record: bool, 

135) -> dict[str, Any]: 

136 """Reconcile observed PR side effects against approved consent scopes. 

137 

138 ``observed`` are the mutations seen on the PR. ``approved_scopes`` are the 

139 scopes the ledger's consent record approved. ``has_consent_record`` is 

140 whether a consent record exists at all for the PR — when ``False`` there is 

141 nothing to reconcile against, so the verdict is ``advisory`` (back-compat for 

142 pre-consent PRs) regardless of what was observed. 

143 

144 Returns a structured report: the per-effect coverage, a flat list of uncovered 

145 mutations (each naming the effect and the missing scope), the verdict, and a 

146 summary. Pure — reads only its arguments. 

147 """ 

148 approved = consent.normalize_scopes(approved_scopes or ()) 

149 effects = [] 

150 uncovered: list[dict[str, Any]] = [] 

151 for kind in observed.as_kinds(): 

152 required = required_scopes_for_effect(kind) 

153 missing = tuple(scope for scope in required if scope not in approved) 

154 covered = not missing 

155 effects.append( 

156 { 

157 "effect": kind, 

158 "required_scopes": list(required), 

159 "missing_scopes": list(missing), 

160 "covered": covered, 

161 } 

162 ) 

163 if not covered and has_consent_record: 

164 uncovered.append( 

165 { 

166 "effect": kind, 

167 "required_scopes": list(required), 

168 "missing_scopes": list(missing), 

169 "message": ( 

170 f"mutation {kind} not covered by approved consent scopes " 

171 f"(requires {', '.join(required)}; missing {', '.join(missing)})" 

172 ), 

173 } 

174 ) 

175 verdict = _verdict(has_consent_record=has_consent_record, uncovered=uncovered) 

176 return { 

177 "schema_version": SCHEMA_VERSION, 

178 "verdict": verdict, 

179 "ok": verdict != VERDICT_FAIL, 

180 "has_consent_record": has_consent_record, 

181 "approved_scopes": list(approved), 

182 "observed_effects": list(observed.as_kinds()), 

183 "effects": effects, 

184 "uncovered": uncovered, 

185 "summary": { 

186 "observed": len(effects), 

187 "covered": sum(1 for effect in effects if effect["covered"]), 

188 "uncovered": len(uncovered), 

189 }, 

190 } 

191 

192 

193def _verdict(*, has_consent_record: bool, uncovered: list[dict[str, Any]]) -> str: 

194 if not has_consent_record: 

195 return VERDICT_ADVISORY 

196 return VERDICT_FAIL if uncovered else VERDICT_PASS 

197 

198 

199def consent_record_from_ledger( 

200 record: dict[str, Any] | None, 

201) -> tuple[bool, tuple[str, ...]]: 

202 """Extract ``(has_record, approved_scopes)`` from a ship_run ledger record. 

203 

204 The ledger stores consent under ``run_context.consent`` as a ``status`` plus 

205 the approved mutation ``scopes`` (see :func:`keel.ledger._run_context`). A 

206 consent record is considered to *exist* only when ``status`` is a non-blank 

207 string — a missing record, a missing/empty ``run_context``, or a blank status 

208 all degrade to "no record" so the verdict falls back to advisory rather than 

209 failing a pre-consent PR. Pure — no I/O. 

210 """ 

211 if not isinstance(record, dict): 

212 return False, () 

213 run_context = record.get("run_context") 

214 consent_block = run_context.get("consent") if isinstance(run_context, dict) else None 

215 if not isinstance(consent_block, dict): 

216 return False, () 

217 status = consent_block.get("status") 

218 has_record = isinstance(status, str) and bool(status.strip()) 

219 raw_scopes = consent_block.get("scopes") 

220 scopes = ( 

221 tuple(str(scope) for scope in raw_scopes if str(scope).strip()) 

222 if isinstance(raw_scopes, list) 

223 else () 

224 ) 

225 return has_record, scopes 

226 

227 

228def consent_for_pr( 

229 records: list[dict[str, Any]], 

230 pr_number: int, 

231 *, 

232 head_sha: str | None = None, 

233) -> dict[str, Any]: 

234 """The consent recorded for pull request ``pr_number``, and where it was read from. 

235 

236 ``records`` are the run ledger's ship runs and consent delegations. In order: 

237 

238 1. The latest ship run for the pull request, when it carries a consent status — the 

239 path every ``keel ship`` run's pull request takes, unchanged. 

240 2. The latest ``pull_request`` delegation record naming the pull request — the consent 

241 a live ``swarm-run`` handed the worker that opened it (#1400) — **only while the 

242 pull request's head is still the commit that worker pushed** (``head_sha``, the 

243 pull request's current head). A head that moved, or one nobody supplied, is not 

244 work the delegation covered, so it gets no delegated consent, and 

245 ``delegation_refused`` says why. 

246 

247 There is no match by branch name: anyone can open a pull request from a fork whose 

248 branch is called ``swarm/<id>/<cluster>``, and a name is not a provenance. A cluster 

249 pull request whose own record did not reach the ledger gets no delegated consent. 

250 

251 Returns ``{"source", "has_record", "scopes", "delegation", "delegation_refused"}``: 

252 ``source`` is :data:`CONSENT_SOURCE_SHIP_RUN`, :data:`CONSENT_SOURCE_DELEGATION` or 

253 ``None`` (no consent recorded — the advisory path); ``delegation`` says who delegated 

254 what when that is the source. Pure. 

255 """ 

256 ship_run = ledger.latest_ship_run_for_pr(records, pr_number) 

257 has_record, scopes = consent_record_from_ledger(ship_run) 

258 refused: str | None = None 

259 if not has_record: 

260 record = ledger.consent_delegation_for_pr(records, pr_number) 

261 refused = None if record is None else _head_refusal(record, head_sha) 

262 if record is not None and refused is None: 

263 delegation = _delegation_summary(record) 

264 return { 

265 "source": CONSENT_SOURCE_DELEGATION, 

266 "has_record": True, 

267 "scopes": list(delegation["scopes"]), 

268 "delegation": delegation, 

269 "delegation_refused": None, 

270 } 

271 return { 

272 "source": CONSENT_SOURCE_SHIP_RUN if has_record else None, 

273 "has_record": has_record, 

274 "scopes": list(scopes), 

275 "delegation": None, 

276 "delegation_refused": refused, 

277 } 

278 

279 

280def _head_refusal(record: dict[str, Any], head_sha: str | None) -> str | None: 

281 """Why the delegation in ``record`` does not cover a pull request at ``head_sha``.""" 

282 pushed = record["pull_request"]["head_sha"] 

283 current = head_sha.strip() if isinstance(head_sha, str) else "" 

284 if not current: 

285 return ( 

286 f"the pull request's current head is unknown, so it cannot be matched to the " 

287 f"commit the worker pushed ({pushed}); pass --head-sha offline" 

288 ) 

289 if current != pushed: 

290 return ( 

291 f"the pull request's head moved since the worker pushed it: the delegation " 

292 f"covers {pushed}, the head is {current}" 

293 ) 

294 return None 

295 

296 

297def _delegation_summary(record: dict[str, Any]) -> dict[str, Any]: 

298 """Who delegated what, for the cluster pull request ``record`` names.""" 

299 pull_request = record["pull_request"] 

300 return { 

301 "swarm_id": record["swarm_id"], 

302 "cluster": pull_request["cluster"], 

303 "operator": record["operator"], 

304 "scopes": list(record["scopes"]), 

305 "source": record["source"], 

306 "mode": record["mode"], 

307 "delegated_at": record["delegated_at"], 

308 "pull_request": pull_request["number"], 

309 "pushed_head": pull_request["head_sha"], 

310 }