autoforge-cli 0.1.0a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/effects.py ADDED
@@ -0,0 +1,3210 @@
1
+ """Controller-owned external effects: records, completion contexts, decisions.
2
+
3
+ ADR 0004 moves every externally visible write of a phase from the agent to
4
+ the controller. This module is the pure part of that layer (D12.1): the
5
+ persisted shapes, their validation on load, and the reconciliation
6
+ decisions as functions of (record, what was read back). It does no I/O,
7
+ knows no provider and speaks no ``gh``; the reads and writes live in
8
+ :mod:`autoforge.effect_ops`, :mod:`autoforge.github` and
9
+ :mod:`autoforge.git_transport`.
10
+
11
+ The vocabulary
12
+ --------------
13
+ An **effect** is one controller write to one exact remote object (D2.1). Its
14
+ **record** (:class:`EffectRecord`) holds the kind, a deterministic identity,
15
+ the exact target, the precondition, the payload (the exact bytes to send,
16
+ redaction-invariant), the completion criterion, the attempt count, the
17
+ stage, the observed result, the owner binding and the record's position in
18
+ the phase's ordered plan (D2.2). The stages are closed (D2.3)::
19
+
20
+ intended -> attempted -> observed
21
+ \\ \\----> conflict
22
+ \\-----------------> observed | conflict
23
+
24
+ Only ``observed`` lets the consuming phase advance. A record lives for one
25
+ phase entry: the save that commits the phase drops the records, the entry
26
+ observation and the completion context together (D2.4).
27
+
28
+ The **entry observation** (:class:`EntryObservation`, D4.4) is what the
29
+ controller read before the phase's first launch, in a kind-neutral shape:
30
+ remote refs, the base revision, the marker-bearing objects of the
31
+ phase's identities, and the open PR headed at a ref, when the phase may
32
+ adopt one. It is the launch fence: a difference after the agent
33
+ returned is something published during the run (ADR 0004 §2.9).
34
+
35
+ The **completion context** (D4.6) is the validated result data the phase
36
+ consumes once its effects are observed, stored in the form its state field
37
+ takes. There is one closed schema per publishing phase; recovery completes
38
+ from it and never from a rendered payload or the run log.
39
+
40
+ Every value here is validated on load, and a value that fails is corruption
41
+ (:class:`~autoforge.errors.StateError`): never defaulted, clipped or
42
+ repaired from GitHub.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import hashlib
48
+ import json
49
+ import re
50
+ from collections.abc import Callable, Mapping, Sequence
51
+ from dataclasses import dataclass, field, replace
52
+ from enum import StrEnum
53
+ from typing import Any, NoReturn
54
+
55
+ from .claims import (
56
+ ACCEPTANCE,
57
+ FOLLOW_UP,
58
+ IMPLEMENTATION,
59
+ OPERATOR_COMMENT,
60
+ PROGRESS,
61
+ REVIEW,
62
+ MarkerKind,
63
+ render_acceptance_marker,
64
+ render_follow_up_marker,
65
+ render_implementation_marker,
66
+ render_operator_comment_marker,
67
+ render_progress_marker,
68
+ render_review_marker,
69
+ scan,
70
+ )
71
+ from .errors import ConfigurationError, ControlResultValidationError, StateError
72
+ from .read_context import MAX_LISTED_ISSUES
73
+ from .redaction import MAX_GROWTH_FACTOR, redact
74
+ from .replan_txn import MARKER_RE as REPLAN_MARKER_RE
75
+ from .replan_txn import TRANSACTION_ID_RE, scan_replan_markers
76
+ from .result_parser import (
77
+ FINDING_ID_RE,
78
+ FIX_RESOLUTIONS,
79
+ MAX_ACCEPTANCE_ACTIONS_PER_FIX,
80
+ MAX_FINDING_ID_CHARS,
81
+ MAX_FINDINGS_PER_REVIEW,
82
+ MAX_FIX_RATIONALE_CHARS,
83
+ MAX_OPERATOR_ACTIONS_PER_FIX,
84
+ MAX_RESOLUTIONS_PER_FIX,
85
+ MAX_ROADMAP_SECTION_CHARS,
86
+ MAX_URL_CHARS,
87
+ REVIEW_PROSE_SECTIONS,
88
+ Finding,
89
+ ReviewResult,
90
+ check_published_finding,
91
+ markdown_code_span,
92
+ operator_markup_problem,
93
+ published_markdown_problem,
94
+ published_payload_problem,
95
+ validate_acceptance_body,
96
+ validate_follow_up_body,
97
+ validate_follow_up_title,
98
+ validate_operator_comment_body,
99
+ validate_pr_body,
100
+ validate_pr_title,
101
+ validate_progress_text,
102
+ validate_review_section,
103
+ validate_roadmap_section,
104
+ )
105
+ from .transitions import Phase
106
+ from .validation import parse_comment_url, parse_issue_url, parse_pr_url
107
+
108
+ # -- bounds ------------------------------------------------------------------------
109
+
110
+ # D4.3: at most two issues per effect, the ``MAX_CLOSE_ATTEMPTS`` precedent.
111
+ # A rebase (D5.5) consumes an attempt too.
112
+ MAX_EFFECT_ATTEMPTS = 2
113
+
114
+ # GitHub's limit for an issue, PR or comment body, and for a title, re-checked
115
+ # against the live API by #160 (D2.4). ``github.py`` refuses a write over
116
+ # either before sending it; the record refuses to hold one.
117
+ MAX_BODY_CHARS = 65536
118
+ MAX_TITLE_CHARS = 256
119
+
120
+ # D2.4: the largest plan is FIX: one push, one follow-up effect per deferred
121
+ # finding, and (#243) up to ``MAX_OPERATOR_ACTIONS_PER_FIX`` operator comments
122
+ # for one more finding, so 49 K5/K6, 4 K10 and one K1 (54). A K9 plan (one
123
+ # acceptance comment for one finding) is smaller.
124
+ MAX_EFFECTS_PER_PLAN = (MAX_FINDINGS_PER_REVIEW - 1) + MAX_OPERATOR_ACTIONS_PER_FIX + 1
125
+
126
+ # D2.4's total bound, over every payload string of the plan and the
127
+ # completion context counted at its stored bound. ``json.dumps`` writes the
128
+ # state with ``ensure_ascii``, at most 12 bytes per character (a surrogate
129
+ # pair of ``\\uXXXX`` escapes), so the effect part of the state file stays
130
+ # under 12 MiB in the worst case, far below ``MAX_STATE_FILE_BYTES`` (64 MiB).
131
+ MAX_EFFECT_STATE_CHARS = 1 << 20
132
+
133
+ # The fixed separator of a body append (D5.5): base, separator, block.
134
+ APPEND_SEPARATOR = "\n\n"
135
+
136
+ # A conflict reason names objects and a rule; it is controller prose, redacted
137
+ # and clipped at the writer and bounded on load.
138
+ MAX_CONFLICT_REASON_CHARS = 4096
139
+
140
+ # The entry observation: a phase reads at most a few refs, and one object per
141
+ # identity (an open finding's follow-up, an earlier round's deferral, a PR).
142
+ MAX_OBSERVED_REFS = 4
143
+ MAX_OBSERVED_OBJECTS = 4 * MAX_FINDINGS_PER_REVIEW
144
+ MAX_OBSERVED_KEY_CHARS = 2048
145
+
146
+ _SHA_RE = re.compile(r"^[0-9a-f]{40}\Z")
147
+ _DIGEST_RE = re.compile(r"^[0-9a-f]{64}\Z")
148
+ _REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\Z")
149
+ _MAX_REPOSITORY_CHARS = 200
150
+ _MAX_REF_CHARS = 255
151
+ # What ``git check-ref-format`` refuses in a component, plus whitespace: the
152
+ # record holds a ref the controller derived, so anything outside this is
153
+ # corruption, not an exotic but valid branch name.
154
+ _REF_FORBIDDEN_RE = re.compile(r"[\x00-\x20\x7f~^:?*\[\\]|\.\.|@\{|//|/\.|\.lock(?:/|\Z)")
155
+ _TEXT_CONTROL_RE = re.compile(r"[\x00\x7f]")
156
+ _RATIONALE_CONTROL_RE = re.compile(r"(?![\n\t])[\x00-\x1f\x7f-\x9f\u2028\u2029]")
157
+
158
+
159
+ def sha256_text(text: str) -> str:
160
+ """The SHA-256 hex digest of ``text`` as UTF-8 (a body base, an outside-markers body)."""
161
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
162
+
163
+
164
+ def compose_append(base: str, block: str) -> str:
165
+ """The payload of a body append: the base, the fixed separator, the block (D5.5)."""
166
+ return f"{base}{APPEND_SEPARATOR}{block}"
167
+
168
+
169
+ def progress_comment_body(progress: str, marker: str) -> str:
170
+ """K8's payload: the validated progress text, a blank line, the marker (§2.5)."""
171
+ return f"{progress}{APPEND_SEPARATOR}{marker}"
172
+
173
+
174
+ # The prose sections of a review comment, in rendered order, under their
175
+ # fixed headings (#162).
176
+ REVIEW_SECTION_HEADINGS: tuple[tuple[str, str], ...] = (
177
+ ("spec", "Spec"),
178
+ ("standards", "Standards"),
179
+ ("assessment", "Assessment"),
180
+ ("observations", "Observations"),
181
+ ("verification", "Verification"),
182
+ ("summary", "Summary"),
183
+ )
184
+
185
+
186
+ def _review_comment_parts(
187
+ result: ReviewResult, head: str, base: str, merge_base: str
188
+ ) -> tuple[str, str]:
189
+ """The round's review comment up to its marker, and the marker (#162)."""
190
+ lines = [
191
+ f"# AI Code Review — Round {result.round}",
192
+ "",
193
+ f"Reviewed HEAD: {markdown_code_span(head)} against base {markdown_code_span(base)} "
194
+ f"(merge base {markdown_code_span(merge_base)})",
195
+ "",
196
+ "## Findings",
197
+ ]
198
+ for finding in result.findings:
199
+ heading = f"### {finding.id} [{finding.classification}]"
200
+ if finding.location:
201
+ heading += f" {markdown_code_span(finding.location)}"
202
+ if finding.title:
203
+ heading += f" — {finding.title}"
204
+ # Every field is a block of its own, at column 0 between blank lines,
205
+ # so GitHub reads it in the context it was judged in alone: a title's
206
+ # unpaired backtick ends with its heading line, and a resolution's
207
+ # fence opens where the resolution starts.
208
+ lines.extend(["", heading, "", "Required resolution:", "", finding.required_resolution])
209
+ if not result.findings:
210
+ lines.extend(["", "None."])
211
+ for key, heading in REVIEW_SECTION_HEADINGS:
212
+ lines.extend(["", f"## {heading}", "", result.sections[key]])
213
+ verdict = "YES" if result.needs_fix_round else "NO"
214
+ lines.extend(["", f"Needs another fix round: {verdict}"])
215
+ marker = render_review_marker(
216
+ result.round,
217
+ head,
218
+ base,
219
+ merge_base,
220
+ result.needs_fix_round,
221
+ [finding.id for finding in result.findings],
222
+ )
223
+ return "\n".join(lines), marker
224
+
225
+
226
+ def review_comment_body(result: ReviewResult, head: str, base: str, merge_base: str) -> str:
227
+ """K4's payload (#162): the round's review comment, rendered from the validated result.
228
+
229
+ The heading, the binding line (``head``, ``base`` and ``merge_base`` are
230
+ the controller's binding of the round, never the reviewer's), the
231
+ findings' layout, the needs-fix line and the marker are the controller's
232
+ rendering; each finding's title, location and required resolution, and
233
+ the reviewer's prose sections under their fixed headings, are blocks of
234
+ their own. The marker's ``needs_fix_round`` and ``finding_ids`` and the
235
+ needs-fix line come from the same result, so they cannot disagree with
236
+ it or with each other.
237
+ """
238
+ text, marker = _review_comment_parts(result, head, base, merge_base)
239
+ return f"{text}{APPEND_SEPARATOR}{marker}"
240
+
241
+
242
+ def review_comment_problem(
243
+ result: ReviewResult, head: str, base: str, merge_base: str
244
+ ) -> str | None:
245
+ """Why the comment :func:`review_comment_body` renders may not be published, or ``None``.
246
+
247
+ Each field passed the published-content policy alone
248
+ (``check_published_finding``, ``validate_review_section``); the comment
249
+ is judged as a whole too, because fields join (#162). The credential rule
250
+ applies to the whole body (D8.3), and the mention rule to everything
251
+ before the controller's marker (D8.2): one field's unclosed fence or raw
252
+ HTML can make a later field's code text. Judged before the result is
253
+ accepted and again when a saved plan is loaded, so recovery never posts
254
+ a comment the result path would have refused.
255
+ """
256
+ text, marker = _review_comment_parts(result, head, base, merge_base)
257
+ return published_payload_problem(
258
+ "review comment", f"{text}{APPEND_SEPARATOR}{marker}"
259
+ ) or published_markdown_problem("review comment", text)
260
+
261
+
262
+ def implementation_closing_block(issue_url: str) -> str:
263
+ """What K2's body ends with and K3 appends: ``Closes #n``, a blank line, the marker (#161)."""
264
+ number = parse_issue_url(issue_url).number
265
+ return f"Closes #{number}{APPEND_SEPARATOR}{render_implementation_marker(issue_url)}"
266
+
267
+
268
+ def replacement_pr_body(text: str, issue_url: str, transaction_marker: str) -> str:
269
+ """K7's body: the agent's text, ``Closes #n`` and the marker, then the transaction marker.
270
+
271
+ The controller renders everything after the agent's text (#164): the
272
+ closing block of the issue (:func:`implementation_closing_block`) and
273
+ the transaction's marker with the validated counts.
274
+ """
275
+ return compose_append(
276
+ compose_append(text, implementation_closing_block(issue_url)), transaction_marker
277
+ )
278
+
279
+
280
+ def follow_up_reference(pr_url: str, finding_id: str) -> str:
281
+ """The controller's line in a follow-up issue it creates: the PR and finding (#163)."""
282
+ return f"Deferred from finding `{finding_id}` of {parse_pr_url(pr_url).canonical}."
283
+
284
+
285
+ def follow_up_issue_body(text: str, pr_url: str, finding_id: str) -> str:
286
+ """K5's body: the agent's text, the controller's reference, then the finding's marker (#163)."""
287
+ return (
288
+ f"{text}{APPEND_SEPARATOR}{follow_up_reference(pr_url, finding_id)}"
289
+ f"{APPEND_SEPARATOR}{render_follow_up_marker(pr_url, finding_id)}"
290
+ )
291
+
292
+
293
+ def acceptance_comment_header(issue_url: str, pr_url: str, round_: int, finding_id: str) -> str:
294
+ """The controller's line that opens an acceptance comment (#211).
295
+
296
+ It names the issue, the PR, the review round and the finding, and says
297
+ whose the text below it is: the FIX agent's, published by the
298
+ controller (or posted by the operator) as the evidence the finding asks
299
+ for, never as the controller's own statement.
300
+ """
301
+ return (
302
+ f"**AutoForge acceptance evidence** for finding `{finding_id}` of review round "
303
+ f"{round_} of {parse_pr_url(pr_url).canonical}, on "
304
+ f"{parse_issue_url(issue_url).canonical}. The text below is the FIX agent's."
305
+ )
306
+
307
+
308
+ def acceptance_comment_body(
309
+ text: str, issue_url: str, pr_url: str, round_: int, finding_id: str
310
+ ) -> str:
311
+ """K9's payload: the controller's header, the agent's text, then the finding's marker (#211)."""
312
+ return (
313
+ f"{acceptance_comment_header(issue_url, pr_url, round_, finding_id)}"
314
+ f"{APPEND_SEPARATOR}{text}{APPEND_SEPARATOR}"
315
+ f"{render_acceptance_marker(issue_url, pr_url, round_, finding_id)}"
316
+ )
317
+
318
+
319
+ def acceptance_comment_problem(body: str, finding_id: str) -> str | None:
320
+ """Why the rendered acceptance comment of ``finding_id`` may not be published, or ``None``.
321
+
322
+ The parser judged the agent's text alone; this judges the comment the
323
+ controller composes from it (ADR 0004 D8.2, D8.3, D8.6): the credential
324
+ rule on the whole payload, the mention rule on everything before the
325
+ marker, and GitHub's comment limit. Names the payload, never the text.
326
+ """
327
+ subject = f"acceptance comment of {finding_id}"
328
+ if len(body) > MAX_BODY_CHARS:
329
+ return (
330
+ f"the rendered {subject!r} is {len(body)} characters, over GitHub's comment limit "
331
+ f"of {MAX_BODY_CHARS}; shorten the acceptance_action body and re-emit the "
332
+ "CONTROL_RESULT"
333
+ )
334
+ marker_at = body.rfind(APPEND_SEPARATOR)
335
+ return published_payload_problem(subject, body) or published_markdown_problem(
336
+ subject, body[:marker_at] if marker_at >= 0 else body
337
+ )
338
+
339
+
340
+ def operator_comment_header(
341
+ issue_url: str, pr_url: str, round_: int, finding_id: str, target_url: str
342
+ ) -> str:
343
+ """The controller's line that opens an operator comment (K10, #243).
344
+
345
+ It names the run's issue, the PR, the review round, the finding and the
346
+ issue the comment is for, and says whose the text below it is: the FIX
347
+ agent's, prepared for the operator to post, and verified (never posted)
348
+ by the controller.
349
+ """
350
+ return (
351
+ f"**AutoForge operator action** for finding `{finding_id}` of review round {round_} "
352
+ f"of {parse_pr_url(pr_url).canonical} ({parse_issue_url(issue_url).canonical}), on "
353
+ f"{parse_issue_url(target_url).canonical}. The text below was prepared by the FIX "
354
+ "agent for the operator to post; the controller only verifies it."
355
+ )
356
+
357
+
358
+ def operator_comment_body(
359
+ text: str, issue_url: str, pr_url: str, round_: int, finding_id: str, target_url: str
360
+ ) -> str:
361
+ """K10's payload: the controller's header, the agent's text, then the action's marker."""
362
+ return (
363
+ f"{operator_comment_header(issue_url, pr_url, round_, finding_id, target_url)}"
364
+ f"{APPEND_SEPARATOR}{text}{APPEND_SEPARATOR}"
365
+ f"{render_operator_comment_marker(issue_url, pr_url, round_, finding_id, target_url)}"
366
+ )
367
+
368
+
369
+ def operator_comment_problem(body: str, finding_id: str, target_url: str) -> str | None:
370
+ """Why the rendered operator comment may not be offered for posting, or ``None`` (#243).
371
+
372
+ The comment as a whole (ADR 0004 D8.2, D8.3, D8.6): the credential rule
373
+ on the whole payload, the mention, image and raw-HTML rules on
374
+ everything before the marker, and GitHub's comment limit. Names the
375
+ payload, never the text.
376
+ """
377
+ subject = f"operator comment of {finding_id} for {target_url}"
378
+ if len(body) > MAX_BODY_CHARS:
379
+ return (
380
+ f"the rendered {subject!r} is {len(body)} characters, over GitHub's comment limit "
381
+ f"of {MAX_BODY_CHARS}; shorten the operator_acceptance body and re-emit the "
382
+ "CONTROL_RESULT"
383
+ )
384
+ marker_at = body.rfind(APPEND_SEPARATOR)
385
+ markdown = body[:marker_at] if marker_at >= 0 else body
386
+ return (
387
+ published_payload_problem(subject, body)
388
+ or published_markdown_problem(subject, markdown)
389
+ or operator_markup_problem(f"rendered {subject}", markdown)
390
+ )
391
+
392
+
393
+ def normalized_comment_text(text: str) -> str:
394
+ """``text`` with CRLF/CR line endings as LF and trailing whitespace dropped per line.
395
+
396
+ Never a comparison of record (K10 compares exact bytes): it only lets a
397
+ conflict reason say that two bodies differ in nothing else (#243).
398
+ """
399
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
400
+ return "\n".join(line.rstrip() for line in lines).rstrip()
401
+
402
+
403
+ # -- closed sets -------------------------------------------------------------------
404
+
405
+
406
+ class EffectKind(StrEnum):
407
+ """The closed effect-kind set (D5.1). Adding one is a protocol bump."""
408
+
409
+ PUSH = "push" # K1
410
+ IMPLEMENTATION_PR = "implementation_pr" # K2
411
+ ADOPT_PR = "adopt_pr" # K3
412
+ REVIEW_COMMENT = "review_comment" # K4
413
+ FOLLOW_UP_ISSUE = "follow_up_issue" # K5
414
+ FOLLOW_UP_APPEND = "follow_up_append" # K6
415
+ REPLACEMENT_PR = "replacement_pr" # K7
416
+ PROGRESS_COMMENT = "progress_comment" # K8
417
+ ACCEPTANCE_COMMENT = "acceptance_comment" # K9 (#211)
418
+ OPERATOR_COMMENT = "operator_comment" # K10 (#243): operator-performed, never issued
419
+
420
+
421
+ class Stage(StrEnum):
422
+ """D2.3: the closed stage set of a record."""
423
+
424
+ INTENDED = "intended" # validated, persisted, never issued
425
+ ATTEMPTED = "attempted" # the count is persisted before each issue
426
+ OBSERVED = "observed" # the read-back matched; terminal
427
+ CONFLICT = "conflict" # BLOCKED, naming the object
428
+
429
+
430
+ # The phases whose external writes are effects, and which kinds each may plan.
431
+ PUBLISHING_PHASES = frozenset(
432
+ {
433
+ Phase.ANALYZE_EXECUTE,
434
+ Phase.REVIEW,
435
+ Phase.FIX,
436
+ Phase.REPLAN_REEXECUTE,
437
+ Phase.UPDATE_EPIC,
438
+ }
439
+ )
440
+ KIND_PHASES: Mapping[EffectKind, frozenset[Phase]] = {
441
+ EffectKind.PUSH: frozenset({Phase.ANALYZE_EXECUTE, Phase.FIX, Phase.REPLAN_REEXECUTE}),
442
+ EffectKind.IMPLEMENTATION_PR: frozenset({Phase.ANALYZE_EXECUTE}),
443
+ EffectKind.ADOPT_PR: frozenset({Phase.ANALYZE_EXECUTE}),
444
+ EffectKind.REVIEW_COMMENT: frozenset({Phase.REVIEW}),
445
+ EffectKind.FOLLOW_UP_ISSUE: frozenset({Phase.FIX}),
446
+ EffectKind.FOLLOW_UP_APPEND: frozenset({Phase.FIX}),
447
+ EffectKind.REPLACEMENT_PR: frozenset({Phase.REPLAN_REEXECUTE}),
448
+ EffectKind.PROGRESS_COMMENT: frozenset({Phase.UPDATE_EPIC}),
449
+ EffectKind.ACCEPTANCE_COMMENT: frozenset({Phase.FIX}),
450
+ EffectKind.OPERATOR_COMMENT: frozenset({Phase.FIX}),
451
+ }
452
+
453
+ # D13.3: the contract a launch ran under, recorded in every pre-launch save of
454
+ # a publishing phase. A phase is controller-published once the running version
455
+ # performs its writes itself; #161-#164 added theirs as they landed, so every
456
+ # publishing phase now is.
457
+ LABEL_NONE = ""
458
+ LABEL_AGENT_PUBLISHES = "agent_publishes"
459
+ LABEL_CONTROLLER_PUBLISHES = "controller_publishes"
460
+ LAUNCH_LABELS = frozenset({LABEL_NONE, LABEL_AGENT_PUBLISHES, LABEL_CONTROLLER_PUBLISHES})
461
+ CONTROLLER_PUBLISHED_PHASES = frozenset(
462
+ {Phase.ANALYZE_EXECUTE, Phase.REVIEW, Phase.FIX, Phase.REPLAN_REEXECUTE, Phase.UPDATE_EPIC}
463
+ )
464
+
465
+
466
+ def launch_label_for(phase: Phase) -> str:
467
+ """The label the pre-launch save of ``phase`` records (D13.3)."""
468
+ if phase not in PUBLISHING_PHASES:
469
+ return LABEL_NONE
470
+ if phase in CONTROLLER_PUBLISHED_PHASES:
471
+ return LABEL_CONTROLLER_PUBLISHES
472
+ return LABEL_AGENT_PUBLISHES
473
+
474
+
475
+ def is_legacy_reentry(phase: Phase, attempt: int, label: str) -> bool:
476
+ """D13.3: a resumed launch ran under the agent-publishing contract of a phase
477
+ this version publishes itself, so its objects are pre-existing, once."""
478
+ return attempt >= 1 and label == LABEL_AGENT_PUBLISHES and phase in CONTROLLER_PUBLISHED_PHASES
479
+
480
+
481
+ # -- field checks ------------------------------------------------------------------
482
+
483
+ Checker = Callable[[object, str], None]
484
+
485
+
486
+ def _fail(what: str, message: str) -> NoReturn:
487
+ raise StateError(f"{what} {message}")
488
+
489
+
490
+ def _exact(raw: object, keys: Sequence[str], what: str, optional: Sequence[str] = ()) -> dict:
491
+ if not isinstance(raw, dict):
492
+ _fail(what, f"must be an object, got {type(raw).__name__}")
493
+ unknown = sorted(str(k) for k in raw if k not in keys and k not in optional)
494
+ if unknown:
495
+ _fail(what, f"has unknown key(s) {unknown}")
496
+ missing = [k for k in keys if k not in raw]
497
+ if missing:
498
+ _fail(what, f"is missing key(s) {missing}")
499
+ return raw
500
+
501
+
502
+ def _str(value: object, what: str, max_chars: int) -> str:
503
+ if not isinstance(value, str):
504
+ _fail(what, f"must be a string, got {type(value).__name__}")
505
+ if len(value) > max_chars:
506
+ _fail(what, f"is {len(value)} characters, over its bound of {max_chars}")
507
+ return value
508
+
509
+
510
+ def _int(value: object, what: str, minimum: int = 0, maximum: int | None = None) -> int:
511
+ if not isinstance(value, int) or isinstance(value, bool):
512
+ _fail(what, f"must be an integer, got {type(value).__name__}")
513
+ if value < minimum or (maximum is not None and value > maximum):
514
+ bound = f">= {minimum}" if maximum is None else f"in [{minimum}, {maximum}]"
515
+ _fail(what, f"must be {bound}, got {value}")
516
+ return value
517
+
518
+
519
+ def _bool(value: object, what: str) -> bool:
520
+ if not isinstance(value, bool):
521
+ _fail(what, f"must be a boolean, got {type(value).__name__}")
522
+ return value
523
+
524
+
525
+ def _sha(value: object, what: str) -> None:
526
+ if not isinstance(value, str) or not _SHA_RE.match(value):
527
+ _fail(what, "must be a full 40-character lowercase commit SHA")
528
+
529
+
530
+ def _opt_sha(value: object, what: str) -> None:
531
+ if value is not None:
532
+ _sha(value, what)
533
+
534
+
535
+ def _digest(value: object, what: str) -> None:
536
+ if not isinstance(value, str) or not _DIGEST_RE.match(value):
537
+ _fail(what, "must be a 64-character lowercase SHA-256 hex digest")
538
+
539
+
540
+ def _opt_digest(value: object, what: str) -> None:
541
+ if value is not None:
542
+ _digest(value, what)
543
+
544
+
545
+ def _repository(value: object, what: str) -> None:
546
+ text = _str(value, what, _MAX_REPOSITORY_CHARS)
547
+ if not _REPOSITORY_RE.match(text):
548
+ _fail(what, "must be an <owner>/<repo> repository name")
549
+
550
+
551
+ def _branch(value: object, what: str) -> None:
552
+ text = _str(value, what, _MAX_REF_CHARS)
553
+ if (
554
+ not text
555
+ or text.startswith(("-", "/", "refs/"))
556
+ or text.endswith(("/", "."))
557
+ or _REF_FORBIDDEN_RE.search(text)
558
+ ):
559
+ _fail(what, "must be a plain branch name")
560
+
561
+
562
+ def _ref(value: object, what: str) -> None:
563
+ text = _str(value, what, _MAX_REF_CHARS)
564
+ if not text.startswith("refs/heads/"):
565
+ _fail(what, "must be a full refs/heads/<branch> ref")
566
+ _branch(text.removeprefix("refs/heads/"), what)
567
+
568
+
569
+ def _canonical(value: object, what: str, parse: Callable[[str], object]) -> str:
570
+ text = _str(value, what, MAX_URL_CHARS)
571
+ try:
572
+ ref = parse(text)
573
+ except ConfigurationError:
574
+ _fail(what, "is not a GitHub URL of the expected kind")
575
+ if getattr(ref, "canonical", None) != text:
576
+ _fail(what, "must be stored in its canonical form")
577
+ return text
578
+
579
+
580
+ def _issue_url(value: object, what: str) -> None:
581
+ _canonical(value, what, parse_issue_url)
582
+
583
+
584
+ def _opt_issue_url(value: object, what: str) -> None:
585
+ if value is not None:
586
+ _issue_url(value, what)
587
+
588
+
589
+ def _pr_url(value: object, what: str) -> None:
590
+ _canonical(value, what, parse_pr_url)
591
+
592
+
593
+ def _comment_url(value: object, what: str) -> None:
594
+ _canonical(value, what, parse_comment_url)
595
+
596
+
597
+ def _object_url(value: object, what: str) -> None:
598
+ """An issue, PR or comment URL: what an observed marker-bearing object is."""
599
+ text = _str(value, what, MAX_URL_CHARS)
600
+ parse: Callable[[str], object] = parse_comment_url if "#" in text else _parse_issue_or_pr
601
+ _canonical(text, what, parse)
602
+
603
+
604
+ def _parse_issue_or_pr(url: str) -> object:
605
+ try:
606
+ return parse_issue_url(url)
607
+ except ConfigurationError:
608
+ return parse_pr_url(url)
609
+
610
+
611
+ def _published_text(max_chars: int) -> Checker:
612
+ """Persisted payload text: bounded and redaction-invariant (D2.4, D8.3)."""
613
+
614
+ def check(value: object, what: str) -> None:
615
+ text = _str(value, what, max_chars)
616
+ if _TEXT_CONTROL_RE.search(text):
617
+ _fail(what, "carries a NUL or DEL character")
618
+ if redact(text) != text:
619
+ # Never quoted: the text is what the check refused to keep.
620
+ _fail(what, "is not redaction-invariant; a payload never holds a credential")
621
+
622
+ return check
623
+
624
+
625
+ def _title(value: object, what: str) -> None:
626
+ _published_text(MAX_TITLE_CHARS)(value, what)
627
+ assert isinstance(value, str)
628
+ if not value.strip() or "\n" in value or "\r" in value:
629
+ _fail(what, "must be one non-empty line")
630
+
631
+
632
+ def _marker_of(kind: MarkerKind) -> Checker:
633
+ def check(value: object, what: str) -> None:
634
+ text = _str(value, what, MAX_OBSERVED_KEY_CHARS)
635
+ found = scan(kind, text)
636
+ if found.defects or len(found.claims) != 1 or not kind.pattern.fullmatch(text):
637
+ _fail(what, f"must be exactly one well-formed {kind.name} marker")
638
+
639
+ return check
640
+
641
+
642
+ def _replan_marker(value: object, what: str) -> None:
643
+ text = _str(value, what, MAX_OBSERVED_KEY_CHARS)
644
+ found = scan_replan_markers(text)
645
+ if found.malformed or len(found.attestations) != 1 or not REPLAN_MARKER_RE.fullmatch(text):
646
+ _fail(what, "must be exactly one well-formed replan transaction marker")
647
+
648
+
649
+ def _marker_list(value: object, what: str) -> None:
650
+ if not isinstance(value, list) or not value:
651
+ _fail(what, "must be a non-empty list of ai-follow-up markers")
652
+ if len(value) > MAX_FINDINGS_PER_REVIEW:
653
+ _fail(what, f"holds {len(value)} markers, over {MAX_FINDINGS_PER_REVIEW}")
654
+ for i, item in enumerate(value):
655
+ _marker_of(FOLLOW_UP)(item, f"{what}[{i}]")
656
+ if len(set(value)) != len(value):
657
+ _fail(what, "repeats a marker")
658
+
659
+
660
+ def _absent(value: object, what: str) -> None:
661
+ if value is not True:
662
+ _fail(what, "must be true: a create's precondition is that no object has the identity")
663
+
664
+
665
+ # K9's authorization (#211): who allowed the one write. ``operator`` is an
666
+ # ``unblock --approve-action`` of the record's action id (none yet while the
667
+ # id is ""); ``policy`` is ``safety.acceptance_actions: auto``, which
668
+ # records the id when the plan is saved. Persisted with the plan, so a
669
+ # later configuration change never widens it.
670
+ AUTHORIZATION_OPERATOR = "operator"
671
+ AUTHORIZATION_POLICY = "policy"
672
+ AUTHORIZATIONS = (AUTHORIZATION_OPERATOR, AUTHORIZATION_POLICY)
673
+ ACTION_ID_RE = re.compile(r"^A-[0-9a-f]{16}\Z")
674
+
675
+
676
+ def _authorization(value: object, what: str) -> None:
677
+ if value not in AUTHORIZATIONS:
678
+ _fail(what, f"must be one of {AUTHORIZATIONS}")
679
+
680
+
681
+ def _approved_action_id(value: object, what: str) -> None:
682
+ text = _str(value, what, 32)
683
+ if text and not ACTION_ID_RE.match(text):
684
+ _fail(what, "must be empty or an action id of the form A-<16 hex>")
685
+
686
+
687
+ # K10 (#243): the action id prefix, distinct from K9's, so no K10 id ever
688
+ # matches ``--approve-action``; why a target was allowed; and the evidence
689
+ # class an observation records.
690
+ OPERATOR_ACTION_ID_RE = re.compile(r"^X-[0-9a-f]{16}\Z")
691
+ ALLOWED_BY_ISSUE_BODY = "issue_body"
692
+ ALLOWED_BY_EPIC = "epic"
693
+ ALLOWED_BY = (ALLOWED_BY_ISSUE_BODY, ALLOWED_BY_EPIC)
694
+ EVIDENCE_EXACT = "exact"
695
+ EVIDENCE_BOUND = "bound"
696
+ EVIDENCE_CLASSES = (EVIDENCE_EXACT, EVIDENCE_BOUND)
697
+ _BINDING_KEYS = ("comment_url", "comment_id", "body_sha256")
698
+
699
+
700
+ def _allowed_by(value: object, what: str) -> None:
701
+ if (
702
+ not isinstance(value, list)
703
+ or not value
704
+ or any(v not in ALLOWED_BY for v in value)
705
+ or value != sorted(set(value), key=ALLOWED_BY.index)
706
+ ):
707
+ _fail(what, f"must be a non-empty list of {ALLOWED_BY}, in that order, without repeats")
708
+
709
+
710
+ def _evidence(value: object, what: str) -> None:
711
+ if value not in EVIDENCE_CLASSES:
712
+ _fail(what, f"must be one of {EVIDENCE_CLASSES}")
713
+
714
+
715
+ def _binding(value: object, what: str) -> None:
716
+ """K10's operator binding: ``null``, or the comment the operator bound (#243)."""
717
+ if value is None:
718
+ return
719
+ data = _exact(value, _BINDING_KEYS, what)
720
+ _comment_url(data["comment_url"], f"{what}.comment_url")
721
+ _number(data["comment_id"], f"{what}.comment_id")
722
+ _digest(data["body_sha256"], f"{what}.body_sha256")
723
+ if parse_comment_url(data["comment_url"]).comment_id != data["comment_id"]:
724
+ _fail(what, "names another comment id than its URL's")
725
+
726
+
727
+ def _watermark(value: object, what: str) -> None:
728
+ _int(value, what)
729
+
730
+
731
+ def _opt_sha_value(value: object, what: str) -> None:
732
+ _opt_sha(value, what)
733
+
734
+
735
+ def _number(value: object, what: str) -> None:
736
+ _int(value, what, minimum=1)
737
+
738
+
739
+ # -- per-kind schemas ----------------------------------------------------------------
740
+
741
+
742
+ @dataclass(frozen=True)
743
+ class _KindSpec:
744
+ identity: Mapping[str, Checker]
745
+ target: Mapping[str, Checker]
746
+ precondition: Mapping[str, Checker]
747
+ payload: Mapping[str, Checker]
748
+ observed: Mapping[str, Checker]
749
+ completion: str
750
+ # K10 (#243): the operator performs the write and the controller only
751
+ # verifies it. Such a record is never attempted: ``attempting`` and
752
+ # ``drive`` refuse it, and the load cross-check refuses one that claims
753
+ # an attempt.
754
+ operator_performed: bool = False
755
+
756
+
757
+ _BODY = _published_text(MAX_BODY_CHARS)
758
+
759
+ KIND_SPECS: Mapping[EffectKind, _KindSpec] = {
760
+ EffectKind.PUSH: _KindSpec(
761
+ identity={"repository": _repository, "ref": _ref, "candidate_sha": _sha},
762
+ target={"repository": _repository, "ref": _ref},
763
+ precondition={"expected_old": _opt_sha_value, "base_sha": _sha},
764
+ payload={"sha": _sha},
765
+ observed={"sha": _sha},
766
+ completion="remote_ref_equals_candidate",
767
+ ),
768
+ EffectKind.IMPLEMENTATION_PR: _KindSpec(
769
+ identity={
770
+ "repository": _repository,
771
+ "marker": _marker_of(IMPLEMENTATION),
772
+ "head_branch": _branch,
773
+ },
774
+ target={"repository": _repository, "base": _branch, "head": _branch},
775
+ precondition={"absent": _absent},
776
+ payload={"title": _title, "body": _BODY},
777
+ observed={"url": _pr_url, "number": _number},
778
+ completion="one_pr_on_head_branch_matching_payload",
779
+ ),
780
+ EffectKind.ADOPT_PR: _KindSpec(
781
+ identity={"pr_url": _pr_url, "marker": _marker_of(IMPLEMENTATION)},
782
+ target={"pr_url": _pr_url},
783
+ precondition={"base_sha256": _digest},
784
+ payload={"body": _BODY, "block": _BODY},
785
+ observed={"url": _pr_url},
786
+ completion="body_equals_payload_marker_exactly_one",
787
+ ),
788
+ EffectKind.REVIEW_COMMENT: _KindSpec(
789
+ identity={"pr_url": _pr_url, "marker": _marker_of(REVIEW)},
790
+ target={"pr_url": _pr_url},
791
+ precondition={"absent": _absent},
792
+ payload={"body": _BODY},
793
+ observed={"url": _comment_url},
794
+ completion="round_comment_exactly_one_body_equals_payload",
795
+ ),
796
+ EffectKind.FOLLOW_UP_ISSUE: _KindSpec(
797
+ identity={"repository": _repository, "marker": _marker_of(FOLLOW_UP)},
798
+ target={"repository": _repository},
799
+ precondition={"absent": _absent, "watermark": _watermark},
800
+ payload={"title": _title, "body": _BODY},
801
+ observed={"url": _issue_url, "number": _number},
802
+ completion="one_issue_above_watermark_matching_payload",
803
+ ),
804
+ EffectKind.FOLLOW_UP_APPEND: _KindSpec(
805
+ identity={"issue_url": _issue_url, "markers": _marker_list},
806
+ target={"issue_url": _issue_url},
807
+ precondition={"base_sha256": _digest},
808
+ payload={"body": _BODY, "block": _BODY},
809
+ observed={"url": _issue_url},
810
+ completion="body_equals_payload_markers_exactly_one",
811
+ ),
812
+ EffectKind.REPLACEMENT_PR: _KindSpec(
813
+ identity={
814
+ "repository": _repository,
815
+ "transaction_marker": _replan_marker,
816
+ "head_branch": _branch,
817
+ },
818
+ target={"repository": _repository, "base": _branch, "head": _branch},
819
+ precondition={"absent": _absent, "watermark": _watermark},
820
+ payload={"title": _title, "body": _BODY},
821
+ observed={"url": _pr_url, "number": _number},
822
+ completion="replacement_bound_at_candidate_matching_payload",
823
+ ),
824
+ EffectKind.PROGRESS_COMMENT: _KindSpec(
825
+ identity={"epic_url": _issue_url, "marker": _marker_of(PROGRESS)},
826
+ target={"epic_url": _issue_url},
827
+ precondition={"absent": _absent},
828
+ payload={"body": _BODY},
829
+ observed={"url": _comment_url},
830
+ completion="progress_comment_exactly_one_body_equals_payload",
831
+ ),
832
+ EffectKind.ACCEPTANCE_COMMENT: _KindSpec(
833
+ identity={"issue_url": _issue_url, "marker": _marker_of(ACCEPTANCE)},
834
+ target={"issue_url": _issue_url},
835
+ precondition={
836
+ "absent": _absent,
837
+ "authorization": _authorization,
838
+ "approved_action_id": _approved_action_id,
839
+ },
840
+ payload={"body": _BODY},
841
+ observed={"url": _comment_url},
842
+ completion="acceptance_comment_exactly_one_body_equals_payload",
843
+ ),
844
+ EffectKind.OPERATOR_COMMENT: _KindSpec(
845
+ identity={"issue_url": _issue_url, "marker": _marker_of(OPERATOR_COMMENT)},
846
+ target={"issue_url": _issue_url},
847
+ precondition={
848
+ "allowed_by": _allowed_by,
849
+ "issue_body_sha256": _digest,
850
+ "binding": _binding,
851
+ },
852
+ payload={"body": _BODY},
853
+ observed={
854
+ "url": _comment_url,
855
+ "comment_id": _number,
856
+ "evidence": _evidence,
857
+ "body_sha256": _digest,
858
+ },
859
+ completion="operator_comment_exact_or_bound_in_complete_listing",
860
+ operator_performed=True,
861
+ ),
862
+ }
863
+
864
+
865
+ def _check_fields(raw: object, schema: Mapping[str, Checker], what: str) -> dict:
866
+ data = _exact(raw, tuple(schema), what)
867
+ for key, check in schema.items():
868
+ check(data[key], f"{what}.{key}")
869
+ return dict(data)
870
+
871
+
872
+ # -- the owner binding ---------------------------------------------------------------
873
+
874
+
875
+ def _same_url(a: str, b: str, parse: Callable[[str], Any]) -> bool:
876
+ if not a or not b:
877
+ return a == b
878
+ try:
879
+ return bool(parse(a).identity == parse(b).identity)
880
+ except ConfigurationError:
881
+ return False
882
+
883
+
884
+ @dataclass(frozen=True)
885
+ class Binding:
886
+ """What persisted effect state must agree with: the state it is loaded with.
887
+
888
+ ``phase`` is the state's phase. A record, an observation or a context
889
+ names a publishing phase; it must be the state's, unless the state is
890
+ ``BLOCKED`` or ``FAILED`` (a conflict, or a rejection at the bound,
891
+ stopped that phase), and then every piece names the same phase.
892
+ """
893
+
894
+ run_id: str
895
+ phase: Phase
896
+ issue_url: str
897
+ pr_url: str
898
+ review_round: int
899
+ transaction_id: str
900
+ open_finding_ids: tuple[str, ...] = ()
901
+
902
+ def check_phase(self, phase: Phase, what: str) -> None:
903
+ if phase not in PUBLISHING_PHASES:
904
+ _fail(what, f"names {phase.value}, which publishes nothing")
905
+ if self.phase in (Phase.BLOCKED, Phase.FAILED):
906
+ return
907
+ if phase != self.phase:
908
+ _fail(what, f"is bound to {phase.value} but the state is in {self.phase.value}")
909
+
910
+ def check_issue(self, issue_url: str, what: str) -> None:
911
+ if not _same_url(issue_url, self.issue_url, parse_issue_url):
912
+ _fail(what, "is bound to another issue than the state's current issue")
913
+
914
+ def check_pr(self, pr_url: str, what: str) -> None:
915
+ if not _same_url(pr_url, self.pr_url, parse_pr_url):
916
+ _fail(what, "is bound to another PR than the state's current PR")
917
+
918
+
919
+ @dataclass(frozen=True)
920
+ class EffectOwner:
921
+ """D2.2: the run, phase, issue, PR and (REPLAN_REEXECUTE) transaction of a record."""
922
+
923
+ run_id: str
924
+ phase: Phase
925
+ issue_url: str
926
+ pr_url: str
927
+ transaction_id: str
928
+
929
+ _KEYS = ("run_id", "phase", "issue_url", "pr_url", "transaction_id")
930
+
931
+ @classmethod
932
+ def from_dict(cls, raw: object, what: str) -> EffectOwner:
933
+ data = _exact(raw, cls._KEYS, what)
934
+ run_id = _str(data["run_id"], f"{what}.run_id", 128)
935
+ phase_text = _str(data["phase"], f"{what}.phase", 64)
936
+ try:
937
+ phase = Phase(phase_text)
938
+ except ValueError:
939
+ _fail(f"{what}.phase", f"is not a phase: {phase_text!r}")
940
+ _issue_url(data["issue_url"], f"{what}.issue_url")
941
+ pr_url = _str(data["pr_url"], f"{what}.pr_url", MAX_URL_CHARS)
942
+ if pr_url:
943
+ _pr_url(pr_url, f"{what}.pr_url")
944
+ txn = _str(data["transaction_id"], f"{what}.transaction_id", 32)
945
+ if phase == Phase.REPLAN_REEXECUTE:
946
+ if not TRANSACTION_ID_RE.match(txn):
947
+ _fail(f"{what}.transaction_id", "must be the 32-hex replan transaction id")
948
+ elif txn:
949
+ _fail(f"{what}.transaction_id", f"must be empty outside {Phase.REPLAN_REEXECUTE.value}")
950
+ return cls(run_id, phase, data["issue_url"], pr_url, txn)
951
+
952
+ def to_dict(self) -> dict:
953
+ return {
954
+ "run_id": self.run_id,
955
+ "phase": self.phase.value,
956
+ "issue_url": self.issue_url,
957
+ "pr_url": self.pr_url,
958
+ "transaction_id": self.transaction_id,
959
+ }
960
+
961
+ def check(self, binding: Binding, what: str) -> None:
962
+ if self.run_id != binding.run_id:
963
+ _fail(what, "is bound to another run than the state's")
964
+ binding.check_phase(self.phase, what)
965
+ binding.check_issue(self.issue_url, what)
966
+ binding.check_pr(self.pr_url, what)
967
+ if self.transaction_id != binding.transaction_id and self.phase == Phase.REPLAN_REEXECUTE:
968
+ _fail(what, "is bound to another replan transaction than the state's")
969
+
970
+
971
+ # -- the record ------------------------------------------------------------------------
972
+
973
+
974
+ @dataclass(frozen=True)
975
+ class EffectRecord:
976
+ """One effect of one phase entry (D2.1, D2.2). Immutable: a stage change is a new record."""
977
+
978
+ position: int
979
+ kind: EffectKind
980
+ owner: EffectOwner
981
+ identity: dict
982
+ target: dict
983
+ precondition: dict
984
+ payload: dict
985
+ completion: str
986
+ stage: Stage = Stage.INTENDED
987
+ attempts: int = 0
988
+ observed: dict | None = None
989
+ reason: str = ""
990
+
991
+ _KEYS = (
992
+ "position",
993
+ "kind",
994
+ "owner",
995
+ "identity",
996
+ "target",
997
+ "precondition",
998
+ "payload",
999
+ "completion",
1000
+ "stage",
1001
+ "attempts",
1002
+ "observed",
1003
+ "reason",
1004
+ )
1005
+
1006
+ # -- construction -------------------------------------------------------------
1007
+ @classmethod
1008
+ def plan(
1009
+ cls,
1010
+ position: int,
1011
+ kind: EffectKind,
1012
+ owner: EffectOwner,
1013
+ *,
1014
+ identity: dict,
1015
+ target: dict,
1016
+ precondition: dict,
1017
+ payload: dict,
1018
+ ) -> EffectRecord:
1019
+ """A new ``intended`` record, validated exactly as it will be on load.
1020
+
1021
+ A value that fails here is a controller bug or a payload the planner
1022
+ should have refused first (an oversized body, a credential); it is
1023
+ raised as :class:`StateError`, never persisted.
1024
+ """
1025
+ record = cls(
1026
+ position=position,
1027
+ kind=kind,
1028
+ owner=owner,
1029
+ identity=dict(identity),
1030
+ target=dict(target),
1031
+ precondition=dict(precondition),
1032
+ payload=dict(payload),
1033
+ completion=KIND_SPECS[kind].completion,
1034
+ )
1035
+ return cls.from_dict(record.to_dict())
1036
+
1037
+ @classmethod
1038
+ def from_dict(cls, raw: object, what: str = "effect record") -> EffectRecord:
1039
+ data = _exact(raw, cls._KEYS, what)
1040
+ position = _int(data["position"], f"{what}.position", 0, MAX_EFFECTS_PER_PLAN - 1)
1041
+ what = f"effect record {position}"
1042
+ kind_text = _str(data["kind"], f"{what}.kind", 64)
1043
+ try:
1044
+ kind = EffectKind(kind_text)
1045
+ except ValueError:
1046
+ _fail(f"{what}.kind", f"is not a Wave 1 effect kind: {kind_text!r}")
1047
+ spec = KIND_SPECS[kind]
1048
+ owner = EffectOwner.from_dict(data["owner"], f"{what}.owner")
1049
+ if owner.phase not in KIND_PHASES[kind]:
1050
+ _fail(what, f"is a {kind.value} effect, which {owner.phase.value} never plans")
1051
+ stage_text = _str(data["stage"], f"{what}.stage", 32)
1052
+ try:
1053
+ stage = Stage(stage_text)
1054
+ except ValueError:
1055
+ _fail(f"{what}.stage", f"is not a stage: {stage_text!r}")
1056
+ attempts = _int(data["attempts"], f"{what}.attempts", 0, MAX_EFFECT_ATTEMPTS)
1057
+ if stage == Stage.INTENDED and attempts != 0:
1058
+ _fail(what, "is intended but records an issued attempt")
1059
+ if stage == Stage.ATTEMPTED and attempts < 1:
1060
+ _fail(what, "is attempted but records no attempt")
1061
+ completion = data["completion"]
1062
+ if completion != spec.completion:
1063
+ _fail(f"{what}.completion", f"must be {spec.completion!r} for a {kind.value} effect")
1064
+ observed = data["observed"]
1065
+ if stage == Stage.OBSERVED:
1066
+ observed = _check_fields(observed, spec.observed, f"{what}.observed")
1067
+ elif observed is not None:
1068
+ _fail(f"{what}.observed", f"must be null while the record is {stage.value}")
1069
+ reason = _str(data["reason"], f"{what}.reason", MAX_CONFLICT_REASON_CHARS)
1070
+ # K10 (#243): an intended operator comment may keep the diagnostic of
1071
+ # its last failed verification (:meth:`diagnosed`); it stays pending.
1072
+ diagnosed = kind == EffectKind.OPERATOR_COMMENT and stage == Stage.INTENDED
1073
+ if (stage == Stage.CONFLICT and not reason) or (
1074
+ reason and stage != Stage.CONFLICT and not diagnosed
1075
+ ):
1076
+ _fail(
1077
+ f"{what}.reason",
1078
+ "is set exactly when the record is a conflict, or an intended operator comment "
1079
+ "whose last verification failed",
1080
+ )
1081
+ record = cls(
1082
+ position=position,
1083
+ kind=kind,
1084
+ owner=owner,
1085
+ identity=_check_fields(data["identity"], spec.identity, f"{what}.identity"),
1086
+ target=_check_fields(data["target"], spec.target, f"{what}.target"),
1087
+ precondition=_check_fields(
1088
+ data["precondition"], spec.precondition, f"{what}.precondition"
1089
+ ),
1090
+ payload=_check_fields(data["payload"], spec.payload, f"{what}.payload"),
1091
+ completion=completion,
1092
+ stage=stage,
1093
+ attempts=attempts,
1094
+ observed=observed,
1095
+ reason=reason,
1096
+ )
1097
+ _CROSS_CHECKS[kind](record, what)
1098
+ return record
1099
+
1100
+ def to_dict(self) -> dict:
1101
+ return {
1102
+ "position": self.position,
1103
+ "kind": self.kind.value,
1104
+ "owner": self.owner.to_dict(),
1105
+ "identity": dict(self.identity),
1106
+ "target": dict(self.target),
1107
+ "precondition": dict(self.precondition),
1108
+ "payload": dict(self.payload),
1109
+ "completion": self.completion,
1110
+ "stage": self.stage.value,
1111
+ "attempts": self.attempts,
1112
+ "observed": None if self.observed is None else dict(self.observed),
1113
+ "reason": self.reason,
1114
+ }
1115
+
1116
+ # -- stage transitions (D2.3, D4.1, D5.5) ---------------------------------------
1117
+ @property
1118
+ def pending(self) -> bool:
1119
+ """Not yet terminal: reconciled before anything else (D4.2)."""
1120
+ return self.stage in (Stage.INTENDED, Stage.ATTEMPTED)
1121
+
1122
+ @property
1123
+ def bound_left(self) -> bool:
1124
+ return self.attempts < MAX_EFFECT_ATTEMPTS
1125
+
1126
+ def attempting(self) -> EffectRecord:
1127
+ """The record saved before an issue: ``attempted``, count incremented (D4.1)."""
1128
+ if not self.pending:
1129
+ raise StateError(f"effect record {self.position} is {self.stage.value}; not issued")
1130
+ if not self.bound_left:
1131
+ raise StateError(f"effect record {self.position} has used its {MAX_EFFECT_ATTEMPTS}")
1132
+ if self.operator_performed:
1133
+ # K10 (#243): the operator performs it; the controller never
1134
+ # issues it, under any policy or approval.
1135
+ raise StateError(
1136
+ f"effect record {self.position} ({self.kind.value} {self.action_id}) is "
1137
+ "operator-performed; the controller never issues it"
1138
+ )
1139
+ if self.kind == EffectKind.ACCEPTANCE_COMMENT and not self.authorized:
1140
+ # The one write K9 makes needs its approval first (#211); the
1141
+ # cross-check refuses the attempted record on load as well.
1142
+ raise StateError(f"effect record {self.position} is not authorized; not issued")
1143
+ return replace(self, stage=Stage.ATTEMPTED, attempts=self.attempts + 1)
1144
+
1145
+ def rebasing(self, body: str) -> EffectRecord:
1146
+ """D5.5: from ``intended`` only, the append over ``body``, saved before its issue."""
1147
+ if self.kind not in APPEND_KINDS or self.stage != Stage.INTENDED:
1148
+ raise StateError(f"effect record {self.position} cannot be rebased")
1149
+ payload = {**self.payload, "body": compose_append(body, self.payload["block"])}
1150
+ rebased = replace(
1151
+ self,
1152
+ payload=payload,
1153
+ precondition={**self.precondition, "base_sha256": sha256_text(body)},
1154
+ ).attempting()
1155
+ return EffectRecord.from_dict(rebased.to_dict())
1156
+
1157
+ def observing(self, observed: dict) -> EffectRecord:
1158
+ if not self.pending:
1159
+ raise StateError(f"effect record {self.position} is {self.stage.value}; not observed")
1160
+ record = replace(self, stage=Stage.OBSERVED, observed=dict(observed), reason="")
1161
+ return EffectRecord.from_dict(record.to_dict())
1162
+
1163
+ def conflicting(self, reason: str) -> EffectRecord:
1164
+ return replace(self, stage=Stage.CONFLICT, reason=bounded_reason(reason))
1165
+
1166
+ def diagnosed(self, reason: str) -> EffectRecord:
1167
+ """K10 (#243): still ``intended``, with why its last verification failed, or "".
1168
+
1169
+ A changed or deleted bound comment, or a target that no longer
1170
+ qualifies or cannot be read, is kept as the record's redacted,
1171
+ bounded ``reason`` until the next verification replaces it or finds
1172
+ nothing wrong ("" clears it), so an offline ``status`` reports
1173
+ ``verification_failed`` with the reason (PR #254 R3-F3).
1174
+ """
1175
+ if self.kind != EffectKind.OPERATOR_COMMENT or self.stage != Stage.INTENDED:
1176
+ raise StateError(f"effect record {self.position} cannot carry a verification failure")
1177
+ return replace(self, reason=bounded_reason(reason) if reason else "")
1178
+
1179
+ def reopened(self) -> EffectRecord:
1180
+ """A ``conflict`` the operator resolved: reconciled again, as ``attempted``.
1181
+
1182
+ ``unblock`` back into the phase keeps the records (D2.4: they belong
1183
+ to the phase entry). A conflict carries no promise that nothing was
1184
+ sent, so it is reconciled as a record whose issue may have landed,
1185
+ with its count kept: the bound of D4.3 still holds across an unblock.
1186
+ """
1187
+ if self.stage != Stage.CONFLICT:
1188
+ return self
1189
+ if self.attempts == 0:
1190
+ return replace(self, stage=Stage.INTENDED, reason="")
1191
+ return replace(self, stage=Stage.ATTEMPTED, reason="")
1192
+
1193
+ # -- convenience ------------------------------------------------------------------
1194
+ @property
1195
+ def body(self) -> str:
1196
+ return str(self.payload.get("body", ""))
1197
+
1198
+ @property
1199
+ def marker(self) -> str:
1200
+ return str(self.identity.get("marker", ""))
1201
+
1202
+ @property
1203
+ def operator_performed(self) -> bool:
1204
+ """K10 (#243): the operator performs the write; the controller only verifies it."""
1205
+ return KIND_SPECS[self.kind].operator_performed
1206
+
1207
+ @property
1208
+ def action_id(self) -> str:
1209
+ """K9's ``A-`` or K10's ``X-`` action id, recomputed; "" for every other kind."""
1210
+ if self.kind == EffectKind.ACCEPTANCE_COMMENT:
1211
+ return acceptance_action_id(self.owner, self.identity, self.target, self.payload)
1212
+ if self.kind == EffectKind.OPERATOR_COMMENT:
1213
+ return operator_action_id(self.owner, self.identity, self.target, self.payload)
1214
+ return ""
1215
+
1216
+ @property
1217
+ def binding(self) -> dict | None:
1218
+ """K10: the comment the operator bound as evidence, or ``None`` (#243)."""
1219
+ value = self.precondition.get("binding")
1220
+ return dict(value) if isinstance(value, dict) else None
1221
+
1222
+ def binding_to(self, comment_url: str, comment_id: int, body_sha256: str) -> EffectRecord:
1223
+ """K10 bound by the operator to one existing comment, digest-pinned (#243).
1224
+
1225
+ The new binding supersedes the last verification's failure, which
1226
+ is cleared: the next verification is of this binding.
1227
+ """
1228
+ if self.kind != EffectKind.OPERATOR_COMMENT or self.stage != Stage.INTENDED:
1229
+ raise StateError(f"effect record {self.position} cannot be bound to evidence")
1230
+ binding = {"comment_url": comment_url, "comment_id": comment_id, "body_sha256": body_sha256}
1231
+ record = replace(self, precondition={**self.precondition, "binding": binding}, reason="")
1232
+ return EffectRecord.from_dict(record.to_dict())
1233
+
1234
+ @property
1235
+ def authorized(self) -> bool:
1236
+ """K9: an approval of exactly this record's action id is recorded (#211)."""
1237
+ approved = self.precondition.get("approved_action_id", "")
1238
+ return bool(approved) and approved == self.action_id
1239
+
1240
+ def approving(self) -> EffectRecord:
1241
+ """K9 approved by the operator: its recomputed action id recorded (#211)."""
1242
+ if self.kind != EffectKind.ACCEPTANCE_COMMENT or self.stage != Stage.INTENDED:
1243
+ raise StateError(f"effect record {self.position} cannot be approved")
1244
+ record = replace(
1245
+ self, precondition={**self.precondition, "approved_action_id": self.action_id}
1246
+ )
1247
+ return EffectRecord.from_dict(record.to_dict())
1248
+
1249
+ def describe(self) -> str:
1250
+ """``<kind> effect <position> on <target>``: names the object, never the payload."""
1251
+ target = (
1252
+ self.target.get("epic_url")
1253
+ or self.target.get("pr_url")
1254
+ or self.target.get("issue_url")
1255
+ or self.target.get("ref")
1256
+ or self.target.get("repository")
1257
+ )
1258
+ return f"{self.kind.value} effect {self.position} on {target}"
1259
+
1260
+
1261
+ APPEND_KINDS = frozenset({EffectKind.ADOPT_PR, EffectKind.FOLLOW_UP_APPEND})
1262
+
1263
+
1264
+ def bounded_reason(reason: str) -> str:
1265
+ """A conflict reason as it may be persisted: redacted, then clipped at the bound."""
1266
+ text = redact(reason or "conflict").strip() or "conflict"
1267
+ if len(text) > MAX_CONFLICT_REASON_CHARS:
1268
+ text = text[: MAX_CONFLICT_REASON_CHARS - 1] + "\u2026"
1269
+ return text
1270
+
1271
+
1272
+ # -- per-kind cross-field checks --------------------------------------------------------
1273
+
1274
+
1275
+ def _same(record: EffectRecord, what: str, *pairs: tuple[object, object, str]) -> None:
1276
+ for a, b, name in pairs:
1277
+ if a != b:
1278
+ _fail(what, f"has a {name} that differs between its identity and its target")
1279
+
1280
+
1281
+ def _marker_claim(kind: MarkerKind, marker: str): # noqa: ANN202 - the claim type varies
1282
+ return scan(kind, marker).claims[0]
1283
+
1284
+
1285
+ def _check_owner_issue_marker(record: EffectRecord, kind: MarkerKind, what: str) -> None:
1286
+ claim = _marker_claim(kind, record.identity["marker"])
1287
+ issue = getattr(claim, "issue", None)
1288
+ if issue is not None and not _same_url(
1289
+ issue.canonical, record.owner.issue_url, parse_issue_url
1290
+ ):
1291
+ _fail(what, "carries a marker for another issue than its owner's")
1292
+ pr = getattr(claim, "pr", None)
1293
+ if pr is not None and not _same_url(pr.canonical, record.owner.pr_url, parse_pr_url):
1294
+ _fail(what, "carries a marker for another PR than its owner's")
1295
+
1296
+
1297
+ def _check_body_ends_with_marker(record: EffectRecord, marker: str, what: str) -> None:
1298
+ if not record.body.endswith(marker):
1299
+ _fail(what, "has a payload body that does not end with its identity's marker")
1300
+
1301
+
1302
+ def _check_append(record: EffectRecord, markers: Sequence[str], what: str) -> None:
1303
+ block = record.payload["block"]
1304
+ tail = APPEND_SEPARATOR + block
1305
+ if not record.body.endswith(tail):
1306
+ _fail(what, "has a payload that is not its base, the separator and its block")
1307
+ base = record.body[: len(record.body) - len(tail)]
1308
+ if sha256_text(base) != record.precondition["base_sha256"]:
1309
+ _fail(what, "has a payload whose base does not match its recorded base digest")
1310
+ for marker in markers:
1311
+ if marker not in block:
1312
+ _fail(what, "has a block that does not carry its identity's marker")
1313
+
1314
+
1315
+ def _cross_push(record: EffectRecord, what: str) -> None:
1316
+ _same(
1317
+ record,
1318
+ what,
1319
+ (record.identity["repository"], record.target["repository"], "repository"),
1320
+ (record.identity["ref"], record.target["ref"], "ref"),
1321
+ )
1322
+ if record.identity["candidate_sha"] != record.payload["sha"]:
1323
+ _fail(what, "pushes another SHA than its identity's candidate")
1324
+ if record.payload["sha"] == record.precondition["base_sha"]:
1325
+ _fail(what, "pushes the base itself; a candidate differs from its base")
1326
+ if record.observed is not None and record.observed["sha"] != record.payload["sha"]:
1327
+ _fail(what, "is observed at another SHA than its candidate")
1328
+
1329
+
1330
+ def _owner_closing_block(record: EffectRecord, repository: str, what: str) -> str:
1331
+ """The closing block of the owner's issue, for a PR in ``repository``.
1332
+
1333
+ ``Closes #n`` names issue ``n`` of the PR's own repository, so it is the
1334
+ owner's issue only when the PR is in that issue's repository.
1335
+ """
1336
+ if not parse_issue_url(record.owner.issue_url).same_repository(repository):
1337
+ _fail(what, "is in another repository than its owner's issue, which 'Closes #n' names")
1338
+ return implementation_closing_block(record.owner.issue_url)
1339
+
1340
+
1341
+ def _cross_implementation_pr(record: EffectRecord, what: str) -> None:
1342
+ _same(
1343
+ record,
1344
+ what,
1345
+ (record.identity["repository"], record.target["repository"], "repository"),
1346
+ (record.identity["head_branch"], record.target["head"], "head branch"),
1347
+ )
1348
+ _check_owner_issue_marker(record, IMPLEMENTATION, what)
1349
+ _check_body_ends_with_marker(record, record.identity["marker"], what)
1350
+ # The payload is the title and body the parser accepted, the body then
1351
+ # followed by a blank line and the controller's closing block; both texts
1352
+ # get the parser's rules again, because a resumed create publishes them
1353
+ # with no agent result in between. The credential rule over the whole
1354
+ # body is the record's redaction invariance.
1355
+ tail = APPEND_SEPARATOR + _owner_closing_block(record, record.target["repository"], what)
1356
+ if not record.body.endswith(tail):
1357
+ _fail(
1358
+ what,
1359
+ "has a payload body that is not its text, a blank line and the closing block "
1360
+ "of its owner's issue",
1361
+ )
1362
+ text = record.body[: len(record.body) - len(tail)]
1363
+ for key, value, validate in (
1364
+ ("title", str(record.payload["title"]), validate_pr_title),
1365
+ ("body", text, validate_pr_body),
1366
+ ):
1367
+ if value != value.strip():
1368
+ _fail(what, f"has a PR {key} that is not in the parser's stored form")
1369
+ try:
1370
+ validate(value)
1371
+ except ControlResultValidationError as exc:
1372
+ _fail(what, f"has an invalid PR {key}: {exc}")
1373
+
1374
+
1375
+ def _cross_adopt_pr(record: EffectRecord, what: str) -> None:
1376
+ _same(record, what, (record.identity["pr_url"], record.target["pr_url"], "PR"))
1377
+ _check_owner_issue_marker(record, IMPLEMENTATION, what)
1378
+ _check_append(record, (record.identity["marker"],), what)
1379
+ # The base is the adopted PR's own body, a human's text the controller
1380
+ # keeps as it is; only the block is the controller's, and it is exactly
1381
+ # the closing block of the owner's issue.
1382
+ repository = parse_pr_url(record.target["pr_url"]).repository
1383
+ if record.payload["block"] != _owner_closing_block(record, repository, what):
1384
+ _fail(what, "has a block that is not the closing block of its owner's issue")
1385
+ if record.observed is not None and record.observed["url"] != record.target["pr_url"]:
1386
+ _fail(what, "is observed on another PR than its target")
1387
+
1388
+
1389
+ def _cross_review_comment(record: EffectRecord, what: str) -> None:
1390
+ _same(record, what, (record.identity["pr_url"], record.target["pr_url"], "PR"))
1391
+ if not _same_url(record.target["pr_url"], record.owner.pr_url, parse_pr_url):
1392
+ _fail(what, "targets another PR than its owner's")
1393
+ # The review marker names a revision, not a PR: the comment is bound to its
1394
+ # PR by where it is posted, which the two checks above pin to the owner's.
1395
+ claim = _marker_claim(REVIEW, record.identity["marker"])
1396
+ if claim.reviewed_base_ref is None or claim.reviewed_merge_base_sha is None:
1397
+ _fail(what, "carries a review marker without the base it reviewed, which no round binds")
1398
+ _check_body_ends_with_marker(record, record.identity["marker"], what)
1399
+ # The rest of the body is checked where its source is: the REVIEW
1400
+ # completion context saved with it must render exactly this body from
1401
+ # fields that pass the parser's rules (ReviewContext.from_dict).
1402
+ if record.observed is not None and not parse_comment_url(record.observed["url"]).on(
1403
+ parse_pr_url(record.target["pr_url"])
1404
+ ):
1405
+ _fail(what, "is observed on another PR than its target")
1406
+
1407
+
1408
+ def _cross_follow_up_issue(record: EffectRecord, what: str) -> None:
1409
+ _same(record, what, (record.identity["repository"], record.target["repository"], "repo"))
1410
+ _check_owner_issue_marker(record, FOLLOW_UP, what)
1411
+ _check_body_ends_with_marker(record, record.identity["marker"], what)
1412
+ if not parse_pr_url(record.owner.pr_url).same_repository(record.target["repository"]):
1413
+ _fail(what, "creates a follow-up issue in another repository than its owner's PR")
1414
+ # The payload is the title and body the parser accepted, the body then
1415
+ # followed by the controller's reference to the PR and finding and the
1416
+ # marker (#163); both texts get the parser's rules again, because a
1417
+ # resumed create publishes them with no agent result in between.
1418
+ claim = _marker_claim(FOLLOW_UP, record.identity["marker"])
1419
+ tail = (
1420
+ f"{APPEND_SEPARATOR}{follow_up_reference(record.owner.pr_url, claim.finding_id)}"
1421
+ f"{APPEND_SEPARATOR}{record.identity['marker']}"
1422
+ )
1423
+ if not record.body.endswith(tail):
1424
+ _fail(
1425
+ what,
1426
+ "has a payload body that is not its text, the controller's reference to its PR "
1427
+ "and finding, and its marker",
1428
+ )
1429
+ text = record.body[: len(record.body) - len(tail)]
1430
+ for key, value, validate in (
1431
+ ("title", str(record.payload["title"]), validate_follow_up_title),
1432
+ ("body", text, validate_follow_up_body),
1433
+ ):
1434
+ if value != value.strip():
1435
+ _fail(what, f"has a follow-up {key} that is not in the parser's stored form")
1436
+ try:
1437
+ validate(value)
1438
+ except ControlResultValidationError as exc:
1439
+ _fail(what, f"has an invalid follow-up {key}: {exc}")
1440
+
1441
+
1442
+ def _cross_follow_up_append(record: EffectRecord, what: str) -> None:
1443
+ _same(record, what, (record.identity["issue_url"], record.target["issue_url"], "issue"))
1444
+ for marker in record.identity["markers"]:
1445
+ claim = _marker_claim(FOLLOW_UP, marker)
1446
+ if not _same_url(claim.pr.canonical, record.owner.pr_url, parse_pr_url):
1447
+ _fail(what, "carries a follow-up marker for another PR than its owner's")
1448
+ if record.payload["block"] != "\n".join(record.identity["markers"]):
1449
+ _fail(what, "has a block that is not its markers, one per line in plan order")
1450
+ _check_append(record, record.identity["markers"], what)
1451
+ if _same_url(record.target["issue_url"], record.owner.issue_url, parse_issue_url):
1452
+ _fail(what, "appends to the current issue, which is never a follow-up")
1453
+ if record.observed is not None and record.observed["url"] != record.target["issue_url"]:
1454
+ _fail(what, "is observed on another issue than its target")
1455
+
1456
+
1457
+ def _cross_replacement_pr(record: EffectRecord, what: str) -> None:
1458
+ _same(
1459
+ record,
1460
+ what,
1461
+ (record.identity["repository"], record.target["repository"], "repository"),
1462
+ (record.identity["head_branch"], record.target["head"], "head branch"),
1463
+ )
1464
+ attestation = scan_replan_markers(record.identity["transaction_marker"]).attestations[0]
1465
+ if attestation.transaction_id != record.owner.transaction_id:
1466
+ _fail(what, "carries the marker of another replan transaction than its owner's")
1467
+ if not attestation.tests_passed:
1468
+ _fail(what, "carries a marker attesting failed tests, which is never published")
1469
+ _check_body_ends_with_marker(record, record.identity["transaction_marker"], what)
1470
+ # The payload is the title and body the parser accepted, the body then
1471
+ # followed by the controller's closing block and transaction marker
1472
+ # (#164); both texts get the parser's rules again, because a resumed
1473
+ # create publishes them with no agent result in between.
1474
+ tail = (
1475
+ APPEND_SEPARATOR
1476
+ + _owner_closing_block(record, record.target["repository"], what)
1477
+ + APPEND_SEPARATOR
1478
+ + record.identity["transaction_marker"]
1479
+ )
1480
+ if not record.body.endswith(tail):
1481
+ _fail(
1482
+ what,
1483
+ "has a payload body that is not its text, the closing block of its owner's issue "
1484
+ "and its transaction marker",
1485
+ )
1486
+ text = record.body[: len(record.body) - len(tail)]
1487
+ for key, value, validate in (
1488
+ ("title", str(record.payload["title"]), validate_pr_title),
1489
+ ("body", text, validate_pr_body),
1490
+ ):
1491
+ if value != value.strip():
1492
+ _fail(what, f"has a PR {key} that is not in the parser's stored form")
1493
+ try:
1494
+ validate(value, phase=Phase.REPLAN_REEXECUTE.value)
1495
+ except ControlResultValidationError as exc:
1496
+ _fail(what, f"has an invalid PR {key}: {exc}")
1497
+ if record.observed is not None and not parse_pr_url(record.observed["url"]).same_repository(
1498
+ record.target["repository"]
1499
+ ):
1500
+ _fail(what, "is observed in another repository than its target")
1501
+
1502
+
1503
+ def _cross_progress_comment(record: EffectRecord, what: str) -> None:
1504
+ _same(record, what, (record.identity["epic_url"], record.target["epic_url"], "EPIC"))
1505
+ _check_owner_issue_marker(record, PROGRESS, what)
1506
+ _check_body_ends_with_marker(record, record.identity["marker"], what)
1507
+ # The payload is the progress text the parser accepted and the marker
1508
+ # (``progress_comment_body``); the text gets the parser's rules again,
1509
+ # because a resumed write publishes it with no agent result in between.
1510
+ tail = APPEND_SEPARATOR + record.identity["marker"]
1511
+ if not record.body.endswith(tail):
1512
+ _fail(what, "has a payload body that is not its progress text, a blank line and its marker")
1513
+ try:
1514
+ validate_progress_text(record.body[: len(record.body) - len(tail)])
1515
+ except ControlResultValidationError as exc:
1516
+ _fail(what, f"has an invalid progress text: {exc}")
1517
+ if record.observed is not None and not parse_comment_url(record.observed["url"]).on(
1518
+ parse_issue_url(record.target["epic_url"])
1519
+ ):
1520
+ _fail(what, "is observed on another issue than its EPIC")
1521
+
1522
+
1523
+ def acceptance_action_id(owner: EffectOwner, identity: dict, target: dict, payload: dict) -> str:
1524
+ """The id of one acceptance action (#211): ``A-`` and 16 hex digits.
1525
+
1526
+ The SHA-256 of a canonical encoding of the record's owner, identity,
1527
+ target and payload, so it names exactly one write of exactly these
1528
+ bytes to exactly this issue, for this run's FIX of this PR. It is
1529
+ recomputed whenever it is shown or used, never read as authority: an
1530
+ approval stores the id it approved, and a record whose stored approval
1531
+ is not its recomputed id fails to load.
1532
+ """
1533
+ return "A-" + _action_digest(owner, identity, target, payload)
1534
+
1535
+
1536
+ def operator_action_id(owner: EffectOwner, identity: dict, target: dict, payload: dict) -> str:
1537
+ """The id of one operator action (K10, #243): ``X-`` and 16 hex digits.
1538
+
1539
+ Computed as :func:`acceptance_action_id` is, over the same four parts
1540
+ (the binding is a precondition and never changes the id), and never
1541
+ stored as authority. Its prefix never matches ``--approve-action``.
1542
+ """
1543
+ return "X-" + _action_digest(owner, identity, target, payload)
1544
+
1545
+
1546
+ def _action_digest(owner: EffectOwner, identity: dict, target: dict, payload: dict) -> str:
1547
+ canonical = json.dumps(
1548
+ {
1549
+ "owner": owner.to_dict(),
1550
+ "identity": identity,
1551
+ "target": target,
1552
+ "payload": payload,
1553
+ },
1554
+ sort_keys=True,
1555
+ separators=(",", ":"),
1556
+ ensure_ascii=True,
1557
+ )
1558
+ return hashlib.sha256(canonical.encode("ascii")).hexdigest()[:16]
1559
+
1560
+
1561
+ def _cross_acceptance_comment(record: EffectRecord, what: str) -> None:
1562
+ _same(record, what, (record.identity["issue_url"], record.target["issue_url"], "issue"))
1563
+ if not _same_url(record.target["issue_url"], record.owner.issue_url, parse_issue_url):
1564
+ _fail(what, "comments on another issue than its owner's; K9 targets the run's issue")
1565
+ _check_owner_issue_marker(record, ACCEPTANCE, what)
1566
+ claim = _marker_claim(ACCEPTANCE, record.identity["marker"])
1567
+ # The payload is the controller's header, the text the parser accepted
1568
+ # and the marker (``acceptance_comment_body``); the text gets the
1569
+ # parser's rules again, because a resumed write publishes it with no
1570
+ # agent result in between.
1571
+ head = (
1572
+ acceptance_comment_header(
1573
+ record.owner.issue_url, record.owner.pr_url, claim.round, claim.finding_id
1574
+ )
1575
+ + APPEND_SEPARATOR
1576
+ )
1577
+ tail = APPEND_SEPARATOR + record.identity["marker"]
1578
+ body = record.body
1579
+ if not body.startswith(head) or not body.endswith(tail) or len(body) < len(head) + len(tail):
1580
+ _fail(
1581
+ what,
1582
+ "has a payload body that is not the controller's header, its text and its marker",
1583
+ )
1584
+ text = body[len(head) : len(body) - len(tail)]
1585
+ if text != text.strip():
1586
+ _fail(what, "has an acceptance text that is not in the parser's stored form")
1587
+ try:
1588
+ validate_acceptance_body(text)
1589
+ except ControlResultValidationError as exc:
1590
+ _fail(what, f"has an invalid acceptance text: {exc}")
1591
+ if body != acceptance_comment_body(
1592
+ text, record.owner.issue_url, record.owner.pr_url, claim.round, claim.finding_id
1593
+ ):
1594
+ _fail(what, "has a payload body that is not the controller's rendering of its text")
1595
+ authorization = record.precondition["authorization"]
1596
+ approved = record.precondition["approved_action_id"]
1597
+ if approved and approved != record.action_id:
1598
+ # An approval names the bytes and the target it approved: one that
1599
+ # is not this record's id approved something else.
1600
+ _fail(f"{what}.precondition.approved_action_id", "is not the record's action id")
1601
+ if authorization == AUTHORIZATION_POLICY and not approved:
1602
+ _fail(what, "is authorized by policy but records no approved action id")
1603
+ if record.attempts and not approved:
1604
+ _fail(what, "records an issued attempt without an approval of its action id")
1605
+ if record.observed is not None and not parse_comment_url(record.observed["url"]).on(
1606
+ parse_issue_url(record.target["issue_url"])
1607
+ ):
1608
+ _fail(what, "is observed on another issue than its target")
1609
+
1610
+
1611
+ def _cross_operator_comment(record: EffectRecord, what: str) -> None:
1612
+ """K10 (#243): never attempted, a target of the owner's repository, the rendered payload."""
1613
+ if record.stage == Stage.ATTEMPTED or record.attempts != 0:
1614
+ _fail(
1615
+ what,
1616
+ "is an operator-performed operator_comment that records an attempt; the "
1617
+ "controller never issues it",
1618
+ )
1619
+ _same(record, what, (record.identity["issue_url"], record.target["issue_url"], "issue"))
1620
+ target_url = record.target["issue_url"]
1621
+ owner_issue = parse_issue_url(record.owner.issue_url)
1622
+ if _same_url(target_url, record.owner.issue_url, parse_issue_url):
1623
+ _fail(what, "targets its owner's own issue; an operator comment is for another issue")
1624
+ if not parse_issue_url(target_url).same_repository(owner_issue.repository):
1625
+ _fail(what, "targets an issue outside its owner's repository")
1626
+ if not record.owner.pr_url:
1627
+ _fail(what, "has an owner without a PR; an operator comment belongs to a PR's FIX")
1628
+ if parse_issue_url(target_url).number == parse_pr_url(record.owner.pr_url).number:
1629
+ # Issues and PRs share one number space in a repository.
1630
+ _fail(what, "targets its owner's PR; an operator comment is for another issue")
1631
+ _check_owner_issue_marker(record, OPERATOR_COMMENT, what)
1632
+ claim = _marker_claim(OPERATOR_COMMENT, record.identity["marker"])
1633
+ if claim.target.canonical != target_url:
1634
+ _fail(what, "carries a marker whose target is not its target")
1635
+ # The payload is the controller's header, the text the parser accepted
1636
+ # and the marker (``operator_comment_body``); the text gets the parser's
1637
+ # rules again, link rule included, because it is offered to the operator
1638
+ # with no agent result in between.
1639
+ head = (
1640
+ operator_comment_header(
1641
+ record.owner.issue_url, record.owner.pr_url, claim.round, claim.finding_id, target_url
1642
+ )
1643
+ + APPEND_SEPARATOR
1644
+ )
1645
+ tail = APPEND_SEPARATOR + record.identity["marker"]
1646
+ body = record.body
1647
+ if not body.startswith(head) or not body.endswith(tail) or len(body) < len(head) + len(tail):
1648
+ _fail(
1649
+ what,
1650
+ "has a payload body that is not the controller's header, its text and its marker",
1651
+ )
1652
+ text = body[len(head) : len(body) - len(tail)]
1653
+ if text != text.strip():
1654
+ _fail(what, "has an operator text that is not in the parser's stored form")
1655
+ try:
1656
+ validate_operator_comment_body(text, repository=owner_issue.repository)
1657
+ except ControlResultValidationError as exc:
1658
+ _fail(what, f"has an invalid operator text: {exc}")
1659
+ if body != operator_comment_body(
1660
+ text, record.owner.issue_url, record.owner.pr_url, claim.round, claim.finding_id, target_url
1661
+ ):
1662
+ _fail(what, "has a payload body that is not the controller's rendering of its text")
1663
+ target_ref = parse_issue_url(target_url)
1664
+ binding = record.binding
1665
+ if binding is not None and not parse_comment_url(binding["comment_url"]).on(target_ref):
1666
+ _fail(f"{what}.precondition.binding", "names a comment on another issue than its target")
1667
+ observed = record.observed
1668
+ if observed is not None:
1669
+ ref = parse_comment_url(observed["url"])
1670
+ if not ref.on(target_ref):
1671
+ _fail(what, "is observed on another issue than its target")
1672
+ if ref.comment_id != observed["comment_id"]:
1673
+ _fail(f"{what}.observed", "names another comment id than its URL's")
1674
+ if observed["evidence"] == EVIDENCE_EXACT:
1675
+ if observed["body_sha256"] != sha256_text(record.body):
1676
+ _fail(f"{what}.observed", "is exact evidence whose digest is not the payload's")
1677
+ elif binding is None or (
1678
+ binding["comment_id"] != observed["comment_id"]
1679
+ or binding["body_sha256"] != observed["body_sha256"]
1680
+ ):
1681
+ _fail(f"{what}.observed", "is bound evidence that is not the recorded binding")
1682
+
1683
+
1684
+ _CROSS_CHECKS: Mapping[EffectKind, Callable[[EffectRecord, str], None]] = {
1685
+ EffectKind.PUSH: _cross_push,
1686
+ EffectKind.IMPLEMENTATION_PR: _cross_implementation_pr,
1687
+ EffectKind.ADOPT_PR: _cross_adopt_pr,
1688
+ EffectKind.REVIEW_COMMENT: _cross_review_comment,
1689
+ EffectKind.FOLLOW_UP_ISSUE: _cross_follow_up_issue,
1690
+ EffectKind.FOLLOW_UP_APPEND: _cross_follow_up_append,
1691
+ EffectKind.REPLACEMENT_PR: _cross_replacement_pr,
1692
+ EffectKind.PROGRESS_COMMENT: _cross_progress_comment,
1693
+ EffectKind.ACCEPTANCE_COMMENT: _cross_acceptance_comment,
1694
+ EffectKind.OPERATOR_COMMENT: _cross_operator_comment,
1695
+ }
1696
+
1697
+
1698
+ def payload_chars(records: Sequence[EffectRecord]) -> int:
1699
+ """The payload characters D2.4's total bound counts for ``records``."""
1700
+ return sum(len(v) for r in records for v in r.payload.values() if isinstance(v, str))
1701
+
1702
+
1703
+ def load_records(raw: object, binding: Binding) -> tuple[EffectRecord, ...]:
1704
+ """The persisted plan, validated: positions in order, one owner, D2.4's bounds."""
1705
+ if not isinstance(raw, list):
1706
+ _fail("effect_records", f"must be a list, got {type(raw).__name__}")
1707
+ if len(raw) > MAX_EFFECTS_PER_PLAN:
1708
+ _fail("effect_records", f"holds {len(raw)} records, over {MAX_EFFECTS_PER_PLAN}")
1709
+ records = tuple(
1710
+ EffectRecord.from_dict(item, f"effect_records[{i}]") for i, item in enumerate(raw)
1711
+ )
1712
+ for i, record in enumerate(records):
1713
+ if record.position != i:
1714
+ _fail(f"effect_records[{i}]", f"is at position {record.position}, not {i}")
1715
+ if record.owner != records[0].owner:
1716
+ _fail(f"effect_records[{i}]", "has another owner than the plan's first record")
1717
+ record.owner.check(binding, f"effect_records[{i}]")
1718
+ if sum(1 for r in records if r.kind == EffectKind.PUSH) > 1:
1719
+ _fail("effect_records", "plans more than one push")
1720
+ acceptance = [r.position for r in records if r.kind == EffectKind.ACCEPTANCE_COMMENT]
1721
+ if len(acceptance) > MAX_ACCEPTANCE_ACTIONS_PER_FIX:
1722
+ _fail("effect_records", "plans more than one acceptance comment")
1723
+ if acceptance and acceptance[0] != 0:
1724
+ # K9 first (#211): nothing else of the plan is sent before the one
1725
+ # write that may wait for an operator.
1726
+ _fail("effect_records", "plans an acceptance comment after another effect")
1727
+ operator = [r.position for r in records if r.kind == EffectKind.OPERATOR_COMMENT]
1728
+ if operator and acceptance:
1729
+ _fail("effect_records", "plans an acceptance comment and operator comments together")
1730
+ if len(operator) > MAX_OPERATOR_ACTIONS_PER_FIX:
1731
+ _fail(
1732
+ "effect_records",
1733
+ f"plans {len(operator)} operator comments, over {MAX_OPERATOR_ACTIONS_PER_FIX}",
1734
+ )
1735
+ if operator != list(range(len(operator))):
1736
+ # K10 is the plan's prefix (#243): nothing the controller sends
1737
+ # precedes an action the operator may still decline.
1738
+ _fail("effect_records", "plans an operator comment after another effect")
1739
+ # By claim key, not marker text: GitHub names owners and repositories
1740
+ # case-insensitively, and the key folds that case.
1741
+ keys = [scan(OPERATOR_COMMENT, records[i].marker).claims[0].key for i in operator]
1742
+ if len(set(keys)) != len(keys):
1743
+ _fail("effect_records", "plans two operator comments with one identity")
1744
+ # One comment verifies one action (PR #254 R2-F1): verification never
1745
+ # observes a comment another record of the plan was observed on.
1746
+ consumed: dict[int, int] = {}
1747
+ for i in operator:
1748
+ if records[i].stage is not Stage.OBSERVED:
1749
+ continue
1750
+ cid = int((records[i].observed or {})["comment_id"])
1751
+ if cid in consumed:
1752
+ _fail(
1753
+ "effect_records",
1754
+ f"verifies the operator comments at positions {consumed[cid]} and {i} with one "
1755
+ "comment",
1756
+ )
1757
+ consumed[cid] = i
1758
+ return records
1759
+
1760
+
1761
+ # -- the entry observation (D4.4) -----------------------------------------------------
1762
+
1763
+ # K10's allowed targets (#243): the issues the run's issue body references
1764
+ # (at most ``MAX_LISTED_ISSUES``, as the EPIC reads them) and the run's EPIC.
1765
+ MAX_OPERATOR_TARGETS = MAX_LISTED_ISSUES + 1
1766
+ _OPERATOR_TARGET_KEYS = ("numbers", "allowed_by", "issue_body_sha256")
1767
+
1768
+
1769
+ @dataclass(frozen=True)
1770
+ class OperatorTargets:
1771
+ """The issues a FIX entry allows operator comments on, read once at entry (#243).
1772
+
1773
+ ``numbers`` are issue numbers of the run's repository in order of first
1774
+ mention in the run's issue body, then the EPIC; ``allowed_by`` names why
1775
+ each is allowed (``issue_body`` and/or ``epic``); ``issue_body_sha256``
1776
+ is the digest of the body they were read from. The prompt's target list
1777
+ is rendered from this, and the result is validated against it, so an
1778
+ issue-body edit during the fixer run cannot make the two disagree.
1779
+ """
1780
+
1781
+ numbers: tuple[int, ...]
1782
+ allowed_by: Mapping[int, tuple[str, ...]]
1783
+ issue_body_sha256: str
1784
+
1785
+ @classmethod
1786
+ def from_dict(cls, raw: object, what: str) -> OperatorTargets:
1787
+ data = _exact(raw, _OPERATOR_TARGET_KEYS, what)
1788
+ numbers = data["numbers"]
1789
+ if not isinstance(numbers, list) or len(numbers) > MAX_OPERATOR_TARGETS:
1790
+ _fail(f"{what}.numbers", f"must be a list of at most {MAX_OPERATOR_TARGETS} numbers")
1791
+ for i, n in enumerate(numbers):
1792
+ _number(n, f"{what}.numbers[{i}]")
1793
+ if len(set(numbers)) != len(numbers):
1794
+ _fail(f"{what}.numbers", "repeats a number")
1795
+ allowed = data["allowed_by"]
1796
+ if not isinstance(allowed, dict) or sorted(allowed) != sorted(str(n) for n in numbers):
1797
+ _fail(f"{what}.allowed_by", "must name exactly the numbers, each by its decimal text")
1798
+ for key, value in allowed.items():
1799
+ _allowed_by(value, f"{what}.allowed_by[{key}]")
1800
+ if sum(1 for v in allowed.values() if ALLOWED_BY_EPIC in v) > 1:
1801
+ _fail(f"{what}.allowed_by", "names more than one EPIC")
1802
+ _digest(data["issue_body_sha256"], f"{what}.issue_body_sha256")
1803
+ return cls(
1804
+ tuple(numbers),
1805
+ {n: tuple(allowed[str(n)]) for n in numbers},
1806
+ data["issue_body_sha256"],
1807
+ )
1808
+
1809
+ def to_dict(self) -> dict:
1810
+ return {
1811
+ "numbers": list(self.numbers),
1812
+ "allowed_by": {str(n): list(self.allowed_by[n]) for n in self.numbers},
1813
+ "issue_body_sha256": self.issue_body_sha256,
1814
+ }
1815
+
1816
+
1817
+ @dataclass(frozen=True)
1818
+ class EntryObservation:
1819
+ """What a publishing phase read before its first launch, kind-neutral (D4.4).
1820
+
1821
+ ``refs`` maps a ``refs/heads/...`` ref to its head SHA, or ``None`` when
1822
+ the ref was absent. ``objects`` maps a marker (the identity text) to the
1823
+ one object that carried it, or ``None`` when none did. ``prs`` maps a
1824
+ ref of ``refs`` whose PRs were read to the one open PR of the repository
1825
+ headed at it, or ``None`` when there was none: the PR a phase may adopt
1826
+ carries no marker yet, so it is recorded by its branch (#161, ADR 0004
1827
+ K3). A ref missing from ``prs`` had its PRs not read, which is not "no
1828
+ PR": a phase that needs the answer fails where it uses it. ``prs`` is
1829
+ stored only when it is not empty, so an observation that read no PR
1830
+ (every phase but ``ANALYZE_EXECUTE``) is stored as before it existed.
1831
+ ``operator_targets`` (FIX only, #243) is the allowed set of K10 targets
1832
+ read at entry, or ``None`` when the run offers no operator action
1833
+ (``safety.acceptance_actions: off``, or an entry observed under protocol
1834
+ 10); it is stored only when set.
1835
+ """
1836
+
1837
+ phase: Phase
1838
+ issue_url: str
1839
+ pr_url: str
1840
+ refs: Mapping[str, str | None]
1841
+ base_sha: str | None
1842
+ objects: Mapping[str, str | None]
1843
+ prs: Mapping[str, str | None] = field(default_factory=dict)
1844
+ operator_targets: OperatorTargets | None = None
1845
+
1846
+ _KEYS = ("phase", "issue_url", "pr_url", "refs", "base_sha", "objects")
1847
+ _OPTIONAL_KEYS = ("prs", "operator_targets")
1848
+
1849
+ @classmethod
1850
+ def from_dict(cls, raw: object, binding: Binding) -> EntryObservation:
1851
+ what = "entry_observation"
1852
+ data = _exact(raw, cls._KEYS, what, cls._OPTIONAL_KEYS)
1853
+ phase_text = _str(data["phase"], f"{what}.phase", 64)
1854
+ try:
1855
+ phase = Phase(phase_text)
1856
+ except ValueError:
1857
+ _fail(f"{what}.phase", f"is not a phase: {phase_text!r}")
1858
+ binding.check_phase(phase, what)
1859
+ _issue_url(data["issue_url"], f"{what}.issue_url")
1860
+ binding.check_issue(data["issue_url"], what)
1861
+ pr_url = _str(data["pr_url"], f"{what}.pr_url", MAX_URL_CHARS)
1862
+ if pr_url:
1863
+ _pr_url(pr_url, f"{what}.pr_url")
1864
+ binding.check_pr(pr_url, what)
1865
+ refs = data["refs"]
1866
+ if not isinstance(refs, dict) or len(refs) > MAX_OBSERVED_REFS:
1867
+ _fail(f"{what}.refs", f"must be an object of at most {MAX_OBSERVED_REFS} refs")
1868
+ for ref, sha in refs.items():
1869
+ _ref(ref, f"{what}.refs key")
1870
+ _opt_sha(sha, f"{what}.refs[{ref}]")
1871
+ _opt_sha(data["base_sha"], f"{what}.base_sha")
1872
+ objects = data["objects"]
1873
+ if not isinstance(objects, dict) or len(objects) > MAX_OBSERVED_OBJECTS:
1874
+ _fail(f"{what}.objects", f"must be an object of at most {MAX_OBSERVED_OBJECTS}")
1875
+ for key, url in objects.items():
1876
+ _observed_identity(key, f"{what}.objects key")
1877
+ if url is not None:
1878
+ _object_url(url, f"{what}.objects value")
1879
+ prs = data.get("prs", {})
1880
+ if not isinstance(prs, dict) or len(prs) > MAX_OBSERVED_REFS or ("prs" in data and not prs):
1881
+ _fail(
1882
+ f"{what}.prs",
1883
+ f"must be a non-empty object of at most {MAX_OBSERVED_REFS} refs when stored",
1884
+ )
1885
+ for ref, url in prs.items():
1886
+ _ref(ref, f"{what}.prs key")
1887
+ if ref not in refs:
1888
+ _fail(f"{what}.prs", f"records a PR on {ref}, a ref the observation did not read")
1889
+ if url is not None:
1890
+ _pr_url(url, f"{what}.prs[{ref}]")
1891
+ targets = None
1892
+ if "operator_targets" in data:
1893
+ if phase != Phase.FIX:
1894
+ _fail(f"{what}.operator_targets", "is recorded by a FIX entry only")
1895
+ targets = OperatorTargets.from_dict(
1896
+ data["operator_targets"], f"{what}.operator_targets"
1897
+ )
1898
+ issue = parse_issue_url(data["issue_url"])
1899
+ if issue.number in targets.numbers or (
1900
+ pr_url and parse_pr_url(pr_url).number in targets.numbers
1901
+ ):
1902
+ _fail(f"{what}.operator_targets", "allows the run's own issue or PR")
1903
+ return cls(
1904
+ phase,
1905
+ data["issue_url"],
1906
+ pr_url,
1907
+ dict(refs),
1908
+ data["base_sha"],
1909
+ dict(objects),
1910
+ dict(prs),
1911
+ targets,
1912
+ )
1913
+
1914
+ def to_dict(self) -> dict:
1915
+ data = {
1916
+ "phase": self.phase.value,
1917
+ "issue_url": self.issue_url,
1918
+ "pr_url": self.pr_url,
1919
+ "refs": dict(self.refs),
1920
+ "base_sha": self.base_sha,
1921
+ "objects": dict(self.objects),
1922
+ }
1923
+ if self.prs:
1924
+ data["prs"] = dict(self.prs)
1925
+ if self.operator_targets is not None:
1926
+ data["operator_targets"] = self.operator_targets.to_dict()
1927
+ return data
1928
+
1929
+
1930
+ def _observed_identity(value: object, what: str) -> None:
1931
+ text = _str(value, what, MAX_OBSERVED_KEY_CHARS)
1932
+ for kind in (IMPLEMENTATION, REVIEW, PROGRESS, FOLLOW_UP):
1933
+ if kind.pattern.fullmatch(text):
1934
+ _marker_of(kind)(text, what)
1935
+ return
1936
+ if REPLAN_MARKER_RE.fullmatch(text):
1937
+ _replan_marker(text, what)
1938
+ return
1939
+ _fail(what, "must be one controller marker (an identity)")
1940
+
1941
+
1942
+ # -- completion contexts (D4.6) -------------------------------------------------------
1943
+
1944
+ # Per-key overhead counted for each stored value: the key, quotes and
1945
+ # punctuation of its JSON form, generously.
1946
+ _KEY_OVERHEAD = 64
1947
+
1948
+
1949
+ def _context_header(raw: object, keys: Sequence[str], binding: Binding, phase: Phase) -> dict:
1950
+ what = f"{phase.value} completion context"
1951
+ data = _exact(raw, ("phase", *keys), what)
1952
+ if data["phase"] != phase.value:
1953
+ _fail(f"{what}.phase", f"must be {phase.value}")
1954
+ binding.check_phase(phase, what)
1955
+ return data
1956
+
1957
+
1958
+ @dataclass(frozen=True)
1959
+ class AnalyzeContext:
1960
+ """``ANALYZE_EXECUTE``: no result data; completion persists K1's and K2's/K3's results.
1961
+
1962
+ The plan saved with it is the push (K1) at position 0, then either the
1963
+ implementation PR (K2) on the pushed branch or the adoption (K3), and the
1964
+ push is the one the entry observation explains: its base is the
1965
+ observed default-branch head and its expected old value the observed
1966
+ head of its ref (#161). So is the PR: the observation read the PRs on
1967
+ the pushed ref, a create follows an observation of none there onto the
1968
+ default branch the observation read (its ref, at the base), and an
1969
+ adoption targets the very PR it recorded (ADR 0004 K2, K3), so a PR
1970
+ that appeared or changed after the entry is never adopted from a plan.
1971
+ """
1972
+
1973
+ issue_url: str
1974
+
1975
+ PHASE = Phase.ANALYZE_EXECUTE
1976
+ STORED_BOUND = MAX_URL_CHARS + 2 * _KEY_OVERHEAD
1977
+
1978
+ @classmethod
1979
+ def from_dict(
1980
+ cls,
1981
+ raw: object,
1982
+ binding: Binding,
1983
+ *,
1984
+ records: Sequence[EffectRecord] = (),
1985
+ observation: EntryObservation | None = None,
1986
+ **_: object,
1987
+ ) -> AnalyzeContext:
1988
+ what = "ANALYZE_EXECUTE completion context"
1989
+ data = _context_header(raw, ("issue_url",), binding, cls.PHASE)
1990
+ _issue_url(data["issue_url"], f"{what}.issue_url")
1991
+ binding.check_issue(data["issue_url"], what)
1992
+ kinds = [r.kind for r in records]
1993
+ if kinds not in (
1994
+ [EffectKind.PUSH, EffectKind.IMPLEMENTATION_PR],
1995
+ [EffectKind.PUSH, EffectKind.ADOPT_PR],
1996
+ ):
1997
+ _fail(what, "must be saved with a push then a PR create or adoption")
1998
+ if observation is None:
1999
+ _fail(what, "must be saved with the entry observation it is checked against")
2000
+ push = records[0]
2001
+ ref = push.target["ref"]
2002
+ if ref not in observation.refs:
2003
+ _fail(what, "pushes a ref the entry observation did not read")
2004
+ if push.precondition["expected_old"] != observation.refs[ref]:
2005
+ _fail(what, "pushes over another head than the entry observed on its ref")
2006
+ if push.precondition["base_sha"] != observation.base_sha:
2007
+ _fail(what, "pushes a candidate checked against another base than the entry read")
2008
+ second = records[1]
2009
+ if second.kind is EffectKind.IMPLEMENTATION_PR and (
2010
+ "refs/heads/" + str(second.target["head"]) != ref
2011
+ ):
2012
+ _fail(what, "opens its PR from another branch than the one it pushes")
2013
+ if second.kind is EffectKind.IMPLEMENTATION_PR:
2014
+ base_ref = "refs/heads/" + str(second.target["base"])
2015
+ if (
2016
+ base_ref == ref
2017
+ or base_ref not in observation.refs
2018
+ or observation.refs[base_ref] != observation.base_sha
2019
+ ):
2020
+ _fail(
2021
+ what, "opens its PR onto another branch than the default branch the entry read"
2022
+ )
2023
+ if ref not in observation.prs:
2024
+ _fail(what, "is saved with an entry observation that did not read the PRs on its ref")
2025
+ observed_pr = observation.prs[ref]
2026
+ if second.kind is EffectKind.IMPLEMENTATION_PR and observed_pr is not None:
2027
+ _fail(what, "opens a PR on a branch where the entry observed an open PR")
2028
+ if second.kind is EffectKind.ADOPT_PR and (
2029
+ observed_pr is None
2030
+ or not _same_url(str(second.target["pr_url"]), observed_pr, parse_pr_url)
2031
+ ):
2032
+ _fail(what, "adopts another PR than the one the entry observed on its branch")
2033
+ return cls(data["issue_url"])
2034
+
2035
+ def to_dict(self) -> dict:
2036
+ return {"phase": self.PHASE.value, "issue_url": self.issue_url}
2037
+
2038
+
2039
+ @dataclass(frozen=True)
2040
+ class ReviewContext:
2041
+ """``REVIEW``: the round, ``needs_fix_round``, the findings and the prose sections.
2042
+
2043
+ It is saved with the round's review comment (K4) alone, and the
2044
+ comment's marker is the context's verdict: the same round, the same
2045
+ ``needs_fix_round`` and the findings' ids in the same order (#162).
2046
+ The comment's body is exactly what :func:`review_comment_body` renders
2047
+ from the context at the revision the marker binds, and passes
2048
+ :func:`review_comment_problem` as a whole, so every published field of a
2049
+ body that recovery posts, and their composition, was validated under the
2050
+ parser's rules when the state was loaded, not only when the result was
2051
+ accepted.
2052
+ """
2053
+
2054
+ issue_url: str
2055
+ pr_url: str
2056
+ round: int
2057
+ needs_fix_round: bool
2058
+ findings: tuple[dict, ...]
2059
+ sections: dict[str, str]
2060
+
2061
+ PHASE = Phase.REVIEW
2062
+ # A finding's stored form is bounded by its parser fields.
2063
+ _FINDING_BOUND = 2000 + 200 + 300 + MAX_FINDING_ID_CHARS + 16 + 5 * _KEY_OVERHEAD
2064
+ _SECTIONS_BOUND = (
2065
+ sum(limit + _KEY_OVERHEAD for _, limit in REVIEW_PROSE_SECTIONS) + _KEY_OVERHEAD
2066
+ )
2067
+ STORED_BOUND = (
2068
+ 2 * MAX_URL_CHARS
2069
+ + MAX_FINDINGS_PER_REVIEW * _FINDING_BOUND
2070
+ + _SECTIONS_BOUND
2071
+ + 7 * _KEY_OVERHEAD
2072
+ )
2073
+
2074
+ @classmethod
2075
+ def from_dict(
2076
+ cls,
2077
+ raw: object,
2078
+ binding: Binding,
2079
+ *,
2080
+ records: Sequence[EffectRecord] = (),
2081
+ **_: object,
2082
+ ) -> ReviewContext:
2083
+ what = "REVIEW completion context"
2084
+ data = _context_header(
2085
+ raw,
2086
+ ("issue_url", "pr_url", "round", "needs_fix_round", "findings", "sections"),
2087
+ binding,
2088
+ cls.PHASE,
2089
+ )
2090
+ _issue_url(data["issue_url"], f"{what}.issue_url")
2091
+ binding.check_issue(data["issue_url"], what)
2092
+ _pr_url(data["pr_url"], f"{what}.pr_url")
2093
+ binding.check_pr(data["pr_url"], what)
2094
+ round_ = _int(data["round"], f"{what}.round", minimum=1)
2095
+ if round_ != binding.review_round + 1:
2096
+ _fail(
2097
+ f"{what}.round",
2098
+ f"is {round_} but the round under review is {binding.review_round + 1}",
2099
+ )
2100
+ needs_fix = _bool(data["needs_fix_round"], f"{what}.needs_fix_round")
2101
+ raw_findings = data["findings"]
2102
+ if not isinstance(raw_findings, list) or len(raw_findings) > MAX_FINDINGS_PER_REVIEW:
2103
+ _fail(f"{what}.findings", f"must be a list of at most {MAX_FINDINGS_PER_REVIEW}")
2104
+ parsed_findings: list[Finding] = []
2105
+ findings: list[dict] = []
2106
+ for i, item in enumerate(raw_findings):
2107
+ try:
2108
+ finding = Finding.from_payload(item, round_, i)
2109
+ # The findings were accepted as publishable in the round's comment.
2110
+ check_published_finding(finding)
2111
+ except ControlResultValidationError as exc:
2112
+ _fail(f"{what}.findings[{i}]", f"is invalid: {exc}")
2113
+ parsed = finding.to_dict()
2114
+ if parsed != item:
2115
+ _fail(f"{what}.findings[{i}]", "is not in the parser's stored form")
2116
+ parsed_findings.append(finding)
2117
+ findings.append(parsed)
2118
+ if len({f["id"] for f in findings}) != len(findings):
2119
+ _fail(f"{what}.findings", "repeats a finding id")
2120
+ if needs_fix != bool(findings):
2121
+ _fail(f"{what}.needs_fix_round", "must be true exactly when findings are present")
2122
+ sections = cls._sections(data["sections"], f"{what}.sections")
2123
+ if [r.kind for r in records] != [EffectKind.REVIEW_COMMENT]:
2124
+ _fail(what, "must be saved with exactly one review comment")
2125
+ record = records[0]
2126
+ claim = _marker_claim(REVIEW, record.identity["marker"])
2127
+ if (
2128
+ claim.round != round_
2129
+ or claim.needs_fix_round != needs_fix
2130
+ or claim.finding_ids != tuple(f["id"] for f in findings)
2131
+ ):
2132
+ _fail(what, "disagrees with its review comment's marker on the round's verdict")
2133
+ # A resumed create posts the body with no reviewer result in between
2134
+ # (D4.6), so the body must be the controller's rendering of what was
2135
+ # just validated, at the revision its marker binds: nothing else in it
2136
+ # is the reviewer's, and nothing in it escaped the parser's rules.
2137
+ # The record's own check has already refused a marker without a base.
2138
+ result = ReviewResult(
2139
+ round=round_,
2140
+ reviewed_head_sha=claim.reviewed_head_sha,
2141
+ needs_fix_round=needs_fix,
2142
+ findings=parsed_findings,
2143
+ sections=sections,
2144
+ )
2145
+ binding_args = (
2146
+ claim.reviewed_head_sha,
2147
+ claim.reviewed_base_ref or "",
2148
+ claim.reviewed_merge_base_sha or "",
2149
+ )
2150
+ if record.body != review_comment_body(result, *binding_args):
2151
+ _fail(
2152
+ what,
2153
+ "is saved with a review comment whose body is not the comment the controller "
2154
+ "renders from it",
2155
+ )
2156
+ # Fields valid one by one can still compose a comment the result path
2157
+ # refuses (an unclosed fence closed by a later field's); such a plan
2158
+ # was never accepted, so it is refused, never replayed.
2159
+ problem = review_comment_problem(result, *binding_args)
2160
+ if problem:
2161
+ _fail(what, f"is saved with a review comment the controller may not publish: {problem}")
2162
+ return cls(data["issue_url"], data["pr_url"], round_, needs_fix, tuple(findings), sections)
2163
+
2164
+ @staticmethod
2165
+ def _sections(raw: object, what: str) -> dict[str, str]:
2166
+ """The prose sections, each in the stored form the parser returns for it."""
2167
+ data = _exact(raw, tuple(key for key, _ in REVIEW_PROSE_SECTIONS), what)
2168
+ sections: dict[str, str] = {}
2169
+ for key, _ in REVIEW_PROSE_SECTIONS:
2170
+ try:
2171
+ parsed = validate_review_section(key, data[key])
2172
+ except ControlResultValidationError as exc:
2173
+ _fail(f"{what}.{key}", f"is invalid: {exc}")
2174
+ if parsed != data[key]:
2175
+ _fail(f"{what}.{key}", "is not in the parser's stored form")
2176
+ sections[key] = parsed
2177
+ return sections
2178
+
2179
+ def to_dict(self) -> dict:
2180
+ return {
2181
+ "phase": self.PHASE.value,
2182
+ "issue_url": self.issue_url,
2183
+ "pr_url": self.pr_url,
2184
+ "round": self.round,
2185
+ "needs_fix_round": self.needs_fix_round,
2186
+ "findings": [dict(f) for f in self.findings],
2187
+ "sections": dict(self.sections),
2188
+ }
2189
+
2190
+
2191
+ FOLLOW_UP_SOURCE_ENTRY = "entry"
2192
+ # D4.6: the redacted form of a FIX rationale, at most this long.
2193
+ MAX_STORED_RATIONALE_CHARS = MAX_FIX_RATIONALE_CHARS * MAX_GROWTH_FACTOR
2194
+
2195
+
2196
+ @dataclass(frozen=True)
2197
+ class FixContext:
2198
+ """``FIX``: one resolution per open finding, rationales in their redacted form.
2199
+
2200
+ ``follow_up_source`` names where a deferred finding's follow-up comes
2201
+ from (D4.6), ``acceptance_source`` the plan position of the K9 record
2202
+ that publishes an ``acceptance_action`` finding's comment (#211), and
2203
+ ``operator_sources`` the plan positions of the K10 records of an
2204
+ ``operator_acceptance`` finding (#243); each is set exactly on the
2205
+ resolutions of its kind.
2206
+ """
2207
+
2208
+ issue_url: str
2209
+ pr_url: str
2210
+ round: int
2211
+ resolutions: tuple[dict, ...]
2212
+
2213
+ PHASE = Phase.FIX
2214
+ _RESOLUTION_KEYS = (
2215
+ "finding_id",
2216
+ "resolution",
2217
+ "rationale",
2218
+ "commit_sha",
2219
+ "follow_up_source",
2220
+ "acceptance_source",
2221
+ "operator_sources",
2222
+ )
2223
+ _RESOLUTION_BOUND = (
2224
+ MAX_STORED_RATIONALE_CHARS
2225
+ + MAX_FINDING_ID_CHARS
2226
+ + 40
2227
+ + 32
2228
+ + 16
2229
+ + 16
2230
+ + (MAX_OPERATOR_ACTIONS_PER_FIX * 16)
2231
+ + (7 * _KEY_OVERHEAD)
2232
+ )
2233
+ STORED_BOUND = (
2234
+ 2 * MAX_URL_CHARS + MAX_RESOLUTIONS_PER_FIX * _RESOLUTION_BOUND + (5 * _KEY_OVERHEAD)
2235
+ )
2236
+
2237
+ @classmethod
2238
+ def from_dict(
2239
+ cls,
2240
+ raw: object,
2241
+ binding: Binding,
2242
+ *,
2243
+ records: Sequence[EffectRecord] = (),
2244
+ observation: EntryObservation | None = None,
2245
+ **_: object,
2246
+ ) -> FixContext:
2247
+ what = "FIX completion context"
2248
+ data = _context_header(
2249
+ raw, ("issue_url", "pr_url", "round", "resolutions"), binding, cls.PHASE
2250
+ )
2251
+ _issue_url(data["issue_url"], f"{what}.issue_url")
2252
+ binding.check_issue(data["issue_url"], what)
2253
+ _pr_url(data["pr_url"], f"{what}.pr_url")
2254
+ binding.check_pr(data["pr_url"], what)
2255
+ round_ = _int(data["round"], f"{what}.round", minimum=1)
2256
+ if round_ != binding.review_round:
2257
+ _fail(
2258
+ f"{what}.round", f"is {round_} but the round being fixed is {binding.review_round}"
2259
+ )
2260
+ raw_resolutions = data["resolutions"]
2261
+ if not isinstance(raw_resolutions, list) or len(raw_resolutions) > MAX_RESOLUTIONS_PER_FIX:
2262
+ _fail(f"{what}.resolutions", f"must be a list of at most {MAX_RESOLUTIONS_PER_FIX}")
2263
+ resolutions = [
2264
+ cls._resolution(item, f"{what}.resolutions[{i}]")
2265
+ for i, item in enumerate(raw_resolutions)
2266
+ ]
2267
+ ids = [r["finding_id"] for r in resolutions]
2268
+ if len(set(ids)) != len(ids):
2269
+ _fail(f"{what}.resolutions", "resolves a finding twice")
2270
+ if sorted(ids) != sorted(binding.open_finding_ids):
2271
+ _fail(f"{what}.resolutions", "must resolve exactly the open findings")
2272
+ cls._check_sources(resolutions, data["pr_url"], records, observation, what)
2273
+ cls._check_acceptance(resolutions, data["issue_url"], data["pr_url"], round_, records, what)
2274
+ cls._check_operator(resolutions, round_, records, observation, what)
2275
+ return cls(data["issue_url"], data["pr_url"], round_, tuple(resolutions))
2276
+
2277
+ @classmethod
2278
+ def _resolution(cls, raw: object, what: str) -> dict:
2279
+ data = _exact(raw, cls._RESOLUTION_KEYS, what)
2280
+ fid = _str(data["finding_id"], f"{what}.finding_id", MAX_FINDING_ID_CHARS)
2281
+ if not FINDING_ID_RE.match(fid):
2282
+ _fail(f"{what}.finding_id", "is not a finding id")
2283
+ resolution = data["resolution"]
2284
+ if resolution not in FIX_RESOLUTIONS:
2285
+ _fail(f"{what}.resolution", f"must be one of {FIX_RESOLUTIONS}")
2286
+ rationale = _str(data["rationale"], f"{what}.rationale", MAX_STORED_RATIONALE_CHARS)
2287
+ if _RATIONALE_CONTROL_RE.search(rationale):
2288
+ _fail(f"{what}.rationale", "carries a control character other than a newline or a tab")
2289
+ commit = _str(data["commit_sha"], f"{what}.commit_sha", 40)
2290
+ if commit:
2291
+ _sha(commit, f"{what}.commit_sha")
2292
+ source = data["follow_up_source"]
2293
+ if source is not None and source != FOLLOW_UP_SOURCE_ENTRY:
2294
+ _int(source, f"{what}.follow_up_source", 0, MAX_EFFECTS_PER_PLAN - 1)
2295
+ if (resolution == "follow_up_created") != (source is not None):
2296
+ _fail(f"{what}.follow_up_source", "is set exactly when the finding is deferred")
2297
+ acceptance = data["acceptance_source"]
2298
+ if acceptance is not None:
2299
+ _int(acceptance, f"{what}.acceptance_source", 0, MAX_EFFECTS_PER_PLAN - 1)
2300
+ if (resolution == "acceptance_action") != (acceptance is not None):
2301
+ _fail(
2302
+ f"{what}.acceptance_source",
2303
+ "is set exactly when the finding is resolved by an acceptance action",
2304
+ )
2305
+ operator = data["operator_sources"]
2306
+ if operator is not None:
2307
+ if (
2308
+ not isinstance(operator, list)
2309
+ or not 1 <= len(operator) <= MAX_OPERATOR_ACTIONS_PER_FIX
2310
+ ):
2311
+ _fail(
2312
+ f"{what}.operator_sources",
2313
+ f"must be a list of 1 to {MAX_OPERATOR_ACTIONS_PER_FIX} plan positions",
2314
+ )
2315
+ for i, position in enumerate(operator):
2316
+ _int(position, f"{what}.operator_sources[{i}]", 0, MAX_EFFECTS_PER_PLAN - 1)
2317
+ if len(set(operator)) != len(operator):
2318
+ _fail(f"{what}.operator_sources", "repeats a plan position")
2319
+ if (resolution == "operator_acceptance") != (operator is not None):
2320
+ _fail(
2321
+ f"{what}.operator_sources",
2322
+ "is set exactly when the finding is resolved by operator actions",
2323
+ )
2324
+ return dict(data)
2325
+
2326
+ @staticmethod
2327
+ def _check_operator(
2328
+ resolutions: Sequence[dict],
2329
+ round_: int,
2330
+ records: Sequence[EffectRecord],
2331
+ observation: EntryObservation | None,
2332
+ what: str,
2333
+ ) -> None:
2334
+ """#243: each ``operator_acceptance`` names its K10 records, each K10 one finding.
2335
+
2336
+ Every K10 is of this round and an open finding, and its target is
2337
+ in the allowed set the FIX entry recorded, for the reasons and from
2338
+ the issue body digest recorded there.
2339
+ """
2340
+ named: set[int] = set()
2341
+ for resolution in resolutions:
2342
+ sources = resolution["operator_sources"]
2343
+ if sources is None:
2344
+ continue
2345
+ fid = resolution["finding_id"]
2346
+ label = f"{what} resolution for {fid}"
2347
+ for source in sources:
2348
+ if source >= len(records):
2349
+ _fail(label, f"names plan position {source}, which the plan does not hold")
2350
+ record = records[source]
2351
+ if record.kind != EffectKind.OPERATOR_COMMENT:
2352
+ _fail(label, f"names effect {source}, which is not an operator comment")
2353
+ claim = _marker_claim(OPERATOR_COMMENT, record.marker)
2354
+ if claim.finding_id != fid or claim.round != round_:
2355
+ _fail(
2356
+ label,
2357
+ f"names operator effect {source}, which is another finding's or round's",
2358
+ )
2359
+ named.add(source)
2360
+ targets = observation.operator_targets if observation is not None else None
2361
+ for record in records:
2362
+ if record.kind != EffectKind.OPERATOR_COMMENT:
2363
+ continue
2364
+ where = f"effect_records[{record.position}]"
2365
+ if record.position not in named:
2366
+ _fail(where, "is an operator comment no resolution of the context names")
2367
+ if targets is None:
2368
+ _fail(
2369
+ where, "is an operator comment, but the FIX entry recorded no allowed targets"
2370
+ )
2371
+ number = parse_issue_url(record.target["issue_url"]).number
2372
+ if number not in targets.numbers:
2373
+ _fail(where, f"targets issue #{number}, which the FIX entry did not allow")
2374
+ if list(targets.allowed_by[number]) != record.precondition["allowed_by"] or (
2375
+ targets.issue_body_sha256 != record.precondition["issue_body_sha256"]
2376
+ ):
2377
+ _fail(where, "records another allowed set than the FIX entry's")
2378
+
2379
+ @staticmethod
2380
+ def _check_acceptance(
2381
+ resolutions: Sequence[dict],
2382
+ issue_url: str,
2383
+ pr_url: str,
2384
+ round_: int,
2385
+ records: Sequence[EffectRecord],
2386
+ what: str,
2387
+ ) -> None:
2388
+ """#211: each ``acceptance_action`` names its K9 record, and each K9 one finding."""
2389
+ named: set[int] = set()
2390
+ for resolution in resolutions:
2391
+ source = resolution["acceptance_source"]
2392
+ if source is None:
2393
+ continue
2394
+ fid = resolution["finding_id"]
2395
+ label = f"{what} resolution for {fid}"
2396
+ if source >= len(records):
2397
+ _fail(label, f"names plan position {source}, which the plan does not hold")
2398
+ record = records[source]
2399
+ if record.kind != EffectKind.ACCEPTANCE_COMMENT:
2400
+ _fail(label, f"names effect {source}, which is not an acceptance comment")
2401
+ marker = render_acceptance_marker(issue_url, pr_url, round_, fid)
2402
+ if record.identity["marker"] != marker:
2403
+ _fail(
2404
+ label,
2405
+ f"names acceptance effect {source}, which is another finding's or round's",
2406
+ )
2407
+ named.add(source)
2408
+ for record in records:
2409
+ if record.kind == EffectKind.ACCEPTANCE_COMMENT and record.position not in named:
2410
+ _fail(
2411
+ f"effect_records[{record.position}]",
2412
+ "is an acceptance comment no resolution of the context names",
2413
+ )
2414
+
2415
+ @staticmethod
2416
+ def _check_sources(
2417
+ resolutions: Sequence[dict],
2418
+ pr_url: str,
2419
+ records: Sequence[EffectRecord],
2420
+ observation: EntryObservation | None,
2421
+ what: str,
2422
+ ) -> None:
2423
+ """D4.6: each deferred finding has exactly one source, fixed at the plan."""
2424
+ from .claims import render_follow_up_marker
2425
+
2426
+ observed = dict(observation.objects) if observation is not None else {}
2427
+ own: dict[str, bool] = {}
2428
+ for resolution in resolutions:
2429
+ marker = render_follow_up_marker(pr_url, resolution["finding_id"])
2430
+ own[marker] = observed.get(marker) is not None
2431
+ block_markers = {
2432
+ m
2433
+ for r in records
2434
+ if r.kind == EffectKind.FOLLOW_UP_APPEND
2435
+ for m in r.identity["markers"]
2436
+ }
2437
+ for resolution in resolutions:
2438
+ fid = resolution["finding_id"]
2439
+ marker = render_follow_up_marker(pr_url, fid)
2440
+ source = resolution["follow_up_source"]
2441
+ label = f"{what} resolution for {fid}"
2442
+ if own[marker]:
2443
+ if source != FOLLOW_UP_SOURCE_ENTRY:
2444
+ _fail(label, "must reuse the finding's own follow-up through 'entry'")
2445
+ if marker in block_markers:
2446
+ _fail(label, "is the marker of a reused follow-up, which no block carries")
2447
+ continue
2448
+ if source == FOLLOW_UP_SOURCE_ENTRY:
2449
+ _fail(label, "names 'entry' but the entry observation holds no follow-up for it")
2450
+ if source is None:
2451
+ continue
2452
+ assert isinstance(source, int)
2453
+ if source >= len(records):
2454
+ _fail(label, f"names plan position {source}, which the plan does not hold")
2455
+ record = records[source]
2456
+ if record.kind == EffectKind.FOLLOW_UP_ISSUE:
2457
+ if record.identity["marker"] != marker:
2458
+ _fail(label, f"names follow-up effect {source}, which is another finding's")
2459
+ elif record.kind == EffectKind.FOLLOW_UP_APPEND:
2460
+ if marker not in record.identity["markers"]:
2461
+ _fail(label, f"names append effect {source}, whose block lacks its marker")
2462
+ else:
2463
+ _fail(label, f"names effect {source}, which is not a follow-up effect")
2464
+
2465
+ def to_dict(self) -> dict:
2466
+ return {
2467
+ "phase": self.PHASE.value,
2468
+ "issue_url": self.issue_url,
2469
+ "pr_url": self.pr_url,
2470
+ "round": self.round,
2471
+ "resolutions": [dict(r) for r in self.resolutions],
2472
+ }
2473
+
2474
+
2475
+ @dataclass(frozen=True)
2476
+ class ReplanContext:
2477
+ """``REPLAN_REEXECUTE``: the counts the K7 marker renders (#164).
2478
+
2479
+ The plan saved with it is the push (K1) of the candidate to the
2480
+ controller-derived replacement branch, compared against "absent", at
2481
+ position 0, then the replacement PR (K7) from that branch, and both are
2482
+ the ones the entry observation explains: the observation recorded the
2483
+ branch absent and the default branch at the base the candidate was
2484
+ checked against, and the PR is opened onto that default branch. The
2485
+ transaction marker the PR carries attests exactly these counts and
2486
+ passed tests, for the state's transaction and execution attempt.
2487
+ """
2488
+
2489
+ issue_url: str
2490
+ pr_url: str
2491
+ transaction_id: str
2492
+ historical_findings_considered: int
2493
+ unique_failure_constraints: int
2494
+
2495
+ PHASE = Phase.REPLAN_REEXECUTE
2496
+ STORED_BOUND = 2 * MAX_URL_CHARS + 32 + 2 * 20 + 6 * _KEY_OVERHEAD
2497
+
2498
+ @classmethod
2499
+ def from_dict(
2500
+ cls,
2501
+ raw: object,
2502
+ binding: Binding,
2503
+ *,
2504
+ records: Sequence[EffectRecord] = (),
2505
+ observation: EntryObservation | None = None,
2506
+ **_: object,
2507
+ ) -> ReplanContext:
2508
+ what = "REPLAN_REEXECUTE completion context"
2509
+ data = _context_header(
2510
+ raw,
2511
+ (
2512
+ "issue_url",
2513
+ "pr_url",
2514
+ "transaction_id",
2515
+ "historical_findings_considered",
2516
+ "unique_failure_constraints",
2517
+ ),
2518
+ binding,
2519
+ cls.PHASE,
2520
+ )
2521
+ _issue_url(data["issue_url"], f"{what}.issue_url")
2522
+ binding.check_issue(data["issue_url"], what)
2523
+ _pr_url(data["pr_url"], f"{what}.pr_url")
2524
+ binding.check_pr(data["pr_url"], what)
2525
+ txn = _str(data["transaction_id"], f"{what}.transaction_id", 32)
2526
+ if not TRANSACTION_ID_RE.match(txn) or txn != binding.transaction_id:
2527
+ _fail(f"{what}.transaction_id", "is not the state's replan transaction id")
2528
+ context = cls(
2529
+ data["issue_url"],
2530
+ data["pr_url"],
2531
+ txn,
2532
+ _int(data["historical_findings_considered"], f"{what}.historical_findings_considered"),
2533
+ _int(data["unique_failure_constraints"], f"{what}.unique_failure_constraints"),
2534
+ )
2535
+ if context.unique_failure_constraints > context.historical_findings_considered:
2536
+ _fail(what, "counts more unique constraints than findings considered")
2537
+ if [r.kind for r in records] != [EffectKind.PUSH, EffectKind.REPLACEMENT_PR]:
2538
+ _fail(what, "must be saved with a push then the replacement PR")
2539
+ if observation is None:
2540
+ _fail(what, "must be saved with the entry observation it is checked against")
2541
+ push, create = records
2542
+ ref = push.target["ref"]
2543
+ if ref not in observation.refs or observation.refs[ref] is not None:
2544
+ _fail(what, "pushes a ref the entry observation did not read as absent")
2545
+ if push.precondition["expected_old"] is not None:
2546
+ _fail(what, "pushes over a head; the replacement branch is compared against absent")
2547
+ if push.precondition["base_sha"] != observation.base_sha:
2548
+ _fail(what, "pushes a candidate checked against another base than the entry read")
2549
+ if "refs/heads/" + str(create.target["head"]) != ref:
2550
+ _fail(what, "opens its PR from another branch than the one it pushes")
2551
+ base_ref = "refs/heads/" + str(create.target["base"])
2552
+ if (
2553
+ base_ref == ref
2554
+ or base_ref not in observation.refs
2555
+ or observation.refs[base_ref] != observation.base_sha
2556
+ ):
2557
+ _fail(what, "opens its PR onto another branch than the default branch the entry read")
2558
+ attestation = scan_replan_markers(create.identity["transaction_marker"]).attestations[0]
2559
+ if (
2560
+ attestation.findings_considered != context.historical_findings_considered
2561
+ or attestation.unique_constraints != context.unique_failure_constraints
2562
+ ):
2563
+ _fail(what, "is saved with a transaction marker that attests other counts")
2564
+ return context
2565
+
2566
+ def to_dict(self) -> dict:
2567
+ return {
2568
+ "phase": self.PHASE.value,
2569
+ "issue_url": self.issue_url,
2570
+ "pr_url": self.pr_url,
2571
+ "transaction_id": self.transaction_id,
2572
+ "historical_findings_considered": self.historical_findings_considered,
2573
+ "unique_failure_constraints": self.unique_failure_constraints,
2574
+ }
2575
+
2576
+
2577
+ @dataclass(frozen=True)
2578
+ class UpdateEpicContext:
2579
+ """``UPDATE_EPIC``: the roadmap section, the selection, and two digests (D4.6).
2580
+
2581
+ ``roadmap_section`` is the section when one is required, else ``None``.
2582
+ ``next_issue_url`` is the validated selection, or ``None`` for "the EPIC
2583
+ is complete". The digests are SHA-256 of the EPIC body outside the
2584
+ roadmap markers: as the entry read before the result's launch saw it,
2585
+ and as the splice of this section into that body leaves it. They are
2586
+ present exactly when the section is.
2587
+
2588
+ A re-request (D4.7) starts from a persisted rejection: ``selection_void``
2589
+ or ``section_void`` marks the input the rejection voided, and a void
2590
+ input is ``None``.
2591
+ """
2592
+
2593
+ issue_url: str
2594
+ pr_url: str
2595
+ roadmap_section: str | None
2596
+ next_issue_url: str | None
2597
+ entry_outside_sha256: str | None
2598
+ spliced_outside_sha256: str | None
2599
+ selection_void: bool = False
2600
+ section_void: bool = False
2601
+
2602
+ PHASE = Phase.UPDATE_EPIC
2603
+ _KEYS = (
2604
+ "issue_url",
2605
+ "pr_url",
2606
+ "roadmap_section",
2607
+ "next_issue_url",
2608
+ "entry_outside_sha256",
2609
+ "spliced_outside_sha256",
2610
+ "selection_void",
2611
+ "section_void",
2612
+ )
2613
+ STORED_BOUND = MAX_ROADMAP_SECTION_CHARS + 3 * MAX_URL_CHARS + 2 * 64 + 9 * _KEY_OVERHEAD
2614
+
2615
+ @classmethod
2616
+ def from_dict(
2617
+ cls,
2618
+ raw: object,
2619
+ binding: Binding,
2620
+ *,
2621
+ records: Sequence[EffectRecord] = (),
2622
+ observation: EntryObservation | None = None,
2623
+ **_: object,
2624
+ ) -> UpdateEpicContext:
2625
+ what = "UPDATE_EPIC completion context"
2626
+ data = _context_header(raw, cls._KEYS, binding, cls.PHASE)
2627
+ _issue_url(data["issue_url"], f"{what}.issue_url")
2628
+ binding.check_issue(data["issue_url"], what)
2629
+ _pr_url(data["pr_url"], f"{what}.pr_url")
2630
+ binding.check_pr(data["pr_url"], what)
2631
+ section = data["roadmap_section"]
2632
+ if section is not None:
2633
+ _str(section, f"{what}.roadmap_section", MAX_ROADMAP_SECTION_CHARS)
2634
+ if not section.strip():
2635
+ _fail(f"{what}.roadmap_section", "must be non-empty when present")
2636
+ if redact(section) != section:
2637
+ _fail(f"{what}.roadmap_section", "is not redaction-invariant")
2638
+ # A parsed-form value gets its parser's rules again (D4.6): the
2639
+ # recovery that completes from this context writes the section
2640
+ # into the EPIC body with no agent result in between.
2641
+ try:
2642
+ validate_roadmap_section(section)
2643
+ except ControlResultValidationError as exc:
2644
+ _fail(f"{what}.roadmap_section", f"is invalid: {exc}")
2645
+ _opt_issue_url(data["next_issue_url"], f"{what}.next_issue_url")
2646
+ _opt_digest(data["entry_outside_sha256"], f"{what}.entry_outside_sha256")
2647
+ _opt_digest(data["spliced_outside_sha256"], f"{what}.spliced_outside_sha256")
2648
+ has_digests = data["entry_outside_sha256"] is not None
2649
+ if (data["spliced_outside_sha256"] is not None) != has_digests:
2650
+ _fail(what, "must hold both outside-markers digests or neither")
2651
+ if has_digests != (section is not None):
2652
+ _fail(what, "holds the outside-markers digests exactly when it holds a section")
2653
+ selection_void = _bool(data["selection_void"], f"{what}.selection_void")
2654
+ section_void = _bool(data["section_void"], f"{what}.section_void")
2655
+ if selection_void and data["next_issue_url"] is not None:
2656
+ _fail(what, "voids its selection but still holds one")
2657
+ if section_void and section is not None:
2658
+ _fail(what, "voids its roadmap section but still holds one")
2659
+ cls._check_publication(data["issue_url"], data["pr_url"], records, observation, what)
2660
+ return cls(
2661
+ data["issue_url"],
2662
+ data["pr_url"],
2663
+ section,
2664
+ data["next_issue_url"],
2665
+ data["entry_outside_sha256"],
2666
+ data["spliced_outside_sha256"],
2667
+ selection_void,
2668
+ section_void,
2669
+ )
2670
+
2671
+ @staticmethod
2672
+ def _check_publication(
2673
+ issue_url: str,
2674
+ pr_url: str,
2675
+ records: Sequence[EffectRecord],
2676
+ observation: EntryObservation | None,
2677
+ what: str,
2678
+ ) -> None:
2679
+ """The progress comment is planned or adopted, never neither (D2.2, D4.6, D13.7).
2680
+
2681
+ Completion publishes only what the plan holds, so an empty plan beside
2682
+ this context would finish the phase with no progress comment. The
2683
+ entry observation decides which plan is consistent: no comment
2684
+ carried the marker of (issue, PR) at entry, and the plan is exactly
2685
+ that comment's K8 record; or one did, the entry adopted it (a legacy
2686
+ re-entry, D13.7), and the plan is empty.
2687
+ """
2688
+ if observation is None:
2689
+ _fail(what, "is persisted without the entry observation read before its launch")
2690
+ marker = render_progress_marker(issue_url, pr_url)
2691
+ if marker not in observation.objects:
2692
+ _fail(what, "has an entry observation that does not record its progress marker")
2693
+ adopted = observation.objects[marker]
2694
+ if adopted is not None:
2695
+ if records:
2696
+ _fail(what, f"plans a progress comment beside the adopted {adopted}")
2697
+ return
2698
+ if (
2699
+ len(records) != 1
2700
+ or records[0].kind != EffectKind.PROGRESS_COMMENT
2701
+ or records[0].identity["marker"] != marker
2702
+ ):
2703
+ _fail(
2704
+ what,
2705
+ "must be saved with the one progress-comment record of its marker: the entry "
2706
+ "observation records no comment to adopt",
2707
+ )
2708
+
2709
+ def to_dict(self) -> dict:
2710
+ return {
2711
+ "phase": self.PHASE.value,
2712
+ "issue_url": self.issue_url,
2713
+ "pr_url": self.pr_url,
2714
+ "roadmap_section": self.roadmap_section,
2715
+ "next_issue_url": self.next_issue_url,
2716
+ "entry_outside_sha256": self.entry_outside_sha256,
2717
+ "spliced_outside_sha256": self.spliced_outside_sha256,
2718
+ "selection_void": self.selection_void,
2719
+ "section_void": self.section_void,
2720
+ }
2721
+
2722
+ @property
2723
+ def void(self) -> bool:
2724
+ return self.selection_void or self.section_void
2725
+
2726
+
2727
+ CompletionContext = AnalyzeContext | ReviewContext | FixContext | ReplanContext | UpdateEpicContext
2728
+
2729
+ _CONTEXT_TYPES: Mapping[str, Any] = {
2730
+ Phase.ANALYZE_EXECUTE.value: AnalyzeContext,
2731
+ Phase.REVIEW.value: ReviewContext,
2732
+ Phase.FIX.value: FixContext,
2733
+ Phase.REPLAN_REEXECUTE.value: ReplanContext,
2734
+ Phase.UPDATE_EPIC.value: UpdateEpicContext,
2735
+ }
2736
+
2737
+
2738
+ def _context_type(raw: dict) -> Any:
2739
+ """The context class of the publishing phase ``raw`` names, or ``None``."""
2740
+ phase = raw.get("phase")
2741
+ return _CONTEXT_TYPES.get(phase) if isinstance(phase, str) else None
2742
+
2743
+
2744
+ def load_context(
2745
+ raw: object,
2746
+ binding: Binding,
2747
+ *,
2748
+ records: Sequence[EffectRecord] = (),
2749
+ observation: EntryObservation | None = None,
2750
+ ) -> CompletionContext:
2751
+ """The persisted completion context of the phase it names, strictly validated."""
2752
+ if not isinstance(raw, dict):
2753
+ _fail("completion_context", f"must be an object, got {type(raw).__name__}")
2754
+ context_type = _context_type(raw)
2755
+ if context_type is None:
2756
+ _fail("completion_context.phase", "must name a publishing phase")
2757
+ context: CompletionContext = context_type.from_dict(
2758
+ raw, binding, records=records, observation=observation
2759
+ )
2760
+ return context
2761
+
2762
+
2763
+ def plan_size_problem(records: Sequence[EffectRecord], context: CompletionContext | None) -> str:
2764
+ """D2.4's total bound, or "" when the plan fits: payloads plus the context's stored bound."""
2765
+ total = payload_chars(records) + (type(context).STORED_BOUND if context is not None else 0)
2766
+ if total > MAX_EFFECT_STATE_CHARS:
2767
+ return (
2768
+ f"the effect plan holds {total} characters with its completion context, over the "
2769
+ f"bound of {MAX_EFFECT_STATE_CHARS}"
2770
+ )
2771
+ return ""
2772
+
2773
+
2774
+ @dataclass(frozen=True)
2775
+ class PhaseEffects:
2776
+ """The effect state of one phase entry, as loaded: plan, observation, context."""
2777
+
2778
+ records: tuple[EffectRecord, ...] = ()
2779
+ observation: EntryObservation | None = None
2780
+ context: CompletionContext | None = None
2781
+
2782
+ @property
2783
+ def phase(self) -> Phase | None:
2784
+ for piece in (self.context, self.observation):
2785
+ if piece is not None:
2786
+ return piece.PHASE if hasattr(piece, "PHASE") else piece.phase # type: ignore[union-attr]
2787
+ return self.records[0].owner.phase if self.records else None
2788
+
2789
+ @property
2790
+ def empty(self) -> bool:
2791
+ return not self.records and self.observation is None and self.context is None
2792
+
2793
+ @property
2794
+ def published(self) -> bool:
2795
+ """D4.7: every record of the plan is observed (an empty plan is published)."""
2796
+ return all(r.stage == Stage.OBSERVED for r in self.records)
2797
+
2798
+
2799
+ def load_phase_effects(
2800
+ records_raw: object, observation_raw: object, context_raw: object, binding: Binding
2801
+ ) -> PhaseEffects:
2802
+ """Validate the three persisted pieces against the state and against each other."""
2803
+ records = load_records(records_raw, binding)
2804
+ observation = None
2805
+ if not isinstance(observation_raw, dict):
2806
+ _fail("entry_observation", f"must be an object, got {type(observation_raw).__name__}")
2807
+ if observation_raw:
2808
+ observation = EntryObservation.from_dict(observation_raw, binding)
2809
+ if not isinstance(context_raw, dict):
2810
+ _fail("completion_context", f"must be an object, got {type(context_raw).__name__}")
2811
+ # One phase first: a context is validated against the plan and the
2812
+ # observation of its own phase, never against another phase's.
2813
+ context_type = _context_type(context_raw)
2814
+ phases = {
2815
+ p
2816
+ for p in (
2817
+ context_type.PHASE if context_type is not None else None,
2818
+ observation.phase if observation is not None else None,
2819
+ records[0].owner.phase if records else None,
2820
+ )
2821
+ if p is not None
2822
+ }
2823
+ if len(phases) > 1:
2824
+ _fail("effect state", "names more than one phase across records, observation and context")
2825
+ context = None
2826
+ if context_raw:
2827
+ context = load_context(context_raw, binding, records=records, observation=observation)
2828
+ effects = PhaseEffects(records, observation, context)
2829
+ if records and context is None:
2830
+ _fail("effect_records", "are persisted without the completion context saved with them")
2831
+ problem = plan_size_problem(records, context)
2832
+ if problem:
2833
+ _fail("effect state", problem)
2834
+ return effects
2835
+
2836
+
2837
+ # -- reconciliation decisions (D4.2, D5.5) -------------------------------------------
2838
+
2839
+
2840
+ class Action(StrEnum):
2841
+ OBSERVE = "observe" # the target is in the intended end state
2842
+ ISSUE = "issue" # save attempted (count + 1), then issue the persisted payload once
2843
+ REBASE = "rebase" # D5.5: save the rebased payload as attempted, then issue it
2844
+ CONFLICT = "conflict" # BLOCKED, naming the object
2845
+ DONE = "done" # already observed: terminal, nothing to do
2846
+ # K9 (#211): the write is due but may not be sent yet (no authorization,
2847
+ # or the run's issue no longer qualifies). Nothing is sent and the
2848
+ # record is unchanged; the phase blocks with the reason.
2849
+ HOLD = "hold"
2850
+
2851
+
2852
+ @dataclass(frozen=True)
2853
+ class Decision:
2854
+ action: Action
2855
+ # OBSERVE: the observed result; HOLD (K9): the exact comment found but not
2856
+ # adopted, because the run's issue no longer qualifies (PR #221 R1-F2).
2857
+ observed: dict | None = None
2858
+ reason: str = "" # CONFLICT, HOLD: names the objects and the rule, never a payload
2859
+ rebase_body: str = "" # REBASE: the body read now, the new base
2860
+ # CONFLICT only because the bound is spent and the write is still absent:
2861
+ # right after an issue that is "not visible yet", not yet a conflict.
2862
+ exhausted: bool = False
2863
+ # HOLD (K10, #243): the verification failed (the bound comment changed,
2864
+ # is gone or is another action's evidence; the target no longer
2865
+ # qualifies), not merely "awaiting the operator". The prefix pass keeps
2866
+ # the reason on the record, so an offline ``status`` reports it.
2867
+ failed: bool = False
2868
+
2869
+
2870
+ @dataclass(frozen=True)
2871
+ class Found:
2872
+ """One remote object that carries a create effect's identity, as read back.
2873
+
2874
+ ``in_end_state`` is the kind's completion predicate, evaluated by the
2875
+ caller's read (open, at the candidate, title and body equal to the
2876
+ payload, ...). ``observed`` is what the record would hold.
2877
+ """
2878
+
2879
+ url: str
2880
+ in_end_state: bool
2881
+ observed: Mapping[str, object]
2882
+
2883
+
2884
+ def _exhausted(record: EffectRecord) -> Decision:
2885
+ return Decision(
2886
+ Action.CONFLICT,
2887
+ exhausted=True,
2888
+ reason=(
2889
+ f"{record.describe()} has used its {MAX_EFFECT_ATTEMPTS} attempts and its outcome "
2890
+ "is still not on GitHub; perform the write by hand or remove the conflicting "
2891
+ "object, then 'unblock'"
2892
+ ),
2893
+ )
2894
+
2895
+
2896
+ def _terminal(record: EffectRecord) -> Decision | None:
2897
+ if record.stage == Stage.OBSERVED:
2898
+ return Decision(Action.DONE, observed=dict(record.observed or {}))
2899
+ if record.stage == Stage.CONFLICT:
2900
+ return Decision(Action.CONFLICT, reason=record.reason)
2901
+ return None
2902
+
2903
+
2904
+ def decide_create(record: EffectRecord, found: Sequence[Found]) -> Decision:
2905
+ """D4.2 for a create (K2, K4, K5, K7, K8) over a *complete* identity read.
2906
+
2907
+ ``found`` is every object that carries the identity, in every state the
2908
+ kind's read covers (D4.5). One in the intended end state is the write;
2909
+ none means the precondition still holds; anything else is a conflict
2910
+ naming every object found.
2911
+ """
2912
+ terminal = _terminal(record)
2913
+ if terminal is not None:
2914
+ return terminal
2915
+ if len(found) == 1 and found[0].in_end_state:
2916
+ return Decision(Action.OBSERVE, observed=dict(found[0].observed))
2917
+ if not found:
2918
+ if record.bound_left:
2919
+ return Decision(Action.ISSUE)
2920
+ return _exhausted(record)
2921
+ urls = ", ".join(f.url for f in found)
2922
+ if len(found) == 1:
2923
+ return Decision(
2924
+ Action.CONFLICT,
2925
+ reason=(
2926
+ f"{record.describe()}: {urls} carries its identity but is not the object the "
2927
+ "controller intended (another body, title, head or state); it is never "
2928
+ "repaired in place or duplicated"
2929
+ ),
2930
+ )
2931
+ return Decision(
2932
+ Action.CONFLICT,
2933
+ reason=f"{record.describe()}: {len(found)} objects carry its identity ({urls})",
2934
+ )
2935
+
2936
+
2937
+ def decide_acceptance(
2938
+ record: EffectRecord, found: Sequence[Found], *, issue_problem: str = ""
2939
+ ) -> Decision:
2940
+ """K9 (#211): :func:`decide_create`, except that a due write may be held.
2941
+
2942
+ The exact payload already on the issue is adopted whoever posted it
2943
+ (an operator may post it instead of approving), and every conflict is
2944
+ decide_create's. ``issue_problem`` (the run's issue is closed, or not in
2945
+ the run's repository) holds an adoption as it holds a write (PR #221
2946
+ R1-F2): the exact payload on such an issue is not adopted, its URL is
2947
+ the hold's ``observed``, and the plan's later writes wait with it. A
2948
+ due write is also held while no approval of this record's action id is
2949
+ recorded. A hold sends nothing and changes nothing; for an ``attempted``
2950
+ record its reason says the earlier write was sent (PR #221 R2-F1).
2951
+ """
2952
+ decision = decide_create(record, found)
2953
+ if decision.action is Action.OBSERVE and issue_problem:
2954
+ url = (decision.observed or {}).get("url", "")
2955
+ return Decision(
2956
+ Action.HOLD,
2957
+ observed=decision.observed,
2958
+ reason=(
2959
+ f"{record.describe()}: its exact payload is on {url}, but {issue_problem}; it "
2960
+ "is not adopted while the issue does not qualify, and nothing more was sent"
2961
+ ),
2962
+ )
2963
+ if decision.action is not Action.ISSUE:
2964
+ return decision
2965
+ if not record.authorized:
2966
+ return Decision(
2967
+ Action.HOLD,
2968
+ reason=(
2969
+ f"{record.describe()}: action {record.action_id} awaits the operator's "
2970
+ "approval; nothing was sent"
2971
+ ),
2972
+ )
2973
+ if issue_problem:
2974
+ # An attempted record's write was sent (PR #221 R2-F1): its comment
2975
+ # is not on the issue, or not visible yet, and is not sent again.
2976
+ sent = (
2977
+ f"the comment of attempt {record.attempts} is not on the issue, and nothing more "
2978
+ "is sent while the issue does not qualify"
2979
+ if record.stage == Stage.ATTEMPTED
2980
+ else "nothing was sent"
2981
+ )
2982
+ return Decision(Action.HOLD, reason=f"{record.describe()}: {issue_problem}; {sent}")
2983
+ return decision
2984
+
2985
+
2986
+ @dataclass(frozen=True)
2987
+ class Listed:
2988
+ """One comment of a K10 target's complete listing, as the decision needs it (#243)."""
2989
+
2990
+ url: str
2991
+ comment_id: int
2992
+ body: str
2993
+
2994
+
2995
+ def decide_operator_comment(
2996
+ record: EffectRecord,
2997
+ *,
2998
+ own: Sequence[Listed],
2999
+ misplaced: Sequence[Listed],
3000
+ listing: Sequence[Listed],
3001
+ defects: Sequence[str] = (),
3002
+ target_problem: str = "",
3003
+ taken: Mapping[int, str] | None = None,
3004
+ ) -> Decision:
3005
+ """K10 (#243): verify an operator-performed comment; never ``ISSUE``.
3006
+
3007
+ ``own`` are the target's comments carrying this record's marker key,
3008
+ ``misplaced`` those carrying the key of another record of this plan
3009
+ whose target is another issue, ``listing`` the target's complete
3010
+ listing (for a binding) and ``defects`` the unreadable
3011
+ ``ai-operator-comment`` markers in it. ``taken`` maps the id of each
3012
+ comment another action already consumes (observed for it, carrying its
3013
+ marker, or verified for it earlier in the round) to that action's id:
3014
+ one comment verifies one action (PR #254 R2-F1). Conflict rows are
3015
+ evaluated first, so an exact or bound match never hides a conflict: a
3016
+ misplaced payload, two comments with this key, one with other bytes, or
3017
+ the exact payload on a comment another action consumes. Then the exact
3018
+ payload (``exact``), then the bound comment with its digest unchanged
3019
+ (``bound``) unless another action consumes it. Everything else is a
3020
+ hold, which sends nothing: where :func:`decide_create` would issue, this
3021
+ waits for the operator. ``target_problem`` (transferred, deleted,
3022
+ unreadable) holds as well. A hold on a target problem or on bound
3023
+ evidence that does not verify is :attr:`Decision.failed`; only "nothing
3024
+ on the target carries it yet" is not.
3025
+ """
3026
+ taken = taken or {}
3027
+ terminal = _terminal(record)
3028
+ if terminal is not None:
3029
+ return terminal
3030
+ action = f"{record.describe()} ({record.action_id})"
3031
+ if target_problem:
3032
+ return Decision(
3033
+ Action.HOLD, reason=f"{action}: {target_problem}; nothing was verified", failed=True
3034
+ )
3035
+ if defects:
3036
+ return Decision(
3037
+ Action.CONFLICT,
3038
+ reason=(
3039
+ f"{action}: the target's comments cannot be read unambiguously "
3040
+ f"({'; '.join(defects)}); edit or delete the named comment, then 'unblock'"
3041
+ ),
3042
+ )
3043
+ if misplaced:
3044
+ urls = ", ".join(c.url for c in misplaced)
3045
+ return Decision(
3046
+ Action.CONFLICT,
3047
+ reason=(
3048
+ f"{action}: {urls} on this issue carries the marker of another operator action "
3049
+ "of this plan, which is meant for another issue (a misplaced payload); delete "
3050
+ "it here and post it on its own issue, then 'unblock'"
3051
+ ),
3052
+ )
3053
+ if len(own) > 1:
3054
+ urls = ", ".join(c.url for c in own)
3055
+ return Decision(
3056
+ Action.CONFLICT,
3057
+ reason=(
3058
+ f"{action}: {len(own)} comments carry its marker ({urls}); delete all but one, "
3059
+ "then 'unblock'"
3060
+ ),
3061
+ )
3062
+ if own:
3063
+ comment = own[0]
3064
+ if comment.body != record.body:
3065
+ cause = (
3066
+ "its bytes differ from the prepared payload only in line endings or trailing "
3067
+ "whitespace (an editor or the web UI changes them)"
3068
+ if normalized_comment_text(comment.body) == normalized_comment_text(record.body)
3069
+ else "its body is not the prepared payload"
3070
+ )
3071
+ return Decision(
3072
+ Action.CONFLICT,
3073
+ reason=(
3074
+ f"{action}: {comment.url} carries its marker but {cause}; edit it to the "
3075
+ "exact bytes, or delete it and post the payload through the pipe, then "
3076
+ "'unblock'"
3077
+ ),
3078
+ )
3079
+ other = taken.get(comment.comment_id)
3080
+ if other:
3081
+ return Decision(
3082
+ Action.CONFLICT,
3083
+ reason=(
3084
+ f"{action}: {comment.url} carries its exact payload but is already the "
3085
+ f"evidence of {other}, and one comment verifies one action; post the "
3086
+ "payload as a comment of its own and remove this action's marker from "
3087
+ f"{comment.url}, then 'unblock'"
3088
+ ),
3089
+ )
3090
+ return Decision(
3091
+ Action.OBSERVE,
3092
+ observed={
3093
+ "url": comment.url,
3094
+ "comment_id": comment.comment_id,
3095
+ "evidence": EVIDENCE_EXACT,
3096
+ "body_sha256": sha256_text(comment.body),
3097
+ },
3098
+ )
3099
+ binding = record.binding
3100
+ if binding is not None:
3101
+ other = taken.get(binding["comment_id"])
3102
+ if other:
3103
+ return Decision(
3104
+ Action.HOLD,
3105
+ reason=(
3106
+ f"{action}: the bound comment {binding['comment_url']} is the evidence of "
3107
+ f"{other}, and one comment verifies one action; bind another comment or "
3108
+ "post the payload"
3109
+ ),
3110
+ failed=True,
3111
+ )
3112
+ bound = [c for c in listing if c.comment_id == binding["comment_id"]]
3113
+ if bound and sha256_text(bound[0].body) == binding["body_sha256"]:
3114
+ return Decision(
3115
+ Action.OBSERVE,
3116
+ observed={
3117
+ "url": bound[0].url,
3118
+ "comment_id": bound[0].comment_id,
3119
+ "evidence": EVIDENCE_BOUND,
3120
+ "body_sha256": binding["body_sha256"],
3121
+ },
3122
+ )
3123
+ now = sha256_text(bound[0].body) if bound else "none: the comment is gone"
3124
+ return Decision(
3125
+ Action.HOLD,
3126
+ reason=(
3127
+ f"{action}: the bound comment {binding['comment_url']} changed since it was "
3128
+ f"bound (bound sha256 {binding['body_sha256']}, now {now}); bind it again or "
3129
+ "post the payload"
3130
+ ),
3131
+ failed=True,
3132
+ )
3133
+ return Decision(
3134
+ Action.HOLD,
3135
+ reason=f"{action}: awaiting the operator; no comment on the target carries it yet",
3136
+ )
3137
+
3138
+
3139
+ def decide_append(
3140
+ record: EffectRecord,
3141
+ body_now: str,
3142
+ *,
3143
+ target_ok: bool,
3144
+ carries_block_marker: bool,
3145
+ rebase_problem: Callable[[str], str],
3146
+ ) -> Decision:
3147
+ """D5.5's table for a body append (K3, K6).
3148
+
3149
+ ``target_ok`` is the kind's other conditions (open, in this repository,
3150
+ the K1 candidate as head, ...). ``carries_block_marker`` says whether
3151
+ ``body_now`` holds any marker of the block. ``rebase_problem`` judges a
3152
+ rebased payload (size and the credential check) and returns "" when it
3153
+ may be persisted; its reason names the pattern class only.
3154
+ """
3155
+ terminal = _terminal(record)
3156
+ if terminal is not None:
3157
+ return terminal
3158
+ if body_now == record.body:
3159
+ if target_ok:
3160
+ return Decision(Action.OBSERVE, observed={"url": _append_target(record)})
3161
+ return Decision(
3162
+ Action.CONFLICT,
3163
+ reason=f"{record.describe()}: the body is written but the target no longer qualifies",
3164
+ )
3165
+ if not target_ok:
3166
+ return Decision(
3167
+ Action.CONFLICT,
3168
+ reason=f"{record.describe()}: the target no longer meets the kind's conditions",
3169
+ )
3170
+ if sha256_text(body_now) == record.precondition["base_sha256"]:
3171
+ return Decision(Action.ISSUE) if record.bound_left else _exhausted(record)
3172
+ if record.stage == Stage.INTENDED and not carries_block_marker:
3173
+ problem = rebase_problem(compose_append(body_now, record.payload["block"]))
3174
+ if not problem:
3175
+ return Decision(Action.REBASE, rebase_body=body_now)
3176
+ return Decision(Action.CONFLICT, reason=f"{record.describe()}: no rebase: {problem}")
3177
+ if record.stage == Stage.INTENDED:
3178
+ why = "the body already carries a marker of the block but is not the payload"
3179
+ else:
3180
+ why = (
3181
+ "after an issue the body equals neither the payload nor its base; a write that "
3182
+ "landed and was edited cannot be told from one that never landed"
3183
+ )
3184
+ return Decision(Action.CONFLICT, reason=f"{record.describe()}: {why}")
3185
+
3186
+
3187
+ def _append_target(record: EffectRecord) -> str:
3188
+ return str(record.target.get("pr_url") or record.target.get("issue_url"))
3189
+
3190
+
3191
+ def decide_push(record: EffectRecord, remote_head: str | None) -> Decision:
3192
+ """K1's windows: the ref at the candidate is observed, at the expected old value is
3193
+ pushed (within the bound), anywhere else is a conflict naming both SHAs."""
3194
+ terminal = _terminal(record)
3195
+ if terminal is not None:
3196
+ return terminal
3197
+ candidate = record.payload["sha"]
3198
+ expected = record.precondition["expected_old"]
3199
+ ref = record.target["ref"]
3200
+ if remote_head == candidate:
3201
+ return Decision(Action.OBSERVE, observed={"sha": candidate})
3202
+ if remote_head == expected:
3203
+ return Decision(Action.ISSUE) if record.bound_left else _exhausted(record)
3204
+ return Decision(
3205
+ Action.CONFLICT,
3206
+ reason=(
3207
+ f"{record.describe()}: {ref} is at {remote_head or 'nothing'}, neither the "
3208
+ f"expected {expected or 'absent ref'} nor the candidate {candidate}"
3209
+ ),
3210
+ )