From 558be67734fa2c98c4cc327f6fddeb19f50fa8f1 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 06:58:50 +0200 Subject: [PATCH 01/14] fix(devtools): stabilize PR scope attestation Keep v2 scope intent stable in the PR body while merge-gate binds it to the current head and canonical Bead records. Validate the full Bead mutation set and support explicitly self-contained PRs. --- .github/pull_request_template.md | 14 +- CLAUDE.md | 16 +- devtools/command_catalog.py | 15 +- devtools/merge_gate.py | 26 +- devtools/pr_scope.py | 413 +++++++++++++++++-------- docs/devtools.md | 2 +- tests/unit/devtools/test_merge_gate.py | 2 + tests/unit/devtools/test_pr_scope.py | 135 ++++++++ 8 files changed, 468 insertions(+), 155 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 263e6ae83a..ba3b626d42 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -24,14 +24,18 @@ _Exact commands run and any manual validation performed._ | --- | --- | --- | --- | | `polylogue-...` | satisfied / partial / deferred / superseded | `test:...`, `command:...` | `polylogue-...` or n/a | - ## Changelog diff --git a/CLAUDE.md b/CLAUDE.md index bf23ef99cd..2f412e8de8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -381,12 +381,14 @@ workflow, not optional conveniences — use them at the point named, every time: - **Before opening a non-draft PR for a Bead lane**: render the versioned carrier with `devtools workspace pr-scope render --input `, put it in the PR body beside the human whole-Bead disposition matrix, then run - `devtools workspace pr-scope check --pr `. The carrier binds the exact - head SHA, canonical Bead records, typed dispositions, evidence refs, and - open successors for residual work; it never parses acceptance prose. After - the final commit is created, regenerate the carrier for that exact SHA and - update the PR body before pushing; CircleCI does not rerun for a body-only - edit. + `devtools workspace pr-scope check --pr `. The v2 carrier is stable PR + intent: scope kind, assigned and mutated Beads, typed dispositions, evidence + refs, and open successors for residual work. It never parses acceptance + prose. `devtools workspace pr-scope sync --pr ` reports the current + head-bound attestation, including canonical Bead state, without editing the + PR body after every commit. `mutated_beads` must name every Bead record this + PR changes. A self-contained PR uses the typed `self_contained` scope with + empty Bead lists. CircleCI uses `pr-scope check-ci`, resolves PR metadata through public GitHub REST when `CIRCLE_PULL_REQUEST` is absent, and executes the validator from the PR base revision so a PR cannot weaken its own scope gate. @@ -404,7 +406,7 @@ workflow, not optional conveniences — use them at the point named, every time: then auto-records a receipt if none is fresh for the current head sha (running `--command`, default `devtools verify`), BLOCKs the merge on any `merge-gate check` failure (no fresh receipt, stale receipt, nonzero - exit, a changed carrier digest, or an unacked review comment newer than the head commit), strips a + exit, a changed head-bound scope attestation, or an unacked review comment newer than the head commit), strips a doubled `(#N) (#N)` squash-subject suffix, then runs the actual `gh pr merge --squash`. `--dry-run` runs every check without merging; `--with-verify` immediately runs and records the merge-train's terminal diff --git a/devtools/command_catalog.py b/devtools/command_catalog.py index 207b2a50c1..f6d46cf21e 100644 --- a/devtools/command_catalog.py +++ b/devtools/command_catalog.py @@ -774,19 +774,20 @@ class CatalogBypassSite: CommandSpec( "workspace pr-scope", "workspace", - "Render and validate the versioned PR Bead-scope carrier.", + "Render stable PR scope intent and inspect its mutable merge attestation.", "devtools.pr_scope", use_when=( - "Before publishing a non-draft PR, render the machine-readable carrier from the assigned " - "Bead dispositions, then validate the exact body against its head SHA and committed Bead records. " - "CircleCI quick-gate and `workspace merge` run the same validator; the command never interprets " - "acceptance prose." + "Before publishing a non-draft PR, render the machine-readable v2 intent from assigned and mutated " + "Beads plus typed dispositions. The PR body stays stable across commits; `sync --pr` reports the " + "current head- and Bead-bound attestation consumed by merge-gate. CircleCI quick-gate and " + "`workspace merge` run the same validator; the command never interprets acceptance prose." ), examples=( "devtools workspace pr-scope render --input .agent/pr-scope.json > /tmp/pr-scope.md", "devtools workspace pr-scope check --pr 3517", + "devtools workspace pr-scope sync --pr 3517", "devtools workspace pr-scope check-ci --pr 3517 --repo Sinity/polylogue --expected-head-sha $(git rev-parse HEAD)", - "devtools workspace pr-scope check --body-file pr-body.md --head-sha $(git rev-parse HEAD)", + "devtools workspace pr-scope check --body-file pr-body.md --head-sha $(git rev-parse HEAD) --base-sha $(git rev-parse origin/master)", ), ), CommandSpec( @@ -805,7 +806,7 @@ class CatalogBypassSite: "3x20s, covering CodeRabbit's 30-60s late-arrival window) and BLOCKs unless a receipt exists " "for the CURRENT head sha within a freshness window with exit_code 0, and no review comment's " "created_at is newer than the head commit's timestamp unless explicitly `ack`'d for that exact " - "head sha. The receipt binds the carrier digest, so a changed scope requires re-recording. Motivated by two 2026-08-01 incidents: PR #3502 merged before CodeRabbit's findings " + "head sha. The receipt binds the carrier digest plus a fresh head- and Bead-bound scope attestation, so a changed scope or Bead state requires re-recording. Motivated by two 2026-08-01 incidents: PR #3502 merged before CodeRabbit's findings " "posted, and PR #3517 nearly merged with a 43-test regression no CI check or review comment " "ever flagged -- plus review findings on this tool itself (recording from an unrelated " "checkout, a --quick example that would have missed its own motivating regression, a single " diff --git a/devtools/merge_gate.py b/devtools/merge_gate.py index 1c4c7a64bf..65325a13fa 100644 --- a/devtools/merge_gate.py +++ b/devtools/merge_gate.py @@ -278,11 +278,12 @@ def _scope_verdict(pr: int, info: dict[str, Any], *, head_sha: str) -> pr_scope. info.get("body") or "", head_sha=head_sha, is_draft=bool(info.get("isDraft")), + base_sha=info.get("baseRefOid") if isinstance(info.get("baseRefOid"), str) else None, ) def cmd_record(pr: int, command: str) -> int: - info = _gh_json(["pr", "view", str(pr), "--json", "headRefOid,headRefName,body,isDraft,author,files"]) + info = _gh_json(["pr", "view", str(pr), "--json", "headRefOid,headRefName,baseRefOid,body,isDraft,author,files"]) head_sha = info["headRefOid"] local_head = _git_head_sha() @@ -328,6 +329,12 @@ def cmd_record(pr: int, command: str) -> int: "pr_scope_digest": scope.scope_digest, "pr_scope_beads_digest": scope.beads_digest, "pr_scope_assigned_beads": scope.assigned_beads, + "pr_scope_mutated_beads": scope.mutated_beads, + "pr_scope_attestation_digest": pr_scope.attestation_payload( + scope, + head_sha=head_sha, + base_sha=info.get("baseRefOid") if isinstance(info.get("baseRefOid"), str) else None, + )["attestation_digest"], "branch": info["headRefName"], "command": command, "skips_tests": _command_skips_tests(command), @@ -459,7 +466,7 @@ def cmd_check( "view", str(pr), "--json", - "headRefOid,mergeStateStatus,state,commits,body,isDraft,author,files", + "headRefOid,baseRefOid,mergeStateStatus,state,commits,body,isDraft,author,files", ] ) except (RuntimeError, json.JSONDecodeError, OSError, subprocess.SubprocessError) as exc: @@ -529,6 +536,21 @@ def cmd_check( verdict.reasons.append( "receipt pr_scope_assigned_beads does not match the current carrier -- re-record" ) + if receipt.get("pr_scope_mutated_beads") != scope.mutated_beads: + verdict.ok = False + verdict.reasons.append( + "receipt pr_scope_mutated_beads does not match the complete current Bead mutation scope -- re-record" + ) + expected_attestation_digest = pr_scope.attestation_payload( + scope, + head_sha=head_sha, + base_sha=info.get("baseRefOid") if isinstance(info.get("baseRefOid"), str) else None, + )["attestation_digest"] + if receipt.get("pr_scope_attestation_digest") != expected_attestation_digest: + verdict.ok = False + verdict.reasons.append( + "receipt pr_scope_attestation_digest does not match the current head-bound scope attestation -- re-record" + ) age_s = time.time() - receipt.get("recorded_at", 0) if age_s > max_age_s: verdict.ok = False diff --git a/devtools/pr_scope.py b/devtools/pr_scope.py index 11f3e86fd7..c4b89096c0 100644 --- a/devtools/pr_scope.py +++ b/devtools/pr_scope.py @@ -1,10 +1,10 @@ """Structured PR scope carrier used by CI and the merge boundary. -The carrier is an embedded JSON comment, not a convention inferred from PR -prose. It binds the declared Bead scope to a PR head, a canonical digest of the -assigned Bead records, whole-Bead dispositions, and concrete evidence refs. -Bead acceptance text is deliberately opaque here: it is hashed as part of the -record snapshot, never interpreted by this module. +The embedded v2 JSON comment is stable intent, not a mutable commit receipt. +The merge boundary binds that intent to the current PR head and canonical Bead +snapshot in its exact-head attestation. Bead acceptance text remains opaque: +the checker consumes typed identifiers, dispositions, evidence refs, and graph +links without interpreting prose. """ from __future__ import annotations @@ -23,14 +23,18 @@ from typing import Any from urllib import error, parse, request -_CARRIER_PREFIX = "polylogue-pr-scope:v1" -_CARRIER_START = f"" -_VERSION = 1 +_V1 = 1 +_VERSION = 2 _BEADS_PATH = Path(".beads/issues.jsonl") _GITHUB_API_URL = "https://api.github.com" _REPOSITORY_PATTERN = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") -_CARRIER_KEYS = frozenset({"version", "head_sha", "assigned_beads", "beads_digest", "dispositions", "scope_digest"}) +_V1_CARRIER_KEYS = frozenset({"version", "head_sha", "assigned_beads", "beads_digest", "dispositions", "scope_digest"}) +_V2_CARRIER_KEYS = frozenset( + {"version", "scope_kind", "assigned_beads", "mutated_beads", "dispositions", "scope_digest"} +) _DISPOSITION_KEYS = frozenset({"bead_id", "disposition", "evidence", "successors"}) _EVIDENCE_KEYS = frozenset({"kind", "ref"}) @@ -51,11 +55,17 @@ class EvidenceKind(StrEnum): TEST = "test" +class ScopeKind(StrEnum): + BEAD = "bead" + SELF_CONTAINED = "self_contained" + + _DISPOSITIONS = frozenset(item.value for item in ScopeDisposition) _RESIDUAL_DISPOSITIONS = frozenset( {ScopeDisposition.PARTIAL.value, ScopeDisposition.DEFERRED.value, ScopeDisposition.SUPERSEDED.value} ) _EVIDENCE_KINDS = frozenset(item.value for item in EvidenceKind) +_SCOPE_KINDS = frozenset(item.value for item in ScopeKind) _SUCCESSOR_LINK_TYPES = frozenset({"blocks", "discovered-from", "relates-to", "supersedes"}) @@ -66,6 +76,7 @@ class ScopeVerdict: scope_digest: str | None = None beads_digest: str | None = None assigned_beads: list[str] = field(default_factory=list) + mutated_beads: list[str] = field(default_factory=list) @dataclass(frozen=True, slots=True) @@ -112,12 +123,14 @@ def load_bead_records(path: Path = _BEADS_PATH) -> dict[str, dict[str, Any]]: return records -def canonical_beads_digest(records: dict[str, dict[str, Any]], bead_ids: list[str]) -> str: +def canonical_beads_digest( + records: dict[str, dict[str, Any]], bead_ids: list[str], *, carrier_version: int = _V1 +) -> str: """Digest whole canonical records for the declared IDs, sorted by Bead ID.""" missing = [bead_id for bead_id in bead_ids if bead_id not in records] if missing: raise ValueError(f"assigned Bead record(s) missing: {', '.join(missing)}") - return _digest({"version": _VERSION, "records": [records[bead_id] for bead_id in sorted(bead_ids)]}) + return _digest({"version": carrier_version, "records": [records[bead_id] for bead_id in sorted(bead_ids)]}) def _successor_is_linked(source_id: str, successor_id: str, records: dict[str, dict[str, Any]]) -> bool: @@ -143,12 +156,13 @@ def carrier_digest(carrier: dict[str, Any]) -> str: def extract_carrier(body: str) -> tuple[dict[str, Any] | None, list[str]]: - starts = [index for index in range(len(body)) if body.startswith(_CARRIER_START, index)] + starts = list(_CARRIER_START_PATTERN.finditer(body)) if not starts: return None, ["PR body is missing the structured pr-scope carrier"] if len(starts) != 1: return None, ["PR body contains more than one structured pr-scope carrier"] - start = starts[0] + len(_CARRIER_START) + marker = starts[0] + start = marker.end() end = body.find(_CARRIER_END, start) if end < 0: return None, ["structured pr-scope carrier is missing its closing comment"] @@ -159,6 +173,8 @@ def extract_carrier(body: str) -> tuple[dict[str, Any] | None, list[str]]: return None, [f"structured pr-scope carrier is not valid JSON: {exc.msg}"] if not isinstance(carrier, dict): return None, ["structured pr-scope carrier must be a JSON object"] + if carrier.get("version") != int(marker.group("version")): + return None, ["structured pr-scope carrier version does not match its comment marker"] return carrier, [] @@ -178,34 +194,159 @@ def _validate_keys( reasons.append(f"{label} is missing required field(s): {', '.join(missing)}") +def _bead_ids(value: object, *, label: str, allow_empty: bool, reasons: list[str]) -> list[str]: + if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): + reasons.append(f"{label} must be a list of Bead IDs") + return [] + if not value and not allow_empty: + reasons.append(f"{label} must be a non-empty list of Bead IDs") + if len(set(value)) != len(value): + reasons.append(f"{label} contains duplicate Bead IDs") + return value + + +def _bead_records_at(base_sha: str) -> dict[str, dict[str, Any]]: + result = subprocess.run( + ["git", "show", f"{base_sha}:.beads/issues.jsonl"], capture_output=True, text=True, check=False + ) + if result.returncode != 0: + raise ValueError(f"cannot read .beads/issues.jsonl at base revision {base_sha[:8]}") + records: dict[str, dict[str, Any]] = {} + for line_no, line in enumerate(result.stdout.splitlines(), start=1): + if not line.strip(): + continue + raw = json.loads(line) + if isinstance(raw, dict) and raw.get("_type") == "issue" and isinstance(raw.get("id"), str): + if raw["id"] in records: + raise ValueError(f"duplicate Bead id {raw['id']!r} on base line {line_no}") + records[raw["id"]] = raw + return records + + +def changed_bead_ids(*, base_sha: str, beads_path: Path = _BEADS_PATH) -> list[str]: + """Return the complete Bead-record mutation set between base and checkout.""" + before = _bead_records_at(base_sha) + after = load_bead_records(beads_path) + return sorted(bead_id for bead_id in set(before) | set(after) if before.get(bead_id) != after.get(bead_id)) + + +def _validate_dispositions( + dispositions: object, + *, + assigned_ids: list[str], + records: dict[str, dict[str, Any]], + reasons: list[str], +) -> None: + by_bead: dict[str, dict[str, Any]] = {} + if not isinstance(dispositions, list): + reasons.append("dispositions must be a list with one entry per assigned Bead") + return + for entry in dispositions: + if not isinstance(entry, dict) or not isinstance(entry.get("bead_id"), str): + reasons.append("each disposition must be an object with a bead_id") + continue + _validate_keys( + entry, + label=f"disposition for {entry['bead_id']}", + allowed=_DISPOSITION_KEYS, + required=_DISPOSITION_KEYS, + reasons=reasons, + ) + bead_id = entry["bead_id"] + if bead_id in by_bead: + reasons.append(f"duplicate disposition for assigned Bead {bead_id}") + by_bead[bead_id] = entry + missing = sorted(set(assigned_ids) - set(by_bead)) + extra = sorted(set(by_bead) - set(assigned_ids)) + if missing: + reasons.append(f"missing whole-Bead disposition(s): {', '.join(missing)}") + if extra: + reasons.append(f"disposition(s) reference unassigned Bead(s): {', '.join(extra)}") + for bead_id, entry in by_bead.items(): + disposition = entry.get("disposition") + if disposition not in _DISPOSITIONS: + reasons.append(f"{bead_id}: unknown whole-Bead disposition {disposition!r}") + evidence = entry.get("evidence") + if not isinstance(evidence, list) or not evidence: + reasons.append(f"{bead_id}: disposition needs at least one typed evidence reference") + else: + for ref in evidence: + if isinstance(ref, dict): + _validate_keys( + ref, + label=f"evidence for {bead_id}", + allowed=_EVIDENCE_KEYS, + required=_EVIDENCE_KEYS, + reasons=reasons, + ) + if ( + not isinstance(ref, dict) + or ref.get("kind") not in _EVIDENCE_KINDS + or not isinstance(ref.get("ref"), str) + or not ref["ref"].strip() + ): + reasons.append(f"{bead_id}: evidence references need a known kind and non-empty ref") + break + successors = entry.get("successors", []) + if not isinstance(successors, list) or not all(isinstance(item, str) and item for item in successors): + reasons.append(f"{bead_id}: successors must be a list of Bead IDs") + continue + if len(set(successors)) != len(successors): + reasons.append(f"{bead_id}: successors contains duplicate Bead IDs") + if disposition in _RESIDUAL_DISPOSITIONS and not successors: + reasons.append(f"{bead_id}: {disposition} disposition requires a named successor Bead") + if disposition == "satisfied" and successors: + reasons.append(f"{bead_id}: satisfied disposition cannot carry residual successors") + for successor in successors: + record = records.get(successor) + if record is None: + reasons.append(f"{bead_id}: successor {successor} is unknown") + elif record.get("status") == "closed": + reasons.append(f"{bead_id}: successor {successor} is closed") + elif not _successor_is_linked(bead_id, successor, records): + reasons.append(f"{bead_id}: successor {successor} has no durable Beads relationship") + if successor == bead_id: + reasons.append(f"{bead_id}: cannot name itself as a successor") + + def validate_carrier( carrier: dict[str, Any], *, head_sha: str, is_draft: bool, beads_path: Path = _BEADS_PATH, + base_sha: str | None = None, ) -> ScopeVerdict: reasons: list[str] = [] + version = carrier.get("version") + if version not in {_V1, _VERSION}: + return ScopeVerdict(ok=False, reasons=[f"carrier version must be {_V1} or {_VERSION}"]) + is_v1 = version == _V1 _validate_keys( carrier, label="carrier", - allowed=_CARRIER_KEYS, - required=_CARRIER_KEYS, + allowed=_V1_CARRIER_KEYS if is_v1 else _V2_CARRIER_KEYS, + required=_V1_CARRIER_KEYS if is_v1 else _V2_CARRIER_KEYS, reasons=reasons, ) - assigned = carrier.get("assigned_beads") - if not isinstance(assigned, list) or not assigned or not all(isinstance(item, str) and item for item in assigned): - reasons.append("assigned_beads must be a non-empty list of Bead IDs") - assigned_ids: list[str] = [] + assigned_ids = _bead_ids( + carrier.get("assigned_beads"), label="assigned_beads", allow_empty=not is_v1, reasons=reasons + ) + mutated_ids: list[str] = [] + if is_v1: + if carrier.get("head_sha") != head_sha: + reasons.append("carrier head_sha does not match the PR head SHA") else: - assigned_ids = assigned - if len(set(assigned_ids)) != len(assigned_ids): - reasons.append("assigned_beads contains duplicate Bead IDs") - - if carrier.get("version") != _VERSION: - reasons.append(f"carrier version must be {_VERSION}") - if carrier.get("head_sha") != head_sha: - reasons.append("carrier head_sha does not match the PR head SHA") + scope_kind = carrier.get("scope_kind") + if scope_kind not in _SCOPE_KINDS: + reasons.append(f"scope_kind must be one of: {', '.join(sorted(_SCOPE_KINDS))}") + mutated_ids = _bead_ids(carrier.get("mutated_beads"), label="mutated_beads", allow_empty=True, reasons=reasons) + if scope_kind == ScopeKind.BEAD.value and not assigned_ids: + reasons.append("bead scope requires at least one assigned Bead") + if scope_kind == ScopeKind.SELF_CONTAINED.value and ( + assigned_ids or mutated_ids or carrier.get("dispositions") != [] + ): + reasons.append("self_contained scope cannot declare Beads, dispositions, or Bead mutations") if is_draft: reasons.append("PR is draft; publish a non-draft PR before validation") @@ -215,97 +356,38 @@ def validate_carrier( reasons.append("carrier scope_digest does not match its canonical content") records: dict[str, dict[str, Any]] = {} - if assigned_ids: - try: - records = load_bead_records(beads_path) - expected_beads_digest = canonical_beads_digest(records, assigned_ids) - except (OSError, ValueError, json.JSONDecodeError) as exc: - expected_beads_digest = None - reasons.append(f"cannot resolve assigned Bead records: {exc}") - if expected_beads_digest is not None and carrier.get("beads_digest") != expected_beads_digest: - reasons.append("carrier beads_digest is stale for the canonical assigned Bead records") - else: + try: + records = load_bead_records(beads_path) + bound_ids = list(assigned_ids) + if not is_v1: + bound_ids.extend(mutated_ids) + for entry in carrier.get("dispositions", []): + if isinstance(entry, dict) and isinstance(entry.get("successors"), list): + bound_ids.extend(item for item in entry["successors"] if isinstance(item, str)) + bound_ids = sorted(set(bound_ids)) + expected_beads_digest = canonical_beads_digest(records, bound_ids, carrier_version=int(version)) + except (OSError, ValueError, json.JSONDecodeError) as exc: expected_beads_digest = None + reasons.append(f"cannot resolve declared Bead records: {exc}") + if is_v1 and expected_beads_digest is not None and carrier.get("beads_digest") != expected_beads_digest: + reasons.append("carrier beads_digest is stale for the canonical assigned Bead records") + if not is_v1 and base_sha is not None: + try: + actual_mutations = changed_bead_ids(base_sha=base_sha, beads_path=beads_path) + except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as exc: + reasons.append(f"cannot resolve Bead mutation scope: {exc}") + else: + if actual_mutations != sorted(mutated_ids): + reasons.append("mutated_beads does not match the complete Bead mutation set") - dispositions = carrier.get("dispositions") - by_bead: dict[str, dict[str, Any]] = {} - if not isinstance(dispositions, list): - reasons.append("dispositions must be a list with one entry per assigned Bead") - else: - for entry in dispositions: - if not isinstance(entry, dict) or not isinstance(entry.get("bead_id"), str): - reasons.append("each disposition must be an object with a bead_id") - continue - _validate_keys( - entry, - label=f"disposition for {entry['bead_id']}", - allowed=_DISPOSITION_KEYS, - required=_DISPOSITION_KEYS, - reasons=reasons, - ) - bead_id = entry["bead_id"] - if bead_id in by_bead: - reasons.append(f"duplicate disposition for assigned Bead {bead_id}") - by_bead[bead_id] = entry - missing = sorted(set(assigned_ids) - set(by_bead)) - extra = sorted(set(by_bead) - set(assigned_ids)) - if missing: - reasons.append(f"missing whole-Bead disposition(s): {', '.join(missing)}") - if extra: - reasons.append(f"disposition(s) reference unassigned Bead(s): {', '.join(extra)}") - - for bead_id, entry in by_bead.items(): - disposition = entry.get("disposition") - if disposition not in _DISPOSITIONS: - reasons.append(f"{bead_id}: unknown whole-Bead disposition {disposition!r}") - evidence = entry.get("evidence") - if not isinstance(evidence, list) or not evidence: - reasons.append(f"{bead_id}: disposition needs at least one typed evidence reference") - else: - for ref in evidence: - if isinstance(ref, dict): - _validate_keys( - ref, - label=f"evidence for {bead_id}", - allowed=_EVIDENCE_KEYS, - required=_EVIDENCE_KEYS, - reasons=reasons, - ) - if ( - not isinstance(ref, dict) - or ref.get("kind") not in _EVIDENCE_KINDS - or not isinstance(ref.get("ref"), str) - or not ref["ref"].strip() - ): - reasons.append(f"{bead_id}: evidence references need a known kind and non-empty ref") - break - successors = entry.get("successors", []) - if not isinstance(successors, list) or not all(isinstance(item, str) and item for item in successors): - reasons.append(f"{bead_id}: successors must be a list of Bead IDs") - continue - if len(set(successors)) != len(successors): - reasons.append(f"{bead_id}: successors contains duplicate Bead IDs") - if disposition in _RESIDUAL_DISPOSITIONS and not successors: - reasons.append(f"{bead_id}: {disposition} disposition requires a named successor Bead") - if disposition == "satisfied" and successors: - reasons.append(f"{bead_id}: satisfied disposition cannot carry residual successors") - for successor in successors: - record = records.get(successor) - if record is None: - reasons.append(f"{bead_id}: successor {successor} is unknown") - elif record.get("status") == "closed": - reasons.append(f"{bead_id}: successor {successor} is closed") - elif not _successor_is_linked(bead_id, successor, records): - reasons.append(f"{bead_id}: successor {successor} has no durable Beads relationship") - if successor == bead_id: - reasons.append(f"{bead_id}: cannot name itself as a successor") - + _validate_dispositions(carrier.get("dispositions"), assigned_ids=assigned_ids, records=records, reasons=reasons) return ScopeVerdict( ok=not reasons, reasons=reasons, scope_digest=scope_digest if isinstance(scope_digest, str) else None, - beads_digest=carrier.get("beads_digest") if isinstance(carrier.get("beads_digest"), str) else None, + beads_digest=expected_beads_digest, assigned_beads=assigned_ids, + mutated_beads=mutated_ids, ) @@ -315,27 +397,36 @@ def validate_pr_body( head_sha: str, is_draft: bool, beads_path: Path = _BEADS_PATH, + base_sha: str | None = None, ) -> ScopeVerdict: carrier, reasons = extract_carrier(body) if carrier is None: return ScopeVerdict(ok=False, reasons=reasons) - return validate_carrier(carrier, head_sha=head_sha, is_draft=is_draft, beads_path=beads_path) + return validate_carrier(carrier, head_sha=head_sha, is_draft=is_draft, beads_path=beads_path, base_sha=base_sha) def render_carrier(carrier: dict[str, Any]) -> str: - return f"{_CARRIER_START}\n{json.dumps(carrier, indent=2, ensure_ascii=False, sort_keys=True)}\n{_CARRIER_END}" + version = carrier.get("version") + return f"