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

212 statements  

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

1"""Deterministic consumer-neutral closure-comment renderer. 

2 

3The ``ship`` backbone posts a human-readable "ship outcome" comment to both the 

4issue and the PR at s11. This module renders that markdown **from** a structured 

5``ship_run`` ledger record (see :func:`keel.ledger.build_ship_run_record`); it is a 

6mirror of the ledger, never a parser source. 

7 

8Pure-core / thin-I/O: :func:`render_closure_comment` takes a plain dict and returns 

9markdown. It is deterministic (stable ordering, no wall-clock, no randomness) and 

10consumer-neutral — the project codename comes from the record's ``target``, never a 

11literal baked into core. 

12""" 

13 

14from __future__ import annotations 

15 

16from typing import Any 

17 

18CLOSURE_SCHEMA_VERSION = "keel.closure-comment.v1" 

19COMMENT_MARKER = f"<!-- {CLOSURE_SCHEMA_VERSION} -->" 

20HEADING = "Ship outcome" 

21JURY_LABEL = "AI Jury" 

22WATERMARK_MARKER = "<!-- keel.watermark.v1 -->" 

23#: The jury clause of the watermark. Appended only when the run's own record says a 

24#: jury sat (see :func:`_jury_sat`): the watermark used to claim ai-jury consensus on 

25#: every closure comment, including the ones whose ``Jury`` line three lines above it 

26#: said ``off`` (docs audit 2026-09-29). 

27WATERMARK_JURY_CLAUSE = " (with [ai-jury](https://github.com/berkayturanci/ai-jury) consensus)" 

28#: The jury modes under which a jury actually reviewed the change; ``off`` and a 

29#: missing value are the two that did not. 

30_JURY_SAT_MODES = frozenset({"gating", "advisory"}) 

31#: Panel decisions under which the panel did **not** sit (#1066): a host bench reviewed 

32#: instead, or the run was refused. 

33_PANEL_DID_NOT_SIT = frozenset({"fallback", "block"}) 

34 

35 

36def watermark_body(*, jury: bool) -> str: 

37 """The default watermark signature, naming ai-jury only when ``jury`` is true.""" 

38 clause = WATERMARK_JURY_CLAUSE if jury else "" 

39 return ( 

40 "⚓ **Shipped by [keel](https://github.com/berkayturanci/keel)** — " 

41 f"*Driven on fixed backbone `s0`→`s12`{clause}* \n" 

42 "[⭐ Star on GitHub](https://github.com/berkayturanci/keel) · " 

43 "[Add Keel to your repo](https://github.com/berkayturanci/keel#readme)" 

44 ) 

45 

46 

47# Project-neutral documentation detection. A changed file counts as docs when any 

48# path component equals ``docs`` (case-insensitive) or its suffix is a documentation 

49# format. Custom docs paths are a project-config/policy concern, not core: keep this 

50# set generic so the consumer-neutrality guard holds. 

51# 

52# ``.txt`` is intentionally excluded: it is false-positive prone (e.g. 

53# ``requirements.txt``, lockfile-style manifests) and matches plenty of non-docs. 

54# A doc-ish text file (e.g. ``docs/notes.txt``) still counts via the ``docs/`` 

55# path-component rule, so rely on that directory rule for text docs. 

56_DOC_SUFFIXES = frozenset({".md", ".mdx", ".markdown", ".rst", ".adoc"}) 

57_DOC_SUFFIXES_TUPLE = tuple(_DOC_SUFFIXES) 

58 

59#: The ``knobs.implement_mode`` value this renderer names. Spelled here rather than 

60#: imported so the renderer keeps its "plain dict in, markdown out" contract. 

61_TDD_MODE = "tdd" 

62#: How a looped s4 run (#1165) names itself on the Implement line. 

63LOOP_LABEL = "loop" 

64 

65 

66def contract_as_dict() -> dict[str, Any]: 

67 """Return the stable closure-comment contract consumed by ship adapters.""" 

