Review #526 found that event_type reached the serialized timeline payload without crossing the redaction/validation boundary every other free-text field on the same event crosses. A synthetic secret-shaped 40-hex canary was redacted through message but survived verbatim through event_type on the same control-plane record. CTH heading path: CTH_TYPES is now the single authority for what a CTH type may be. canonical_thread_handoff.is_known_cth_type() is that authority, used by format_cth_body (write), assess_cth_comment (assess), and now the read path too; parse_cth_comment reports membership as cth_type_known and stays total. adapt_cth_comments serializes handoff:<type> only for a declared type and otherwise emits the constant handoff:unrecognized, so arbitrary, malformed, secret-shaped, or whitespace-manipulated heading content never becomes an event_type. Control-plane path: a stored event_type is treated as source data. _safe_cp_event_type accepts only an ordinary identifier that is not a bare secret-shaped hex run and that a redaction pass leaves unchanged; anything else fails closed to the constant unsafe:redacted and marks the event sensitive. The value is never emitted verbatim, never partially sanitized, and never rewritten into a different valid-looking type. Remaining serialized-field audit: event_key ids must be plain numeric identifiers, the CTH adapter refuses a scope it cannot express, per-source failure reasons are redacted (they can quote an authenticated fetch error), and echoed scope/filter values are guarded so reflection is not a bypass. Legitimate values are preserved: every declared CTH type, the real producer types (assigned, lease_released, lease_adopted, dependency_edge_state_change, allocation, pr.opened, lease.renew), and the existing issue, PR, evidence, and full-SHA references. Tests seed the canary independently through both event_type paths with a benign message, so message redaction cannot be why they pass; each inspects event_type directly, asserts the canary is absent from the complete serialized payload, and asserts scan_for_secrets finds nothing. tests/test_webui_timeline.py 64 passed, 15 subtests pytest -k webui 362 passed, 285 subtests pytest -k redact 79 passed tests/test_canonical_thread_handoff.py tests/test_control_plane_db.py 29 passed Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
230 lines
7.3 KiB
Python
230 lines
7.3 KiB
Python
"""Canonical Thread Handoff (CTH) protocol for issue and PR comments (#505).
|
|
|
|
A CTH comment is the authoritative workflow handoff in a Gitea issue or PR
|
|
thread. It records current state, decisions, blockers, proof, and the exact
|
|
next prompt/action for the next LLM or person.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
MARKER = "<!-- cth:v1 -->"
|
|
|
|
CTH_TERM = "Canonical Thread Handoff"
|
|
CTH_ABBREV = "CTH"
|
|
|
|
CTH_TYPES = frozenset({
|
|
"State Handoff",
|
|
"Controller Decision",
|
|
"Author Handoff",
|
|
"Reviewer Handoff",
|
|
"Merger Handoff",
|
|
"Supersession Notice",
|
|
"Blocker",
|
|
})
|
|
|
|
_REQUIRED_BASE_FIELDS = (
|
|
"status",
|
|
"next owner",
|
|
"current blocker",
|
|
"decision",
|
|
"proof",
|
|
"next action",
|
|
"ready-to-paste prompt",
|
|
)
|
|
|
|
_HEADING_RE = re.compile(
|
|
r"^##\s+CTH:\s*(.+?)\s*$",
|
|
re.IGNORECASE | re.MULTILINE,
|
|
)
|
|
_FIELD_RE = re.compile(
|
|
r"^([A-Za-z][A-Za-z0-9 /-]*):\s*(.+?)\s*$",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
|
|
def is_known_cth_type(value: str | None) -> bool:
|
|
"""True when *value* is a declared member of the :data:`CTH_TYPES` contract.
|
|
|
|
``CTH_TYPES`` is the single authority for what a CTH type may be. The
|
|
heading a comment carries is free text, so a *read* path that turns a parsed
|
|
type into something durable — a serialized field, a routing decision — must
|
|
check membership here rather than trust the parse or keep a list of its own.
|
|
"""
|
|
return (value or "").strip() in CTH_TYPES
|
|
|
|
|
|
def format_cth_body(
|
|
*,
|
|
cth_type: str,
|
|
status: str,
|
|
next_owner: str,
|
|
current_blocker: str = "none",
|
|
decision: str = "none",
|
|
proof: str = "none",
|
|
next_action: str = "none",
|
|
ready_to_paste_prompt: str = "none",
|
|
extra_fields: dict[str, str] | None = None,
|
|
) -> str:
|
|
"""Render a canonical CTH comment body."""
|
|
normalized_type = (cth_type or "").strip()
|
|
if not is_known_cth_type(normalized_type):
|
|
raise ValueError(
|
|
f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}"
|
|
)
|
|
lines = [
|
|
MARKER,
|
|
f"## CTH: {normalized_type}",
|
|
"",
|
|
f"Status: {(status or 'unknown').strip() or 'unknown'}",
|
|
f"Next owner: {(next_owner or 'unknown').strip() or 'unknown'}",
|
|
f"Current blocker: {(current_blocker or 'none').strip() or 'none'}",
|
|
f"Decision: {(decision or 'none').strip() or 'none'}",
|
|
f"Proof: {(proof or 'none').strip() or 'none'}",
|
|
f"Next action: {(next_action or 'none').strip() or 'none'}",
|
|
f"Ready-to-paste prompt: {(ready_to_paste_prompt or 'none').strip() or 'none'}",
|
|
]
|
|
for key, value in (extra_fields or {}).items():
|
|
label = (key or "").strip()
|
|
if not label:
|
|
continue
|
|
lines.append(f"{label}: {(value or 'none').strip() or 'none'}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def parse_cth_comment(body: str) -> dict[str, Any] | None:
|
|
"""Parse one CTH comment body, or None when not a CTH comment."""
|
|
text = body or ""
|
|
if MARKER not in text and not _HEADING_RE.search(text):
|
|
return None
|
|
heading = _HEADING_RE.search(text)
|
|
if not heading:
|
|
return None
|
|
cth_type = heading.group(1).strip()
|
|
fields: dict[str, str] = {}
|
|
for match in _FIELD_RE.finditer(text):
|
|
key = match.group(1).strip().lower()
|
|
if key.startswith("cth"):
|
|
continue
|
|
fields[key] = match.group(2).strip()
|
|
return {
|
|
"cth_type": cth_type,
|
|
# The heading capture is unconstrained free text, so the parse states
|
|
# whether it satisfies the CTH_TYPES contract instead of leaving every
|
|
# reader to decide (or forget). Parsing stays total — an unknown type is
|
|
# still parsed and reported, never raised on — but a reader that turns
|
|
# the type into a durable value can now tell the two apart.
|
|
"cth_type_known": is_known_cth_type(cth_type),
|
|
"fields": fields,
|
|
"raw_body": text,
|
|
}
|
|
|
|
|
|
def assess_cth_comment(body: str) -> dict[str, Any]:
|
|
"""Validate a CTH comment has required base fields and a known type."""
|
|
parsed = parse_cth_comment(body)
|
|
reasons: list[str] = []
|
|
if not parsed:
|
|
return {
|
|
"valid": False,
|
|
"block": True,
|
|
"reasons": ["comment is not a Canonical Thread Handoff (CTH)"],
|
|
"parsed": None,
|
|
}
|
|
|
|
cth_type = parsed.get("cth_type") or ""
|
|
if not is_known_cth_type(cth_type):
|
|
reasons.append(
|
|
f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}"
|
|
)
|
|
|
|
fields = parsed.get("fields") or {}
|
|
missing = [name for name in _REQUIRED_BASE_FIELDS if not (fields.get(name) or "").strip()]
|
|
if missing:
|
|
reasons.append(
|
|
"CTH missing required fields: " + ", ".join(missing)
|
|
)
|
|
|
|
vague_prompt = (fields.get("ready-to-paste prompt") or "").strip().lower()
|
|
if vague_prompt in {"", "none", "tbd", "n/a", "todo"}:
|
|
reasons.append("ready-to-paste prompt must be concrete and paste-ready")
|
|
|
|
return {
|
|
"valid": not reasons,
|
|
"block": bool(reasons),
|
|
"reasons": reasons,
|
|
"parsed": parsed,
|
|
"cth_type": cth_type,
|
|
}
|
|
|
|
|
|
def find_latest_cth(comments: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
"""Return the newest valid CTH comment from a Gitea comment list."""
|
|
latest: dict[str, Any] | None = None
|
|
latest_ts: datetime | None = None
|
|
for comment in comments or []:
|
|
body = comment.get("body") or ""
|
|
parsed = parse_cth_comment(body)
|
|
if not parsed:
|
|
continue
|
|
created = _parse_timestamp(comment.get("created_at"))
|
|
if latest is None or (created and (latest_ts is None or created >= latest_ts)):
|
|
latest = {
|
|
"comment_id": comment.get("id"),
|
|
"created_at": comment.get("created_at"),
|
|
"author": (comment.get("user") or {}).get("login"),
|
|
**parsed,
|
|
}
|
|
latest_ts = created
|
|
return latest
|
|
|
|
|
|
def assess_cth_supersedes_non_cth(
|
|
*,
|
|
latest_cth: dict[str, Any] | None,
|
|
narrative_claim: str,
|
|
) -> dict[str, Any]:
|
|
"""Fail closed when narrative relies on stale non-CTH state despite newer CTH."""
|
|
if not latest_cth:
|
|
return {"block": False, "reasons": []}
|
|
text = (narrative_claim or "").strip()
|
|
if not text:
|
|
return {"block": False, "reasons": []}
|
|
if parse_cth_comment(text):
|
|
return {"block": False, "reasons": []}
|
|
return {
|
|
"block": True,
|
|
"reasons": [
|
|
"workflow narrative must treat the latest CTH comment as authoritative; "
|
|
f"found newer CTH type '{latest_cth.get('cth_type')}' "
|
|
f"(comment #{latest_cth.get('comment_id')})"
|
|
],
|
|
}
|
|
|
|
|
|
def _parse_timestamp(value: str | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
text = value.strip()
|
|
if text.endswith("Z"):
|
|
text = text[:-1] + "+00:00"
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError:
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
return parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
EXAMPLE_SCENARIOS = (
|
|
"pr_approved_ready_for_merger",
|
|
"pr_request_changes_to_author",
|
|
"duplicate_superseded_pr_closure",
|
|
"blocked_dirty_root_worktree",
|
|
"stale_head_requires_fresh_review",
|
|
"issue_implementation_handoff",
|
|
) |