68 return { 

69 "schema_version": CLOSURE_SCHEMA_VERSION, 

70 "comment_marker": COMMENT_MARKER, 

71 "heading": HEADING, 

72 "source": "run-ledger ship_run record", 

73 "deterministic": True, 

74 "consumer_neutral": True, 

75 "mirror_not_parser": True, 

76 "renderer": "keel.closure.render_closure_comment", 

77 "sections": [ 

78 "implementer", 

79 "reviewers", 

80 "tester", 

81 # Rendered only when the run spent an s9 fix round (#1016); a clean run's 

82 # comment is byte-identical to what it was before the section existed. 

83 "fix_rounds", 

84 "pull_request", 

85 "changed_files", 

86 "docs_touched", 

87 "capture", 

88 "run_id", 

89 "run_context", 

90 "watermark", 

91 ], 

92 "run_context_fields": [ 

93 "host_agent", 

94 "transport", 

95 "profile", 

96 "jury_mode", 

97 "jury_panel", 

98 "implement_mode", 

99 "implement_loop", 

100 "consent", 

101 ], 

102 "jury_label": JURY_LABEL, 

103 # Published so a consumer reading the closure comment back — keel's own 

104 # verification surfaces included — has the marker name from the contract 

105 # rather than from a literal (#1068). 

106 "jury_panel_marker": JURY_PANEL_MARKER, 

107 "watermark_marker": WATERMARK_MARKER, 

108 } 

109 

110 

111def render_closure_comment(record: dict[str, Any]) -> str: 

112 """Render one ``ship_run`` ledger record as the ship outcome markdown comment. 

113 

114 Missing or ``None`` optional fields degrade gracefully. Only the target line is 

115 omitted when its value is absent or blank; every other field always renders. The 

116 implementer, reviewers, tester, run id, and capture all render ``none`` (capture 

117 renders ``not recorded``) when missing. An empty or jury-only reviewer list 

118 renders ``none`` / ``AI Jury``; a missing PR number renders ``none``; a 

119 ``capture`` status of ``None`` renders ``not recorded``. 

120 """ 

121 actors = record.get("actors") or {} 

122 lines: list[str] = [COMMENT_MARKER, "", f"## {HEADING}", ""] 

123 lines.extend(_target_line(record.get("target"))) 

124 lines.append(f"- **Implementer:** {_value(actors.get('implementer'))}") 

125 lines.append(f"- **Reviewers:** {_reviewers(actors.get('reviewers'))}") 

126 lines.append(f"- **Tester:** {_value(actors.get('tester'))}") 

127 lines.extend(_fix_rounds(actors)) 

128 lines.append(f"- **PR:** {_pull_request(record.get('pull_request'))}") 

129 lines.extend(_changed_files(record.get("changes"))) 

130 lines.append(f"- **Docs touched:** {_docs_touched(record.get('changes'))}") 

131 lines.append(f"- **Capture:** {_capture(record.get('capture'))}") 

132 lines.append(f"- **Run id:** {_value(record.get('run_id'))}") 

133 lines.extend(_run_context(record.get("run_context"), _head_sha(record))) 

134 lines.extend(_watermark(record.get("watermark"), record.get("run_context"))) 

135 return "\n".join(lines) + "\n" 

136 

137 

138def _head_sha(record: dict[str, Any]) -> Any: 

139 """``git.head_sha`` off the record, or ``None`` when the block is unreadable. 

140 

141 The head the ship produced, which is the head every pin is taken against. Read 

142 defensively for the same reason every other field here is: the renderer's contract 

143 is "plain dict in, markdown out", and a malformed block degrades to no marker 

144 rather than to an exception on the one artifact a human reads. 

145 """ 

146 git = record.get("git") 

147 return git.get("head_sha") if isinstance(git, dict) else None 

148 

149 

150def _fix_rounds(actors: dict[str, Any]) -> list[str]: 

151 """The s9 fix rounds, when any were recorded (#1016). 

152 

153 Omitted entirely on a run with no fix round, which is most of them — a blank 

154 ``- **Fix rounds:** none`` on every clean ship would be noise, and the field is 

155 additive to a comment shape other tooling already reads. 

156 """ 

157 fixers = actors.get("fixers") 

158 if not isinstance(fixers, list) or not fixers: 

159 return [] 

160 rendered = ", ".join( 

161 f"round {item.get('round')}: {_value(item.get('actor'))}{_stage_suffix(item.get('stage'))}" 

162 for item in fixers 

163 if isinstance(item, dict) 

164 ) 

165 if not rendered: 

166 return [] 

167 return [f"- **Fix rounds:** {rendered}"] 

168 

169 

170def _stage_suffix(stage: Any) -> str: 

171 return f" ({stage.strip()})" if isinstance(stage, str) and stage.strip() else "" 

172 

173 

174def _target_line(target: Any) -> list[str]: 

175 if not isinstance(target, str) or not target.strip(): 

176 return [] 

177 return [f"**Target:** {target.strip()}", ""] 

178 

179 

180def _reviewers(reviewers: Any) -> str: 

181 if not isinstance(reviewers, list): 

182 return "none" 

183 entries = [reviewer.strip() for reviewer in reviewers if _is_reviewer(reviewer)] 

184 listed = [reviewer for reviewer in entries if not _is_jury(reviewer)] 

185 # ⚡ Bolt Optimization: Compare lengths instead of redundant predicate evaluation via any() 

186 has_jury = len(listed) < len(entries) 

187 if not listed: 

188 return JURY_LABEL if has_jury else "none" 

189 rendered = ", ".join(listed) 

190 if has_jury: 

191 return f"{rendered} — {JURY_LABEL}" 

192 return rendered 

193 

194 

195def _is_reviewer(reviewer: Any) -> bool: 

196 return isinstance(reviewer, str) and bool(reviewer.strip()) 

197 

198 

199def _is_jury(reviewer: str) -> bool: 

200 return "jury" in reviewer.lower() 

201 

202 

203def _pull_request(pull_request: Any) -> str: 

204 number = pull_request.get("number") if isinstance(pull_request, dict) else None 

205 return f"#{number}" if isinstance(number, int) else "none" 

206 

207 

208def _changed_files(changes: Any) -> list[str]: 

209 block = changes if isinstance(changes, dict) else {} 

210 if block.get("unreadable") is True: 

211 # The record says git could not be read. The defensive coercions below would 

212 # otherwise absorb the `None`s and post "0" to the PR — an affirmative claim 

213 # about a diff nobody saw, in the one artifact a human actually reads. 

214 return ["- **Changed files:** unreadable (git diff failed)"] 

215 files = block.get("files") 

216 files = list(files) if isinstance(files, list) else [] 

217 count = block.get("file_count") 

218 count = count if isinstance(count, int) else len(files) 

219 lines = [f"- **Changed files:** {count}"] 

220 lines.extend(f" - `{file}`" for file in files) 

221 return lines 

222 

223 

224def _docs_touched(changes: Any) -> str: 

225 """Return ``"yes"`` when any changed file is documentation, else ``"no"``. 

226 

227 Derived deterministically and consumer-neutrally from ``changes.files``; the 

228 ledger schema is unchanged and no project config is read. An unreadable diff 

229 answers ``"unknown"`` rather than ``"no"`` — the file list it would be derived 

230 from does not exist. 

231 """ 

232 block = changes if isinstance(changes, dict) else {} 

233 if block.get("unreadable") is True: 

234 return "unknown" 

235 files = block.get("files") 

236 files = files if isinstance(files, list) else [] 

237 return "yes" if any(_is_doc(file) for file in files) else "no" 

238 

239 

240def _is_doc(file: Any) -> bool: 

241 if not isinstance(file, str): 

242 return False 

243 lowered = file.lower() 

244 if "docs" in lowered.replace("\\", "/").split("/"): 

245 return True 

246 return lowered.endswith(_DOC_SUFFIXES_TUPLE) 

247 

248 

249def _capture(capture: Any) -> str: 

250 block = capture if isinstance(capture, dict) else {} 

251 status = block.get("status") 

252 if not isinstance(status, str) or not status: 

253 return "not recorded" 

254 reason = block.get("reason") 

255 learning = _learning(block.get("learning")) 

256 suffix = f"; learning: {learning}" if learning else "" 

257 if isinstance(reason, str) and reason.strip(): 

258 return f"{status} ({reason.strip()}){suffix}" 

259 return f"{status}{suffix}" 

260 

261 

262def _learning(learning: Any) -> str | None: 

263 block = learning if isinstance(learning, dict) else {} 

264 decision = block.get("decision") 

265 if not isinstance(decision, str) or not decision.strip(): 

266 return None 

267 reason = block.get("reason") 

268 if isinstance(reason, str) and reason.strip(): 

269 return f"{decision.strip()} ({reason.strip()})" 

270 return decision.strip() 

271 

272 

273def _run_context(run_context: Any, head_sha: Any = None) -> list[str]: 

274 """Render the deterministic preflight Run context block. 

275 

276 Always emitted (additive section, appended after the existing lines). Each 

277 field degrades gracefully when missing: host agent / profile / consent 

278 status render ``unknown``; transport renders ``unknown``; jury renders 

279 ``off``; an empty consent scope list renders ``none``. 

280 """ 

281 block = run_context if isinstance(run_context, dict) else {} 

282 return [ 

283 "", 

284 "### Run context", 

285 "", 

286 f"- **Host agent:** {_unknown(block.get('host_agent'))}", 

287 f"- **Transport:** {_unknown(block.get('transport'))}", 

288 f"- **Profile:** {_unknown(block.get('profile'))}", 

289 f"- **Jury:** {_jury_mode(block.get('jury_mode'))}", 

290 *_jury_panel(block, head_sha), 

291 *_implement_mode(block), 

292 f"- **Consent:** {_consent(block.get('consent'))}", 

293 ] 

294 

295 

296#: How a run whose panel could not be staffed names that in the closure comment 

297#: (#1066). The point of the line is that a reader can tell a jury-reviewed change 

298#: from a fallback-reviewed one without re-deriving it — ai-jury #682 is what a 

299#: panel that quietly collapsed and still reported success costs. 

300PANEL_UNAVAILABLE_LABEL = "panel unavailable" 

301 

302#: How a run whose panel *did* sit names that (#1068 round 7). Round 6 emitted the 

303#: line only for the two unavailable decisions, on the reasoning that a run whose 

304#: panel convened should post the comment it always did. That reasoning was wrong for 

305#: the marker below: silence is not a statement, so the *latest* closure comment for a 

306#: head could not outrank an earlier one. See :func:`_jury_panel`. 

307PANEL_AVAILABLE_LABEL = "panel sat" 

308 

309#: How a run's panel decision reads to a machine (#1068 round 6). The prose above is 

310#: for a human; this is for :func:`keel.evidence.shipped_panel_decision`, which reads 

311#: the run's own panel decision back off the pull request on a host that cannot see the 

312#: run ledger — ``.keel/state/`` is gitignored, so a hosted ``evidence-verify`` or 

313#: ``merge`` has no ledger to pin to and the closure comment is the only place this 

314#: run still speaks. Both halves are always emitted together: the marker is the 

315#: parser's input so nothing downstream has to regex over Markdown, and the sentence 

316#: stays because the comment is read by people. 

317#: 

318#: Deliberately **not** one of :data:`keel.evidence.CLASSIFICATION_MARKERS`: it does 

319#: not classify a comment (the closure marker in the header already did that), it is 

320#: a field inside one. It is head-pinned for the same reason every pin is — a pull 

321#: request outlives its heads — and is emitted only when the record carries a head. 

322JURY_PANEL_MARKER = "keel.jury-panel.v1" 

323 

324#: Every panel decision that renders a line. ``available`` is here since #1068 round 7, 

325#: which is the whole of that round's fix: see :func:`_jury_panel`. 

326PANEL_DECISIONS: dict[str, tuple[str, str]] = { 

327 "available": (PANEL_AVAILABLE_LABEL, "the cross-vendor panel reviewed this change"), 

328 "fallback": (PANEL_UNAVAILABLE_LABEL, "a host bench of the same size reviewed instead"), 

329 "block": ( 

330 PANEL_UNAVAILABLE_LABEL, 

331 "the run was refused (knobs.team.jury.on_unavailable: block)", 

332 ), 

333} 

334 

335 

336def _jury_panel(block: dict[str, Any], head_sha: Any) -> list[str]: 

337 """The s7 panel-availability line — emitted for **every** panel decision (#1068). 

338 

339 Conditional, like the s4 profile line, but only on whether this run resolved a 

340 panel at all: a project that has no panel posts the comment it always did, byte 

341 for byte, and a run that did resolve one says which way it went and names the 

342 seats. 

343 

344 **Why ``available`` renders too, since round 7.** The line is followed by the 

345 machine-readable :data:`JURY_PANEL_MARKER`, which is the run's own record of its 

346 panel decision in the one place that travels with the pull request, and 

347 :func:`keel.juryavail.pin` ranks that record above a posted verdict. Round 6 

348 emitted it only for ``fallback`` and ``block``, so a run whose panel *sat* left no 

349 marker — and "no marker" is indistinguishable from "no run". Ship a commit once 

350 under the fallback and again on a machine where the panel convenes, and the older 

351 ``decision=fallback`` marker was still the only statement on the pull request: on 

352 CI, where there is no ledger, it pinned the host-bench contract onto a change the 

353 panel had just reviewed. Last-wins needs both runs to speak, so both do. 

354 

355 The marker is a mirror of the ledger record like every other line here, never a 

356 second source of truth. 

357 """ 

358 panel = block.get("jury_panel") 

359 panel = panel if isinstance(panel, dict) else {} 

360 decision = panel.get("decision") 

361 if decision not in PANEL_DECISIONS: 

362 return [] 

363 label, outcome = PANEL_DECISIONS[decision] 

364 return [ 

365 f"- **Jury panel:** {label} — {outcome}{_panel_detail(panel)}", 

366 *_jury_panel_marker(head_sha, decision), 

367 ] 

368 

369 

370def _jury_panel_marker(head_sha: Any, decision: str) -> list[str]: 

371 """``<!-- keel.jury-panel.v1 head=… decision=… -->``, or nothing without a head. 

372 

373 A record whose run resolved no head is not pinnable by anything (see 

374 :func:`keel.juryavail.is_pinnable_head`), so a marker naming no head could only 

375 ever be noise a reader has to ignore. Omitted, the comment is exactly the one this 

376 renderer wrote before the marker existed. 

377 """ 

378 head = head_sha.strip() if isinstance(head_sha, str) and head_sha.strip() else None 

379 if head is None: 

380 return [] 

381 return [f"<!-- {JURY_PANEL_MARKER} head={head} decision={decision} -->"] 

382 

383 

384def _panel_detail(panel: dict[str, Any]) -> str: 

385 """`` (claude: not found on PATH; …)`` — the seats, or nothing when none named.""" 

386 unavailable = panel.get("unavailable") 

387 seats = [ 

388 f"{seat.get('provider')}: {seat.get('reason')}" 

389 for seat in (unavailable if isinstance(unavailable, list) else []) 

390 if isinstance(seat, dict) 

391 ] 

392 return f" ({'; '.join(seats)})" if seats else "" 

393 

394 

395#: How a test-first run names itself in the closure comment (#1020). 

396TDD_LABEL = "TDD" 

397 

398 

399def _implement_mode(block: dict[str, Any]) -> list[str]: 

400 """The s4 profile line — emitted only for a ``tdd`` run, a looped run, or both. 

401 

402 Conditional, unlike every other run-context field: ``default`` is what s4 has always 

403 done, and a line saying so on every closure comment keel has ever posted would be 

404 noise. A test-first run is the exception worth naming, and it names both phases' 

405 commits so a reader can check the order the gate checked. A looped run (#1165) is 

406 the other exception: it names every iteration's commit and what the gates said after 

407 it, which is the record a Ralph-style loop never leaves. 

408 """ 

409 parts = [part for part in (_tdd_part(block), _loop_part(block)) if part] 

410 if not parts: 

411 return [] 

412 return [f"- **Implement:** {' · '.join(parts)}"] 

413 

414 

415def _tdd_part(block: dict[str, Any]) -> str | None: 

416 if block.get("implement_mode") != _TDD_MODE: 

417 return None 

418 phases = block.get("implement_phases") 

419 parts = [ 

420 f"{phase.get('phase')} {_short(phase.get('commit'))}{_by(phase.get('implementer'))}" 

421 for phase in (phases if isinstance(phases, list) else []) 

422 if isinstance(phase, dict) 

423 ] 

424 detail = f" ({' → '.join(parts)})" if parts else "" 

425 return f"{TDD_LABEL}{detail}" 

426 

427 

428def _loop_part(block: dict[str, Any]) -> str | None: 

429 """``loop (k/N iterations: sha red → sha green)`` for a looped run, else nothing.""" 

430 record = block.get("implement_loop") 

431 if not isinstance(record, dict): 

432 return None 

433 iterations = [entry for entry in (record.get("iterations") or []) if isinstance(entry, dict)] 

434 if not iterations and not record.get("enabled"): 

435 return None 

436 budget = record.get("max_iterations") 

437 budget_text = str(budget) if isinstance(budget, int) else "?" 

438 if not iterations: 

439 return f"{LOOP_LABEL} (0/{budget_text} iterations recorded)" 

440 steps = " → ".join( 

441 f"{_short(entry.get('commit'))} {'green' if entry.get('gates_ok') else 'red'}" 

442 for entry in iterations 

443 ) 

444 if not record.get("enabled"): 

445 # A policy that was off bounded nothing, so a count over its nominal budget would 

446 # read as a fraction of a budget the run never had. 

447 noun = "iteration" if len(iterations) == 1 else "iterations" 

448 return f"{LOOP_LABEL} ({len(iterations)} {noun} recorded, policy off: {steps})" 

449 return f"{LOOP_LABEL} ({len(iterations)}/{budget_text} iterations: {steps})" 

450 

451 

452def _by(implementer: Any) -> str: 

453 """`` by <implementer>``, or nothing when the phase records none. 

454 

455 Rendered per phase rather than once for the run: the profile's whole premise is that 

456 one provider wrote the tests it then had to satisfy, so two different names here are 

457 the finding a reader is looking for. 

458 """ 

459 if isinstance(implementer, str) and implementer.strip(): 

460 return f" by {implementer.strip()}" 

461 return "" 

462 

463 

464def _short(sha: Any) -> str: 

465 if isinstance(sha, str) and sha.strip(): 

466 return sha.strip()[:7] 

467 return "unknown" 

468 

469 

470def _unknown(value: Any) -> str: 

471 if isinstance(value, str) and value.strip(): 

472 return value.strip() 

473 return "unknown" 

474 

475 

476def _jury_mode(value: Any) -> str: 

477 if isinstance(value, str) and value.strip(): 

478 return value.strip() 

479 return "off" 

480 

481 

482def _consent(consent: Any) -> str: 

483 block = consent if isinstance(consent, dict) else {} 

484 status = _unknown(block.get("status")) 

485 scopes = block.get("scopes") 

486 scopes = scopes if isinstance(scopes, list) else [] 

487 listed = [scope.strip() for scope in scopes if _is_scope(scope)] 

488 rendered = ", ".join(listed) if listed else "none" 

489 return f"{status} (scopes: {rendered})" 

490 

491 

492def _is_scope(scope: Any) -> bool: 

493 return isinstance(scope, str) and bool(scope.strip()) 

494 

495 

496def _value(value: Any) -> str: 

497 if isinstance(value, str) and value.strip(): 

498 return value.strip() 

499 return "none" 

500 

501 

502def _watermark(watermark: Any, run_context: Any) -> list[str]: 

503 """Render the attribution watermark signature. 

504 

505 Emitted by default. The renderer honours a ``watermark`` field on the record — 

506 ``False`` omits it, a string replaces it — but nothing in keel writes that field: 

507 there is no knob or flag for it, so only a hand-edited record changes it. 

508 

509 The default names ai-jury only when this run's record says a jury sat 

510 (:func:`_jury_sat`); a comment whose ``Jury`` line says ``off`` no longer claims 

511 ai-jury consensus beneath it. 

512 """ 

513 if watermark is False: 

514 return [] 

515 if isinstance(watermark, str) and watermark.strip(): 

516 return ["", "---", watermark.strip()] 

517 return ["", "---", watermark_body(jury=_jury_sat(run_context))] 

518 

519 

520def _jury_sat(run_context: Any) -> bool: 

521 """Did a jury review this run? Read off the record's ``run_context`` only. 

522 

523 True when ``jury_mode`` is ``gating`` or ``advisory`` and the recorded panel 

524 decision, if any, is not one where the panel failed to sit (``fallback`` / 

525 ``block``). A record with no run context, or one written before the field existed, 

526 reads as no jury — the watermark never claims more than the record shows. 

527 """ 

528 block = run_context if isinstance(run_context, dict) else {} 

529 if _jury_mode(block.get("jury_mode")) not in _JURY_SAT_MODES: 

530 return False 

531 panel = block.get("jury_panel") 

532 decision = panel.get("decision") if isinstance(panel, dict) else None 

533 return decision not in _PANEL_DID_NOT_SIT