diff --git a/.github/trust-root/memory_queue_ci_requirements.txt b/.github/trust-root/memory_queue_ci_requirements.txt new file mode 100644 index 00000000..047a1ba1 --- /dev/null +++ b/.github/trust-root/memory_queue_ci_requirements.txt @@ -0,0 +1,10 @@ +# Frozen dependency artifacts for protected Memory Queue evidence workflows. +# ubuntu-latest / Python 3.12 is intentionally wheel-only. Each selected wheel +# is exact-version and SHA-256 bound; workflows must install with +# --require-hashes --only-binary=:all:. +iniconfig==2.3.0 --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 +packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +Pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 +pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c +PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc diff --git a/.github/trust-root/memory_queue_pip_bootstrap.txt b/.github/trust-root/memory_queue_pip_bootstrap.txt new file mode 100644 index 00000000..64cf8462 --- /dev/null +++ b/.github/trust-root/memory_queue_pip_bootstrap.txt @@ -0,0 +1,3 @@ +# Frozen pip bootstrap artifact for protected Memory Queue evidence workflows. +# ubuntu-latest / Python 3.12 is intentionally wheel-only. +pip==26.1.2 --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab diff --git a/.github/trust-root/protected_files.json b/.github/trust-root/protected_files.json index e6a7fb1f..0d338d3c 100644 --- a/.github/trust-root/protected_files.json +++ b/.github/trust-root/protected_files.json @@ -1,22 +1,30 @@ { "files": { ".bandit": "5568293d2e15266a57eb51cc81b0bef223c2b269", + ".github/trust-root/memory_queue_ci_requirements.txt": "047a1ba13cc17f9ef566936c6d5e104217d140bd", + ".github/trust-root/memory_queue_pip_bootstrap.txt": "64cf8462e4e31152f4c1c056b0265fc53fd53b8d", ".github/trust-root/scripts/memory_learning_core.py": "9df0bfffac466dca5128d21d5f3cfba9f0b663b3", ".github/trust-root/scripts/memory_learning_fallback.py": "cb70887f2a5afa1b07fa970f3252a85a977de260", ".github/trust-root/scripts/memory_learning_github.py": "d4d679ef1956e7ab072d95e496152675f2f298df", ".github/trust-root/scripts/memory_learning_loop.py": "f7bf7d06d5b8cb41c67ab2ba9bed04cd4168dc17", + ".github/trust-root/scripts/memory_queue_revalidation_collect.py": "e1d0552a05306f64d768d31b3b73a180357f3583", ".github/trust-root/scripts/memory_retrieval_core.py": "433b4cd302e6bfd3e10bc188aaf5774919892052", ".github/trust-root/scripts/memory_retrieval_github.py": "2598b3f903676f3705f7f69399c9158257af462a", ".github/trust-root/scripts/memory_retrieval_hardened.py": "21334e2e235a9bd0506449a4adcefc70c2427d07", ".github/trust-root/scripts/memory_retrieval_hardened_loop.py": "80db30dc486d46bdf767815a2d501c6176bd446a", ".github/trust-root/scripts/memory_retrieval_loop.py": "bea8f6ac10f2da9221f3ed52b41fee1daa3b1d79", + ".github/trust-root/scripts/memory_review_workbench_collect.py": "04191973e6425f19e44edc52eddab1c21bcac3ab", ".github/trust-root/scripts/reviewer_fallback.py": "e17b2fb720cc46f91e82ab0482486091b456bc9e", ".github/trust-root/scripts/reviewer_fallback_entrypoint.py": "0cdf6a3f43724d8754361ad46fc996d98ef4b8b6", ".github/trust-root/scripts/reviewer_fallback_reconcile.py": "74b0bc06c899a9ee3462c203831215a9ac39af64", ".github/trust-root/scripts/reviewer_fallback_runtime.py": "041c602be70bf61f0f6c84b85c9e84e559986556", + ".github/trust-root/scripts/verify_memory_queue_dependency_contract.py": "a81bb5019e2d61316cbb2b07e9b703ba450c2c53", ".github/workflows/ci.yml": "ae103606ca5b4b99609ee48c2f867e6bde1d55ec", ".github/workflows/ebpf-runtime-proof.yml": "19baaa8f66ab586ca747a52b3e3b4486e1ff3ee3", ".github/workflows/memory-learning-loop.yml": "8627fbeea2db9bbaeb0fb10c8095479cf2a04829", + ".github/workflows/memory-queue-revalidation.yml": "8d830bd83cd061728761bc9c9c2b6935296a51f6", + ".github/workflows/memory-queue-review-workbench.yml": "b97ab9d4f3bf35e3b90d5c7901a98d8212cdd612", + ".github/workflows/memory-queue-semantic-acceptance.yml": "49d0da91c0fa065a8c706b027c63c1ad592bb743", ".github/workflows/memory-retrieval.yml": "4a226ab2caacfc1fc840659572dde66df622058e", ".github/workflows/python-package-validation.yml": "13bd15e4010a1cc04f64b972a69c8da07f0e58a7", ".github/workflows/reviewer-fallback.yml": "0646491db9729279a36f89d3a14f3203c94e029c", diff --git a/.github/trust-root/scripts/memory_queue_revalidation_collect.py b/.github/trust-root/scripts/memory_queue_revalidation_collect.py new file mode 100644 index 00000000..e1d0552a --- /dev/null +++ b/.github/trust-root/scripts/memory_queue_revalidation_collect.py @@ -0,0 +1,585 @@ +#!/usr/bin/env python3 +"""Collect live, read-only evidence for every open CML Memory Proposal. + +Network collection is intentionally separated from trust decisions. For each +proposal this collector validates the frozen pack, rebuilds it from current +GitHub observations, then compares evidence components before handing an +immutable source core to the pure v0.3 adapter. +""" + +from __future__ import annotations + +import argparse +import base64 +from collections import Counter +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import re +import sys +from typing import Any, Mapping +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + +import memory_learning_core as learning +import memory_retrieval_core as retrieval + +from cml.experimental.memory_proposal_queue import audit +from cml.experimental.memory_proposal_queue_planner import plan +from cml.experimental.memory_proposal_queue_revalidation import build_planner_record + +API = "https://api.github.com" +TITLE_PREFIX = "memory: learn from merged PR #" +SOURCE_PR_RE = re.compile(r"^memory: learn from merged PR #(\d+)$") +SOURCE_MERGE_RE = re.compile(r"^- source merge: `([0-9a-f]{40})`$", re.MULTILINE) +SOURCE_HEAD_RE = re.compile(r"^- source head: `([0-9a-f]{40})`$", re.MULTILINE) +MEMORY_PATH_RE = re.compile(r"^- memory path: `([^`]+)`$", re.MULTILINE) +PACK_ID_RE = re.compile(r"^- pack ID: `([0-9a-f]{64})`$", re.MULTILINE) +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +REQUIRED_EVIDENCE = ( + "source-pr", + "source-files", + "source-reviews", + "source-checks", + "source-merge", +) +DESCRIPTIVE_EVIDENCE = ("source-pr",) +OPERATIONAL_EVIDENCE = ("source-reviews", "source-checks") +MUTABLE_EVIDENCE = (*DESCRIPTIVE_EVIDENCE, *OPERATIONAL_EVIDENCE) +SELF_CHECK_NAME = "Propose merged-cycle memory" + + +class CollectionError(RuntimeError): + pass + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + + +def _repository(value: Any) -> str: + if not isinstance(value, str) or not REPOSITORY_RE.fullmatch(value.strip()): + raise CollectionError("repository must be owner/name") + return value.strip() + + +class GitHubReader: + def __init__(self, token: str) -> None: + if not token: + raise CollectionError("GITHUB_TOKEN is required") + self._headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "cml-memory-queue-revalidation-v0.3", + } + + def get(self, path: str) -> Any: + if path.startswith("https://") and not path.startswith(API + "/"): + raise CollectionError("GitHub API URL must remain on api.github.com") + url = path if path.startswith("https://") else API + path + request = Request(url, headers=self._headers) + try: + with urlopen(request, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:1000] + raise CollectionError(f"GitHub API {exc.code} for {url}: {detail}") from exc + except (URLError, TimeoutError, OSError) as exc: + raise CollectionError(f"GitHub API transport failure for {url}: {exc}") from exc + + def paginate_list(self, path: str) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + page = 1 + separator = "&" if "?" in path else "?" + while True: + payload = self.get(f"{path}{separator}per_page=100&page={page}") + if not isinstance(payload, list): + raise CollectionError(f"expected list response for {path}") + rows = [item for item in payload if isinstance(item, dict)] + result.extend(rows) + if len(payload) < 100: + return result + page += 1 + + def check_runs(self, repository: str, head_sha: str) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + page = 1 + while True: + payload = self.get( + f"/repos/{repository}/commits/{head_sha}/check-runs?filter=latest&per_page=100&page={page}" + ) + if not isinstance(payload, dict): + raise CollectionError("check-runs response must be an object") + rows = payload.get("check_runs") + total = payload.get("total_count") + if not isinstance(rows, list) or not isinstance(total, int): + raise CollectionError("check-runs response is incomplete") + result.extend(item for item in rows if isinstance(item, dict)) + if len(result) >= total or not rows: + return result + page += 1 + + +def _required(pattern: re.Pattern[str], text: str, field: str) -> str: + match = pattern.search(text) + if match is None: + raise CollectionError(f"proposal body missing {field}") + return match.group(1) + + +def _parse_proposal(pull: dict[str, Any]) -> dict[str, Any]: + title = pull.get("title") + body = pull.get("body") + if not isinstance(title, str) or not isinstance(body, str): + raise CollectionError("proposal title/body must be strings") + source_match = SOURCE_PR_RE.fullmatch(title) + if source_match is None: + raise CollectionError(f"unexpected proposal title: {title!r}") + + number = pull.get("number") + draft = pull.get("draft") + head = pull.get("head") + html_url = pull.get("html_url") + created_at = pull.get("created_at") + if isinstance(number, bool) or not isinstance(number, int) or number <= 0: + raise CollectionError("proposal number must be positive") + if draft is not True: + raise CollectionError(f"proposal #{number} must remain draft") + if not isinstance(head, dict) or not isinstance(head.get("sha"), str): + raise CollectionError(f"proposal #{number} missing head SHA") + if not isinstance(html_url, str) or not isinstance(created_at, str): + raise CollectionError(f"proposal #{number} missing URL/timestamp") + + return { + "proposal_pr": number, + "source_pr": int(source_match.group(1)), + "source_merge": _required(SOURCE_MERGE_RE, body, "source merge"), + "source_head": _required(SOURCE_HEAD_RE, body, "source head"), + "memory_path": _required(MEMORY_PATH_RE, body, "memory path"), + "pack_id": _required(PACK_ID_RE, body, "pack ID"), + "proposal_head": head["sha"], + "proposal_url": html_url, + "created_at": created_at, + } + + +def _pack_text( + reader: GitHubReader, + *, + repository: str, + path: str, + ref: str, +) -> tuple[str, str]: + encoded_path = quote(path, safe="/") + encoded_ref = quote(ref, safe="") + payload = reader.get( + f"/repos/{repository}/contents/{encoded_path}?ref={encoded_ref}" + ) + if not isinstance(payload, dict): + raise CollectionError("contents response must be an object") + content = payload.get("content") + encoding = payload.get("encoding") + blob_sha = payload.get("sha") + if not isinstance(content, str) or encoding != "base64" or not isinstance(blob_sha, str): + raise CollectionError("Memory Pack contents response is incomplete") + try: + text = base64.b64decode(content).decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + raise CollectionError("Memory Pack is not valid UTF-8/base64") from exc + return text, blob_sha + + +def _evidence_digests(pack: Mapping[str, Any]) -> dict[str, str]: + raw = pack.get("evidence") + if not isinstance(raw, list): + raise CollectionError("Memory Pack evidence must be a list") + result: dict[str, str] = {} + for item in raw: + if not isinstance(item, dict): + raise CollectionError("Memory Pack evidence entry must be an object") + evidence_id = item.get("id") + digest = item.get("digest") + if not isinstance(evidence_id, str) or not isinstance(digest, str): + raise CollectionError("Memory Pack evidence entry missing id/digest") + if evidence_id in result: + raise CollectionError(f"duplicate Memory Pack evidence id: {evidence_id}") + result[evidence_id] = digest + required = set(REQUIRED_EVIDENCE) + if not required.issubset(result): + missing = ",".join(sorted(required - set(result))) + raise CollectionError(f"Memory Pack missing required evidence: {missing}") + return result + + +def _source_core_digest( + *, + repository: str, + source_pr: int, + source_head: str, + source_merge: str, + source_files_digest: str, +) -> str: + """Hash immutable source bindings, excluding mutable PR narrative/check data.""" + + return learning.sha256_json( + { + "repository": repository, + "source_pr": source_pr, + "source_head": source_head, + "source_merge": source_merge, + "source_files_digest": source_files_digest, + } + ) + + +def _self_completion_explains_checks( + checks: list[dict[str, Any]], + original_checks_digest: str, +) -> bool: + """Test one narrow self-observation TOCTOU hypothesis without asserting it.""" + + normalized = learning.normalize_checks(checks) + found = False + hypothetical: list[dict[str, Any]] = [] + for item in normalized: + candidate = dict(item) + if candidate.get("name") == SELF_CHECK_NAME: + candidate["status"] = "in_progress" + candidate["conclusion"] = None + found = True + hypothetical.append(candidate) + return found and learning.sha256_json(hypothetical) == original_checks_digest + + +def collect(repository: str, token: str) -> dict[str, Any]: + repository = _repository(repository) + reader = GitHubReader(token) + captured_at = _utc_now() + + main_ref = reader.get(f"/repos/{repository}/git/ref/heads/main") + if not isinstance(main_ref, dict): + raise CollectionError("main ref response must be an object") + obj = main_ref.get("object") + if not isinstance(obj, dict) or not isinstance(obj.get("sha"), str): + raise CollectionError("main ref missing commit SHA") + main_sha = obj["sha"] + + open_pulls = reader.paginate_list(f"/repos/{repository}/pulls?state=open") + memory_pulls = [ + pull + for pull in open_pulls + if isinstance(pull.get("title"), str) + and pull["title"].startswith(TITLE_PREFIX) + ] + if not memory_pulls: + raise CollectionError("no open automatic Memory Proposals found") + + proposals = sorted( + (_parse_proposal(pull) for pull in memory_pulls), + key=lambda item: item["proposal_pr"], + ) + + queue_snapshot = { + "schema": "cml.memory-proposal-queue.snapshot.v0.1", + "captured_at": captured_at, + "main_revision": main_sha, + "reported_total_count": len(proposals), + "proposals": [ + { + "proposal_pr": item["proposal_pr"], + "source_pr": item["source_pr"], + "source_merge": item["source_merge"], + "pack_id": item["pack_id"], + "created_at": item["created_at"], + "state": "open", + "draft": True, + "lesson_status": "proposed", + "visibility": "team", + "contains_private_data": True, + "merge_authority": False, + "execution_authority": False, + } + for item in proposals + ], + } + queue_result = audit(queue_snapshot) + + records: list[dict[str, Any]] = [] + full_replay_matches = 0 + stable_core_matches = 0 + ancestry_matches = 0 + operational_drift_count = 0 + descriptive_drift_count = 0 + mutable_drift_count = 0 + self_completion_drift_count = 0 + generator_contract_drift_count = 0 + component_mismatches: Counter[str] = Counter() + + for item in proposals: + pack_text, pack_blob_sha = _pack_text( + reader, + repository=repository, + path=item["memory_path"], + ref=item["proposal_head"], + ) + document = retrieval.parse_memory_pack( + pack_text, + path=item["memory_path"], + repository=repository, + ) + if document.pack_id != item["pack_id"]: + raise CollectionError( + f"proposal #{item['proposal_pr']} body/Memory Pack identity mismatch" + ) + if document.source_commit != item["source_merge"]: + raise CollectionError( + f"proposal #{item['proposal_pr']} source commit mismatch" + ) + original_pack = json.loads(pack_text) + if not isinstance(original_pack, dict): + raise CollectionError("Memory Pack must decode to an object") + original_evidence = _evidence_digests(original_pack) + + source_pull = reader.get(f"/repos/{repository}/pulls/{item['source_pr']}") + if not isinstance(source_pull, dict): + raise CollectionError("source pull response must be an object") + if source_pull.get("merged_at") is None: + raise CollectionError( + f"source PR #{item['source_pr']} is no longer recorded as merged" + ) + current_merge = source_pull.get("merge_commit_sha") + if current_merge != item["source_merge"]: + raise CollectionError( + f"source PR #{item['source_pr']} merge SHA contradicts proposal" + ) + head = source_pull.get("head") + if not isinstance(head, dict) or head.get("sha") != item["source_head"]: + raise CollectionError( + f"source PR #{item['source_pr']} head SHA contradicts proposal" + ) + current_head = head["sha"] + + files = reader.paginate_list( + f"/repos/{repository}/pulls/{item['source_pr']}/files" + ) + reviews = reader.paginate_list( + f"/repos/{repository}/pulls/{item['source_pr']}/reviews" + ) + checks = reader.check_runs(repository, item["source_head"]) + replayed = learning.build_memory_pack( + repository=repository, + pull=source_pull, + files=files, + reviews=reviews, + check_runs=checks, + ) + replayed_pack_id = replayed["pack_id"] + replayed_evidence = _evidence_digests(replayed) + + full_replay_match = replayed_pack_id == item["pack_id"] + full_replay_matches += int(full_replay_match) + changed_components = sorted( + component + for component in set((*original_evidence, *replayed_evidence)) + if original_evidence.get(component) != replayed_evidence.get(component) + ) + component_mismatches.update(changed_components) + + expected_source_core = _source_core_digest( + repository=repository, + source_pr=item["source_pr"], + source_head=item["source_head"], + source_merge=item["source_merge"], + source_files_digest=original_evidence["source-files"], + ) + observed_source_core = _source_core_digest( + repository=repository, + source_pr=item["source_pr"], + source_head=current_head, + source_merge=current_merge, + source_files_digest=replayed_evidence["source-files"], + ) + stable_core_match = expected_source_core == observed_source_core + stable_core_matches += int(stable_core_match) + + operational_changed = [ + component + for component in changed_components + if component in OPERATIONAL_EVIDENCE + ] + descriptive_changed = [ + component + for component in changed_components + if component in DESCRIPTIVE_EVIDENCE + ] + mutable_changed = [ + component + for component in changed_components + if component in MUTABLE_EVIDENCE + ] + operational_drift_count += int(bool(operational_changed)) + descriptive_drift_count += int(bool(descriptive_changed)) + mutable_drift_count += int(bool(mutable_changed)) + + self_completion_drift = ( + "source-checks" in changed_components + and _self_completion_explains_checks( + checks, + original_evidence["source-checks"], + ) + ) + self_completion_drift_count += int(self_completion_drift) + generator_contract_drift = bool( + not full_replay_match and not changed_components + ) + generator_contract_drift_count += int(generator_contract_drift) + + compare = reader.get( + f"/repos/{repository}/compare/{item['source_merge']}...{main_sha}" + ) + if not isinstance(compare, dict): + raise CollectionError("compare response must be an object") + compare_status = compare.get("status") + ancestor = compare_status in {"ahead", "identical"} + ancestry_matches += int(ancestor) + + record = build_planner_record( + { + "repository": repository, + "proposal_pr": item["proposal_pr"], + "source_pr": item["source_pr"], + "source_merge": item["source_merge"], + "current_main_revision": main_sha, + "pack_id": item["pack_id"], + "validated_pack_id": document.pack_id, + "replayed_pack_id": replayed_pack_id, + "expected_source_core_digest": expected_source_core, + "observed_source_core_digest": observed_source_core, + "full_pack_replay_match": full_replay_match, + "changed_evidence_components": changed_components, + "self_observation_completion_drift": self_completion_drift, + "source_exists": True, + "source_ancestor_of_main": ancestor, + "evidence_refs": [ + item["proposal_url"], + f"https://github.com/{repository}/blob/{item['proposal_head']}/{item['memory_path']}", + f"https://github.com/{repository}/pull/{item['source_pr']}", + f"https://github.com/{repository}/commit/{item['source_merge']}", + f"https://github.com/{repository}/compare/{item['source_merge']}...{main_sha}", + f"https://github.com/{repository}/commit/{item['source_head']}/checks", + f"git-blob:{pack_blob_sha}", + ], + } + ) + records.append(record) + + planner_input = { + "schema": "cml.memory-proposal-queue.revalidation-input.v0.2", + "source_audit_schema": queue_result["schema"], + "source_audit_digest": queue_result["snapshot_digest"], + "current_main_revision": main_sha, + "captured_at": captured_at, + "synthetic": False, + "expected_record_count": len(records), + "records": records, + } + planner_result = plan(planner_input) + if planner_result["record_count"] != len(proposals): + raise CollectionError("Planner record coverage does not match live proposal count") + if planner_result["authority_granted"] is not False: + raise CollectionError("Planner unexpectedly granted authority") + + fitness_counts = Counter( + decision["canonical_fitness"]["status"] + for decision in planner_result["decisions"] + ) + applicability_counts = Counter( + record["applicability"]["status"] for record in records + ) + quality_counts = Counter(record["quality"]["readiness"] for record in records) + + summary = { + "schema": "cml.memory-proposal-queue.live-revalidation-summary.v0.3", + "captured_at": captured_at, + "main_revision": main_sha, + "proposal_count": len(proposals), + "planner_record_count": planner_result["record_count"], + "planner_group_count": planner_result["group_count"], + "full_pack_replay_match_count": full_replay_matches, + "full_pack_replay_drift_count": len(proposals) - full_replay_matches, + "stable_source_core_match_count": stable_core_matches, + "stable_source_core_drift_count": len(proposals) - stable_core_matches, + "mutable_evidence_drift_count": mutable_drift_count, + "descriptive_pr_metadata_drift_count": descriptive_drift_count, + "operational_evidence_drift_count": operational_drift_count, + "self_observation_completion_drift_count": self_completion_drift_count, + "generator_contract_drift_count": generator_contract_drift_count, + "evidence_component_mismatch_counts": dict(sorted(component_mismatches.items())), + "source_ancestor_of_main_count": ancestry_matches, + "source_not_ancestor_of_main_count": len(proposals) - ancestry_matches, + "applicability_counts": dict(sorted(applicability_counts.items())), + "quality_readiness_counts": dict(sorted(quality_counts.items())), + "fitness_counts": dict(sorted(fitness_counts.items())), + "semantic_acceptance_evidence": "NOT_COLLECTED", + "authority_granted": False, + } + + return { + "queue_snapshot": queue_snapshot, + "queue_audit": queue_result, + "planner_input": planner_input, + "planner_result": planner_result, + "summary": summary, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Collect live CML queue revalidation evidence" + ) + parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY")) + parser.add_argument("--token-env", default="GITHUB_TOKEN") + parser.add_argument( + "--out-dir", + type=Path, + default=Path("artifacts/memory-queue-revalidation"), + ) + args = parser.parse_args() + + try: + repository = _repository(args.repository) + except CollectionError as exc: + print(str(exc), file=sys.stderr) + return 2 + token = os.environ.get(args.token_env, "") + + try: + result = collect(repository, token) + except ( + CollectionError, + retrieval.RetrievalError, + learning.LearningLoopError, + ValueError, + ) as exc: + print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr) + return 2 + + args.out_dir.mkdir(parents=True, exist_ok=True) + for name, payload in result.items(): + path = args.out_dir / f"{name.replace('_', '-')}.json" + path.write_text( + json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + + print(json.dumps(result["summary"], ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/trust-root/scripts/memory_review_workbench_collect.py b/.github/trust-root/scripts/memory_review_workbench_collect.py new file mode 100644 index 00000000..04191973 --- /dev/null +++ b/.github/trust-root/scripts/memory_review_workbench_collect.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +"""Collect read-only live context for Human Review Workbench v0.5. + +The collector consumes frozen Semantic Acceptance v0.4 packets. It does not +recompute or override their machine verdicts. It enriches each packet with the +actual generated lesson/situation/action and a net current-main comparison for +every source-PR changed path. + +Path comparison is source-merge state vs current-main state. It is not evidence +that a path was never changed and reverted between those revisions. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +from pathlib import Path +import re +import sys +from typing import Any, Mapping +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + +from cml.experimental.memory_proposal_review_workbench import ( + CONTEXT_SCHEMA, + PATH_DIVERGED, + PATH_MISSING, + PATH_SAME, +) +from cml.experimental.memory_proposal_semantic_acceptance import ( + INTAKE_SCHEMA, + SemanticAcceptanceError, + verify_semantic_acceptance_intake, +) + +API = "https://api.github.com" +BLOB_REF_RE = re.compile(r"/blob/([0-9a-f]{40})/(.+)$") +SHA40_RE = re.compile(r"^[0-9a-f]{40}$") +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") + + +class WorkbenchCollectionError(RuntimeError): + pass + + +def _repository(value: Any) -> str: + if not isinstance(value, str) or not REPOSITORY_RE.fullmatch(value.strip()): + raise WorkbenchCollectionError("repository must be owner/name") + return value.strip() + + +def _sha40(value: Any, field: str) -> str: + text = _text(value, field) + if not SHA40_RE.fullmatch(text): + raise WorkbenchCollectionError(f"{field} must be a 40-char lowercase hex SHA") + return text + + +class GitHubReader: + def __init__(self, token: str) -> None: + if not token: + raise WorkbenchCollectionError("GITHUB_TOKEN is required") + self._headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "cml-human-review-workbench-v0.5", + } + + def get(self, path: str, *, allow_404: bool = False) -> Any: + if path.startswith("https://") and not path.startswith(API + "/"): + raise WorkbenchCollectionError("GitHub API URL must remain on api.github.com") + url = path if path.startswith("https://") else API + path + request = Request(url, headers=self._headers) + try: + with urlopen(request, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + if exc.code == 404 and allow_404: + return None + detail = exc.read().decode("utf-8", errors="replace")[:1000] + raise WorkbenchCollectionError( + f"GitHub API {exc.code} for {url}: {detail}" + ) from exc + except (URLError, TimeoutError, OSError) as exc: + raise WorkbenchCollectionError( + f"GitHub API transport failure for {url}: {exc}" + ) from exc + + def paginate_list(self, path: str) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + page = 1 + separator = "&" if "?" in path else "?" + while True: + payload = self.get(f"{path}{separator}per_page=100&page={page}") + if not isinstance(payload, list): + raise WorkbenchCollectionError(f"expected list response for {path}") + rows = [item for item in payload if isinstance(item, dict)] + result.extend(rows) + if len(payload) < 100: + return result + page += 1 + + +def _mapping(value: Any, field: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise WorkbenchCollectionError(f"{field} must be an object") + return value + + +def _text(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise WorkbenchCollectionError(f"{field} must be a non-empty string") + return value.strip() + + +def _pack_ref(packet: Mapping[str, Any], repository: str) -> tuple[str, str, str]: + refs = packet.get("gate_evidence_refs") + if not isinstance(refs, list): + raise WorkbenchCollectionError("packet gate_evidence_refs must be a list") + prefix = f"https://github.com/{repository}/blob/" + candidates = [ref for ref in refs if isinstance(ref, str) and ref.startswith(prefix)] + if len(candidates) != 1: + raise WorkbenchCollectionError( + "packet must contain exactly one repository Memory Pack blob reference" + ) + match = BLOB_REF_RE.search(candidates[0]) + if match is None: + raise WorkbenchCollectionError("Memory Pack blob reference is malformed") + return candidates[0], match.group(1), match.group(2) + + +def _contents( + reader: GitHubReader, + repository: str, + path: str, + ref: str, + *, + allow_missing: bool = False, +) -> Mapping[str, Any] | None: + encoded_path = quote(path, safe="/") + encoded_ref = quote(ref, safe="") + payload = reader.get( + f"/repos/{repository}/contents/{encoded_path}?ref={encoded_ref}", + allow_404=allow_missing, + ) + if payload is None: + return None + if not isinstance(payload, Mapping): + raise WorkbenchCollectionError(f"contents response for {path} must be an object") + return payload + + +def _memory_pack( + reader: GitHubReader, repository: str, ref: str, path: str +) -> Mapping[str, Any]: + payload = _contents(reader, repository, path, ref) + assert payload is not None + if payload.get("encoding") != "base64" or not isinstance(payload.get("content"), str): + raise WorkbenchCollectionError("Memory Pack contents must be base64") + try: + decoded = base64.b64decode(payload["content"]).decode("utf-8") + pack = json.loads(decoded) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise WorkbenchCollectionError("Memory Pack is not valid UTF-8 JSON") from exc + return _mapping(pack, "Memory Pack") + + +def _selected_node(pack: Mapping[str, Any], kind: str) -> Mapping[str, Any]: + graph = _mapping(pack.get("graph"), "Memory Pack graph") + nodes_raw = graph.get("nodes") + selected_raw = graph.get("selected_path") + if not isinstance(nodes_raw, list) or not isinstance(selected_raw, list): + raise WorkbenchCollectionError("Memory Pack graph nodes/selected_path are required") + selected = {item for item in selected_raw if isinstance(item, str)} + matches = [ + node + for node in nodes_raw + if isinstance(node, Mapping) + and node.get("kind") == kind + and node.get("id") in selected + ] + if len(matches) != 1: + raise WorkbenchCollectionError( + f"Memory Pack selected_path must contain exactly one {kind} node" + ) + return matches[0] + + +def _state_token(payload: Mapping[str, Any] | None) -> str | None: + if payload is None: + return None + sha = payload.get("sha") + if not isinstance(sha, str) or not sha: + raise WorkbenchCollectionError("contents response missing blob SHA") + return sha + + +def _path_states( + reader: GitHubReader, + repository: str, + source_pr: int, + source_merge: str, + current_main: str, +) -> tuple[list[dict[str, Any]], list[str]]: + files = reader.paginate_list(f"/repos/{repository}/pulls/{source_pr}/files") + if not files: + raise WorkbenchCollectionError(f"source PR #{source_pr} has no changed files") + + states: list[dict[str, Any]] = [] + refs: list[str] = [] + seen: set[str] = set() + for item in files: + path = _text(item.get("filename"), f"source PR #{source_pr} filename") + if path in seen: + raise WorkbenchCollectionError(f"source PR #{source_pr} duplicate filename") + seen.add(path) + status = _text(item.get("status"), f"source PR #{source_pr} file status") + + if status == "removed": + source_token = "ABSENT" + source_payload = None + else: + source_payload = _contents(reader, repository, path, source_merge) + if source_payload is None: + raise WorkbenchCollectionError( + f"source PR #{source_pr} path missing at source merge: {path}" + ) + source_token = _state_token(source_payload) + assert source_token is not None + + current_payload = _contents( + reader, + repository, + path, + current_main, + allow_missing=True, + ) + current_token = _state_token(current_payload) + + if source_token == "ABSENT": + if current_token is None: + state = PATH_SAME + current_token = "ABSENT" + else: + state = PATH_DIVERGED + elif current_token is None: + state = PATH_MISSING + elif current_token == source_token: + state = PATH_SAME + else: + state = PATH_DIVERGED + + states.append( + { + "path": path, + "status": state, + "source_blob_sha": source_token, + "current_blob_sha": current_token, + "source_pr_file_status": status, + "previous_filename": item.get("previous_filename"), + } + ) + refs.append( + f"https://github.com/{repository}/blob/{source_merge}/{path}" + if source_token != "ABSENT" + else f"https://github.com/{repository}/pull/{source_pr}/files" + ) + if current_token is not None and current_token != "ABSENT": + refs.append(f"https://github.com/{repository}/blob/{current_main}/{path}") + + return states, sorted(set(refs)) + + +def _validate_context_coverage( + contexts: list[dict[str, Any]], expected_count: int +) -> None: + if len(contexts) != expected_count: + raise WorkbenchCollectionError("review context coverage is incomplete") + for key in ("packet_id", "decision_id", "pack_id", "proposal_pr", "source_pr"): + if len({item[key] for item in contexts}) != len(contexts): + raise WorkbenchCollectionError( + f"review context coverage has duplicate {key}" + ) + + +def collect( + intake: Mapping[str, Any], repository: str, token: str +) -> dict[str, Any]: + repository = _repository(repository) + intake = _mapping(intake, "intake") + if intake.get("schema") != INTAKE_SCHEMA: + raise WorkbenchCollectionError(f"intake.schema must be {INTAKE_SCHEMA}") + try: + verified_intake_digest = verify_semantic_acceptance_intake(intake) + except SemanticAcceptanceError as exc: + raise WorkbenchCollectionError(f"frozen semantic intake is invalid: {exc}") from exc + current_main = _sha40( + intake.get("current_main_revision"), "intake.current_main_revision" + ) + intake_digest = _text(intake.get("intake_digest"), "intake.intake_digest") + if intake_digest != verified_intake_digest: + raise WorkbenchCollectionError("verified intake digest mismatch") + packets = intake.get("packets") + if not isinstance(packets, list) or not packets: + raise WorkbenchCollectionError("intake.packets must be a non-empty list") + + reader = GitHubReader(token) + contexts: list[dict[str, Any]] = [] + for raw in packets: + packet = _mapping(raw, "packet") + packet_id = _text(packet.get("packet_id"), "packet.packet_id") + decision_id = _text(packet.get("decision_id"), "packet.decision_id") + pack_id = _text(packet.get("pack_id"), "packet.pack_id") + proposal_pr = packet.get("proposal_pr") + source_pr = packet.get("source_pr") + source_merge = _sha40(packet.get("source_merge"), "packet.source_merge") + if isinstance(proposal_pr, bool) or not isinstance(proposal_pr, int) or proposal_pr <= 0: + raise WorkbenchCollectionError("packet.proposal_pr must be positive") + if isinstance(source_pr, bool) or not isinstance(source_pr, int) or source_pr <= 0: + raise WorkbenchCollectionError("packet.source_pr must be positive") + if packet.get("current_main_revision") != current_main: + raise WorkbenchCollectionError( + f"proposal #{proposal_pr} packet is stale against intake main" + ) + + pack_url, pack_ref, pack_path = _pack_ref(packet, repository) + pack = _memory_pack(reader, repository, pack_ref, pack_path) + if pack.get("pack_id") != pack_id: + raise WorkbenchCollectionError( + f"proposal #{proposal_pr} Memory Pack identity mismatch" + ) + manifest = _mapping(pack.get("manifest"), "Memory Pack manifest") + if manifest.get("source_commit") != source_merge: + raise WorkbenchCollectionError( + f"proposal #{proposal_pr} Memory Pack source commit mismatch" + ) + + situation = _selected_node(pack, "situation") + action = _selected_node(pack, "action") + lesson = _selected_node(pack, "lesson") + lesson_confidence = lesson.get("confidence") + if ( + isinstance(lesson_confidence, bool) + or not isinstance(lesson_confidence, int) + or not 0 <= lesson_confidence <= 100 + ): + raise WorkbenchCollectionError("lesson confidence must be an integer in [0, 100]") + + source_pull = reader.get(f"/repos/{repository}/pulls/{source_pr}") + source_pull = _mapping(source_pull, f"source PR #{source_pr}") + source_title = _text(source_pull.get("title"), f"source PR #{source_pr} title") + if source_pull.get("merge_commit_sha") != source_merge: + raise WorkbenchCollectionError( + f"source PR #{source_pr} merge SHA changed or is misbound" + ) + + states, path_refs = _path_states( + reader, + repository, + source_pr, + source_merge, + current_main, + ) + context_refs = sorted( + set( + [ + pack_url, + f"https://github.com/{repository}/pull/{source_pr}", + f"https://github.com/{repository}/commit/{source_merge}", + f"https://github.com/{repository}/commit/{current_main}", + *path_refs, + ] + ) + ) + contexts.append( + { + "packet_id": packet_id, + "decision_id": decision_id, + "proposal_pr": proposal_pr, + "source_pr": source_pr, + "pack_id": pack_id, + "current_main_revision": current_main, + "source_title": source_title, + "situation_label": _text(situation.get("label"), "situation.label"), + "action_label": _text(action.get("label"), "action.label"), + "lesson_label": _text(lesson.get("label"), "lesson.label"), + "lesson_confidence": lesson_confidence, + "pack_created_at": _text( + manifest.get("created_at"), "Memory Pack manifest.created_at" + ), + "path_states": states, + "context_evidence_refs": context_refs, + } + ) + + _validate_context_coverage(contexts, len(packets)) + return { + "schema": CONTEXT_SCHEMA, + "source_intake_digest": intake_digest, + "current_main_revision": current_main, + "context_count": len(contexts), + "contexts": sorted(contexts, key=lambda item: item["proposal_pr"]), + "non_claims": [ + "path comparison is net source-merge versus current-main state", + "a matching blob does not prove the path was never modified and reverted", + "path divergence does not prove semantic invalidity", + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Collect Human Review Workbench v0.5 context") + parser.add_argument("intake", type=Path) + parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY")) + parser.add_argument("--token-env", default="GITHUB_TOKEN") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + try: + repository = _repository(args.repository) + except WorkbenchCollectionError as exc: + print(str(exc), file=sys.stderr) + return 2 + try: + intake = json.loads(args.intake.read_text(encoding="utf-8")) + result = collect(intake, repository, os.environ.get(args.token_env, "")) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + except (OSError, json.JSONDecodeError, WorkbenchCollectionError) as exc: + print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr) + return 2 + print(json.dumps({"context_count": result["context_count"]}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/trust-root/scripts/verify_memory_queue_dependency_contract.py b/.github/trust-root/scripts/verify_memory_queue_dependency_contract.py new file mode 100644 index 00000000..a81bb501 --- /dev/null +++ b/.github/trust-root/scripts/verify_memory_queue_dependency_contract.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Fail closed when protected Memory Queue dependency installs are not hash-bound.""" + +from __future__ import annotations + +from pathlib import Path +import re +import shlex +import sys + +ROOT = Path(__file__).resolve().parents[3] +BOOTSTRAP = Path(".github/trust-root/memory_queue_pip_bootstrap.txt") +REQUIREMENTS = Path(".github/trust-root/memory_queue_ci_requirements.txt") +WORKFLOWS = ( + Path(".github/workflows/memory-queue-revalidation.yml"), + Path(".github/workflows/memory-queue-review-workbench.yml"), + Path(".github/workflows/memory-queue-semantic-acceptance.yml"), +) +HASHED_PIN_RE = re.compile( + r"^[A-Za-z0-9_.-]+==[^\s]+\s+--hash=sha256:[0-9a-f]{64}$" +) +INSTALL_STEP_NAME = "Install hash-bound test tooling" +CANONICAL_INSTALL_COMMANDS = tuple( + "python -m pip install --require-hashes --only-binary=:all: " + f"--requirement {requirement}" + for requirement in (BOOTSTRAP, REQUIREMENTS) +) +ALLOWED_PYTHON_MODULES = frozenset( + { + "scripts.ci.assert_exact_head", + "pytest", + "cml.experimental.memory_proposal_semantic_acceptance", + "cml.experimental.memory_proposal_review_workbench", + "scripts.ci.build_evidence_manifest", + } +) +ALLOWED_PYTHON_SCRIPTS = frozenset( + { + ".github/trust-root/scripts/verify_memory_queue_dependency_contract.py", + ".github/trust-root/scripts/memory_queue_revalidation_collect.py", + ".github/trust-root/scripts/memory_review_workbench_collect.py", + } +) +SHELL_CONTROL_TOKENS = frozenset({"&&", "||", ";", "|", "&"}) +SHELL_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$") +HEREDOC_RE = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1") + + +class DependencyContractError(ValueError): + """Raised when a protected dependency or install loses integrity binding.""" + + +def _require_hash_bound_file(path: Path) -> None: + lines = (ROOT / path).read_text(encoding="utf-8").splitlines() + requirements = [ + line.strip() + for line in lines + if line.strip() and not line.lstrip().startswith("#") + ] + if not requirements: + raise DependencyContractError(f"{path} contains no requirements") + invalid = [line for line in requirements if not HASHED_PIN_RE.fullmatch(line)] + if invalid: + raise DependencyContractError( + f"{path} contains non-exact or hashless requirements: {invalid}" + ) + + +def _leading_spaces(line: str) -> int: + return len(line) - len(line.lstrip(" ")) + + +def _logical_shell_commands( + lines: list[str], path: Path, step_name: str +) -> tuple[str, ...]: + commands: list[str] = [] + current: list[str] = [] + heredoc_end: str | None = None + + for raw_line in lines: + stripped = raw_line.strip() + if heredoc_end is not None: + if stripped == heredoc_end: + heredoc_end = None + continue + if not stripped or stripped.startswith("#"): + continue + + continued = stripped.endswith("\\") + current.append(stripped[:-1].rstrip() if continued else stripped) + if continued: + continue + + command = " ".join(current).strip() + current = [] + commands.append(command) + heredoc = HEREDOC_RE.search(command) + if heredoc: + heredoc_end = heredoc.group(2) + + if heredoc_end is not None: + raise DependencyContractError( + f"{path} step {step_name!r} contains an unterminated heredoc" + ) + if current: + raise DependencyContractError( + f"{path} step {step_name!r} contains an unterminated continuation" + ) + return tuple(commands) + + +def _extract_run_blocks(text: str, path: Path) -> tuple[tuple[str, tuple[str, ...]], ...]: + lines = text.splitlines() + blocks: list[tuple[str, tuple[str, ...]]] = [] + step_name: str | None = None + step_indent: int | None = None + index = 0 + + while index < len(lines): + line = lines[index] + stripped = line.strip() + indent = _leading_spaces(line) + + if stripped.startswith("- run:"): + raise DependencyContractError( + f"{path} all run steps must have an explicit name" + ) + + if stripped.startswith("- name: "): + step_name = stripped[len("- name: ") :].strip() + step_indent = indent + index += 1 + continue + + if ( + step_indent is not None + and indent == step_indent + and stripped.startswith("- ") + and not stripped.startswith("- name: ") + ): + step_name = None + step_indent = None + + if stripped.startswith("run:"): + if step_name is None or step_indent is None or indent != step_indent + 2: + raise DependencyContractError( + f"{path} contains a run block outside a named step" + ) + suffix = stripped[len("run:") :].strip() + if suffix == "|": + run_indent = indent + block_lines: list[str] = [] + cursor = index + 1 + while cursor < len(lines): + candidate = lines[cursor] + candidate_stripped = candidate.strip() + if candidate_stripped and _leading_spaces(candidate) <= run_indent: + break + block_lines.append(candidate) + cursor += 1 + commands = _logical_shell_commands(block_lines, path, step_name) + blocks.append((step_name, commands)) + index = cursor + continue + if suffix.startswith("|") or suffix.startswith(">") or not suffix: + raise DependencyContractError( + f"{path} step {step_name!r} must use run: | or a single literal command" + ) + for probe in range(index + 1, len(lines)): + probe_line = lines[probe] + if not probe_line.strip(): + continue + if _leading_spaces(probe_line) > indent: + raise DependencyContractError( + f"{path} step {step_name!r} must not use a multi-line plain run scalar" + ) + break + blocks.append((step_name, (suffix,))) + + index += 1 + + return tuple(blocks) + + +def _extract_install_step_commands(text: str, path: Path) -> tuple[str, ...]: + lines = text.splitlines() + marker = f"- name: {INSTALL_STEP_NAME}" + matches = [index for index, line in enumerate(lines) if line.strip() == marker] + if len(matches) != 1: + raise DependencyContractError( + f"{path} must contain exactly one {INSTALL_STEP_NAME!r} step" + ) + + start = matches[0] + step_indent = _leading_spaces(lines[start]) + end = len(lines) + for index in range(start + 1, len(lines)): + stripped = lines[index].strip() + if ( + stripped + and _leading_spaces(lines[index]) <= step_indent + and stripped.startswith("- ") + ): + end = index + break + + step_lines = lines[start + 1 : end] + run_indexes = [ + index + for index, line in enumerate(step_lines) + if line.strip() == "run: |" and _leading_spaces(line) == step_indent + 2 + ] + if len(run_indexes) != 1: + raise DependencyContractError( + f"{path} {INSTALL_STEP_NAME!r} step must contain exactly one literal run block" + ) + + run_index = run_indexes[0] + if any(line.strip() for line in step_lines[:run_index]): + raise DependencyContractError( + f"{path} {INSTALL_STEP_NAME!r} step must not contain extra configuration" + ) + + run_indent = _leading_spaces(step_lines[run_index]) + commands: list[str] = [] + for raw_line in step_lines[run_index + 1 :]: + stripped = raw_line.strip() + if not stripped: + continue + if _leading_spaces(raw_line) <= run_indent: + raise DependencyContractError( + f"{path} {INSTALL_STEP_NAME!r} step must contain only its run block" + ) + if stripped.startswith("#"): + continue + commands.append(stripped) + return tuple(commands) + + +def _require_restricted_non_install_run_blocks(text: str, path: Path) -> None: + for step_name, commands in _extract_run_blocks(text, path): + if step_name == INSTALL_STEP_NAME: + continue + for command in commands: + if any(marker in command for marker in ("$(", "`", "<(", ">(")): + raise DependencyContractError( + f"{path} step {step_name!r} uses forbidden command indirection: {command}" + ) + try: + tokens = shlex.split(command, comments=True, posix=True) + except ValueError as exc: + raise DependencyContractError( + f"{path} step {step_name!r} contains an unparsable command: {command}" + ) from exc + if not tokens: + continue + if any(token in SHELL_CONTROL_TOKENS for token in tokens): + raise DependencyContractError( + f"{path} step {step_name!r} must not chain or pipe commands: {command}" + ) + if any(SHELL_ASSIGNMENT_RE.fullmatch(token) for token in tokens): + raise DependencyContractError( + f"{path} step {step_name!r} must not construct commands through shell assignments: {command}" + ) + if tokens[0] != "python": + raise DependencyContractError( + f"{path} step {step_name!r} command is outside the restricted Python grammar: {command}" + ) + if len(tokens) < 2: + raise DependencyContractError( + f"{path} step {step_name!r} contains a bare python command" + ) + + target = tokens[1] + if target == "-m": + if len(tokens) < 3 or tokens[2] not in ALLOWED_PYTHON_MODULES: + raise DependencyContractError( + f"{path} step {step_name!r} uses an unapproved Python module target: {command}" + ) + elif target == "-": + if len(tokens) < 3 or not tokens[2].startswith("<<"): + raise DependencyContractError( + f"{path} step {step_name!r} uses unsupported stdin execution: {command}" + ) + elif target not in ALLOWED_PYTHON_SCRIPTS: + raise DependencyContractError( + f"{path} step {step_name!r} uses an unapproved Python script target: {command}" + ) + + +def _require_hash_enforced_workflow(path: Path) -> None: + text = (ROOT / path).read_text(encoding="utf-8") + install_commands = _extract_install_step_commands(text, path) + if install_commands != CANONICAL_INSTALL_COMMANDS: + raise DependencyContractError( + f"{path} {INSTALL_STEP_NAME!r} commands must exactly match the protected install contract" + ) + _require_restricted_non_install_run_blocks(text, path) + + +def verify() -> None: + for path in (BOOTSTRAP, REQUIREMENTS): + _require_hash_bound_file(path) + for path in WORKFLOWS: + _require_hash_enforced_workflow(path) + + +def main() -> int: + try: + verify() + except (OSError, DependencyContractError) as exc: + print(f"CML Memory Queue dependency contract failed closed: {exc}", file=sys.stderr) + return 2 + print("CML Memory Queue dependency contract verified") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/memory-queue-revalidation.yml b/.github/workflows/memory-queue-revalidation.yml new file mode 100644 index 00000000..8d830bd8 --- /dev/null +++ b/.github/workflows/memory-queue-revalidation.yml @@ -0,0 +1,154 @@ +name: Memory Queue Revalidation v0.3 + +on: + pull_request: + branches: [main] + paths: + - "cml/experimental/memory_proposal_queue.py" + - "cml/experimental/memory_proposal_queue_planner.py" + - "cml/experimental/memory_proposal_queue_revalidation.py" + - ".github/trust-root/scripts/memory_queue_revalidation_collect.py" + - ".github/trust-root/scripts/verify_memory_queue_dependency_contract.py" + - ".github/trust-root/memory_queue_pip_bootstrap.txt" + - ".github/trust-root/memory_queue_ci_requirements.txt" + - "tests/test_memory_proposal_queue.py" + - "tests/test_memory_proposal_queue_planner.py" + - "tests/test_memory_proposal_queue_revalidation.py" + - "tests/test_memory_queue_collectors.py" + - "docs/integrations/MEMORY_PROPOSAL_QUEUE_REVALIDATION_V0_3.md" + - ".github/workflows/memory-queue-revalidation.yml" + workflow_dispatch: + +permissions: {} + +concurrency: + group: cml-memory-queue-revalidation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SOURCE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PYTHONUNBUFFERED: "1" + PYTHONPATH: ${{ github.workspace }} + +jobs: + revalidate: + name: Read-only live queue revalidation + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: read + checks: read + + steps: + - name: Checkout exact verifier head + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: ${{ env.SOURCE_REPOSITORY }} + ref: ${{ env.EXPECTED_SHA }} + fetch-depth: 1 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + .github/trust-root/memory_queue_pip_bootstrap.txt + .github/trust-root/memory_queue_ci_requirements.txt + + - name: Prove exact-head checkout + run: | + python -m scripts.ci.assert_exact_head \ + --expected "$EXPECTED_SHA" \ + --output artifacts/memory-queue-revalidation/exact-head.json + + - name: Verify dependency integrity contract + run: python .github/trust-root/scripts/verify_memory_queue_dependency_contract.py + + - name: Install hash-bound test tooling + run: | + python -m pip install --require-hashes --only-binary=:all: --requirement .github/trust-root/memory_queue_pip_bootstrap.txt + python -m pip install --require-hashes --only-binary=:all: --requirement .github/trust-root/memory_queue_ci_requirements.txt + + - name: Run queue contract tests + run: | + python -m pytest \ + tests/test_memory_proposal_queue.py \ + tests/test_memory_proposal_queue_planner.py \ + tests/test_memory_proposal_queue_revalidation.py \ + tests/test_memory_queue_collectors.py + + - name: Collect and revalidate every live Memory Proposal + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + python .github/trust-root/scripts/memory_queue_revalidation_collect.py \ + --out-dir artifacts/memory-queue-revalidation/live + + - name: Verify coverage and authority boundary + run: | + python - <<'PY' + import json + from pathlib import Path + + root = Path("artifacts/memory-queue-revalidation/live") + summary = json.loads((root / "summary.json").read_text(encoding="utf-8")) + plan = json.loads((root / "planner-result.json").read_text(encoding="utf-8")) + queue = json.loads((root / "queue-audit.json").read_text(encoding="utf-8")) + + proposal_count = summary["proposal_count"] + assert proposal_count == summary["planner_record_count"] + assert proposal_count == queue["queue"]["proposal_count"] + assert plan["record_count"] == proposal_count + + stable_matches = summary["stable_source_core_match_count"] + stable_drifts = summary["stable_source_core_drift_count"] + assert stable_matches + stable_drifts == proposal_count + + ancestry_matches = summary["source_ancestor_of_main_count"] + ancestry_misses = summary["source_not_ancestor_of_main_count"] + assert ancestry_matches + ancestry_misses == proposal_count + + assert sum(summary["applicability_counts"].values()) == proposal_count + assert sum(summary["quality_readiness_counts"].values()) == proposal_count + assert sum(summary["fitness_counts"].values()) == proposal_count + + assert summary["authority_granted"] is False + assert plan["authority_granted"] is False + assert plan["merge_authority"] is False + assert plan["close_authority"] is False + assert plan["acceptance_authority"] is False + assert plan["execution_authority"] is False + assert plan["policy_mutation_authority"] is False + assert summary["semantic_acceptance_evidence"] == "NOT_COLLECTED" + PY + + - name: Build exact-head evidence manifest + if: always() + run: | + python -m scripts.ci.build_evidence_manifest \ + --artifacts-root artifacts/memory-queue-revalidation \ + --repository "${{ github.repository }}" \ + --source-repository "$SOURCE_REPOSITORY" \ + --tested-sha "$EXPECTED_SHA" \ + --event-name "${{ github.event_name }}" \ + --run-id "${{ github.run_id }}" \ + --run-attempt "${{ github.run_attempt }}" \ + --change-number "${{ github.event.pull_request.number || 0 }}" \ + --workflow-ref "${{ github.workflow_ref }}" \ + --require exact-head.json \ + --output artifacts/memory-queue-revalidation/evidence-manifest.json + + - name: Upload live queue evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cml-memory-queue-revalidation-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/memory-queue-revalidation/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/memory-queue-review-workbench.yml b/.github/workflows/memory-queue-review-workbench.yml new file mode 100644 index 00000000..b97ab9d4 --- /dev/null +++ b/.github/workflows/memory-queue-review-workbench.yml @@ -0,0 +1,200 @@ +name: Memory Queue Human Review Workbench v0.5 + +on: + pull_request: + branches: [main] + paths: + - "cml/experimental/memory_proposal_queue.py" + - "cml/experimental/memory_proposal_queue_planner.py" + - "cml/experimental/memory_proposal_queue_revalidation.py" + - "cml/experimental/memory_proposal_semantic_acceptance.py" + - "cml/experimental/memory_proposal_review_workbench.py" + - ".github/trust-root/scripts/memory_queue_revalidation_collect.py" + - ".github/trust-root/scripts/memory_review_workbench_collect.py" + - ".github/trust-root/scripts/verify_memory_queue_dependency_contract.py" + - ".github/trust-root/memory_queue_pip_bootstrap.txt" + - ".github/trust-root/memory_queue_ci_requirements.txt" + - "tests/test_memory_proposal_queue.py" + - "tests/test_memory_proposal_queue_planner.py" + - "tests/test_memory_proposal_queue_revalidation.py" + - "tests/test_memory_proposal_semantic_acceptance.py" + - "tests/test_memory_proposal_review_workbench.py" + - "tests/test_memory_queue_collectors.py" + - "docs/integrations/MEMORY_PROPOSAL_REVIEW_WORKBENCH_V0_5.md" + - ".github/workflows/memory-queue-review-workbench.yml" + workflow_dispatch: + +permissions: {} + +concurrency: + group: cml-memory-queue-review-workbench-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SOURCE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PYTHONUNBUFFERED: "1" + PYTHONPATH: ${{ github.workspace }} + +jobs: + workbench: + name: Build live human review workbench + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + pull-requests: read + checks: read + + steps: + - name: Checkout exact verifier head + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: ${{ env.SOURCE_REPOSITORY }} + ref: ${{ env.EXPECTED_SHA }} + fetch-depth: 1 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + .github/trust-root/memory_queue_pip_bootstrap.txt + .github/trust-root/memory_queue_ci_requirements.txt + + - name: Prove exact-head checkout + run: | + python -m scripts.ci.assert_exact_head \ + --expected "$EXPECTED_SHA" \ + --output artifacts/memory-queue-review-workbench/exact-head.json + + - name: Verify dependency integrity contract + run: python .github/trust-root/scripts/verify_memory_queue_dependency_contract.py + + - name: Install hash-bound test tooling + run: | + python -m pip install --require-hashes --only-binary=:all: --requirement .github/trust-root/memory_queue_pip_bootstrap.txt + python -m pip install --require-hashes --only-binary=:all: --requirement .github/trust-root/memory_queue_ci_requirements.txt + + - name: Run queue through workbench contract tests + run: | + python -m pytest \ + tests/test_memory_proposal_queue.py \ + tests/test_memory_proposal_queue_planner.py \ + tests/test_memory_proposal_queue_revalidation.py \ + tests/test_memory_proposal_semantic_acceptance.py \ + tests/test_memory_proposal_review_workbench.py \ + tests/test_memory_queue_collectors.py + + - name: Collect exact live revalidation evidence + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + python .github/trust-root/scripts/memory_queue_revalidation_collect.py \ + --out-dir artifacts/memory-queue-review-workbench/live + + - name: Freeze semantic review intake + run: | + python -m cml.experimental.memory_proposal_semantic_acceptance build \ + artifacts/memory-queue-review-workbench/live/planner-input.json \ + artifacts/memory-queue-review-workbench/live/planner-result.json \ + > artifacts/memory-queue-review-workbench/live/semantic-intake.json + + - name: Collect exact workbench review context + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + python .github/trust-root/scripts/memory_review_workbench_collect.py \ + artifacts/memory-queue-review-workbench/live/semantic-intake.json \ + --output artifacts/memory-queue-review-workbench/live/review-contexts.json + + - name: Build deterministic workbench and markdown queue + run: | + python -m cml.experimental.memory_proposal_review_workbench \ + artifacts/memory-queue-review-workbench/live/semantic-intake.json \ + artifacts/memory-queue-review-workbench/live/review-contexts.json \ + --markdown artifacts/memory-queue-review-workbench/live/WORKBENCH.md \ + > artifacts/memory-queue-review-workbench/live/workbench.json + + - name: Verify workbench coverage, ranking, and authority boundary + run: | + python - <<'PY' + import json + from pathlib import Path + + root = Path("artifacts/memory-queue-review-workbench/live") + summary = json.loads((root / "summary.json").read_text(encoding="utf-8")) + intake = json.loads((root / "semantic-intake.json").read_text(encoding="utf-8")) + contexts = json.loads((root / "review-contexts.json").read_text(encoding="utf-8")) + workbench = json.loads((root / "workbench.json").read_text(encoding="utf-8")) + + proposal_count = summary["proposal_count"] + assert isinstance(proposal_count, int) and proposal_count > 0 + assert intake["packet_count"] == proposal_count + assert contexts["context_count"] == proposal_count + assert workbench["card_count"] == proposal_count + assert workbench["pending_review_count"] == intake["pending_human_review_count"] + assert workbench["completed_review_count"] == 0 + assert workbench["current_main_revision"] == summary["main_revision"] + assert workbench["source_intake_digest"] == intake["intake_digest"] + + ranks = [card["queue_rank"] for card in workbench["cards"]] + assert ranks == list(range(1, proposal_count + 1)) + assert len({card["packet_id"] for card in workbench["cards"]}) == proposal_count + assert len({card["card_id"] for card in workbench["cards"]}) == proposal_count + + for card in workbench["cards"]: + assert card["review_completed"] is False + assert card["authority_granted"] is False + assert card["merge_authority"] is False + assert card["close_authority"] is False + assert card["acceptance_authority"] is False + assert card["execution_authority"] is False + assert card["policy_mutation_authority"] is False + if card["human_review_required"]: + template = card["submission_template"] + assert isinstance(template, dict) + assert template["reviewer_id"] is None + assert template["reviewed_at"] is None + assert template["verdict"] is None + assert template["rationale"] is None + else: + assert card["submission_template"] is None + + assert workbench["authority_granted"] is False + assert workbench["merge_authority"] is False + assert workbench["close_authority"] is False + assert workbench["acceptance_authority"] is False + assert workbench["execution_authority"] is False + assert workbench["policy_mutation_authority"] is False + PY + + - name: Build exact-head evidence manifest + if: always() + run: | + python -m scripts.ci.build_evidence_manifest \ + --artifacts-root artifacts/memory-queue-review-workbench \ + --repository "${{ github.repository }}" \ + --source-repository "$SOURCE_REPOSITORY" \ + --tested-sha "$EXPECTED_SHA" \ + --event-name "${{ github.event_name }}" \ + --run-id "${{ github.run_id }}" \ + --run-attempt "${{ github.run_attempt }}" \ + --change-number "${{ github.event.pull_request.number || 0 }}" \ + --workflow-ref "${{ github.workflow_ref }}" \ + --require exact-head.json \ + --output artifacts/memory-queue-review-workbench/evidence-manifest.json + + - name: Upload workbench evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cml-memory-queue-review-workbench-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/memory-queue-review-workbench/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/memory-queue-semantic-acceptance.yml b/.github/workflows/memory-queue-semantic-acceptance.yml new file mode 100644 index 00000000..49d0da91 --- /dev/null +++ b/.github/workflows/memory-queue-semantic-acceptance.yml @@ -0,0 +1,172 @@ +name: Memory Queue Semantic Acceptance v0.4 + +on: + pull_request: + branches: [main] + paths: + - "cml/experimental/memory_proposal_queue.py" + - "cml/experimental/memory_proposal_queue_planner.py" + - "cml/experimental/memory_proposal_queue_revalidation.py" + - "cml/experimental/memory_proposal_semantic_acceptance.py" + - ".github/trust-root/scripts/memory_queue_revalidation_collect.py" + - ".github/trust-root/scripts/verify_memory_queue_dependency_contract.py" + - ".github/trust-root/memory_queue_pip_bootstrap.txt" + - ".github/trust-root/memory_queue_ci_requirements.txt" + - "tests/test_memory_proposal_queue.py" + - "tests/test_memory_proposal_queue_planner.py" + - "tests/test_memory_proposal_queue_revalidation.py" + - "tests/test_memory_proposal_semantic_acceptance.py" + - "tests/test_memory_queue_collectors.py" + - "docs/integrations/MEMORY_PROPOSAL_QUEUE_REVALIDATION_V0_3.md" + - "docs/integrations/MEMORY_PROPOSAL_SEMANTIC_ACCEPTANCE_V0_4.md" + - ".github/workflows/memory-queue-semantic-acceptance.yml" + workflow_dispatch: + +permissions: {} + +concurrency: + group: cml-memory-queue-semantic-acceptance-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SOURCE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PYTHONUNBUFFERED: "1" + PYTHONPATH: ${{ github.workspace }} + +jobs: + semantic-intake: + name: Freeze live semantic review packets + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + checks: read + + steps: + - name: Checkout exact verifier head + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: ${{ env.SOURCE_REPOSITORY }} + ref: ${{ env.EXPECTED_SHA }} + fetch-depth: 1 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + .github/trust-root/memory_queue_pip_bootstrap.txt + .github/trust-root/memory_queue_ci_requirements.txt + + - name: Prove exact-head checkout + run: | + python -m scripts.ci.assert_exact_head \ + --expected "$EXPECTED_SHA" \ + --output artifacts/memory-queue-semantic-acceptance/exact-head.json + + - name: Verify dependency integrity contract + run: python .github/trust-root/scripts/verify_memory_queue_dependency_contract.py + + - name: Install hash-bound test tooling + run: | + python -m pip install --require-hashes --only-binary=:all: --requirement .github/trust-root/memory_queue_pip_bootstrap.txt + python -m pip install --require-hashes --only-binary=:all: --requirement .github/trust-root/memory_queue_ci_requirements.txt + + - name: Run queue and semantic-intake contract tests + run: | + python -m pytest \ + tests/test_memory_proposal_queue.py \ + tests/test_memory_proposal_queue_planner.py \ + tests/test_memory_proposal_queue_revalidation.py \ + tests/test_memory_proposal_semantic_acceptance.py \ + tests/test_memory_queue_collectors.py + + - name: Collect exact live revalidation evidence + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + python .github/trust-root/scripts/memory_queue_revalidation_collect.py \ + --out-dir artifacts/memory-queue-semantic-acceptance/live + + - name: Freeze semantic review intake + run: | + python -m cml.experimental.memory_proposal_semantic_acceptance build \ + artifacts/memory-queue-semantic-acceptance/live/planner-input.json \ + artifacts/memory-queue-semantic-acceptance/live/planner-result.json \ + > artifacts/memory-queue-semantic-acceptance/live/semantic-intake.json + + - name: Verify live packet coverage and authority boundary + run: | + python - <<'PY' + import json + from pathlib import Path + + root = Path("artifacts/memory-queue-semantic-acceptance/live") + summary = json.loads((root / "summary.json").read_text(encoding="utf-8")) + plan = json.loads((root / "planner-result.json").read_text(encoding="utf-8")) + intake = json.loads((root / "semantic-intake.json").read_text(encoding="utf-8")) + + proposal_count = summary["proposal_count"] + fitness_counts = summary["fitness_counts"] + assert "REVIEW_REQUIRED" in fitness_counts, fitness_counts + review_required = fitness_counts["REVIEW_REQUIRED"] + + assert plan["record_count"] == proposal_count + assert intake["packet_count"] == proposal_count + assert intake["pending_human_review_count"] == review_required + assert intake["completed_human_review_count"] == 0 + assert intake["current_main_revision"] == summary["main_revision"] + assert intake["source_plan_digest"] == plan["plan_digest"] + + assert intake["authority_granted"] is False + assert intake["merge_authority"] is False + assert intake["close_authority"] is False + assert intake["acceptance_authority"] is False + assert intake["execution_authority"] is False + assert intake["policy_mutation_authority"] is False + + packet_ids = set() + for packet in intake["packets"]: + assert packet["packet_id"] not in packet_ids + packet_ids.add(packet["packet_id"]) + assert packet["current_main_revision"] == summary["main_revision"] + assert packet["authority_granted"] is False + assert packet["merge_authority"] is False + assert packet["close_authority"] is False + assert packet["acceptance_authority"] is False + assert packet["execution_authority"] is False + assert packet["policy_mutation_authority"] is False + + assert len(packet_ids) == proposal_count + PY + + - name: Build exact-head evidence manifest + if: always() + run: | + python -m scripts.ci.build_evidence_manifest \ + --artifacts-root artifacts/memory-queue-semantic-acceptance \ + --repository "${{ github.repository }}" \ + --source-repository "$SOURCE_REPOSITORY" \ + --tested-sha "$EXPECTED_SHA" \ + --event-name "${{ github.event_name }}" \ + --run-id "${{ github.run_id }}" \ + --run-attempt "${{ github.run_attempt }}" \ + --change-number "${{ github.event.pull_request.number || 0 }}" \ + --workflow-ref "${{ github.workflow_ref }}" \ + --require exact-head.json \ + --output artifacts/memory-queue-semantic-acceptance/evidence-manifest.json + + - name: Upload semantic intake evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cml-memory-queue-semantic-acceptance-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/memory-queue-semantic-acceptance/ + if-no-files-found: error + retention-days: 30 diff --git a/benchmarks/experimental/memory-proposal-queue-2026-08-15.json b/benchmarks/experimental/memory-proposal-queue-2026-08-15.json new file mode 100644 index 00000000..8a185cc2 --- /dev/null +++ b/benchmarks/experimental/memory-proposal-queue-2026-08-15.json @@ -0,0 +1,50 @@ +{ + "schema": "cml.memory-proposal-queue.snapshot.v0.1", + "captured_at": "2026-08-15T12:45:00Z", + "main_revision": "90c7fdaaf31ad7c17ddc0c3c55b7ccd33f6affc2", + "reported_total_count": 36, + "proposals": [ + {"proposal_pr":191,"source_pr":190,"source_merge":"ae497a2469ce66faa6113a54c44fb3beb5a76a07","pack_id":"47d44487ef9c462f5dd5772295cf323d279e1b46b5a0096b81c6bbe14f573654","created_at":"2026-07-17T11:20:36Z","state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":194,"source_pr":193,"source_merge":"f707139694622a3878d00440e67819a3a5a4c95a","pack_id":"9291ee449beaff75209e407ada12c66d84ce402ee1d968593121b8e2f7f8c300","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":198,"source_pr":197,"source_merge":"2914230709a76db405e70dfaf46f0450a725db45","pack_id":"bf67e15fcfe9186fc815b56ecff301cf51352f93a45f43c3fd0ae7af85026a10","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":201,"source_pr":200,"source_merge":"c684257d675a6252bc4a9d6a4e56f65a6d03cd13","pack_id":"db58181ce1eccaecc4efacc775280dc5851395bb1739c3c4f3494bbac9aab18e","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":204,"source_pr":203,"source_merge":"df3bc2cd35fdb8eacf99c2f390b5d69666651956","pack_id":"f496d7fb8cf4abeeda076f74ad43815805261c2353def3b0416a36fc881bee38","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":207,"source_pr":206,"source_merge":"e84ba18b52ae697789071ceae816e467ab5f36de","pack_id":"64d4c35e0643ff5d95d4e309f835213aee4d882da0e471da53b04e285c0c0132","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":210,"source_pr":209,"source_merge":"33f904d28c78a560aaab3b0be4f6fd501f22116d","pack_id":"06a67faef5d275dc7b3e276ba0f3d84a47af1bc488ff3379a03a9f025d7d838c","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":212,"source_pr":211,"source_merge":"cfe8b7f9705b5a1483cef4a30e49f408adb846c2","pack_id":"fff1d42a0e158a42943b52caae4b875b062edc95809d016e6626fe35f2e79592","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":222,"source_pr":221,"source_merge":"14e31d2afe1d972f2cdb3707ef5483640d676bc5","pack_id":"3dfca9ebe372c8c042fd03353d3acfecebd948a85d8ea0fb19371a238f01fcb7","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":224,"source_pr":223,"source_merge":"aeba6d3c851a302a8162b0f19a0d80c0b40f3e66","pack_id":"919dc54222dccc419465de042d88c9896bcfa42f00ee9b3c918886e572d0c199","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":226,"source_pr":225,"source_merge":"6389280728c2466faafcd1c3422ab122a8626c8b","pack_id":"e4dcc5f0c1cd8b31c557728903bc70ca189fa8be114dede12cf6bbe2318e1c90","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":228,"source_pr":227,"source_merge":"af5fc7d441c7ae7730c94f9ddf48785be2becab0","pack_id":"5e3cf18606606310d1fa50617d6795e9654d2f245aaed4fbcbaa92280ee51676","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":231,"source_pr":230,"source_merge":"c6d5d4ff31b6a35d24dd16ea1ee0a7b13958dd5f","pack_id":"38a79d50106c7690f93cedebabdd3edf51fa48465d6a83720a2611803a631d8e","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":233,"source_pr":232,"source_merge":"f88aa5cb9a67cfa392e2501220f9c2778d2f2a57","pack_id":"44dbc9d5d6a0eeb9f547af3cbe4aef78b12b7fc229d8ed77bd30ab5b1e8e975f","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":235,"source_pr":234,"source_merge":"08b5c8fd9336b6e4e2d2eb70154723a45aa0fbae","pack_id":"65d51b2086fe3bd0da70a720c97c7f782102ee9a0728f4e270f240ff7455dc94","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":237,"source_pr":236,"source_merge":"9470a03cbdd78d7b9d2e84236d21e74ce0fe89eb","pack_id":"f9b6da79feed9ab233a8f8944ed93b0505e1296129b3e3afa1b4ab040253b78c","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":239,"source_pr":238,"source_merge":"daf9bca3db8af10d8c6dad6a9a0d8ecb26fbdddb","pack_id":"5fd1155bb7d34ba8bcee93dc846b89d44e4b0db498655f36bcf812323429fa47","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":242,"source_pr":241,"source_merge":"a76915ce5b4f279a69e5e783f75d1f596449949c","pack_id":"d825321426222b28696cba770c59bb63dc577b1970b39487993a0bbd04d581ba","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":245,"source_pr":244,"source_merge":"872b3f8012447e7bfc1652e4cb696a32758c579d","pack_id":"46bf6db082af4e2e65e6597cf7a79f2f2f95fd70d501b9f9963f1b6dda5b9290","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":247,"source_pr":246,"source_merge":"918127be30fcc2b7595b48ca746559c0ad5fb36c","pack_id":"0bb45d4584feefd38b1b01b16ddd74764785909eb55ee545b60fbfe34cb09023","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":249,"source_pr":248,"source_merge":"aa1cdccd42d5c1779d9c19a824bb74fcf50ca0bf","pack_id":"3e0996da6bad39f2e9369038813d36b7f6b32099ec66cfa4bf5894741a9205eb","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":255,"source_pr":254,"source_merge":"245964d52552c841ff43445db069921687b6b0f1","pack_id":"7dd9b10b0011e773457d0803fe73b765cd5f5fe8728edd241f5856fd5607d091","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":256,"source_pr":215,"source_merge":"d1ed48afa04ce4ecedf32d741b3a1c4af9c4f9ae","pack_id":"321422d87e524416c3ed90f241cf2faeb20cc65862f4820f63a1476c684fbaa6","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":257,"source_pr":213,"source_merge":"47222cb6a6839fefe6d7821248c85b582de7fa01","pack_id":"a22be38997026445dcbec42c18955626e2d043a366bf32a4da1c907efff70c9d","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":258,"source_pr":216,"source_merge":"c4ed205ba8a6123083b9249af5fe0b60415490fd","pack_id":"2838a00d739280593398276cb24f31ccbbfb3b6584bac6cf540e0da5dd33c249","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":260,"source_pr":259,"source_merge":"5e25467f978f903fd3f2ca31da6d57c494dce183","pack_id":"5aff9f44c4b390b4e30187388a063b88887f5771d4bf9a8cbe499708eeb37ec9","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":262,"source_pr":261,"source_merge":"925cf37fb28e443a983a2463a1754d6e39e975cf","pack_id":"cb757f3b1ba447de5b921988a3ae36c637dc2f7245890bc57550d2fa98dbe3a8","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":265,"source_pr":264,"source_merge":"942ff0bcd08c97d8be5bfab0fe72e1b0a725896f","pack_id":"a8dfe4dde362c5a5d57d2e5ce49ceb855cc0fd5672c738bfa51407a012f2792f","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":267,"source_pr":266,"source_merge":"1635804f127b7840dca0cd2679c0f001552b7b10","pack_id":"a44efeab756d5783e55607e7f9f6b086447890aaff9d76943212e1c0c61ab878","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":271,"source_pr":270,"source_merge":"18e3bee21cedd8249bd5634da37fb3a551d3b342","pack_id":"59ecb501a79c3c18eee13dc938f6a7163a613194909444b2edd5df73c8ec47b0","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":273,"source_pr":272,"source_merge":"0c27e1918c27e5223fe8343df0aac77b2db5ccdb","pack_id":"3c16e4dd42a716bdec74ded012ba7ea14332c5ced055cdef6079bc80ec87e876","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":275,"source_pr":274,"source_merge":"c87d9810fa3611538379e5148f6b9bc998bbefb1","pack_id":"c74c80b38fc89eae6e298967f6e705e9d9dec0378fdef5069c2f5916512ba913","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":277,"source_pr":276,"source_merge":"e2813aa7c471e3b91c8e5fb86fc853183a4bf9f0","pack_id":"281abb22d05d93999e76027977d680b4ac5ccbb59124e160cd920c0e124f44ad","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":279,"source_pr":278,"source_merge":"25ca381505433369e46128d09ee60c662b898fcb","pack_id":"e4703cbd3f8c6362fd8baf90eb5fe6ca996774c771561f9082292f4db58dcc64","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":282,"source_pr":280,"source_merge":"7d77046f89740a92d66206e4804fffc7150a81dd","pack_id":"2200ff67efed17a3bd3f26240b789c77bfcdf9cebf002124c14726fa01293024","created_at":null,"state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false}, + {"proposal_pr":284,"source_pr":283,"source_merge":"2a649903693fc61a560ee056834127ada3120206","pack_id":"684ddfce4f9ddbe263c965680fc253f012d5e15ad46c29699ee6e9f124cfc20d","created_at":"2026-08-14T05:07:04Z","state":"open","draft":true,"lesson_status":"proposed","visibility":"team","contains_private_data":true,"merge_authority":false,"execution_authority":false} + ], + "evidence_notes": [ + "GitHub search API exact-title query reported total_count=36 open automatic memory proposal PRs.", + "PR #191 created_at=2026-07-17T11:20:36Z.", + "PR #284 created_at=2026-08-14T05:07:04Z.", + "Age coverage is intentionally partial in this normalized fixture; missing timestamps are not invented." + ] +} diff --git a/benchmarks/experimental/memory-proposal-queue-planner-v0.2.synthetic.json b/benchmarks/experimental/memory-proposal-queue-planner-v0.2.synthetic.json new file mode 100644 index 00000000..606cb498 --- /dev/null +++ b/benchmarks/experimental/memory-proposal-queue-planner-v0.2.synthetic.json @@ -0,0 +1,122 @@ +{ + "schema": "cml.memory-proposal-queue.revalidation-input.v0.2", + "source_audit_schema": "cml.memory-proposal-queue.audit.v0.1", + "source_audit_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "current_main_revision": "9999999999999999999999999999999999999999", + "captured_at": "2026-08-15T13:00:00Z", + "synthetic": true, + "expected_record_count": 6, + "records": [ + { + "proposal_pr": 1001, + "source_pr": 901, + "source_merge": "1111111111111111111111111111111111111111", + "pack_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "lineage_root_id": "pr-contracts", + "lineage_evidence_refs": ["synthetic:lineage:pr-contracts:a"], + "gate_evidence_refs": ["synthetic:gates:a"], + "applicability": {"status": "MATCH", "reasons": []}, + "quality": { + "semantic_truth": "SUPPORTED", + "completeness": "COMPLETE", + "relevance": "RELEVANT", + "readiness": "READY", + "reasons": [] + }, + "claimed_fitness_status": "READY_FOR_AUTHORITY_CHECK" + }, + { + "proposal_pr": 1002, + "source_pr": 902, + "source_merge": "2222222222222222222222222222222222222222", + "pack_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "lineage_root_id": "pr-contracts", + "lineage_evidence_refs": ["synthetic:lineage:pr-contracts:b"], + "gate_evidence_refs": ["synthetic:gates:b"], + "applicability": {"status": "MATCH", "reasons": []}, + "quality": { + "semantic_truth": "SUPPORTED", + "completeness": "COMPLETE", + "relevance": "RELEVANT", + "readiness": "READY", + "reasons": [] + }, + "claimed_fitness_status": "READY_FOR_AUTHORITY_CHECK" + }, + { + "proposal_pr": 1003, + "source_pr": 903, + "source_merge": "3333333333333333333333333333333333333333", + "pack_id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "lineage_root_id": "pr-contracts", + "lineage_evidence_refs": ["synthetic:lineage:pr-contracts:c"], + "gate_evidence_refs": ["synthetic:gates:c"], + "applicability": { + "status": "REVALIDATE", + "reasons": ["environment_mismatch:commit_sha"] + }, + "quality": { + "semantic_truth": "SUPPORTED", + "completeness": "COMPLETE", + "relevance": "RELEVANT", + "readiness": "READY", + "reasons": [] + }, + "claimed_fitness_status": "REVIEW_REQUIRED" + }, + { + "proposal_pr": 1004, + "source_pr": 904, + "source_merge": "4444444444444444444444444444444444444444", + "pack_id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "lineage_root_id": "security", + "lineage_evidence_refs": ["synthetic:lineage:security:d"], + "gate_evidence_refs": ["synthetic:gates:d"], + "applicability": {"status": "MATCH", "reasons": []}, + "quality": { + "semantic_truth": "UNRESOLVED", + "completeness": "INCOMPLETE", + "relevance": "RELEVANT", + "readiness": "REVIEW", + "reasons": ["completeness_missing:independent-review"] + }, + "claimed_fitness_status": "REVIEW_REQUIRED" + }, + { + "proposal_pr": 1005, + "source_pr": 905, + "source_merge": "5555555555555555555555555555555555555555", + "pack_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "lineage_root_id": "security", + "lineage_evidence_refs": ["synthetic:lineage:security:e"], + "gate_evidence_refs": ["synthetic:gates:e"], + "applicability": {"status": "DRIFT", "reasons": ["source_digest_mismatch"]}, + "quality": { + "semantic_truth": "SUPPORTED", + "completeness": "COMPLETE", + "relevance": "RELEVANT", + "readiness": "READY", + "reasons": [] + }, + "claimed_fitness_status": "NOT_FIT" + }, + { + "proposal_pr": 1006, + "source_pr": 906, + "source_merge": "6666666666666666666666666666666666666666", + "pack_id": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "lineage_root_id": "docs", + "lineage_evidence_refs": ["synthetic:lineage:docs:f"], + "gate_evidence_refs": ["synthetic:gates:f"], + "applicability": {"status": "MATCH", "reasons": []}, + "quality": { + "semantic_truth": "CONTRADICTED", + "completeness": "COMPLETE", + "relevance": "RELEVANT", + "readiness": "EXCLUDE", + "reasons": ["truth_contradicted"] + }, + "claimed_fitness_status": "NOT_FIT" + } + ] +} diff --git a/cml/experimental/memory_proposal_queue.py b/cml/experimental/memory_proposal_queue.py new file mode 100644 index 00000000..422b1fef --- /dev/null +++ b/cml/experimental/memory_proposal_queue.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Read-only audit for CML automatic memory proposal queues. + +The auditor measures queue pressure and repeated review-envelope structure. It does +not decide whether two Memory Packs are semantically duplicate and it never grants +merge, close, acceptance, execution, or policy-mutation authority. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +SNAPSHOT_SCHEMA = "cml.memory-proposal-queue.snapshot.v0.1" +RESULT_SCHEMA = "cml.memory-proposal-queue.audit.v0.1" +HEX40 = re.compile(r"^[0-9a-f]{40}$") +HEX64 = re.compile(r"^[0-9a-f]{64}$") + + +class QueueAuditError(ValueError): + pass + + +def _parse_time(value: Any, field: str) -> datetime: + if not isinstance(value, str) or not value.strip(): + raise QueueAuditError(f"{field} must be an ISO-8601 timestamp") + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError as exc: + raise QueueAuditError(f"{field} must be an ISO-8601 timestamp") from exc + if parsed.tzinfo is None: + raise QueueAuditError(f"{field} must include a timezone") + return parsed.astimezone(timezone.utc) + + +def _positive_int(value: Any, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise QueueAuditError(f"{field} must be a positive integer") + return value + + +def _false(value: Any, field: str) -> bool: + if value is not False: + raise QueueAuditError(f"{field} must be false") + return False + + +def _proposal(raw: Any, captured_at: datetime) -> dict[str, Any]: + if not isinstance(raw, dict): + raise QueueAuditError("each proposal must be an object") + + proposal_pr = _positive_int(raw.get("proposal_pr"), "proposal_pr") + source_pr = _positive_int(raw.get("source_pr"), f"proposal {proposal_pr}.source_pr") + + source_merge = raw.get("source_merge") + if not isinstance(source_merge, str) or not HEX40.fullmatch(source_merge): + raise QueueAuditError(f"proposal {proposal_pr}.source_merge must be a 40-char lowercase hex SHA") + + pack_id = raw.get("pack_id") + if not isinstance(pack_id, str) or not HEX64.fullmatch(pack_id): + raise QueueAuditError(f"proposal {proposal_pr}.pack_id must be a 64-char lowercase hex digest") + + if raw.get("state") != "open": + raise QueueAuditError(f"proposal {proposal_pr}.state must be open") + if raw.get("draft") is not True: + raise QueueAuditError(f"proposal {proposal_pr}.draft must be true") + if raw.get("lesson_status") != "proposed": + raise QueueAuditError(f"proposal {proposal_pr}.lesson_status must be proposed") + if raw.get("visibility") != "team": + raise QueueAuditError(f"proposal {proposal_pr}.visibility must be team") + if raw.get("contains_private_data") is not True: + raise QueueAuditError(f"proposal {proposal_pr}.contains_private_data must be true") + _false(raw.get("merge_authority"), f"proposal {proposal_pr}.merge_authority") + _false(raw.get("execution_authority"), f"proposal {proposal_pr}.execution_authority") + + created_at_raw = raw.get("created_at") + created_at = None + age_days = None + if created_at_raw is not None: + created_at = _parse_time(created_at_raw, f"proposal {proposal_pr}.created_at") + if created_at > captured_at: + raise QueueAuditError(f"proposal {proposal_pr}.created_at cannot be after captured_at") + age_days = (captured_at - created_at).total_seconds() / 86400.0 + + envelope = { + "state": "open", + "draft": True, + "lesson_status": "proposed", + "visibility": "team", + "contains_private_data": True, + "merge_authority": False, + "execution_authority": False, + } + + return { + "proposal_pr": proposal_pr, + "source_pr": source_pr, + "source_merge": source_merge, + "pack_id": pack_id, + "created_at": created_at, + "age_days": age_days, + "envelope": envelope, + } + + +def _pressure(count: int) -> str: + if count >= 30: + return "CRITICAL_REVIEW_PRESSURE" + if count >= 20: + return "HIGH_REVIEW_PRESSURE" + if count >= 10: + return "ELEVATED_REVIEW_PRESSURE" + return "BOUNDED_REVIEW_PRESSURE" + + +def _digest(payload: dict[str, Any]) -> str: + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _reject_duplicate_json_object_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Fail closed when JSON contains duplicate object keys.""" + + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise QueueAuditError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def audit(payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict): + raise QueueAuditError("top-level payload must be an object") + if payload.get("schema") != SNAPSHOT_SCHEMA: + raise QueueAuditError(f"schema must be {SNAPSHOT_SCHEMA}") + + captured_at = _parse_time(payload.get("captured_at"), "captured_at") + main_revision = payload.get("main_revision") + if not isinstance(main_revision, str) or not HEX40.fullmatch(main_revision): + raise QueueAuditError("main_revision must be a 40-char lowercase hex SHA") + + proposals_raw = payload.get("proposals") + if not isinstance(proposals_raw, list) or not proposals_raw: + raise QueueAuditError("proposals must be a non-empty list") + + reported_total = _positive_int(payload.get("reported_total_count"), "reported_total_count") + if reported_total != len(proposals_raw): + raise QueueAuditError( + "snapshot coverage incomplete: reported_total_count must equal the number of proposals" + ) + + proposals = [_proposal(raw, captured_at) for raw in proposals_raw] + + for key in ("proposal_pr", "source_pr", "source_merge", "pack_id"): + values = [item[key] for item in proposals] + if len(set(values)) != len(values): + raise QueueAuditError(f"duplicate {key} detected") + + envelope_keys = [ + json.dumps(item["envelope"], sort_keys=True, separators=(",", ":")) + for item in proposals + ] + envelope_counts = Counter(envelope_keys) + dominant_envelope_count = max(envelope_counts.values()) + envelope_share = dominant_envelope_count / len(proposals) + + dated = [item for item in proposals if item["created_at"] is not None] + ages = [item["age_days"] for item in dated if item["age_days"] is not None] + oldest = min((item["created_at"] for item in dated), default=None) + newest = max((item["created_at"] for item in dated), default=None) + + pressure = _pressure(len(proposals)) + aged_14d_known = sum(1 for age in ages if age >= 14.0) + oldest_known_age = max(ages) if ages else None + + snapshot_identity = { + "schema": SNAPSHOT_SCHEMA, + "captured_at": captured_at.isoformat(), + "main_revision": main_revision, + "reported_total_count": reported_total, + "proposals": sorted( + ( + { + "proposal_pr": item["proposal_pr"], + "source_pr": item["source_pr"], + "source_merge": item["source_merge"], + "pack_id": item["pack_id"], + "created_at": ( + item["created_at"].isoformat() + if item["created_at"] is not None + else None + ), + "envelope": item["envelope"], + } + for item in proposals + ), + key=lambda item: item["proposal_pr"], + ), + } + + if pressure in {"CRITICAL_REVIEW_PRESSURE", "HIGH_REVIEW_PRESSURE"}: + next_safe_transition = "QUEUE_LEVEL_GROUP_REVALIDATE_THEN_REVIEW" + elif pressure == "ELEVATED_REVIEW_PRESSURE": + next_safe_transition = "BATCH_REVALIDATE_THEN_REVIEW" + else: + next_safe_transition = "REVIEW_WITH_CURRENT_CONTRACT_REVALIDATION" + + return { + "schema": RESULT_SCHEMA, + "mode": "REVIEW_ADVISORY_ONLY", + "authority_granted": False, + "merge_authority": False, + "close_authority": False, + "acceptance_authority": False, + "policy_mutation_authority": False, + "main_revision": main_revision, + "captured_at": captured_at.isoformat(), + "queue": { + "proposal_count": len(proposals), + "pressure": pressure, + "unique_source_pr_count": len({item["source_pr"] for item in proposals}), + "unique_source_merge_count": len({item["source_merge"] for item in proposals}), + "unique_pack_id_count": len({item["pack_id"] for item in proposals}), + }, + "age": { + "coverage_count": len(dated), + "coverage_ratio": round(len(dated) / len(proposals), 6), + "distribution_status": "COMPLETE" if len(dated) == len(proposals) else "PARTIAL", + "oldest_known_created_at": oldest.isoformat() if oldest else None, + "newest_known_created_at": newest.isoformat() if newest else None, + "oldest_known_age_days": round(oldest_known_age, 3) if oldest_known_age is not None else None, + "aged_14d_known_count": aged_14d_known, + }, + "review_envelope": { + "unique_envelope_count": len(envelope_counts), + "dominant_envelope_count": dominant_envelope_count, + "dominant_envelope_share": round(envelope_share, 6), + "structural_repetition_count": len(proposals) - len(envelope_counts), + "semantic_duplicate_status": "NOT_MEASURED", + "semantic_duplicate_claim": False, + }, + "ancestry": { + "status": "NOT_MEASURED", + "claim": "source commit ancestry/current applicability requires a separate exact-main revalidation pass", + }, + "next_safe_transition": next_safe_transition, + "recommendations": [ + "group proposals for review planning without merging or closing them", + "revalidate each selected pack against current applicability/information-quality contracts before acceptance", + "measure source ancestry/current-main drift separately; do not infer it from queue age", + "preserve distinct pack identities even when the review envelope is structurally repeated", + ], + "non_claims": [ + "structural review-envelope repetition is not semantic Memory Pack duplication", + "queue age does not invalidate historical evidence", + "queue pressure does not authorize automatic acceptance, closure, merge, or deletion", + "this audit does not establish current source ancestry or applicability", + ], + "snapshot_digest": _digest(snapshot_identity), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Audit a normalized CML memory proposal queue snapshot") + parser.add_argument("input", type=Path) + parser.add_argument("--pretty", action="store_true") + args = parser.parse_args() + + try: + payload = json.loads( + args.input.read_text(encoding="utf-8"), + object_pairs_hook=_reject_duplicate_json_object_pairs, + ) + result = audit(payload) + except (OSError, json.JSONDecodeError, QueueAuditError) as exc: + print(json.dumps({"error": str(exc)}, ensure_ascii=False)) + return 2 + + print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2 if args.pretty else None)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cml/experimental/memory_proposal_queue_planner.py b/cml/experimental/memory_proposal_queue_planner.py new file mode 100644 index 00000000..c54b5539 --- /dev/null +++ b/cml/experimental/memory_proposal_queue_planner.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +"""Read-only grouping and revalidation planner for CML memory proposals. + +The planner consumes source-owned CML applicability and information-quality +results, composes them through the canonical information-fitness gate, and emits +one advisory decision record per Memory Pack. Grouping is review ergonomics only: +it never collapses pack identity and never grants merge, close, acceptance, +execution, or policy-mutation authority. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from cml.integrations.information_fitness import ( + InformationFitnessStatus, + evaluate_information_fitness, +) +from cml.integrations.information_quality import ( + CompletenessStatus, + InformationQualityResult, + QualityReadiness, + RelevanceStatus, + SemanticTruthStatus, +) +from cml.integrations.memory_applicability import ( + ApplicabilityResult, + ApplicabilityStatus, +) + +INPUT_SCHEMA = "cml.memory-proposal-queue.revalidation-input.v0.2" +RESULT_SCHEMA = "cml.memory-proposal-queue.revalidation-plan.v0.2" +SOURCE_AUDIT_SCHEMA = "cml.memory-proposal-queue.audit.v0.1" +HEX40 = re.compile(r"^[0-9a-f]{40}$") +HEX64 = re.compile(r"^[0-9a-f]{64}$") +SHA256_REF = re.compile(r"^sha256:[0-9a-f]{64}$") + + +class QueuePlanningError(ValueError): + pass + + +def _positive_int(value: Any, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise QueuePlanningError(f"{field} must be a positive integer") + return value + + +def _nonempty(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise QueuePlanningError(f"{field} must be a non-empty string") + return value.strip() + + +def _parse_time(value: Any, field: str) -> datetime: + text = _nonempty(value, field) + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError as exc: + raise QueuePlanningError(f"{field} must be an ISO-8601 timestamp") from exc + if parsed.tzinfo is None: + raise QueuePlanningError(f"{field} must include a timezone") + return parsed.astimezone(timezone.utc) + + +def _string_tuple( + value: Any, + field: str, + *, + require_nonempty: bool = False, +) -> tuple[str, ...]: + if not isinstance(value, list): + raise QueuePlanningError(f"{field} must be a list") + normalized = tuple(_nonempty(item, f"{field} entry") for item in value) + if require_nonempty and not normalized: + raise QueuePlanningError(f"{field} must not be empty") + if len(set(normalized)) != len(normalized): + raise QueuePlanningError(f"{field} entries must be unique") + return normalized + + +def _enum(enum_type, value: Any, field: str): + text = _nonempty(value, field) + try: + return enum_type(text) + except ValueError as exc: + allowed = ", ".join(item.value for item in enum_type) + raise QueuePlanningError(f"{field} must be one of: {allowed}") from exc + + +def _digest(payload: Any) -> str: + canonical = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _record(raw: Any) -> dict[str, Any]: + if not isinstance(raw, dict): + raise QueuePlanningError("each record must be an object") + + proposal_pr = _positive_int(raw.get("proposal_pr"), "proposal_pr") + source_pr = _positive_int( + raw.get("source_pr"), + f"proposal {proposal_pr}.source_pr", + ) + + source_merge = raw.get("source_merge") + if not isinstance(source_merge, str) or not HEX40.fullmatch(source_merge): + raise QueuePlanningError( + f"proposal {proposal_pr}.source_merge must be a 40-char lowercase hex SHA" + ) + + pack_id = raw.get("pack_id") + if not isinstance(pack_id, str) or not HEX64.fullmatch(pack_id): + raise QueuePlanningError( + f"proposal {proposal_pr}.pack_id must be a 64-char lowercase hex digest" + ) + + lineage_root_id = _nonempty( + raw.get("lineage_root_id"), + f"proposal {proposal_pr}.lineage_root_id", + ) + lineage_evidence_refs = _string_tuple( + raw.get("lineage_evidence_refs"), + f"proposal {proposal_pr}.lineage_evidence_refs", + require_nonempty=True, + ) + gate_evidence_refs = _string_tuple( + raw.get("gate_evidence_refs"), + f"proposal {proposal_pr}.gate_evidence_refs", + require_nonempty=True, + ) + + applicability_raw = raw.get("applicability") + if not isinstance(applicability_raw, dict): + raise QueuePlanningError( + f"proposal {proposal_pr}.applicability must be an object" + ) + applicability = ApplicabilityResult( + status=_enum( + ApplicabilityStatus, + applicability_raw.get("status"), + f"proposal {proposal_pr}.applicability.status", + ), + reasons=_string_tuple( + applicability_raw.get("reasons"), + f"proposal {proposal_pr}.applicability.reasons", + ), + ) + + quality_raw = raw.get("quality") + if not isinstance(quality_raw, dict): + raise QueuePlanningError(f"proposal {proposal_pr}.quality must be an object") + quality = InformationQualityResult( + semantic_truth=_enum( + SemanticTruthStatus, + quality_raw.get("semantic_truth"), + f"proposal {proposal_pr}.quality.semantic_truth", + ), + completeness=_enum( + CompletenessStatus, + quality_raw.get("completeness"), + f"proposal {proposal_pr}.quality.completeness", + ), + relevance=_enum( + RelevanceStatus, + quality_raw.get("relevance"), + f"proposal {proposal_pr}.quality.relevance", + ), + readiness=_enum( + QualityReadiness, + quality_raw.get("readiness"), + f"proposal {proposal_pr}.quality.readiness", + ), + reasons=_string_tuple( + quality_raw.get("reasons"), + f"proposal {proposal_pr}.quality.reasons", + ), + ) + + canonical_fitness = evaluate_information_fitness( + applicability=applicability, + quality=quality, + ) + claimed_fitness = _enum( + InformationFitnessStatus, + raw.get("claimed_fitness_status"), + f"proposal {proposal_pr}.claimed_fitness_status", + ) + if claimed_fitness is not canonical_fitness.status: + raise QueuePlanningError( + f"proposal {proposal_pr}.claimed_fitness_status contradicts " + "canonical CML fitness" + ) + + return { + "proposal_pr": proposal_pr, + "source_pr": source_pr, + "source_merge": source_merge, + "pack_id": pack_id, + "lineage_root_id": lineage_root_id, + "lineage_evidence_refs": lineage_evidence_refs, + "gate_evidence_refs": gate_evidence_refs, + "applicability": applicability, + "quality": quality, + "fitness": canonical_fitness, + } + + +def _route(status: InformationFitnessStatus) -> str: + if status is InformationFitnessStatus.NOT_FIT: + return "BLOCK_ACCEPTANCE_PENDING_NEW_EVIDENCE_OR_CONTEXT" + if status is InformationFitnessStatus.REVIEW_REQUIRED: + return "HUMAN_REVALIDATION_REQUIRED" + if status is InformationFitnessStatus.READY_FOR_AUTHORITY_CHECK: + return "ELIGIBLE_FOR_SEPARATE_ACCEPTANCE_REVIEW" + raise QueuePlanningError("unmapped information fitness status") + + +def plan(payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict): + raise QueuePlanningError("top-level payload must be an object") + if payload.get("schema") != INPUT_SCHEMA: + raise QueuePlanningError(f"schema must be {INPUT_SCHEMA}") + if payload.get("source_audit_schema") != SOURCE_AUDIT_SCHEMA: + raise QueuePlanningError( + f"source_audit_schema must be {SOURCE_AUDIT_SCHEMA}" + ) + + source_audit_digest = payload.get("source_audit_digest") + if ( + not isinstance(source_audit_digest, str) + or not SHA256_REF.fullmatch(source_audit_digest) + ): + raise QueuePlanningError("source_audit_digest must be a sha256: digest") + + current_main_revision = payload.get("current_main_revision") + if ( + not isinstance(current_main_revision, str) + or not HEX40.fullmatch(current_main_revision) + ): + raise QueuePlanningError( + "current_main_revision must be a 40-char lowercase hex SHA" + ) + + captured_at = _parse_time(payload.get("captured_at"), "captured_at") + synthetic = payload.get("synthetic") + if not isinstance(synthetic, bool): + raise QueuePlanningError("synthetic must be boolean") + + records_raw = payload.get("records") + if not isinstance(records_raw, list) or not records_raw: + raise QueuePlanningError("records must be a non-empty list") + expected_record_count = _positive_int( + payload.get("expected_record_count"), + "expected_record_count", + ) + if expected_record_count != len(records_raw): + raise QueuePlanningError( + "revalidation coverage incomplete: expected_record_count must equal " + "records length" + ) + + records = [_record(raw) for raw in records_raw] + for key in ("proposal_pr", "source_pr", "source_merge", "pack_id"): + values = [record[key] for record in records] + if len(values) != len(set(values)): + raise QueuePlanningError(f"duplicate {key} detected") + + decision_records: list[dict[str, Any]] = [] + grouped: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list) + + for record in records: + fitness = record["fitness"] + decision_identity = { + "proposal_pr": record["proposal_pr"], + "pack_id": record["pack_id"], + "lineage_root_id": record["lineage_root_id"], + "current_main_revision": current_main_revision, + "applicability_status": record["applicability"].status.value, + "quality_readiness": record["quality"].readiness.value, + "fitness_status": fitness.status.value, + "lineage_evidence_refs": sorted(record["lineage_evidence_refs"]), + "gate_evidence_refs": sorted(record["gate_evidence_refs"]), + } + decision = { + "decision_id": _digest(decision_identity), + "proposal_pr": record["proposal_pr"], + "source_pr": record["source_pr"], + "source_merge": record["source_merge"], + "pack_id": record["pack_id"], + "lineage_root_id": record["lineage_root_id"], + "applicability": { + "status": record["applicability"].status.value, + "reasons": list(record["applicability"].reasons), + }, + "quality": { + "semantic_truth": record["quality"].semantic_truth.value, + "completeness": record["quality"].completeness.value, + "relevance": record["quality"].relevance.value, + "readiness": record["quality"].readiness.value, + "reasons": list(record["quality"].reasons), + }, + "canonical_fitness": { + "status": fitness.status.value, + "reasons": list(fitness.reasons), + "authorizes_action": fitness.authorizes_action, + }, + "review_route": _route(fitness.status), + "lineage_evidence_refs": list(record["lineage_evidence_refs"]), + "gate_evidence_refs": list(record["gate_evidence_refs"]), + "acceptance_authority": False, + "merge_authority": False, + "close_authority": False, + "execution_authority": False, + } + decision_records.append(decision) + grouped[(record["lineage_root_id"], fitness.status.value)].append(decision) + + groups: list[dict[str, Any]] = [] + for (lineage_root_id, fitness_status), members in sorted(grouped.items()): + member_identity = { + "lineage_root_id": lineage_root_id, + "fitness_status": fitness_status, + "pack_ids": sorted(member["pack_id"] for member in members), + "decision_ids": sorted(member["decision_id"] for member in members), + } + groups.append( + { + "group_id": _digest(member_identity), + "lineage_root_id": lineage_root_id, + "canonical_fitness_status": fitness_status, + "group_size": len(members), + "proposal_prs": sorted(member["proposal_pr"] for member in members), + "pack_ids": sorted(member["pack_id"] for member in members), + "decision_ids": sorted(member["decision_id"] for member in members), + "scope": "REVIEW_ERGONOMICS_ONLY", + "semantic_merge": False, + "group_decision_authority": False, + } + ) + + plan_identity = { + "schema": RESULT_SCHEMA, + "synthetic": synthetic, + "source_audit_digest": source_audit_digest, + "current_main_revision": current_main_revision, + "captured_at": captured_at.isoformat(), + "decision_ids": sorted(item["decision_id"] for item in decision_records), + "group_ids": sorted(item["group_id"] for item in groups), + } + + return { + "schema": RESULT_SCHEMA, + "mode": "REVIEW_ADVISORY_ONLY", + "synthetic": synthetic, + "authority_granted": False, + "merge_authority": False, + "close_authority": False, + "acceptance_authority": False, + "execution_authority": False, + "policy_mutation_authority": False, + "source_audit_schema": SOURCE_AUDIT_SCHEMA, + "source_audit_digest": source_audit_digest, + "current_main_revision": current_main_revision, + "captured_at": captured_at.isoformat(), + "record_count": len(decision_records), + "group_count": len(groups), + "decisions": sorted( + decision_records, + key=lambda item: item["proposal_pr"], + ), + "groups": groups, + "invariants": [ + "one planner decision is preserved per Memory Pack", + "grouping does not merge or deduplicate Memory Pack identity", + "canonical CML information fitness is recomputed from supplied gate results", + "review routing does not grant acceptance, merge, close, execution, or policy authority", + ], + "non_claims": [ + "shared lineage_root_id does not prove semantic duplication", + "READY_FOR_AUTHORITY_CHECK does not authorize Memory Pack acceptance", + "NOT_FIT does not authorize deletion or closure", + "synthetic fixtures are conformance evidence only, not production queue outcomes", + ], + "plan_digest": _digest(plan_identity), + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Plan bounded review groups from CML memory proposal " + "revalidation results" + ) + ) + parser.add_argument("input", type=Path) + parser.add_argument("--pretty", action="store_true") + args = parser.parse_args() + + try: + payload = json.loads(args.input.read_text(encoding="utf-8")) + result = plan(payload) + except (OSError, json.JSONDecodeError, QueuePlanningError) as exc: + print(json.dumps({"error": str(exc)}, ensure_ascii=False)) + return 2 + + print( + json.dumps( + result, + ensure_ascii=False, + sort_keys=True, + indent=2 if args.pretty else None, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cml/experimental/memory_proposal_queue_revalidation.py b/cml/experimental/memory_proposal_queue_revalidation.py new file mode 100644 index 00000000..f6c9f63d --- /dev/null +++ b/cml/experimental/memory_proposal_queue_revalidation.py @@ -0,0 +1,369 @@ +"""Evidence-bounded revalidation adapter for live CML Memory Proposals. + +The adapter keeps three identities separate: + +1. Memory Pack identity proves the frozen proposal was not tampered with; +2. stable provenance core tracks immutable source code/commit bindings; +3. mutable evidence (PR narrative, reviews, checks) may legitimately evolve. + +Only stable provenance core is used as the source digest for canonical CML +applicability. Full-pack replay remains a diagnostic and never grants authority. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import json +import re +from typing import Any, Mapping + +from cml.integrations.information_fitness import evaluate_information_fitness +from cml.integrations.information_quality import ( + EvidenceBinding, + InformationQualityObservation, + evaluate_information_quality, +) +from cml.integrations.memory_applicability import ( + EnvironmentBinding, + LineageDependency, + SourceObservation, + evaluate_memory_applicability, +) + +HEX40 = re.compile(r"^[0-9a-f]{40}$") +HEX64 = re.compile(r"^[0-9a-f]{64}$") +REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +DESCRIPTIVE_COMPONENTS = frozenset({"source-pr"}) +OPERATIONAL_COMPONENTS = frozenset({"source-reviews", "source-checks"}) +MUTABLE_COMPONENTS = DESCRIPTIVE_COMPONENTS | OPERATIONAL_COMPONENTS +IMMUTABLE_EVIDENCE_COMPONENTS = frozenset({"source-files", "source-merge"}) +KNOWN_EVIDENCE_COMPONENTS = MUTABLE_COMPONENTS | IMMUTABLE_EVIDENCE_COMPONENTS + + +class QueueRevalidationError(ValueError): + """Raised when a trusted collector observation is internally inconsistent.""" + + +def _positive_int(value: Any, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise QueueRevalidationError(f"{field} must be a positive integer") + return value + + +def _text(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise QueueRevalidationError(f"{field} must be a non-empty string") + return value.strip() + + +def _sha40(value: Any, field: str) -> str: + text = _text(value, field) + if not HEX40.fullmatch(text): + raise QueueRevalidationError(f"{field} must be a lowercase 40-char Git SHA") + return text + + +def _sha64(value: Any, field: str) -> str: + text = _text(value, field) + if not HEX64.fullmatch(text): + raise QueueRevalidationError(f"{field} must be a lowercase SHA-256 digest") + return text + + +def _boolean(value: Any, field: str) -> bool: + if not isinstance(value, bool): + raise QueueRevalidationError(f"{field} must be boolean") + return value + + +def _refs(value: Any, field: str) -> tuple[str, ...]: + if not isinstance(value, list) or not value: + raise QueueRevalidationError(f"{field} must be a non-empty list") + refs = tuple(_text(item, f"{field} entry") for item in value) + if len(refs) != len(set(refs)): + raise QueueRevalidationError(f"{field} entries must be unique") + return refs + + +def _components(value: Any, field: str) -> tuple[str, ...]: + if not isinstance(value, list): + raise QueueRevalidationError(f"{field} must be a list") + components = tuple(_text(item, f"{field} entry") for item in value) + if len(components) != len(set(components)): + raise QueueRevalidationError(f"{field} entries must be unique") + unknown = sorted(set(components) - KNOWN_EVIDENCE_COMPONENTS) + if unknown: + raise QueueRevalidationError( + f"{field} contains unknown evidence components: {', '.join(unknown)}" + ) + return components + + +def _digest(value: Mapping[str, Any]) -> str: + canonical = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def build_planner_record(observation: Mapping[str, Any]) -> dict[str, Any]: + """Convert one trusted live observation into a Planner v0.2 record. + + The strict proposal ``pack_id`` is validated first. Applicability then + compares an immutable source core supplied by the collector. PR body/title, + reviews and checks are mutable evidence: changes there require human review + but do not by themselves establish source-code DRIFT. + + ``evidence_captured_main_revision`` records the immutable main revision the + evidence bundle was collected against. It defaults to the evaluated main for + backward-compatible callers, while trusted collectors provide it explicitly. + Reusing the same evidence against a later main therefore becomes a detectable + evidence-binding state-token mismatch instead of a self-referential check. + """ + + if not isinstance(observation, Mapping): + raise QueueRevalidationError("observation must be an object") + + repository = _text(observation.get("repository"), "repository") + if not REPOSITORY.fullmatch(repository): + raise QueueRevalidationError("repository must be owner/name") + + proposal_pr = _positive_int(observation.get("proposal_pr"), "proposal_pr") + source_pr = _positive_int(observation.get("source_pr"), "source_pr") + source_merge = _sha40(observation.get("source_merge"), "source_merge") + current_main_revision = _sha40( + observation.get("current_main_revision"), "current_main_revision" + ) + evidence_captured_main_revision = _sha40( + observation.get("evidence_captured_main_revision", current_main_revision), + "evidence_captured_main_revision", + ) + pack_id = _sha64(observation.get("pack_id"), "pack_id") + validated_pack_id = _sha64( + observation.get("validated_pack_id"), "validated_pack_id" + ) + replayed_pack_id = _sha64( + observation.get("replayed_pack_id"), "replayed_pack_id" + ) + expected_source_core_digest = _sha64( + observation.get("expected_source_core_digest"), + "expected_source_core_digest", + ) + observed_source_core_digest = _sha64( + observation.get("observed_source_core_digest"), + "observed_source_core_digest", + ) + + if validated_pack_id != pack_id: + raise QueueRevalidationError( + "validated Memory Pack identity contradicts proposal pack_id" + ) + + source_exists = _boolean(observation.get("source_exists"), "source_exists") + source_ancestor_of_main = _boolean( + observation.get("source_ancestor_of_main"), "source_ancestor_of_main" + ) + full_pack_replay_match = _boolean( + observation.get("full_pack_replay_match"), "full_pack_replay_match" + ) + self_observation_completion_drift = _boolean( + observation.get("self_observation_completion_drift"), + "self_observation_completion_drift", + ) + evidence_refs = _refs(observation.get("evidence_refs"), "evidence_refs") + changed_components = _components( + observation.get("changed_evidence_components"), + "changed_evidence_components", + ) + + stable_source_core_match = ( + expected_source_core_digest == observed_source_core_digest + ) + descriptive_changed = tuple( + component + for component in changed_components + if component in DESCRIPTIVE_COMPONENTS + ) + operational_changed = tuple( + component + for component in changed_components + if component in OPERATIONAL_COMPONENTS + ) + mutable_changed = tuple( + component for component in changed_components if component in MUTABLE_COMPONENTS + ) + immutable_changed = tuple( + component + for component in changed_components + if component in IMMUTABLE_EVIDENCE_COMPONENTS + ) + + if stable_source_core_match and immutable_changed: + raise QueueRevalidationError( + "stable source core cannot match while immutable evidence changed" + ) + + source = SourceObservation( + locator=f"https://github.com/{repository}/pull/{source_pr}", + refetchable=True, + exists=source_exists, + expected_digest=expected_source_core_digest, + observed_digest=observed_source_core_digest if source_exists else None, + ) + stored_environment = EnvironmentBinding( + repository=repository, + branch="main", + commit_sha=source_merge, + ) + current_environment = EnvironmentBinding( + repository=repository, + branch="main", + commit_sha=current_main_revision, + ) + lineage = ( + LineageDependency( + dependency_id=f"source-merge:{source_merge}", + state="active" if source_ancestor_of_main else "superseded", + expected_digest=expected_source_core_digest, + observed_digest=observed_source_core_digest if source_exists else None, + ), + ) + + applicability = evaluate_memory_applicability( + source=source, + stored_environment=stored_environment, + current_environment=current_environment, + now=datetime(2000, 1, 1, tzinfo=timezone.utc), + lineage=lineage, + ) + + supporting: list[str] = ["pack-integrity"] + contradicting: list[str] = [] + observed_aspects: list[str] = ["pack_identity"] + + if stable_source_core_match: + supporting.append("stable-source-core") + observed_aspects.append("stable_source_core") + else: + contradicting.append("stable-source-core-mismatch") + + if source_ancestor_of_main: + supporting.append("main-ancestry") + observed_aspects.append("current_main_ancestry") + + supporting.append("mutable-evidence-observed") + observed_aspects.append("mutable_evidence") + + evidence_ids = tuple((*supporting, *contradicting)) + item_id = f"memory-pack:{pack_id}" + source_record_id = f"proposal-pr:{proposal_pr}" + bindings = tuple( + EvidenceBinding( + evidence_id=evidence_id, + evaluated_item_id=item_id, + source_record_id=source_record_id, + accepted_state_token=evidence_captured_main_revision, + ) + for evidence_id in evidence_ids + ) + + quality = evaluate_information_quality( + InformationQualityObservation( + supporting_evidence=tuple(supporting), + contradicting_evidence=tuple(contradicting), + required_aspects=( + "pack_identity", + "stable_source_core", + "current_main_ancestry", + "mutable_evidence", + "semantic_acceptance", + ), + observed_aspects=tuple(observed_aspects), + claim_aspects=( + "pack_identity", + "stable_source_core", + "current_main_ancestry", + "mutable_evidence", + "semantic_acceptance", + ), + evaluated_item_id=item_id, + source_record_id=source_record_id, + accepted_state_token=current_main_revision, + evidence_bindings=bindings, + ) + ) + fitness = evaluate_information_fitness( + applicability=applicability, + quality=quality, + ) + + lineage_root_id = f"source-pr:{source_pr}" + identity = { + "proposal_pr": proposal_pr, + "source_pr": source_pr, + "source_merge": source_merge, + "pack_id": pack_id, + "current_main_revision": current_main_revision, + "evidence_captured_main_revision": evidence_captured_main_revision, + "replayed_pack_id": replayed_pack_id, + "stable_source_core_match": stable_source_core_match, + "changed_evidence_components": sorted(changed_components), + "self_observation_completion_drift": self_observation_completion_drift, + "source_ancestor_of_main": source_ancestor_of_main, + "applicability": applicability.status.value, + "quality": quality.readiness.value, + "fitness": fitness.status.value, + } + + return { + "proposal_pr": proposal_pr, + "source_pr": source_pr, + "source_merge": source_merge, + "pack_id": pack_id, + "lineage_root_id": lineage_root_id, + "lineage_evidence_refs": list(evidence_refs), + "gate_evidence_refs": list(evidence_refs), + "applicability": { + "status": applicability.status.value, + "reasons": list(applicability.reasons), + }, + "quality": { + "semantic_truth": quality.semantic_truth.value, + "completeness": quality.completeness.value, + "relevance": quality.relevance.value, + "readiness": quality.readiness.value, + "reasons": list(quality.reasons), + }, + "claimed_fitness_status": fitness.status.value, + "revalidation": { + "validated_pack_id": validated_pack_id, + "replayed_pack_id": replayed_pack_id, + "full_pack_replay_match": full_pack_replay_match, + "expected_source_core_digest": expected_source_core_digest, + "observed_source_core_digest": observed_source_core_digest, + "stable_source_core_match": stable_source_core_match, + "current_main_revision": current_main_revision, + "evidence_captured_main_revision": evidence_captured_main_revision, + "changed_evidence_components": list(changed_components), + "descriptive_metadata_changed_components": list(descriptive_changed), + "operational_evidence_changed_components": list(operational_changed), + "mutable_evidence_changed_components": list(mutable_changed), + "immutable_evidence_changed_components": list(immutable_changed), + "self_observation_completion_drift": self_observation_completion_drift, + "source_exists": source_exists, + "source_ancestor_of_main": source_ancestor_of_main, + "semantic_acceptance_evidence": "NOT_COLLECTED", + "observation_digest": _digest(identity), + }, + "authority_granted": False, + "merge_authority": False, + "close_authority": False, + "acceptance_authority": False, + "execution_authority": False, + "policy_mutation_authority": False, + } diff --git a/cml/experimental/memory_proposal_review_workbench.py b/cml/experimental/memory_proposal_review_workbench.py new file mode 100644 index 00000000..0dd3f9da --- /dev/null +++ b/cml/experimental/memory_proposal_review_workbench.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +"""Deterministic Human Review Workbench for CML Memory Proposals. + +v0.5 turns frozen v0.4 semantic-review packets into a bounded review queue. +It does not score truth, accept Memory Packs, or mutate repository state. +Priority is lexicographic over observed review effort/risk signals: + +1. source scope missing on current main; +2. source scope diverged on current main; +3. descriptive/review context changed; +4. operational evidence changed; +5. source scope currently matches the source-merge snapshot. + +Within a class, lower generated-lesson confidence, broader source scope, and +older source merge timestamps are reviewed first. These are review-order +heuristics only, never semantic or authority verdicts. +""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +from typing import Any, Mapping + +from cml.experimental.memory_proposal_semantic_acceptance import ( + INTAKE_SCHEMA, + SemanticAcceptanceError, + verify_semantic_acceptance_intake, +) + +CONTEXT_SCHEMA = "cml.memory-proposal-queue.review-contexts.v0.5" +WORKBENCH_SCHEMA = "cml.memory-proposal-queue.human-review-workbench.v0.5" +SUBMISSION_SCHEMA = "cml.memory-proposal-queue.semantic-review-submission.v0.4" + +PATH_SAME = "SAME_AS_SOURCE_MERGE" +PATH_DIVERGED = "DIVERGED_FROM_SOURCE_MERGE" +PATH_MISSING = "MISSING_OR_RENAMED_ON_CURRENT_MAIN" +ALLOWED_PATH_STATES = (PATH_SAME, PATH_DIVERGED, PATH_MISSING) + + +class ReviewWorkbenchError(ValueError): + """Raised when workbench context is incomplete, stale, or misbound.""" + + +def _mapping(value: Any, field: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise ReviewWorkbenchError(f"{field} must be an object") + return value + + +def _text(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ReviewWorkbenchError(f"{field} must be a non-empty string") + return value.strip() + + +def _positive_int(value: Any, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ReviewWorkbenchError(f"{field} must be a positive integer") + return value + + +def _confidence(value: Any, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 100: + raise ReviewWorkbenchError(f"{field} must be an integer in [0, 100]") + return value + + +def _parse_time(value: Any, field: str) -> datetime: + text = _text(value, field) + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError as exc: + raise ReviewWorkbenchError(f"{field} must be ISO-8601") from exc + if parsed.tzinfo is None: + raise ReviewWorkbenchError(f"{field} must include a timezone") + return parsed.astimezone(timezone.utc) + + +def _strings(value: Any, field: str, *, allow_empty: bool = False) -> tuple[str, ...]: + if not isinstance(value, list): + raise ReviewWorkbenchError(f"{field} must be a list") + items = tuple(_text(item, f"{field} entry") for item in value) + if not allow_empty and not items: + raise ReviewWorkbenchError(f"{field} must not be empty") + if len(items) != len(set(items)): + raise ReviewWorkbenchError(f"{field} entries must be unique") + return items + + +def _digest(payload: Any) -> str: + canonical = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _authority_false(payload: Mapping[str, Any], field: str) -> None: + for key in ( + "authority_granted", + "merge_authority", + "close_authority", + "acceptance_authority", + "execution_authority", + "policy_mutation_authority", + ): + if payload.get(key) is not False: + raise ReviewWorkbenchError(f"{field}.{key} must be false") + + +def _path_state(raw: Any, proposal_pr: int) -> dict[str, Any]: + item = _mapping(raw, f"proposal {proposal_pr}.path_state") + path = _text(item.get("path"), f"proposal {proposal_pr}.path_state.path") + status = _text(item.get("status"), f"proposal {proposal_pr}.path_state.status") + if status not in ALLOWED_PATH_STATES: + raise ReviewWorkbenchError( + f"proposal {proposal_pr}.path_state.status must be one of: " + + ", ".join(ALLOWED_PATH_STATES) + ) + source_blob = _text( + item.get("source_blob_sha"), f"proposal {proposal_pr}.path_state.source_blob_sha" + ) + current_blob = item.get("current_blob_sha") + if status == PATH_MISSING: + if current_blob is not None: + raise ReviewWorkbenchError( + f"proposal {proposal_pr} missing path cannot have current_blob_sha" + ) + else: + current_blob = _text( + current_blob, f"proposal {proposal_pr}.path_state.current_blob_sha" + ) + if status == PATH_SAME and current_blob != source_blob: + raise ReviewWorkbenchError( + f"proposal {proposal_pr} SAME path must preserve blob identity" + ) + if status == PATH_DIVERGED and current_blob == source_blob: + raise ReviewWorkbenchError( + f"proposal {proposal_pr} DIVERGED path must change blob identity" + ) + return { + "path": path, + "status": status, + "source_blob_sha": source_blob, + "current_blob_sha": current_blob, + } + + +def _priority_class(packet: Mapping[str, Any], context: Mapping[str, Any]) -> tuple[int, str]: + path_states = context["path_states"] + statuses = {item["status"] for item in path_states} + changed = set(packet["machine_gate"]["changed_evidence_components"]) + if PATH_MISSING in statuses: + return 0, "P0_SOURCE_SCOPE_MISSING" + if PATH_DIVERGED in statuses: + return 1, "P1_SOURCE_SCOPE_DIVERGED" + if changed.intersection({"source-pr", "source-reviews"}): + return 2, "P2_REVIEW_CONTEXT_DRIFT" + if changed.intersection({"source-checks"}): + return 3, "P3_OPERATIONAL_EVIDENCE_REFRESH" + return 4, "P4_CURRENT_SCOPE_MATCH" + + +def build_review_workbench( + intake: Mapping[str, Any], + contexts_payload: Mapping[str, Any], +) -> dict[str, Any]: + """Build a deterministic review queue from frozen intake + live context.""" + + intake = _mapping(intake, "intake") + contexts_payload = _mapping(contexts_payload, "contexts_payload") + if intake.get("schema") != INTAKE_SCHEMA: + raise ReviewWorkbenchError(f"intake.schema must be {INTAKE_SCHEMA}") + if contexts_payload.get("schema") != CONTEXT_SCHEMA: + raise ReviewWorkbenchError(f"contexts_payload.schema must be {CONTEXT_SCHEMA}") + _authority_false(intake, "intake") + try: + intake_digest = verify_semantic_acceptance_intake(intake) + except SemanticAcceptanceError as exc: + raise ReviewWorkbenchError(f"frozen semantic intake is invalid: {exc}") from exc + + current_main = _text(intake.get("current_main_revision"), "intake.current_main_revision") + if contexts_payload.get("current_main_revision") != current_main: + raise ReviewWorkbenchError("review contexts are stale or bound to a different main") + if contexts_payload.get("source_intake_digest") != intake_digest: + raise ReviewWorkbenchError("review contexts do not bind the frozen intake") + + packets_raw = intake.get("packets") + contexts_raw = contexts_payload.get("contexts") + if not isinstance(packets_raw, list) or not packets_raw: + raise ReviewWorkbenchError("intake.packets must be a non-empty list") + if not isinstance(contexts_raw, list) or not contexts_raw: + raise ReviewWorkbenchError("contexts_payload.contexts must be a non-empty list") + if len(packets_raw) != len(contexts_raw): + raise ReviewWorkbenchError("workbench context coverage must equal packet coverage") + + packets: dict[str, Mapping[str, Any]] = {} + for raw in packets_raw: + packet = _mapping(raw, "packet") + _authority_false(packet, "packet") + packet_id = _text(packet.get("packet_id"), "packet.packet_id") + if packet_id in packets: + raise ReviewWorkbenchError("duplicate packet_id in intake") + packets[packet_id] = packet + + contexts: dict[str, dict[str, Any]] = {} + for raw in contexts_raw: + context = _mapping(raw, "review context") + packet_id = _text(context.get("packet_id"), "context.packet_id") + if packet_id in contexts: + raise ReviewWorkbenchError("duplicate packet_id in review contexts") + packet = packets.get(packet_id) + if packet is None: + raise ReviewWorkbenchError("review context references an unknown packet") + proposal_pr = _positive_int(context.get("proposal_pr"), "context.proposal_pr") + if proposal_pr != packet.get("proposal_pr"): + raise ReviewWorkbenchError(f"proposal #{proposal_pr} context/packet mismatch") + source_pr = _positive_int(context.get("source_pr"), "context.source_pr") + if source_pr != packet.get("source_pr"): + raise ReviewWorkbenchError(f"proposal #{proposal_pr} context source PR mismatch") + if context.get("pack_id") != packet.get("pack_id"): + raise ReviewWorkbenchError(f"proposal #{proposal_pr} context pack mismatch") + if context.get("decision_id") != packet.get("decision_id"): + raise ReviewWorkbenchError(f"proposal #{proposal_pr} context decision mismatch") + if context.get("current_main_revision") != current_main: + raise ReviewWorkbenchError(f"proposal #{proposal_pr} context main mismatch") + + path_states_raw = context.get("path_states") + if not isinstance(path_states_raw, list) or not path_states_raw: + raise ReviewWorkbenchError(f"proposal #{proposal_pr} path_states must be non-empty") + path_states = [_path_state(item, proposal_pr) for item in path_states_raw] + paths = [item["path"] for item in path_states] + if len(paths) != len(set(paths)): + raise ReviewWorkbenchError(f"proposal #{proposal_pr} duplicate source path") + + lesson_confidence = _confidence( + context.get("lesson_confidence"), f"proposal {proposal_pr}.lesson_confidence" + ) + created_at = _parse_time(context.get("pack_created_at"), "context.pack_created_at") + context_refs = _strings( + context.get("context_evidence_refs"), "context.context_evidence_refs" + ) + normalized = { + "packet_id": packet_id, + "proposal_pr": proposal_pr, + "source_pr": source_pr, + "pack_id": _text(context.get("pack_id"), "context.pack_id"), + "decision_id": _text(context.get("decision_id"), "context.decision_id"), + "current_main_revision": current_main, + "source_title": _text(context.get("source_title"), "context.source_title"), + "situation_label": _text( + context.get("situation_label"), "context.situation_label" + ), + "action_label": _text(context.get("action_label"), "context.action_label"), + "lesson_label": _text(context.get("lesson_label"), "context.lesson_label"), + "lesson_confidence": lesson_confidence, + "pack_created_at": created_at, + "path_states": path_states, + "context_evidence_refs": context_refs, + } + contexts[packet_id] = normalized + + if set(contexts) != set(packets): + raise ReviewWorkbenchError("review context packet set must exactly match intake") + + sortable: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + for packet_id, packet in packets.items(): + context = contexts[packet_id] + machine_gate = _mapping(packet.get("machine_gate"), "packet.machine_gate") + changed_components = _strings( + machine_gate.get("changed_evidence_components"), + "packet.machine_gate.changed_evidence_components", + allow_empty=True, + ) + priority_packet = { + "machine_gate": {"changed_evidence_components": changed_components} + } + priority_index, priority_class = _priority_class(priority_packet, context) + missing_paths = [ + item["path"] for item in context["path_states"] if item["status"] == PATH_MISSING + ] + diverged_paths = [ + item["path"] + for item in context["path_states"] + if item["status"] == PATH_DIVERGED + ] + same_paths = [ + item["path"] for item in context["path_states"] if item["status"] == PATH_SAME + ] + human_review_required = packet.get("human_review_required") is True + submission_template = None + if human_review_required: + submission_template = { + "schema": SUBMISSION_SCHEMA, + "packet_id": packet_id, + "decision_id": packet.get("decision_id"), + "pack_id": packet.get("pack_id"), + "observed_main_revision": current_main, + "reviewer_id": None, + "reviewed_at": None, + "verdict": None, + "rationale": None, + "reviewed_gate_evidence_refs": list(packet.get("gate_evidence_refs", [])), + "additional_evidence_refs": [], + } + + queue_identity = { + "packet_id": packet_id, + "current_main_revision": current_main, + "priority_class": priority_class, + "human_review_required": human_review_required, + "lesson_label": context["lesson_label"], + "path_states": context["path_states"], + "changed_evidence_components": sorted(changed_components), + } + card = { + "card_id": _digest(queue_identity), + "packet_id": packet_id, + "decision_id": packet.get("decision_id"), + "proposal_pr": packet.get("proposal_pr"), + "source_pr": packet.get("source_pr"), + "pack_id": packet.get("pack_id"), + "current_main_revision": current_main, + "priority_class": priority_class, + "human_review_required": human_review_required, + "review_reason": { + "missing_or_renamed_paths": sorted(missing_paths), + "diverged_paths": sorted(diverged_paths), + "same_as_source_paths": sorted(same_paths), + "changed_evidence_components": sorted(changed_components), + }, + "review_context": { + "source_title": context["source_title"], + "situation": context["situation_label"], + "action": context["action_label"], + "lesson": context["lesson_label"], + "lesson_confidence": context["lesson_confidence"], + "pack_created_at": context["pack_created_at"].isoformat(), + "source_path_count": len(context["path_states"]), + "path_states": context["path_states"], + }, + "machine_gate": dict(machine_gate), + "gate_evidence_refs": list(packet.get("gate_evidence_refs", [])), + "lineage_evidence_refs": list(packet.get("lineage_evidence_refs", [])), + "context_evidence_refs": list(context["context_evidence_refs"]), + "allowed_human_verdicts": list(packet.get("allowed_human_verdicts", [])), + "submission_template": submission_template, + "review_completed": False, + "authority_granted": False, + "merge_authority": False, + "close_authority": False, + "acceptance_authority": False, + "execution_authority": False, + "policy_mutation_authority": False, + } + sort_key = ( + priority_index, + context["lesson_confidence"], + -len(context["path_states"]), + context["pack_created_at"], + int(packet.get("proposal_pr")), + ) + sortable.append((sort_key, card)) + + sortable.sort(key=lambda item: item[0]) + cards: list[dict[str, Any]] = [] + priority_counts: dict[str, int] = {} + for rank, (_, card) in enumerate(sortable, start=1): + card["queue_rank"] = rank + cards.append(card) + key = card["priority_class"] + priority_counts[key] = priority_counts.get(key, 0) + 1 + + workbench_identity = { + "source_intake_digest": intake_digest, + "current_main_revision": current_main, + "cards": cards, + } + return { + "schema": WORKBENCH_SCHEMA, + "mode": "HUMAN_REVIEW_WORKBENCH_ONLY", + "source_intake_digest": intake_digest, + "current_main_revision": current_main, + "card_count": len(cards), + "pending_review_count": sum( + 1 for card in cards if card["human_review_required"] + ), + "completed_review_count": 0, + "priority_class_counts": dict(sorted(priority_counts.items())), + "cards": cards, + "authority_granted": False, + "merge_authority": False, + "close_authority": False, + "acceptance_authority": False, + "execution_authority": False, + "policy_mutation_authority": False, + "invariants": [ + "one review card is preserved per frozen semantic packet", + "only packets explicitly marked human_review_required receive a bound submission template", + "queue priority orders human attention and does not score truth", + "current path state is a net current-main comparison, not proof of historical touches", + "review-eligible submission templates are intentionally incomplete until a human supplies identity, time, verdict, and rationale", + "workbench digest binds the complete deterministic card payload, including rendered context and evidence references", + "workbench output grants no acceptance, merge, close, execution, or policy authority", + ], + "non_claims": [ + "a higher queue rank does not mean a Memory Pack is more correct or more important", + "a current blob match does not prove the path was never changed and reverted", + "source-path divergence does not prove the stored lesson is invalid", + "an ACCEPT template or human verdict does not itself accept a Memory Pack", + ], + "workbench_digest": _digest(workbench_identity), + } + + +def _inline(value: Any) -> str: + """Flatten untrusted text to a single Markdown-safe inline fragment.""" + + text = " ".join(str(value).split()) + for char in ("\\", "`", "*", "_", "#", "[", "]", "<", ">", "|"): + text = text.replace(char, "\\" + char) + return text + + +def render_markdown(workbench: Mapping[str, Any]) -> str: + """Render a compact human-facing queue without inventing verdicts.""" + + workbench = _mapping(workbench, "workbench") + if workbench.get("schema") != WORKBENCH_SCHEMA: + raise ReviewWorkbenchError(f"workbench.schema must be {WORKBENCH_SCHEMA}") + cards = workbench.get("cards") + if not isinstance(cards, list): + raise ReviewWorkbenchError("workbench.cards must be a list") + + lines = [ + "# CML Human Review Workbench v0.5", + "", + f"Current main: `{workbench.get('current_main_revision')}`", + f"Pending cards: **{workbench.get('pending_review_count')}**", + "", + "> Queue rank orders review attention only. It is not a truth or authority score.", + "", + ] + for card in cards: + context = card["review_context"] + reason = card["review_reason"] + human_review_required = card.get("human_review_required") is True + lines.extend( + [ + f"## {card['queue_rank']}. PR #{card['proposal_pr']} ← source #{card['source_pr']}", + "", + f"**Priority:** `{card['priority_class']}`", + f"**Human semantic review required:** {'yes' if human_review_required else 'no'}", + f"**Lesson:** {_inline(context['lesson'])}", + f"**Situation:** {_inline(context['situation'])}", + f"**Action:** {_inline(context['action'])}", + f"**Source title:** {_inline(context['source_title'])}", + f"**Generated lesson confidence:** {context['lesson_confidence']}/100", + f"**Path state:** {len(reason['missing_or_renamed_paths'])} missing/renamed, " + f"{len(reason['diverged_paths'])} diverged, " + f"{len(reason['same_as_source_paths'])} same", + "**Evidence drift:** " + + ( + ", ".join(_inline(item) for item in reason["changed_evidence_components"]) + or "none observed" + ), + f"**Packet:** `{card['packet_id']}`", + "", + ] + ) + if human_review_required: + lines.extend( + [ + "Human action: inspect the frozen evidence, then fill `reviewer_id`, " + "`reviewed_at`, `verdict`, and `rationale` in the bound submission template.", + "", + ] + ) + else: + lines.extend( + [ + "Human action: no semantic-review submission is permitted for this packet; " + "follow its machine route until new evidence/context or a separate authority review is available.", + "", + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build CML Human Review Workbench v0.5") + parser.add_argument("intake", type=Path) + parser.add_argument("contexts", type=Path) + parser.add_argument("--markdown", type=Path) + args = parser.parse_args() + try: + intake = json.loads(args.intake.read_text(encoding="utf-8")) + contexts = json.loads(args.contexts.read_text(encoding="utf-8")) + result = build_review_workbench(intake, contexts) + if args.markdown is not None: + args.markdown.write_text(render_markdown(result), encoding="utf-8") + except (OSError, json.JSONDecodeError, ReviewWorkbenchError) as exc: + print(json.dumps({"error": str(exc)}, ensure_ascii=False)) + return 2 + print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cml/experimental/memory_proposal_semantic_acceptance.py b/cml/experimental/memory_proposal_semantic_acceptance.py new file mode 100644 index 00000000..9d85a1f9 --- /dev/null +++ b/cml/experimental/memory_proposal_semantic_acceptance.py @@ -0,0 +1,682 @@ +#!/usr/bin/env python3 +"""Evidence-bound human semantic review intake for CML Memory Proposals. + +v0.4 deliberately separates three things that are easy to conflate: + +1. machine revalidation says what is structurally/currently known; +2. a frozen review packet says exactly what a human is being asked to judge; +3. a human verdict records semantic evidence but grants no repository authority. + +Even ``ACCEPT`` only records semantic support for a later, separate authority +check. This module never merges, closes, accepts, rejects, rewrites, or executes +Memory Packs. +""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import re +from typing import Any, Mapping + +from cml.experimental.memory_proposal_queue_planner import ( + QueuePlanningError, + plan as build_revalidation_plan, +) + +PLANNER_INPUT_SCHEMA = "cml.memory-proposal-queue.revalidation-input.v0.2" +PLANNER_RESULT_SCHEMA = "cml.memory-proposal-queue.revalidation-plan.v0.2" +INTAKE_SCHEMA = "cml.memory-proposal-queue.semantic-acceptance-intake.v0.4" +SUBMISSION_SCHEMA = "cml.memory-proposal-queue.semantic-review-submission.v0.4" +RECORD_SCHEMA = "cml.memory-proposal-queue.semantic-review-record.v0.4" +SHA256_REF = re.compile(r"^sha256:[0-9a-f]{64}$") +HEX40 = re.compile(r"^[0-9a-f]{40}$") +HEX64 = re.compile(r"^[0-9a-f]{64}$") +ALLOWED_VERDICTS = ("ACCEPT", "REJECT", "DEFER") + + +class SemanticAcceptanceError(ValueError): + """Raised when semantic-review evidence is incomplete or misbound.""" + + +def _mapping(value: Any, field: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise SemanticAcceptanceError(f"{field} must be an object") + return value + + +def _text(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise SemanticAcceptanceError(f"{field} must be a non-empty string") + return value.strip() + + +def _positive_int(value: Any, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise SemanticAcceptanceError(f"{field} must be a positive integer") + return value + + +def _boolean(value: Any, field: str) -> bool: + if not isinstance(value, bool): + raise SemanticAcceptanceError(f"{field} must be boolean") + return value + + +def _sha256_ref(value: Any, field: str) -> str: + text = _text(value, field) + if not SHA256_REF.fullmatch(text): + raise SemanticAcceptanceError(f"{field} must be a sha256: digest") + return text + + +def _sha40(value: Any, field: str) -> str: + text = _text(value, field) + if not HEX40.fullmatch(text): + raise SemanticAcceptanceError(f"{field} must be a lowercase 40-char SHA") + return text + + +def _sha64(value: Any, field: str) -> str: + text = _text(value, field) + if not HEX64.fullmatch(text): + raise SemanticAcceptanceError(f"{field} must be a lowercase 64-char digest") + return text + + +def _refs(value: Any, field: str, *, allow_empty: bool = False) -> tuple[str, ...]: + if not isinstance(value, list): + raise SemanticAcceptanceError(f"{field} must be a list") + refs = tuple(_text(item, f"{field} entry") for item in value) + if not allow_empty and not refs: + raise SemanticAcceptanceError(f"{field} must not be empty") + if len(refs) != len(set(refs)): + raise SemanticAcceptanceError(f"{field} entries must be unique") + return refs + + +def _parse_time(value: Any, field: str) -> datetime: + text = _text(value, field) + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError as exc: + raise SemanticAcceptanceError(f"{field} must be ISO-8601") from exc + if parsed.tzinfo is None: + raise SemanticAcceptanceError(f"{field} must include a timezone") + return parsed.astimezone(timezone.utc) + + +def _digest(payload: Any) -> str: + canonical = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _authority_false(payload: Mapping[str, Any], field: str) -> None: + for key in ( + "authority_granted", + "merge_authority", + "close_authority", + "acceptance_authority", + "execution_authority", + "policy_mutation_authority", + ): + if payload.get(key) is not False: + raise SemanticAcceptanceError(f"{field}.{key} must be false") + + +def _review_requirement(fitness_status: str) -> tuple[str, bool]: + if fitness_status == "REVIEW_REQUIRED": + return "PENDING_HUMAN_SEMANTIC_REVIEW", True + if fitness_status == "NOT_FIT": + return "BLOCKED_PENDING_NEW_EVIDENCE_OR_CONTEXT", False + if fitness_status == "READY_FOR_AUTHORITY_CHECK": + return "SEPARATE_AUTHORITY_REVIEW_ONLY", False + raise SemanticAcceptanceError(f"unsupported canonical fitness: {fitness_status}") + + +def _packet_identity(packet: Mapping[str, Any]) -> dict[str, Any]: + """Rebuild the trust-relevant identity of a frozen review packet.""" + + packet = _mapping(packet, "packet") + machine_gate = _mapping(packet.get("machine_gate"), "packet.machine_gate") + allowed_verdicts = _refs( + packet.get("allowed_human_verdicts"), + "packet.allowed_human_verdicts", + ) + if allowed_verdicts != ALLOWED_VERDICTS: + raise SemanticAcceptanceError( + "packet.allowed_human_verdicts must match the canonical verdict set" + ) + return { + "status": _text(packet.get("status"), "packet.status"), + "human_review_required": _boolean( + packet.get("human_review_required"), "packet.human_review_required" + ), + "decision_id": _sha256_ref(packet.get("decision_id"), "packet.decision_id"), + "proposal_pr": _positive_int(packet.get("proposal_pr"), "packet.proposal_pr"), + "source_pr": _positive_int(packet.get("source_pr"), "packet.source_pr"), + "source_merge": _sha40(packet.get("source_merge"), "packet.source_merge"), + "pack_id": _sha64(packet.get("pack_id"), "packet.pack_id"), + "current_main_revision": _sha40( + packet.get("current_main_revision"), "packet.current_main_revision" + ), + "observation_digest": _sha256_ref( + packet.get("observation_digest"), "packet.observation_digest" + ), + "machine_gate": { + "applicability_status": _text( + machine_gate.get("applicability_status"), + "packet.machine_gate.applicability_status", + ), + "quality_readiness": _text( + machine_gate.get("quality_readiness"), + "packet.machine_gate.quality_readiness", + ), + "canonical_fitness_status": _text( + machine_gate.get("canonical_fitness_status"), + "packet.machine_gate.canonical_fitness_status", + ), + "review_route": _text( + machine_gate.get("review_route"), "packet.machine_gate.review_route" + ), + "stable_source_core_match": _boolean( + machine_gate.get("stable_source_core_match"), + "packet.machine_gate.stable_source_core_match", + ), + "source_ancestor_of_main": _boolean( + machine_gate.get("source_ancestor_of_main"), + "packet.machine_gate.source_ancestor_of_main", + ), + "changed_evidence_components": sorted( + _refs( + machine_gate.get("changed_evidence_components"), + "packet.machine_gate.changed_evidence_components", + allow_empty=True, + ) + ), + }, + "gate_evidence_refs": sorted( + _refs(packet.get("gate_evidence_refs"), "packet.gate_evidence_refs") + ), + "lineage_evidence_refs": sorted( + _refs(packet.get("lineage_evidence_refs"), "packet.lineage_evidence_refs") + ), + "allowed_human_verdicts": list(allowed_verdicts), + } + + +def verify_semantic_acceptance_intake(intake: Mapping[str, Any]) -> str: + """Recompute every packet identity and the enclosing intake digest. + + Returns the verified intake digest. Any edited packet, duplicated packet, + inconsistent coverage count, or stale digest fails closed. + """ + + intake = _mapping(intake, "intake") + if intake.get("schema") != INTAKE_SCHEMA: + raise SemanticAcceptanceError(f"intake.schema must be {INTAKE_SCHEMA}") + _authority_false(intake, "intake") + + source_plan_digest = _sha256_ref( + intake.get("source_plan_digest"), "intake.source_plan_digest" + ) + source_audit_digest = _sha256_ref( + intake.get("source_audit_digest"), "intake.source_audit_digest" + ) + current_main = _sha40( + intake.get("current_main_revision"), "intake.current_main_revision" + ) + captured_at = _parse_time(intake.get("captured_at"), "intake.captured_at") + claimed_digest = _sha256_ref(intake.get("intake_digest"), "intake.intake_digest") + + packets_raw = intake.get("packets") + if not isinstance(packets_raw, list) or not packets_raw: + raise SemanticAcceptanceError("intake.packets must be a non-empty list") + if intake.get("packet_count") != len(packets_raw): + raise SemanticAcceptanceError("intake.packet_count is inconsistent") + if intake.get("completed_human_review_count") != 0: + raise SemanticAcceptanceError("intake.completed_human_review_count must be zero") + + packet_ids: list[str] = [] + proposal_ids: set[int] = set() + pending_count = 0 + blocked_count = 0 + authority_only_count = 0 + for raw in packets_raw: + packet = _mapping(raw, "packet") + _authority_false(packet, "packet") + claimed_packet_id = _sha256_ref(packet.get("packet_id"), "packet.packet_id") + identity = _packet_identity(packet) + expected_packet_id = _digest(identity) + if claimed_packet_id != expected_packet_id: + raise SemanticAcceptanceError("packet identity digest does not match packet content") + if claimed_packet_id in packet_ids: + raise SemanticAcceptanceError("duplicate semantic review packet identity") + proposal_pr = identity["proposal_pr"] + if proposal_pr in proposal_ids: + raise SemanticAcceptanceError(f"duplicate semantic review proposal #{proposal_pr}") + proposal_ids.add(proposal_pr) + packet_ids.append(claimed_packet_id) + status = identity["status"] + if status == "PENDING_HUMAN_SEMANTIC_REVIEW": + if identity["human_review_required"] is not True: + raise SemanticAcceptanceError("pending packet must require human review") + pending_count += 1 + elif status == "BLOCKED_PENDING_NEW_EVIDENCE_OR_CONTEXT": + if identity["human_review_required"] is not False: + raise SemanticAcceptanceError("blocked packet cannot require semantic review") + blocked_count += 1 + elif status == "SEPARATE_AUTHORITY_REVIEW_ONLY": + if identity["human_review_required"] is not False: + raise SemanticAcceptanceError("authority-only packet cannot require semantic review") + authority_only_count += 1 + else: + raise SemanticAcceptanceError(f"unsupported packet status: {status}") + + if intake.get("pending_human_review_count") != pending_count: + raise SemanticAcceptanceError("intake pending review count is inconsistent") + if intake.get("blocked_pending_evidence_count") != blocked_count: + raise SemanticAcceptanceError("intake blocked count is inconsistent") + if intake.get("separate_authority_review_only_count") != authority_only_count: + raise SemanticAcceptanceError("intake authority-only count is inconsistent") + + expected_digest = _digest( + { + "source_plan_digest": source_plan_digest, + "source_audit_digest": source_audit_digest, + "current_main_revision": current_main, + "captured_at": captured_at.isoformat(), + "packet_ids": sorted(packet_ids), + } + ) + if claimed_digest != expected_digest: + raise SemanticAcceptanceError("intake digest does not match frozen packet set") + return claimed_digest + + +def build_semantic_acceptance_intake( + planner_input: Mapping[str, Any], + planner_result: Mapping[str, Any], +) -> dict[str, Any]: + """Freeze one human-review packet per planner decision. + + The packet binds the human judgment to a specific machine observation and + current-main revision. It is review input only, never acceptance authority. + """ + + planner_input = _mapping(planner_input, "planner_input") + planner_result = _mapping(planner_result, "planner_result") + if planner_input.get("schema") != PLANNER_INPUT_SCHEMA: + raise SemanticAcceptanceError( + f"planner_input.schema must be {PLANNER_INPUT_SCHEMA}" + ) + if planner_result.get("schema") != PLANNER_RESULT_SCHEMA: + raise SemanticAcceptanceError( + f"planner_result.schema must be {PLANNER_RESULT_SCHEMA}" + ) + _authority_false(planner_result, "planner_result") + + try: + canonical_planner_result = build_revalidation_plan(dict(planner_input)) + except QueuePlanningError as exc: + raise SemanticAcceptanceError( + f"planner_input cannot reproduce a canonical planner result: {exc}" + ) from exc + if dict(planner_result) != canonical_planner_result: + raise SemanticAcceptanceError( + "planner_result does not match canonical recomputation from planner_input" + ) + planner_result = canonical_planner_result + + current_main = _sha40( + planner_input.get("current_main_revision"), + "planner_input.current_main_revision", + ) + if planner_result.get("current_main_revision") != current_main: + raise SemanticAcceptanceError("planner input/result current main mismatch") + + source_plan_digest = _sha256_ref( + planner_result.get("plan_digest"), "planner_result.plan_digest" + ) + source_audit_digest = _sha256_ref( + planner_input.get("source_audit_digest"), "planner_input.source_audit_digest" + ) + captured_at = _parse_time(planner_input.get("captured_at"), "captured_at") + + records_raw = planner_input.get("records") + decisions_raw = planner_result.get("decisions") + if not isinstance(records_raw, list) or not records_raw: + raise SemanticAcceptanceError("planner_input.records must be a non-empty list") + if not isinstance(decisions_raw, list) or not decisions_raw: + raise SemanticAcceptanceError("planner_result.decisions must be a non-empty list") + if len(records_raw) != len(decisions_raw): + raise SemanticAcceptanceError("planner input/result record coverage mismatch") + if planner_result.get("record_count") != len(decisions_raw): + raise SemanticAcceptanceError("planner_result.record_count is inconsistent") + + records: dict[int, Mapping[str, Any]] = {} + for raw in records_raw: + record = _mapping(raw, "planner record") + proposal_pr = _positive_int(record.get("proposal_pr"), "record.proposal_pr") + if proposal_pr in records: + raise SemanticAcceptanceError(f"duplicate planner record #{proposal_pr}") + records[proposal_pr] = record + + packets: list[dict[str, Any]] = [] + seen_packet_ids: set[str] = set() + seen_proposals: set[int] = set() + pending_count = 0 + blocked_count = 0 + authority_only_count = 0 + + for raw in decisions_raw: + decision = _mapping(raw, "planner decision") + proposal_pr = _positive_int(decision.get("proposal_pr"), "decision.proposal_pr") + if proposal_pr in seen_proposals: + raise SemanticAcceptanceError(f"duplicate planner decision #{proposal_pr}") + seen_proposals.add(proposal_pr) + record = records.get(proposal_pr) + if record is None: + raise SemanticAcceptanceError( + f"planner decision #{proposal_pr} has no matching input record" + ) + + decision_id = _sha256_ref(decision.get("decision_id"), "decision.decision_id") + pack_id = _sha64(decision.get("pack_id"), "decision.pack_id") + source_pr = _positive_int(decision.get("source_pr"), "decision.source_pr") + source_merge = _sha40(decision.get("source_merge"), "decision.source_merge") + if record.get("pack_id") != pack_id: + raise SemanticAcceptanceError(f"proposal #{proposal_pr} pack mismatch") + if record.get("source_pr") != source_pr: + raise SemanticAcceptanceError(f"proposal #{proposal_pr} source PR mismatch") + if record.get("source_merge") != source_merge: + raise SemanticAcceptanceError(f"proposal #{proposal_pr} source merge mismatch") + + revalidation = _mapping(record.get("revalidation"), "record.revalidation") + observation_digest = _sha256_ref( + revalidation.get("observation_digest"), + "record.revalidation.observation_digest", + ) + stable_match = revalidation.get("stable_source_core_match") + ancestor = revalidation.get("source_ancestor_of_main") + if not isinstance(stable_match, bool) or not isinstance(ancestor, bool): + raise SemanticAcceptanceError("revalidation stable/ancestry flags must be boolean") + changed_components = _refs( + revalidation.get("changed_evidence_components"), + "record.revalidation.changed_evidence_components", + allow_empty=True, + ) + + applicability = _mapping(decision.get("applicability"), "decision.applicability") + quality = _mapping(decision.get("quality"), "decision.quality") + fitness = _mapping(decision.get("canonical_fitness"), "decision.canonical_fitness") + fitness_status = _text(fitness.get("status"), "decision.canonical_fitness.status") + packet_status, human_review_required = _review_requirement(fitness_status) + if packet_status == "PENDING_HUMAN_SEMANTIC_REVIEW": + pending_count += 1 + elif packet_status == "BLOCKED_PENDING_NEW_EVIDENCE_OR_CONTEXT": + blocked_count += 1 + else: + authority_only_count += 1 + + gate_refs = _refs(decision.get("gate_evidence_refs"), "decision.gate_evidence_refs") + lineage_refs = _refs( + decision.get("lineage_evidence_refs"), "decision.lineage_evidence_refs" + ) + machine_gate = { + "applicability_status": _text( + applicability.get("status"), "decision.applicability.status" + ), + "quality_readiness": _text( + quality.get("readiness"), "decision.quality.readiness" + ), + "canonical_fitness_status": fitness_status, + "review_route": _text(decision.get("review_route"), "decision.review_route"), + "stable_source_core_match": stable_match, + "source_ancestor_of_main": ancestor, + "changed_evidence_components": sorted(changed_components), + } + packet_core = { + "status": packet_status, + "human_review_required": human_review_required, + "decision_id": decision_id, + "proposal_pr": proposal_pr, + "source_pr": source_pr, + "source_merge": source_merge, + "pack_id": pack_id, + "current_main_revision": current_main, + "observation_digest": observation_digest, + "machine_gate": machine_gate, + "gate_evidence_refs": list(gate_refs), + "lineage_evidence_refs": list(lineage_refs), + "allowed_human_verdicts": list(ALLOWED_VERDICTS), + } + packet_id = _digest(_packet_identity(packet_core)) + if packet_id in seen_packet_ids: + raise SemanticAcceptanceError("duplicate semantic review packet identity") + seen_packet_ids.add(packet_id) + + packet = { + "packet_id": packet_id, + **packet_core, + "submission_contract": { + "reviewer_id_required": True, + "reviewed_at_required": True, + "rationale_required": True, + "reviewed_gate_evidence_refs_must_match_packet": True, + "observed_main_revision_must_match_packet": True, + "additional_evidence_refs_optional": True, + }, + "authority_granted": False, + "merge_authority": False, + "close_authority": False, + "acceptance_authority": False, + "execution_authority": False, + "policy_mutation_authority": False, + } + packets.append(packet) + + if seen_proposals != set(records): + raise SemanticAcceptanceError( + "planner decision coverage does not exactly match input records" + ) + + intake_identity = { + "source_plan_digest": source_plan_digest, + "source_audit_digest": source_audit_digest, + "current_main_revision": current_main, + "captured_at": captured_at.isoformat(), + "packet_ids": sorted(packet["packet_id"] for packet in packets), + } + result = { + "schema": INTAKE_SCHEMA, + "mode": "HUMAN_SEMANTIC_REVIEW_INTAKE_ONLY", + "source_plan_digest": source_plan_digest, + "source_audit_digest": source_audit_digest, + "current_main_revision": current_main, + "captured_at": captured_at.isoformat(), + "packet_count": len(packets), + "pending_human_review_count": pending_count, + "blocked_pending_evidence_count": blocked_count, + "separate_authority_review_only_count": authority_only_count, + "completed_human_review_count": 0, + "packets": sorted(packets, key=lambda item: item["proposal_pr"]), + "authority_granted": False, + "merge_authority": False, + "close_authority": False, + "acceptance_authority": False, + "execution_authority": False, + "policy_mutation_authority": False, + "invariants": [ + "one semantic review packet is preserved per planner decision", + "a packet binds human review to exact machine evidence and current main", + "human ACCEPT records semantic support but does not accept a Memory Pack", + "human REJECT records semantic rejection but does not close or delete a proposal", + "human DEFER records no semantic conclusion", + ], + "intake_digest": _digest(intake_identity), + } + verify_semantic_acceptance_intake(result) + return result + + +def validate_human_submission( + intake: Mapping[str, Any], submission: Mapping[str, Any] +) -> dict[str, Any]: + """Validate one human verdict against a frozen v0.4 packet. + + The returned record is evidence only. No verdict grants repository authority. + """ + + intake = _mapping(intake, "intake") + submission = _mapping(submission, "submission") + verify_semantic_acceptance_intake(intake) + if submission.get("schema") != SUBMISSION_SCHEMA: + raise SemanticAcceptanceError( + f"submission.schema must be {SUBMISSION_SCHEMA}" + ) + + packet_id = _sha256_ref(submission.get("packet_id"), "submission.packet_id") + packets_raw = intake.get("packets") + assert isinstance(packets_raw, list) + matches = [ + packet + for packet in packets_raw + if isinstance(packet, Mapping) and packet.get("packet_id") == packet_id + ] + if len(matches) != 1: + raise SemanticAcceptanceError("submission packet_id is not uniquely present") + packet = matches[0] + if packet.get("human_review_required") is not True: + raise SemanticAcceptanceError("packet is not eligible for human semantic review") + _authority_false(packet, "packet") + + decision_id = _sha256_ref(submission.get("decision_id"), "submission.decision_id") + pack_id = _sha64(submission.get("pack_id"), "submission.pack_id") + observed_main = _sha40( + submission.get("observed_main_revision"), "submission.observed_main_revision" + ) + if decision_id != packet.get("decision_id"): + raise SemanticAcceptanceError("submission decision_id does not match packet") + if pack_id != packet.get("pack_id"): + raise SemanticAcceptanceError("submission pack_id does not match packet") + if observed_main != packet.get("current_main_revision"): + raise SemanticAcceptanceError("submission current main is stale or misbound") + + reviewer_id = _text(submission.get("reviewer_id"), "submission.reviewer_id") + reviewed_at = _parse_time(submission.get("reviewed_at"), "submission.reviewed_at") + verdict = _text(submission.get("verdict"), "submission.verdict") + if verdict not in ALLOWED_VERDICTS: + raise SemanticAcceptanceError( + "submission.verdict must be one of: " + ", ".join(ALLOWED_VERDICTS) + ) + rationale = _text(submission.get("rationale"), "submission.rationale") + reviewed_gate_refs = _refs( + submission.get("reviewed_gate_evidence_refs"), + "submission.reviewed_gate_evidence_refs", + ) + packet_gate_refs = _refs(packet.get("gate_evidence_refs"), "packet.gate_evidence_refs") + if sorted(reviewed_gate_refs) != sorted(packet_gate_refs): + raise SemanticAcceptanceError( + "reviewed gate evidence refs must exactly match the frozen packet" + ) + additional_refs = _refs( + submission.get("additional_evidence_refs", []), + "submission.additional_evidence_refs", + allow_empty=True, + ) + + if verdict == "ACCEPT": + next_route = "SEPARATE_ACCEPTANCE_AUTHORITY_CHECK_REQUIRED" + semantic_effect = "SEMANTIC_SUPPORT_RECORDED" + elif verdict == "REJECT": + next_route = "SEPARATE_REJECTION_OR_CLOSURE_AUTHORITY_CHECK_REQUIRED" + semantic_effect = "SEMANTIC_REJECTION_RECORDED" + else: + next_route = "AWAIT_NEW_EVIDENCE_OR_FURTHER_HUMAN_REVIEW" + semantic_effect = "SEMANTIC_REVIEW_DEFERRED" + + record_identity = { + "packet_id": packet_id, + "decision_id": decision_id, + "pack_id": pack_id, + "observed_main_revision": observed_main, + "reviewer_id": reviewer_id, + "reviewed_at": reviewed_at.isoformat(), + "verdict": verdict, + "rationale": rationale, + "reviewed_gate_evidence_refs": sorted(reviewed_gate_refs), + "additional_evidence_refs": sorted(additional_refs), + } + return { + "schema": RECORD_SCHEMA, + "record_id": _digest(record_identity), + "packet_id": packet_id, + "decision_id": decision_id, + "proposal_pr": packet.get("proposal_pr"), + "source_pr": packet.get("source_pr"), + "source_merge": packet.get("source_merge"), + "pack_id": pack_id, + "observed_main_revision": observed_main, + "observation_digest": packet.get("observation_digest"), + "reviewer_id": reviewer_id, + "reviewed_at": reviewed_at.isoformat(), + "verdict": verdict, + "rationale": rationale, + "reviewed_gate_evidence_refs": list(reviewed_gate_refs), + "additional_evidence_refs": list(additional_refs), + "semantic_effect": semantic_effect, + "next_route": next_route, + "state_mutation_performed": False, + "authority_granted": False, + "merge_authority": False, + "close_authority": False, + "acceptance_authority": False, + "execution_authority": False, + "policy_mutation_authority": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + build = sub.add_parser("build", help="build semantic review intake") + build.add_argument("planner_input", type=Path) + build.add_argument("planner_result", type=Path) + + validate = sub.add_parser("validate", help="validate one human review submission") + validate.add_argument("intake", type=Path) + validate.add_argument("submission", type=Path) + + args = parser.parse_args() + try: + if args.command == "build": + planner_input = json.loads(args.planner_input.read_text(encoding="utf-8")) + planner_result = json.loads(args.planner_result.read_text(encoding="utf-8")) + result = build_semantic_acceptance_intake(planner_input, planner_result) + else: + intake = json.loads(args.intake.read_text(encoding="utf-8")) + submission = json.loads(args.submission.read_text(encoding="utf-8")) + result = validate_human_submission(intake, submission) + except (OSError, json.JSONDecodeError, SemanticAcceptanceError) as exc: + print(json.dumps({"error": str(exc)}, ensure_ascii=False)) + return 2 + + print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/integrations/MEMORY_PROPOSAL_QUEUE_AUDITOR_V0_1.md b/docs/integrations/MEMORY_PROPOSAL_QUEUE_AUDITOR_V0_1.md new file mode 100644 index 00000000..78425e91 --- /dev/null +++ b/docs/integrations/MEMORY_PROPOSAL_QUEUE_AUDITOR_V0_1.md @@ -0,0 +1,196 @@ +# CML Memory Proposal Queue Auditor v0.1 + +## Purpose + +The Memory Proposal Queue Auditor is a **read-only planning tool** for the automatic CML Memory Learning Loop. + +The learning loop intentionally produces one reviewable Memory Pack proposal per eligible merged pull request. That preserves source identity and requires explicit human review, but a sufficiently large open queue creates a second problem: review effort itself needs orientation. + +The auditor answers: + +> How large and structurally repetitive is the current proposal queue, and what is the safest review-planning transition before any individual memory is accepted or rejected? + +It does **not** answer: + +> Which Memory Packs are true, false, duplicates, safe to merge, or safe to delete? + +## Current observed snapshot — 2026-08-15 + +The exact-title GitHub search for open automatic learning proposals reported: + +```text +36 open proposals +``` + +The normalized repository fixture is: + +`benchmarks/experimental/memory-proposal-queue-2026-08-15.json` + +It is bound to CML main: + +`90c7fdaaf31ad7c17ddc0c3c55b7ccd33f6affc2` + +The oldest explicitly timestamped proposal in the current fixture is PR #191, created `2026-07-17T11:20:36Z`. The newest explicitly timestamped proposal is PR #284, created `2026-08-14T05:07:04Z`. + +Only those timestamps are frozen in v0.1; missing timestamps for the other proposals are **not invented**. Age coverage is therefore explicitly `PARTIAL`. + +## Input contract + +Schema: + +```text +cml.memory-proposal-queue.snapshot.v0.1 +``` + +Each proposal carries its distinct source identity: + +```text +proposal_pr +source_pr +source_merge +pack_id +created_at | null +state = open +draft = true +lesson_status = proposed +visibility = team +contains_private_data = true +merge_authority = false +execution_authority = false +``` + +The snapshot fails closed when: + +- `reported_total_count` does not equal the number of normalized proposals; +- proposal, source PR, source merge, or pack identity is duplicated; +- any proposal claims merge or execution authority; +- proposal state escapes the automatic-learning contract; +- a timestamp is impossible relative to the snapshot capture time. + +## Queue pressure bands + +v0.1 uses review-planning bands only: + +```text +0–9 BOUNDED_REVIEW_PRESSURE +10–19 ELEVATED_REVIEW_PRESSURE +20–29 HIGH_REVIEW_PRESSURE +30+ CRITICAL_REVIEW_PRESSURE +``` + +These bands are not truth, safety, or merge scores. They only choose the granularity of the next review-planning step. + +At `HIGH` or `CRITICAL` pressure the recommended transition is: + +```text +QUEUE_LEVEL_GROUP_REVALIDATE_THEN_REVIEW +``` + +That means: + +1. plan review at queue level; +2. preserve every distinct pack identity; +3. revalidate selected packs against current applicability and information-quality contracts; +4. then perform explicit human acceptance/rejection review per pack. + +## Structural repetition vs semantic duplication + +Every automatic proposal currently shares the same review envelope: + +```text +open +draft +status=proposed +visibility=team +contains_private_data=true +merge_authority=false +execution_authority=false +``` + +The auditor measures repetition of that **envelope**, not similarity of the Memory Pack contents. + +Invariant: + +> Structural review-envelope repetition ≠ semantic Memory Pack duplication. + +Accordingly v0.1 emits: + +```text +semantic_duplicate_status = NOT_MEASURED +semantic_duplicate_claim = false +``` + +No automatic grouping result may be used to merge, close, accept, delete, or deduplicate a Memory Pack. + +## Age boundary + +Queue age can identify review debt, but it does not invalidate evidence. + +Invariant: + +> Old ≠ stale; stale ≠ invalid; invalid ≠ authorized to delete. + +Current-main ancestry and applicability are therefore reported separately as: + +```text +ancestry.status = NOT_MEASURED +``` + +A later exact-main revalidation pass may add commit ancestry/current-contract evidence, but v0.1 does not infer it from PR age or PR number. + +## Output contract + +Schema: + +```text +cml.memory-proposal-queue.audit.v0.1 +``` + +Every result remains: + +```text +mode = REVIEW_ADVISORY_ONLY +authority_granted = false +merge_authority = false +close_authority = false +acceptance_authority = false +policy_mutation_authority = false +``` + +The output includes: + +- complete queue count; +- queue-pressure band; +- distinct source/pack identity counts; +- explicit age coverage and known age facts; +- structural review-envelope repetition; +- `NOT_MEASURED` semantic-duplicate status; +- `NOT_MEASURED` ancestry status; +- a deterministic snapshot digest; +- a bounded next safe transition. + +## Relationship to Graph–Field Dynamics + +RESONANCE Graph–Field Dynamics prospectively selected `cml-memory-proposal-pressure` over a FIFO maintenance baseline. The first investigation saw seven recent proposals, but a complete exact-title inventory later showed **36** open automatic proposals. + +That is an important falsification lesson: + +> Field orientation may correctly identify a system-level hotspot while the first local observation still undercounts its extent. + +The Queue Auditor exists to turn that hotspot into an inspectable CML-native measurement rather than making GFD itself the authority over CML memory policy. + +## Non-claims + +The auditor does not claim: + +- semantic equivalence between packs; +- correctness of generated lessons; +- current applicability of historical evidence; +- source-commit ancestry relative to current main; +- permission to merge, close, accept, reject, delete, or rewrite proposals; +- permission to change the Memory Learning Loop policy; +- proof that a smaller queue is intrinsically better. + +## Next boundary + +After v0.1 queue measurement, the natural next experiment is a **read-only queue grouping/revalidation planner** that uses explicit source metadata and current CML trust gates while preserving one decision record per Memory Pack. diff --git a/docs/integrations/MEMORY_PROPOSAL_QUEUE_PLANNER_V0_2.md b/docs/integrations/MEMORY_PROPOSAL_QUEUE_PLANNER_V0_2.md new file mode 100644 index 00000000..e1abab21 --- /dev/null +++ b/docs/integrations/MEMORY_PROPOSAL_QUEUE_PLANNER_V0_2.md @@ -0,0 +1,211 @@ +# Memory Proposal Queue Grouping / Revalidation Planner v0.2 + +Status: experimental, read-only, review-advisory only. + +## Purpose + +The v0.1 Queue Auditor answers a queue-level question: how large and structurally repetitive is the open automatic Memory Pack proposal queue? + +The v0.2 Planner answers the next narrower question: + +> Given explicit per-pack CML applicability and information-quality results, how can review work be grouped without collapsing Memory Pack identity or granting acceptance authority? + +The planner is not a new trust gate. It imports and reuses the existing CML information-fitness composition. + +## Canonical trust composition + +For each Memory Pack, the caller supplies source-owned serialized results from: + +- `ApplicabilityResult` / `ApplicabilityStatus`; +- `InformationQualityResult` / `QualityReadiness`; +- explicit lineage evidence references; +- explicit gate evidence references. + +The planner reconstructs those typed results and calls: + +```python +evaluate_information_fitness( + applicability=applicability, + quality=quality, +) +``` + +The canonical precedence remains owned by `cml.integrations.information_fitness`: + +```text +NOT_FIT -> REVIEW_REQUIRED -> READY_FOR_AUTHORITY_CHECK +``` + +The planner does not duplicate or override that precedence. + +A caller-provided `claimed_fitness_status` must equal the canonical recomputation or the input fails closed. + +## Input contract + +```text +schema = cml.memory-proposal-queue.revalidation-input.v0.2 +source_audit_schema = cml.memory-proposal-queue.audit.v0.1 +source_audit_digest = sha256: +current_main_revision = <40-char Git SHA> +captured_at = +synthetic = true|false +expected_record_count = N +records = [...] +``` + +Each record preserves: + +```text +proposal_pr +source_pr +source_merge +pack_id +lineage_root_id +lineage_evidence_refs[] +gate_evidence_refs[] +applicability { status, reasons[] } +quality { + semantic_truth, + completeness, + relevance, + readiness, + reasons[] +} +claimed_fitness_status +``` + +`lineage_root_id` must be explicit and evidence-backed. The planner never infers lineage from PR number, title similarity, age, file path, or shared review-envelope structure. + +## One decision record per pack + +The output schema is: + +```text +cml.memory-proposal-queue.revalidation-plan.v0.2 +``` + +Every input Memory Pack receives a distinct deterministic `decision_id` bound to: + +- pack identity; +- explicit lineage root; +- current main revision; +- applicability status; +- quality readiness; +- canonical information-fitness status; +- lineage and gate evidence refs. + +Even when multiple packs share a review group, their decision records remain independent. + +## Grouping rule + +Review groups are formed only by: + +```text +explicit lineage_root_id × canonical information-fitness status +``` + +Example: + +```text +pr-contracts × READY_FOR_AUTHORITY_CHECK +pr-contracts × REVIEW_REQUIRED +security × REVIEW_REQUIRED +security × NOT_FIT +``` + +The same lineage can therefore appear in more than one group when current trust posture differs. + +Every group is fixed to: + +```text +scope = REVIEW_ERGONOMICS_ONLY +semantic_merge = false +group_decision_authority = false +``` + +A group does not become a new Memory Pack, a shared verdict, a deduplication claim, or an acceptance unit. + +## Review routes + +Canonical fitness is mapped only to a review-routing suggestion: + +| Canonical fitness | Planner route | +|---|---| +| `NOT_FIT` | `BLOCK_ACCEPTANCE_PENDING_NEW_EVIDENCE_OR_CONTEXT` | +| `REVIEW_REQUIRED` | `HUMAN_REVALIDATION_REQUIRED` | +| `READY_FOR_AUTHORITY_CHECK` | `ELIGIBLE_FOR_SEPARATE_ACCEPTANCE_REVIEW` | + +These routes are advisory. In particular: + +```text +READY_FOR_AUTHORITY_CHECK != accepted memory +NOT_FIT != authority to close/delete +REVIEW_REQUIRED != automatic rejection +``` + +## Authority boundary + +Every plan is fixed to: + +```text +mode = REVIEW_ADVISORY_ONLY +authority_granted = false +merge_authority = false +close_authority = false +acceptance_authority = false +execution_authority = false +policy_mutation_authority = false +``` + +The planner cannot merge, close, accept, reject, delete, rewrite, or execute anything. + +## Synthetic conformance fixture + +`benchmarks/experimental/memory-proposal-queue-planner-v0.2.synthetic.json` is deliberately marked: + +```text +synthetic = true +``` + +It exercises six distinct Memory Pack identities across three explicit lineage roots and all three canonical information-fitness postures. + +The synthetic fixture is mechanical conformance evidence only. It is not a claim about the current 36-proposal production queue. + +## Fail-closed cases + +The planner rejects: + +- incomplete record coverage; +- duplicate proposal/source/merge/pack identities; +- missing lineage or gate evidence refs; +- malformed source-audit/current-main bindings; +- invalid enum values; +- caller-claimed fitness that contradicts canonical CML composition. + +## Relationship to Graph–Field Dynamics + +Graph–Field Dynamics may identify `cml-memory-proposal-pressure` as an orientation hotspot. + +CML remains the source of truth for: + +- Memory Pack identity; +- applicability; +- information quality; +- information fitness; +- review/acceptance policy. + +Therefore the separation remains: + +> Field proposes. Graph constrains. Authority permits. Evidence verifies. + +For this workflow specifically: + +```text +GFD orientation + -> CML queue audit + -> CML per-pack trust gates + -> planner review grouping + -> separate human acceptance review +``` + +No step before the final authority process can accept a Memory Pack. diff --git a/docs/integrations/MEMORY_PROPOSAL_QUEUE_REVALIDATION_V0_3.md b/docs/integrations/MEMORY_PROPOSAL_QUEUE_REVALIDATION_V0_3.md new file mode 100644 index 00000000..4a2e2907 --- /dev/null +++ b/docs/integrations/MEMORY_PROPOSAL_QUEUE_REVALIDATION_V0_3.md @@ -0,0 +1,205 @@ +# Memory Proposal Queue Revalidation v0.3 + +Status: **experimental / read-only**. + +This layer turns the open automatic Memory Proposal queue into individually evidence-bound revalidation records without granting acceptance, merge, close, execution, or policy authority. + +## Boundary + +```text +live GitHub facts + -> strict frozen Memory Pack identity validation + -> current source replay for diagnostics + -> immutable source-core comparison + -> mutable-evidence drift classification + -> current-main ancestry observation + -> canonical CML applicability + -> conservative information quality + -> canonical information fitness + -> Planner v0.2 + -> separate human acceptance review +``` + +The network collector is not a trust oracle. It gathers GitHub observations only. The pure adapter consumes normalized observations and reuses existing CML applicability, information-quality, and information-fitness gates. + +## Three identities must not be collapsed + +v0.3 separates three different questions. + +### 1. Frozen Memory Pack identity + +The proposal's existing `pack_id` is strictly revalidated with the source-owned retrieval core. This proves that the frozen proposal artifact itself has not been silently rewritten. + +### 2. Immutable source identity + +Current applicability is not based on full Memory Pack equality. The stable source core binds: + +```text +repository ++ source PR number ++ exact source head SHA ++ exact source merge SHA ++ source-files digest +``` + +These fields describe the code/source transition whose lesson was generated. + +### 3. Mutable evidence + +The following remain evidence, but are not immutable source-code identity: + +```text +source-pr # includes mutable PR narrative metadata/body +source-reviews # appendable review history +source-checks # rerunnable / latest operational check state +``` + +Changes in these components require review and remain visible in evidence. They do not by themselves establish that the source code transition changed. + +## Full-pack replay is diagnostic, not applicability authority + +The collector still replays the source-owned `memory_learning_core.build_memory_pack()` against current GitHub observations and records: + +```text +original pack_id == replayed pack_id +``` + +But this is deliberately diagnostic. A full-pack mismatch can be caused by mutable PR narrative, later reviews, or rerun/replaced check results. + +Therefore: + +```text +full pack replay drift != immutable source drift +immutable source match != semantic acceptance +``` + +## Verification-of-verifier history + +The first live v0.3 pass observed: + +```text +full_pack_replay_match = 0 / 36 +``` + +Treating full pack identity as current source identity would have classified all 36 proposals as `DRIFT / NOT_FIT`. + +Instead of accepting that interpretation, the verifier was decomposed by evidence component. The component-level observation showed: + +```text +source-checks changed in 36 / 36 +source-pr changed in 8 / 36 +source-reviews changed in 6 / 36 +source-files changed in 0 / 36 +source-merge changed in 0 / 36 +``` + +Representative `source-pr` mismatches were traced to post-merge PR-body updates such as appended CodeRabbit release notes while exact source head/files/merge remained unchanged. + +The resulting correction was not to ignore drift. It was to move mutable narrative/operational evidence out of immutable source identity while preserving every observed difference for review. + +This is the intended verifier loop: + +```text +FAIL + -> locate first meaningful divergence + -> classify verifier granularity + -> refine evidence boundary + -> replay +``` + +## Final live result on the refined boundary + +Verified live run on semantic head: + +```text +head = d48bbc09bf6ac12ce9ede7f056bfeaf368d51680 +run = 31889575661 +job = 95023764300 +``` + +Observed queue: + +```text +proposal_count = 36 +planner_record_count = 36 +stable_source_core_match_count = 36 +stable_source_core_drift_count = 0 +source_ancestor_of_main_count = 36 +source_not_ancestor_of_main_count = 0 +``` + +Canonical CML outcomes: + +```text +Applicability: REVALIDATE = 36 +Quality: REVIEW = 36 +Fitness: REVIEW_REQUIRED = 36 +``` + +Mutable/full-pack diagnostics remain: + +```text +full_pack_replay_match_count = 0 +full_pack_replay_drift_count = 36 +mutable_evidence_drift_count = 36 +descriptive_pr_metadata_drift = 8 +operational_evidence_drift = 36 +source-checks mismatch = 36 +source-pr mismatch = 8 +source-reviews mismatch = 6 +``` + +No semantic acceptance evidence was collected: + +```text +semantic_acceptance_evidence = NOT_COLLECTED +``` + +So even though all 36 immutable source cores remain intact and all 36 source merges remain ancestors of current main, **none is automatically accepted**. Historical environment differs from current main and semantic acceptance is still a separate review decision. + +The live evidence artifact from this semantic-head run was uploaded by the workflow as `cml-memory-queue-revalidation-31889575661-1`. + +## Semantic acceptance is intentionally missing + +The quality gate requires bounded evidence for: + +1. `pack_identity` +2. `stable_source_core` +3. `current_main_ancestry` +4. `mutable_evidence` +5. `semantic_acceptance` + +The collector can observe the first four. It deliberately does not manufacture independent semantic acceptance evidence. Consequently, even an intact current source core remains `QualityReadiness.REVIEW` and cannot skip human acceptance review. + +## Live outputs + +The workflow writes: + +- `queue-snapshot.json` +- `queue-audit.json` +- `planner-input.json` +- `planner-result.json` +- `summary.json` + +Coverage must satisfy: + +```text +live proposal count == planner record count +stable source matches + stable source drift == live proposal count +``` + +Every Planner record remains independent. v0.3 uses `source-pr:` as the lineage root unless future evidence proves a broader shared lineage. Similar templates are not enough to merge groups. + +## Authority invariant + +```text +mode = REVIEW_ADVISORY_ONLY +authority_granted = false +merge_authority = false +close_authority = false +acceptance_authority = false +execution_authority = false +policy_mutation_authority = false +``` + +`READY_FOR_AUTHORITY_CHECK` is not acceptance. `NOT_FIT` is not deletion authority. Structural source replay is not semantic truth. diff --git a/docs/integrations/MEMORY_PROPOSAL_REVIEW_WORKBENCH_V0_5.md b/docs/integrations/MEMORY_PROPOSAL_REVIEW_WORKBENCH_V0_5.md new file mode 100644 index 00000000..ec4ba585 --- /dev/null +++ b/docs/integrations/MEMORY_PROPOSAL_REVIEW_WORKBENCH_V0_5.md @@ -0,0 +1,157 @@ +# Memory Proposal Human Review Workbench v0.5 + +Status: experimental, review-only. + +## Purpose + +The Human Review Workbench turns the frozen Semantic Acceptance v0.4 intake into a deterministic queue that a human can actually inspect. + +It does **not** decide whether a Memory Pack is true, useful, accepted, rejected, mergeable, closable, or executable. It orders review attention and preserves exact evidence bindings. + +```text +live queue + -> v0.1 audit + -> v0.2 canonical planner + -> v0.3 live revalidation + -> v0.4 frozen semantic packet + -> v0.5 human review workbench + -> human semantic submission + -> separate authority check +``` + +## Review card + +One workbench card is preserved for every frozen semantic packet. + +Each card binds: + +- packet ID; +- planner decision ID; +- proposal PR; +- source PR; +- Memory Pack ID; +- exact current-main revision; +- generated situation, action, and lesson text from the frozen Memory Pack; +- generated lesson confidence; +- source-PR changed paths; +- net path state at source merge versus current main; +- machine gate result and evidence drift; +- exact gate, lineage, and workbench context evidence references; +- an intentionally incomplete Semantic Acceptance v0.4 submission template. + +The submission template pre-binds machine-controlled identity fields but leaves these human fields empty: + +```text +reviewer_id = null +reviewed_at = null +verdict = null +rationale = null +``` + +No workflow fabricates a human semantic verdict. + +## Current path-state signal + +For every file changed by the source PR, the live collector compares the state at the exact source merge with the state at the frozen current-main revision. + +States: + +```text +SAME_AS_SOURCE_MERGE +DIVERGED_FROM_SOURCE_MERGE +MISSING_OR_RENAMED_ON_CURRENT_MAIN +``` + +A source PR that intentionally removed a path uses an explicit `ABSENT` state token. If the path remains absent on current main, its net state matches the source merge; if it reappears, the state diverges. + +This comparison is a **net state comparison**, not historical-touch evidence. A matching blob cannot prove that a path was never changed and later reverted. + +## Priority classes + +v0.5 deliberately does not invent a probability or universal score. Review ordering is lexicographic: + +```text +P0_SOURCE_SCOPE_MISSING +P1_SOURCE_SCOPE_DIVERGED +P2_REVIEW_CONTEXT_DRIFT +P3_OPERATIONAL_EVIDENCE_REFRESH +P4_CURRENT_SCOPE_MATCH +``` + +Meaning: + +- `P0_SOURCE_SCOPE_MISSING`: at least one source path is no longer present at its recorded path on current main; +- `P1_SOURCE_SCOPE_DIVERGED`: at least one source path currently has a different state from the source merge; +- `P2_REVIEW_CONTEXT_DRIFT`: code-path state matches, but descriptive PR or review evidence changed; +- `P3_OPERATIONAL_EVIDENCE_REFRESH`: only operational check evidence changed; +- `P4_CURRENT_SCOPE_MATCH`: no higher-priority signal was observed. + +Within one class, the deterministic tiebreak order is: + +1. lower generated lesson confidence; +2. broader source changed-path count; +3. older Memory Pack creation time; +4. proposal PR number. + +These rules order **human attention only**. + +```text +higher review priority +!= higher truth probability +!= semantic invalidity +!= acceptance authority +``` + +## Human submission boundary + +The workbench does not replace the v0.4 Semantic Acceptance validator. + +A human still submits exactly one of: + +```text +ACCEPT +REJECT +DEFER +``` + +against the frozen packet, exact current-main revision, and exact reviewed evidence set. + +Even then: + +```text +ACCEPT -> semantic support evidence -> separate acceptance authority check +REJECT -> semantic rejection evidence -> separate rejection/closure authority check +DEFER -> no semantic conclusion -> wait for more evidence/review +``` + +The workbench itself never performs those later authority transitions. + +## Authority invariant + +Every v0.5 result and card is fixed to: + +```text +authority_granted = false +merge_authority = false +close_authority = false +acceptance_authority = false +execution_authority = false +policy_mutation_authority = false +review_completed = false +``` + +## Non-claims + +v0.5 does not claim that: + +- a missing or diverged source path invalidates a lesson; +- a matching source path proves a lesson is still useful; +- mutable review/check evidence is equivalent to source-code drift; +- queue rank measures truth, importance, confidence, or business value; +- generated Memory Pack confidence is human confidence; +- a pre-filled submission template is a completed review; +- a human semantic verdict grants repository authority. + +The intended boundary is: + +> **Machine evidence decides what must be inspected. The workbench decides only inspection order. Human judgment records meaning. Authority remains a later and separate transition.** diff --git a/docs/integrations/MEMORY_PROPOSAL_SEMANTIC_ACCEPTANCE_V0_4.md b/docs/integrations/MEMORY_PROPOSAL_SEMANTIC_ACCEPTANCE_V0_4.md new file mode 100644 index 00000000..2b10b0e5 --- /dev/null +++ b/docs/integrations/MEMORY_PROPOSAL_SEMANTIC_ACCEPTANCE_V0_4.md @@ -0,0 +1,152 @@ +# Memory Proposal Semantic Acceptance Intake v0.4 + +Status: **experimental / human-review intake only**. + +v0.4 starts where live Queue Revalidation v0.3 intentionally stops. v0.3 can prove pack identity, immutable source bindings, current-main ancestry, mutable evidence drift, canonical applicability, and information-quality state. It deliberately does not manufacture semantic acceptance evidence. + +v0.4 freezes the exact machine evidence a human is being asked to judge and validates a later human verdict against that frozen packet. + +## Lifecycle + +```text +live GitHub facts + -> Queue Revalidation v0.3 + -> Planner v0.2 decision + -> frozen Semantic Review Packet v0.4 + -> human review + -> bound Semantic Review Record v0.4 + -> separate authority check +``` + +The semantic review record is evidence. It is **not** repository authority. + +## Why the packet must be frozen + +A review such as “I accept this memory” is unsafe if it is not bound to the exact state that was reviewed. Between review and use, any of these may change: + +- current `main`; +- the Planner decision; +- the Memory Pack; +- the machine revalidation observation; +- evidence references. + +Therefore every packet binds: + +```text +decision_id ++ proposal_pr ++ source_pr ++ source_merge ++ pack_id ++ current_main_revision ++ observation_digest ++ exact gate evidence refs ++ exact lineage evidence refs +``` + +The resulting `packet_id` is deterministic. + +## Packet states + +Canonical information fitness controls whether semantic review is appropriate: + +```text +REVIEW_REQUIRED + -> PENDING_HUMAN_SEMANTIC_REVIEW + +NOT_FIT + -> BLOCKED_PENDING_NEW_EVIDENCE_OR_CONTEXT + +READY_FOR_AUTHORITY_CHECK + -> SEPARATE_AUTHORITY_REVIEW_ONLY +``` + +A `NOT_FIT` packet cannot use a human semantic verdict as a shortcut around missing or contradictory machine evidence. + +## Human submission contract + +A valid human submission must bind the exact packet and provide: + +```text +packet_id +decision_id +pack_id +observed_main_revision +reviewer_id +reviewed_at +verdict +rationale +reviewed_gate_evidence_refs +optional additional_evidence_refs +``` + +The frozen gate evidence refs must be reviewed exactly. `observed_main_revision` must equal the packet revision. Stale or misbound review submissions fail closed. + +Allowed verdicts: + +```text +ACCEPT +REJECT +DEFER +``` + +Their effects are intentionally bounded: + +```text +ACCEPT + -> SEMANTIC_SUPPORT_RECORDED + -> SEPARATE_ACCEPTANCE_AUTHORITY_CHECK_REQUIRED + +REJECT + -> SEMANTIC_REJECTION_RECORDED + -> SEPARATE_REJECTION_OR_CLOSURE_AUTHORITY_CHECK_REQUIRED + +DEFER + -> SEMANTIC_REVIEW_DEFERRED + -> AWAIT_NEW_EVIDENCE_OR_FURTHER_HUMAN_REVIEW +``` + +## Critical authority boundary + +Even a valid human `ACCEPT` does **not** mean the Memory Pack is accepted. + +Even a valid human `REJECT` does **not** close or delete its proposal. + +Every packet and every review record remains: + +```text +authority_granted = false +merge_authority = false +close_authority = false +acceptance_authority = false +execution_authority = false +policy_mutation_authority = false +state_mutation_performed = false # review record +``` + +Any repository mutation requires a later, separate authority-bearing path. + +## Live v0.4 evidence contract + +The v0.4 workflow does **not** invent human reviews. It must end with all currently review-required live packets pending: + +```text +packet_count == Planner record_count +pending_human_review_count == count(REVIEW_REQUIRED) +completed_human_review_count == 0 +``` + +The artifact contains the underlying v0.3 live evidence plus `semantic-intake.json` and a SHA256 manifest. + +## Non-claims + +- packet generation is not semantic review; +- `ACCEPT` is not merge or acceptance authority; +- `REJECT` is not closure or deletion authority; +- one human reviewer does not establish universal truth; +- historical source integrity does not prove a lesson remains optimal; +- semantic review cannot repair immutable source drift or contradictory evidence. + +## System invariant + +> **Machine evidence constrains what may be reviewed. Human judgment records meaning. Authority remains separate from both.** diff --git a/tests/test_memory_proposal_queue.py b/tests/test_memory_proposal_queue.py new file mode 100644 index 00000000..59131d47 --- /dev/null +++ b/tests/test_memory_proposal_queue.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import copy +import json +import unittest +from pathlib import Path + +from cml.experimental.memory_proposal_queue import ( + QueueAuditError, + _reject_duplicate_json_object_pairs, + audit, +) + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "benchmarks" / "experimental" / "memory-proposal-queue-2026-08-15.json" + + +class MemoryProposalQueueAuditTests(unittest.TestCase): + def payload(self): + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + def test_current_snapshot_reports_queue_level_pressure_without_authority(self): + result = audit(self.payload()) + self.assertEqual(result["schema"], "cml.memory-proposal-queue.audit.v0.1") + self.assertEqual(result["mode"], "REVIEW_ADVISORY_ONLY") + self.assertFalse(result["authority_granted"]) + self.assertFalse(result["merge_authority"]) + self.assertFalse(result["close_authority"]) + self.assertFalse(result["acceptance_authority"]) + self.assertFalse(result["policy_mutation_authority"]) + self.assertEqual(result["queue"]["proposal_count"], 36) + self.assertEqual(result["queue"]["pressure"], "CRITICAL_REVIEW_PRESSURE") + self.assertEqual(result["queue"]["unique_source_pr_count"], 36) + self.assertEqual(result["queue"]["unique_source_merge_count"], 36) + self.assertEqual(result["queue"]["unique_pack_id_count"], 36) + self.assertEqual( + result["next_safe_transition"], + "QUEUE_LEVEL_GROUP_REVALIDATE_THEN_REVIEW", + ) + + def test_partial_age_coverage_is_explicit_and_never_filled_in(self): + result = audit(self.payload()) + self.assertEqual(result["age"]["coverage_count"], 2) + self.assertEqual(result["age"]["coverage_ratio"], 0.055556) + self.assertEqual(result["age"]["distribution_status"], "PARTIAL") + self.assertEqual(result["age"]["oldest_known_created_at"], "2026-07-17T11:20:36+00:00") + self.assertEqual(result["age"]["newest_known_created_at"], "2026-08-14T05:07:04+00:00") + self.assertEqual(result["age"]["oldest_known_age_days"], 29.059) + self.assertEqual(result["age"]["aged_14d_known_count"], 1) + + def test_structural_repetition_is_not_semantic_duplicate_claim(self): + result = audit(self.payload()) + envelope = result["review_envelope"] + self.assertEqual(envelope["unique_envelope_count"], 1) + self.assertEqual(envelope["dominant_envelope_count"], 36) + self.assertEqual(envelope["dominant_envelope_share"], 1.0) + self.assertEqual(envelope["structural_repetition_count"], 35) + self.assertEqual(envelope["semantic_duplicate_status"], "NOT_MEASURED") + self.assertFalse(envelope["semantic_duplicate_claim"]) + self.assertEqual(result["ancestry"]["status"], "NOT_MEASURED") + + def test_snapshot_must_be_complete_relative_to_reported_total(self): + payload = self.payload() + payload["reported_total_count"] = 37 + with self.assertRaisesRegex(QueueAuditError, "snapshot coverage incomplete"): + audit(payload) + + def test_duplicate_pack_identity_fails_closed(self): + payload = self.payload() + payload["proposals"][1]["pack_id"] = payload["proposals"][0]["pack_id"] + with self.assertRaisesRegex(QueueAuditError, "duplicate pack_id"): + audit(payload) + + def test_cross_record_pack_swap_changes_snapshot_digest(self): + payload = self.payload() + baseline = audit(payload)["snapshot_digest"] + swapped = copy.deepcopy(payload) + first = swapped["proposals"][0]["pack_id"] + swapped["proposals"][0]["pack_id"] = swapped["proposals"][1]["pack_id"] + swapped["proposals"][1]["pack_id"] = first + self.assertNotEqual(baseline, audit(swapped)["snapshot_digest"]) + + def test_duplicate_json_object_keys_fail_closed(self): + raw = '{"merge_authority":true,"merge_authority":false}' + with self.assertRaisesRegex(QueueAuditError, "duplicate JSON key"): + json.loads(raw, object_pairs_hook=_reject_duplicate_json_object_pairs) + + def test_authority_escalation_fails_closed(self): + payload = self.payload() + payload["proposals"][0]["merge_authority"] = True + with self.assertRaisesRegex(QueueAuditError, "merge_authority must be false"): + audit(payload) + + def test_future_timestamp_fails_closed(self): + payload = self.payload() + payload["proposals"][0]["created_at"] = "2026-08-16T00:00:00Z" + with self.assertRaisesRegex(QueueAuditError, "cannot be after captured_at"): + audit(payload) + + def test_digest_is_deterministic(self): + first = audit(self.payload())["snapshot_digest"] + second = audit(copy.deepcopy(self.payload()))["snapshot_digest"] + self.assertEqual(first, second) + self.assertTrue(first.startswith("sha256:")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_memory_proposal_queue_planner.py b/tests/test_memory_proposal_queue_planner.py new file mode 100644 index 00000000..93a5c707 --- /dev/null +++ b/tests/test_memory_proposal_queue_planner.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import copy +import json +import unittest +from pathlib import Path + +from cml.experimental.memory_proposal_queue_planner import QueuePlanningError, plan + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ( + ROOT + / "benchmarks" + / "experimental" + / "memory-proposal-queue-planner-v0.2.synthetic.json" +) + + +class MemoryProposalQueuePlannerTests(unittest.TestCase): + def payload(self): + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + def test_one_decision_is_preserved_per_pack_without_authority(self): + result = plan(self.payload()) + self.assertEqual( + result["schema"], + "cml.memory-proposal-queue.revalidation-plan.v0.2", + ) + self.assertEqual(result["mode"], "REVIEW_ADVISORY_ONLY") + self.assertTrue(result["synthetic"]) + self.assertFalse(result["authority_granted"]) + self.assertFalse(result["merge_authority"]) + self.assertFalse(result["close_authority"]) + self.assertFalse(result["acceptance_authority"]) + self.assertFalse(result["execution_authority"]) + self.assertFalse(result["policy_mutation_authority"]) + self.assertEqual(result["record_count"], 6) + self.assertEqual(len(result["decisions"]), 6) + self.assertEqual(len({item["decision_id"] for item in result["decisions"]}), 6) + self.assertEqual(len({item["pack_id"] for item in result["decisions"]}), 6) + self.assertTrue(all(not item["acceptance_authority"] for item in result["decisions"])) + + def test_grouping_is_lineage_and_fitness_scoped_not_semantic_merge(self): + result = plan(self.payload()) + self.assertEqual(result["group_count"], 5) + + groups = { + (item["lineage_root_id"], item["canonical_fitness_status"]): item + for item in result["groups"] + } + ready = groups[("pr-contracts", "READY_FOR_AUTHORITY_CHECK")] + self.assertEqual(ready["group_size"], 2) + self.assertEqual(ready["proposal_prs"], [1001, 1002]) + self.assertEqual(ready["scope"], "REVIEW_ERGONOMICS_ONLY") + self.assertFalse(ready["semantic_merge"]) + self.assertFalse(ready["group_decision_authority"]) + + review = groups[("pr-contracts", "REVIEW_REQUIRED")] + self.assertEqual(review["group_size"], 1) + self.assertEqual(review["proposal_prs"], [1003]) + + def test_canonical_cml_fitness_drives_review_route(self): + result = plan(self.payload()) + routes = {item["proposal_pr"]: item["review_route"] for item in result["decisions"]} + self.assertEqual(routes[1001], "ELIGIBLE_FOR_SEPARATE_ACCEPTANCE_REVIEW") + self.assertEqual(routes[1003], "HUMAN_REVALIDATION_REQUIRED") + self.assertEqual(routes[1004], "HUMAN_REVALIDATION_REQUIRED") + self.assertEqual( + routes[1005], + "BLOCK_ACCEPTANCE_PENDING_NEW_EVIDENCE_OR_CONTEXT", + ) + self.assertEqual( + routes[1006], + "BLOCK_ACCEPTANCE_PENDING_NEW_EVIDENCE_OR_CONTEXT", + ) + self.assertTrue( + all( + not item["canonical_fitness"]["authorizes_action"] + for item in result["decisions"] + ) + ) + + def test_claimed_fitness_must_match_canonical_composition(self): + payload = self.payload() + payload["records"][2]["claimed_fitness_status"] = "READY_FOR_AUTHORITY_CHECK" + with self.assertRaisesRegex( + QueuePlanningError, + "contradicts canonical CML fitness", + ): + plan(payload) + + def test_revalidation_coverage_must_be_complete(self): + payload = self.payload() + payload["expected_record_count"] = 7 + with self.assertRaisesRegex(QueuePlanningError, "revalidation coverage incomplete"): + plan(payload) + + def test_duplicate_pack_identity_fails_closed(self): + payload = self.payload() + payload["records"][1]["pack_id"] = payload["records"][0]["pack_id"] + with self.assertRaisesRegex(QueuePlanningError, "duplicate pack_id"): + plan(payload) + + def test_lineage_group_requires_explicit_evidence(self): + payload = self.payload() + payload["records"][0]["lineage_evidence_refs"] = [] + with self.assertRaisesRegex(QueuePlanningError, "lineage_evidence_refs must not be empty"): + plan(payload) + + def test_plan_digest_is_deterministic(self): + first = plan(self.payload())["plan_digest"] + second = plan(copy.deepcopy(self.payload()))["plan_digest"] + self.assertEqual(first, second) + self.assertTrue(first.startswith("sha256:")) + + def test_plan_digest_binds_synthetic_mode(self): + payload = self.payload() + baseline = plan(payload)["plan_digest"] + changed = copy.deepcopy(payload) + changed["synthetic"] = not changed["synthetic"] + self.assertNotEqual(baseline, plan(changed)["plan_digest"]) + + def test_plan_digest_binds_current_main_revision(self): + payload = self.payload() + baseline = plan(payload)["plan_digest"] + changed = copy.deepcopy(payload) + changed["current_main_revision"] = "f" * 40 + self.assertNotEqual(baseline, plan(changed)["plan_digest"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_memory_proposal_queue_revalidation.py b/tests/test_memory_proposal_queue_revalidation.py new file mode 100644 index 00000000..cd82a5e1 --- /dev/null +++ b/tests/test_memory_proposal_queue_revalidation.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import copy +import unittest + +from cml.experimental.memory_proposal_queue_revalidation import ( + QueueRevalidationError, + build_planner_record, +) + + +PACK = "a" * 64 +REPLAY = "b" * 64 +SOURCE_CORE = "c" * 64 +SOURCE_CORE_DRIFT = "d" * 64 +SOURCE = "1" * 40 +MAIN = "2" * 40 + + +class MemoryProposalQueueRevalidationTests(unittest.TestCase): + def observation(self): + return { + "repository": "safal207/Causal-Memory-Layer", + "proposal_pr": 191, + "source_pr": 190, + "source_merge": SOURCE, + "current_main_revision": MAIN, + "evidence_captured_main_revision": MAIN, + "pack_id": PACK, + "validated_pack_id": PACK, + "replayed_pack_id": REPLAY, + "expected_source_core_digest": SOURCE_CORE, + "observed_source_core_digest": SOURCE_CORE, + "full_pack_replay_match": False, + "changed_evidence_components": ["source-pr", "source-checks"], + "self_observation_completion_drift": False, + "source_exists": True, + "source_ancestor_of_main": True, + "evidence_refs": [ + "https://github.com/safal207/Causal-Memory-Layer/pull/191", + "https://github.com/safal207/Causal-Memory-Layer/pull/190", + ], + } + + def exact_current_observation(self): + observation = self.observation() + observation.update( + { + "current_main_revision": SOURCE, + "evidence_captured_main_revision": SOURCE, + "replayed_pack_id": PACK, + "full_pack_replay_match": True, + "changed_evidence_components": [], + } + ) + return observation + + def assert_no_authority(self, record): + for field in ( + "authority_granted", + "merge_authority", + "close_authority", + "acceptance_authority", + "execution_authority", + "policy_mutation_authority", + ): + self.assertFalse(record[field], field) + + def test_mutable_pr_and_check_drift_do_not_become_source_identity_drift(self): + record = build_planner_record(self.observation()) + self.assertEqual(record["applicability"]["status"], "REVALIDATE") + self.assertIn( + "environment_mismatch:commit_sha", + record["applicability"]["reasons"], + ) + self.assertEqual(record["quality"]["semantic_truth"], "SUPPORTED") + self.assertEqual(record["quality"]["completeness"], "INCOMPLETE") + self.assertEqual(record["quality"]["readiness"], "REVIEW") + self.assertIn( + "completeness_missing:semantic_acceptance", + record["quality"]["reasons"], + ) + self.assertEqual(record["claimed_fitness_status"], "REVIEW_REQUIRED") + self.assertFalse(record["revalidation"]["full_pack_replay_match"]) + self.assertTrue(record["revalidation"]["stable_source_core_match"]) + self.assertEqual(record["revalidation"]["current_main_revision"], MAIN) + self.assertEqual(record["revalidation"]["evidence_captured_main_revision"], MAIN) + self.assertEqual( + record["revalidation"]["descriptive_metadata_changed_components"], + ["source-pr"], + ) + self.assertEqual( + record["revalidation"]["operational_evidence_changed_components"], + ["source-checks"], + ) + self.assertEqual( + record["revalidation"]["mutable_evidence_changed_components"], + ["source-pr", "source-checks"], + ) + self.assertEqual( + record["revalidation"]["immutable_evidence_changed_components"], + [], + ) + self.assert_no_authority(record) + + def test_stale_evidence_state_token_requires_review(self): + fresh_observation = self.exact_current_observation() + fresh = build_planner_record(fresh_observation) + self.assertFalse( + any( + reason.startswith("evidence_binding_state_token_mismatch:") + for reason in fresh["quality"]["reasons"] + ) + ) + self.assert_no_authority(fresh) + + stale_observation = copy.deepcopy(fresh_observation) + stale_observation["evidence_captured_main_revision"] = MAIN + stale = build_planner_record(stale_observation) + self.assertEqual(stale["quality"]["readiness"], "REVIEW") + self.assertTrue( + any( + reason.startswith("evidence_binding_state_token_mismatch:") + for reason in stale["quality"]["reasons"] + ) + ) + self.assertEqual(stale["claimed_fitness_status"], "REVIEW_REQUIRED") + self.assertEqual(stale["revalidation"]["evidence_captured_main_revision"], MAIN) + self.assertEqual(stale["revalidation"]["current_main_revision"], SOURCE) + self.assert_no_authority(stale) + + def test_stable_source_core_drift_is_canonical_drift_and_not_fit(self): + observation = self.observation() + observation["observed_source_core_digest"] = SOURCE_CORE_DRIFT + observation["changed_evidence_components"] = ["source-files", "source-checks"] + record = build_planner_record(observation) + self.assertEqual(record["applicability"]["status"], "DRIFT") + self.assertIn("source_digest_mismatch", record["applicability"]["reasons"]) + self.assertEqual(record["claimed_fitness_status"], "NOT_FIT") + self.assertFalse(record["revalidation"]["stable_source_core_match"]) + self.assert_no_authority(record) + + def test_non_ancestor_source_requires_revalidation_not_delete_authority(self): + observation = self.observation() + observation["source_ancestor_of_main"] = False + record = build_planner_record(observation) + self.assertEqual(record["applicability"]["status"], "REVALIDATE") + self.assertTrue( + any( + reason.startswith("lineage_invalidated:source-merge:") + for reason in record["applicability"]["reasons"] + ) + ) + self.assertEqual(record["claimed_fitness_status"], "REVIEW_REQUIRED") + self.assert_no_authority(record) + + def test_even_exact_current_source_cannot_skip_semantic_acceptance_review(self): + record = build_planner_record(self.exact_current_observation()) + self.assertEqual(record["applicability"]["status"], "MATCH") + self.assertEqual(record["quality"]["readiness"], "REVIEW") + self.assertEqual(record["claimed_fitness_status"], "REVIEW_REQUIRED") + self.assertFalse( + any( + reason.startswith("evidence_binding_state_token_mismatch:") + for reason in record["quality"]["reasons"] + ) + ) + self.assert_no_authority(record) + + def test_proposal_pack_identity_mismatch_fails_closed(self): + observation = self.exact_current_observation() + observation["validated_pack_id"] = REPLAY + with self.assertRaisesRegex( + QueueRevalidationError, + "identity contradicts proposal pack_id", + ): + build_planner_record(observation) + + def test_immutable_evidence_change_cannot_claim_stable_core_match(self): + observation = self.observation() + observation["changed_evidence_components"] = ["source-files"] + with self.assertRaisesRegex( + QueueRevalidationError, + "stable source core cannot match", + ): + build_planner_record(observation) + + def test_unknown_evidence_component_fails_closed(self): + observation = self.observation() + observation["changed_evidence_components"] = ["source-unknown"] + with self.assertRaisesRegex(QueueRevalidationError, "unknown evidence components"): + build_planner_record(observation) + + def test_duplicate_evidence_component_fails_closed(self): + observation = self.observation() + observation["changed_evidence_components"] = [ + "source-checks", + "source-checks", + ] + with self.assertRaisesRegex(QueueRevalidationError, "entries must be unique"): + build_planner_record(observation) + + def test_observation_digest_is_canonical_and_binds_captured_state(self): + observation = self.observation() + reordered = dict(reversed(list(observation.items()))) + first = build_planner_record(observation) + second = build_planner_record(reordered) + self.assertEqual( + first["revalidation"]["observation_digest"], + second["revalidation"]["observation_digest"], + ) + self.assertRegex( + first["revalidation"]["observation_digest"], + r"^sha256:[0-9a-f]{64}$", + ) + + changed = copy.deepcopy(observation) + changed["evidence_captured_main_revision"] = SOURCE + third = build_planner_record(changed) + self.assertNotEqual( + first["revalidation"]["observation_digest"], + third["revalidation"]["observation_digest"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_memory_proposal_review_workbench.py b/tests/test_memory_proposal_review_workbench.py new file mode 100644 index 00000000..7184c442 --- /dev/null +++ b/tests/test_memory_proposal_review_workbench.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import copy +import unittest + +from cml.experimental.memory_proposal_review_workbench import ( + CONTEXT_SCHEMA, + PATH_DIVERGED, + PATH_MISSING, + PATH_SAME, + ReviewWorkbenchError, + build_review_workbench, + render_markdown, +) +from cml.experimental.memory_proposal_semantic_acceptance import ( + INTAKE_SCHEMA, + _digest as semantic_digest, + _packet_identity as semantic_packet_identity, +) + + +MAIN = "1" * 40 +SOURCE_PLAN_DIGEST = "sha256:" + "9" * 64 +SOURCE_AUDIT_DIGEST = "sha256:" + "8" * 64 +CAPTURED_AT = "2026-08-15T14:22:27+00:00" + + +def authority_false(): + return { + "authority_granted": False, + "merge_authority": False, + "close_authority": False, + "acceptance_authority": False, + "execution_authority": False, + "policy_mutation_authority": False, + } + + +class MemoryProposalReviewWorkbenchTests(unittest.TestCase): + def packet(self, proposal_pr: int, changed=None): + changed = ["source-checks"] if changed is None else changed + packet = { + "status": "PENDING_HUMAN_SEMANTIC_REVIEW", + "human_review_required": True, + "decision_id": f"sha256:{proposal_pr + 100:064x}", + "proposal_pr": proposal_pr, + "source_pr": proposal_pr - 1, + "source_merge": f"{proposal_pr + 300:040x}", + "pack_id": f"{proposal_pr + 200:064x}", + "current_main_revision": MAIN, + "observation_digest": f"sha256:{proposal_pr + 400:064x}", + "machine_gate": { + "applicability_status": "REVALIDATE", + "quality_readiness": "REVIEW", + "canonical_fitness_status": "REVIEW_REQUIRED", + "review_route": "HUMAN_REVALIDATION_REQUIRED", + "stable_source_core_match": True, + "source_ancestor_of_main": True, + "changed_evidence_components": changed, + }, + "gate_evidence_refs": [f"https://example.test/gate/{proposal_pr}"], + "lineage_evidence_refs": [f"https://example.test/lineage/{proposal_pr}"], + "allowed_human_verdicts": ["ACCEPT", "REJECT", "DEFER"], + **authority_false(), + } + packet["packet_id"] = semantic_digest(semantic_packet_identity(packet)) + return packet + + def blocked_packet(self, proposal_pr: int): + packet = self.packet(proposal_pr) + packet["status"] = "BLOCKED_PENDING_NEW_EVIDENCE_OR_CONTEXT" + packet["human_review_required"] = False + packet["machine_gate"]["canonical_fitness_status"] = "NOT_FIT" + packet["machine_gate"]["review_route"] = ( + "BLOCK_ACCEPTANCE_PENDING_NEW_EVIDENCE_OR_CONTEXT" + ) + packet["packet_id"] = semantic_digest(semantic_packet_identity(packet)) + return packet + + def intake(self, packets): + packet_ids = sorted(packet["packet_id"] for packet in packets) + pending_count = sum(packet.get("human_review_required") is True for packet in packets) + blocked_count = sum( + packet.get("status") == "BLOCKED_PENDING_NEW_EVIDENCE_OR_CONTEXT" + for packet in packets + ) + authority_only_count = sum( + packet.get("status") == "SEPARATE_AUTHORITY_REVIEW_ONLY" + for packet in packets + ) + intake = { + "schema": INTAKE_SCHEMA, + "mode": "HUMAN_SEMANTIC_REVIEW_INTAKE_ONLY", + "source_plan_digest": SOURCE_PLAN_DIGEST, + "source_audit_digest": SOURCE_AUDIT_DIGEST, + "current_main_revision": MAIN, + "captured_at": CAPTURED_AT, + "packet_count": len(packets), + "pending_human_review_count": pending_count, + "blocked_pending_evidence_count": blocked_count, + "separate_authority_review_only_count": authority_only_count, + "completed_human_review_count": 0, + "packets": packets, + **authority_false(), + } + intake["intake_digest"] = semantic_digest( + { + "source_plan_digest": SOURCE_PLAN_DIGEST, + "source_audit_digest": SOURCE_AUDIT_DIGEST, + "current_main_revision": MAIN, + "captured_at": CAPTURED_AT, + "packet_ids": packet_ids, + } + ) + return intake + + def context( + self, + packet, + *, + path_status=PATH_SAME, + confidence=75, + created_at="2026-07-17T11:20:21Z", + extra_path=False, + ): + source_blob = "b" * 40 + if path_status == PATH_SAME: + current_blob = source_blob + elif path_status == PATH_DIVERGED: + current_blob = "c" * 40 + else: + current_blob = None + states = [ + { + "path": f"docs/pr-{packet['source_pr']}.md", + "status": path_status, + "source_blob_sha": source_blob, + "current_blob_sha": current_blob, + } + ] + if extra_path: + states.append( + { + "path": f"src/pr-{packet['source_pr']}.py", + "status": PATH_SAME, + "source_blob_sha": "d" * 40, + "current_blob_sha": "d" * 40, + } + ) + return { + "packet_id": packet["packet_id"], + "decision_id": packet["decision_id"], + "proposal_pr": packet["proposal_pr"], + "source_pr": packet["source_pr"], + "pack_id": packet["pack_id"], + "current_main_revision": MAIN, + "source_title": f"Source PR {packet['source_pr']}", + "situation_label": "Observed repository situation", + "action_label": "Apply the merged approach", + "lesson_label": "Reuse only when recorded constraints still apply", + "lesson_confidence": confidence, + "pack_created_at": created_at, + "path_states": states, + "context_evidence_refs": [f"https://example.test/context/{packet['proposal_pr']}"], + } + + def contexts(self, contexts, intake): + return { + "schema": CONTEXT_SCHEMA, + "source_intake_digest": intake["intake_digest"], + "current_main_revision": MAIN, + "context_count": len(contexts), + "contexts": contexts, + } + + def build(self, packets, contexts): + intake = self.intake(packets) + return build_review_workbench(intake, self.contexts(contexts, intake)) + + def test_preserves_one_card_per_packet_and_no_authority(self): + packets = [self.packet(191), self.packet(194)] + result = self.build(packets, [self.context(item) for item in packets]) + self.assertEqual(result["card_count"], 2) + self.assertEqual(result["pending_review_count"], 2) + self.assertEqual(result["completed_review_count"], 0) + self.assertEqual(len({card["packet_id"] for card in result["cards"]}), 2) + self.assertFalse(result["authority_granted"]) + for card in result["cards"]: + self.assertFalse(card["acceptance_authority"]) + self.assertFalse(card["merge_authority"]) + self.assertFalse(card["review_completed"]) + + def test_missing_scope_is_reviewed_before_diverged_scope(self): + missing_packet = self.packet(191) + diverged_packet = self.packet(194) + result = self.build( + [diverged_packet, missing_packet], + [ + self.context(diverged_packet, path_status=PATH_DIVERGED), + self.context(missing_packet, path_status=PATH_MISSING), + ], + ) + self.assertEqual(result["cards"][0]["proposal_pr"], 191) + self.assertEqual(result["cards"][0]["priority_class"], "P0_SOURCE_SCOPE_MISSING") + self.assertEqual(result["cards"][1]["priority_class"], "P1_SOURCE_SCOPE_DIVERGED") + + def test_review_context_drift_precedes_operational_refresh(self): + review_packet = self.packet(191, ["source-reviews", "source-checks"]) + checks_packet = self.packet(194, ["source-checks"]) + result = self.build( + [checks_packet, review_packet], + [self.context(checks_packet), self.context(review_packet)], + ) + self.assertEqual(result["cards"][0]["proposal_pr"], 191) + self.assertEqual(result["cards"][0]["priority_class"], "P2_REVIEW_CONTEXT_DRIFT") + self.assertEqual(result["cards"][1]["priority_class"], "P3_OPERATIONAL_EVIDENCE_REFRESH") + + def test_lower_lesson_confidence_breaks_ties_first(self): + low = self.packet(191) + high = self.packet(194) + result = self.build( + [high, low], + [self.context(high, confidence=90), self.context(low, confidence=60)], + ) + self.assertEqual(result["cards"][0]["proposal_pr"], 191) + + def test_broader_scope_breaks_remaining_ties_first(self): + narrow = self.packet(191) + broad = self.packet(194) + result = self.build( + [narrow, broad], + [ + self.context(narrow, confidence=75), + self.context(broad, confidence=75, extra_path=True), + ], + ) + self.assertEqual(result["cards"][0]["proposal_pr"], 194) + + def test_input_order_does_not_change_workbench(self): + first = self.packet(191) + second = self.packet(194) + intake_a = self.intake([first, second]) + contexts_a = self.contexts([self.context(first), self.context(second)], intake_a) + intake_b = self.intake([second, first]) + contexts_b = self.contexts([self.context(second), self.context(first)], intake_b) + self.assertEqual( + build_review_workbench(intake_a, contexts_a), + build_review_workbench(intake_b, contexts_b), + ) + + def test_submission_template_is_bound_but_has_no_fabricated_human_verdict(self): + packet = self.packet(191) + result = self.build([packet], [self.context(packet)]) + card = result["cards"][0] + self.assertTrue(card["human_review_required"]) + template = card["submission_template"] + self.assertEqual(template["packet_id"], packet["packet_id"]) + self.assertEqual(template["decision_id"], packet["decision_id"]) + self.assertEqual(template["observed_main_revision"], MAIN) + self.assertIsNone(template["reviewer_id"]) + self.assertIsNone(template["reviewed_at"]) + self.assertIsNone(template["verdict"]) + self.assertIsNone(template["rationale"]) + + def test_blocked_packet_has_no_submission_template_or_pending_review(self): + packet = self.blocked_packet(191) + result = self.build([packet], [self.context(packet)]) + card = result["cards"][0] + self.assertEqual(result["card_count"], 1) + self.assertEqual(result["pending_review_count"], 0) + self.assertFalse(card["human_review_required"]) + self.assertIsNone(card["submission_template"]) + rendered = render_markdown(result) + self.assertIn("Human semantic review required:** no", rendered) + self.assertIn("no semantic-review submission is permitted", rendered) + + def test_stale_context_main_fails_closed(self): + packet = self.packet(191) + intake = self.intake([packet]) + payload = self.contexts([self.context(packet)], intake) + payload["current_main_revision"] = "2" * 40 + with self.assertRaisesRegex(ReviewWorkbenchError, "stale"): + build_review_workbench(intake, payload) + + def test_context_source_pr_mismatch_fails_closed(self): + packet = self.packet(191) + intake = self.intake([packet]) + context = self.context(packet) + context["source_pr"] = packet["source_pr"] + 100 + with self.assertRaisesRegex(ReviewWorkbenchError, "context source PR mismatch"): + build_review_workbench(intake, self.contexts([context], intake)) + + def test_context_pack_mismatch_fails_closed(self): + packet = self.packet(191) + intake = self.intake([packet]) + context = self.context(packet) + context["pack_id"] = "f" * 64 + with self.assertRaisesRegex(ReviewWorkbenchError, "context pack mismatch"): + build_review_workbench(intake, self.contexts([context], intake)) + + def test_same_path_cannot_hide_blob_divergence(self): + packet = self.packet(191) + intake = self.intake([packet]) + context = self.context(packet) + context["path_states"][0]["current_blob_sha"] = "e" * 40 + with self.assertRaisesRegex(ReviewWorkbenchError, "SAME path"): + build_review_workbench(intake, self.contexts([context], intake)) + + def test_workbench_digest_binds_rendered_context_and_evidence(self): + packet = self.packet(191) + baseline_context = self.context(packet) + changed_context = copy.deepcopy(baseline_context) + changed_context["source_title"] = "Different source title" + changed_context["context_evidence_refs"] = ["https://example.test/context/rebound"] + baseline = self.build([packet], [baseline_context]) + changed = self.build([packet], [changed_context]) + self.assertNotEqual(baseline["workbench_digest"], changed["workbench_digest"]) + + def test_tampered_intake_is_rejected_before_workbench_build(self): + packet = self.packet(191) + intake = self.intake([packet]) + contexts = self.contexts([self.context(packet)], intake) + intake["packets"][0]["machine_gate"]["review_route"] = "FORGED_ROUTE" + with self.assertRaisesRegex(ReviewWorkbenchError, "frozen semantic intake is invalid"): + build_review_workbench(intake, contexts) + + def test_markdown_is_review_guidance_not_a_verdict(self): + packet = self.packet(191) + result = self.build([packet], [self.context(packet)]) + rendered = render_markdown(result) + self.assertIn("Queue rank orders review attention only", rendered) + self.assertIn("Reuse only when recorded constraints still apply", rendered) + self.assertNotIn("verdict=ACCEPT", rendered) + self.assertNotIn("accepted Memory Pack", rendered) + + def test_markdown_escapes_forged_card_content(self): + packet = self.packet(191) + context = self.context(packet) + context["source_title"] = "Fix parser\n\n## forged\n**Verdict:** ACCEPT" + result = self.build([packet], [context]) + rendered = render_markdown(result) + headings = [line for line in rendered.splitlines() if line.startswith("## ")] + self.assertEqual(len(headings), 1) + self.assertNotIn("\n## forged", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_memory_proposal_semantic_acceptance.py b/tests/test_memory_proposal_semantic_acceptance.py new file mode 100644 index 00000000..cb8df418 --- /dev/null +++ b/tests/test_memory_proposal_semantic_acceptance.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import copy +import unittest + +from cml.experimental.memory_proposal_queue_planner import plan +from cml.experimental.memory_proposal_queue_revalidation import build_planner_record +from cml.experimental.memory_proposal_semantic_acceptance import ( + SemanticAcceptanceError, + build_semantic_acceptance_intake, + validate_human_submission, + verify_semantic_acceptance_intake, +) + + +PACK = "a" * 64 +REPLAY = "b" * 64 +SOURCE_CORE = "c" * 64 +SOURCE_CORE_DRIFT = "d" * 64 +SOURCE = "1" * 40 +MAIN = "2" * 40 +AUDIT = "sha256:" + "e" * 64 + + +class MemoryProposalSemanticAcceptanceTests(unittest.TestCase): + def observation(self): + return { + "repository": "safal207/Causal-Memory-Layer", + "proposal_pr": 191, + "source_pr": 190, + "source_merge": SOURCE, + "current_main_revision": MAIN, + "pack_id": PACK, + "validated_pack_id": PACK, + "replayed_pack_id": REPLAY, + "expected_source_core_digest": SOURCE_CORE, + "observed_source_core_digest": SOURCE_CORE, + "full_pack_replay_match": False, + "changed_evidence_components": ["source-pr", "source-checks"], + "self_observation_completion_drift": False, + "source_exists": True, + "source_ancestor_of_main": True, + "evidence_refs": [ + "https://github.com/safal207/Causal-Memory-Layer/pull/191", + "https://github.com/safal207/Causal-Memory-Layer/pull/190", + ], + } + + def second_observation(self): + observation = self.observation() + observation.update( + { + "proposal_pr": 194, + "source_pr": 193, + "source_merge": "3" * 40, + "pack_id": "4" * 64, + "validated_pack_id": "4" * 64, + "replayed_pack_id": "5" * 64, + "expected_source_core_digest": "6" * 64, + "observed_source_core_digest": "6" * 64, + "evidence_refs": [ + "https://github.com/safal207/Causal-Memory-Layer/pull/194", + "https://github.com/safal207/Causal-Memory-Layer/pull/193", + ], + } + ) + return observation + + def planner_pair(self, observation=None): + observation = observation or self.observation() + record = build_planner_record(observation) + planner_input = { + "schema": "cml.memory-proposal-queue.revalidation-input.v0.2", + "source_audit_schema": "cml.memory-proposal-queue.audit.v0.1", + "source_audit_digest": AUDIT, + "current_main_revision": observation["current_main_revision"], + "captured_at": "2026-08-15T14:22:27Z", + "synthetic": True, + "expected_record_count": 1, + "records": [record], + } + return planner_input, plan(planner_input) + + def two_record_planner_pair(self): + records = [ + build_planner_record(self.observation()), + build_planner_record(self.second_observation()), + ] + planner_input = { + "schema": "cml.memory-proposal-queue.revalidation-input.v0.2", + "source_audit_schema": "cml.memory-proposal-queue.audit.v0.1", + "source_audit_digest": AUDIT, + "current_main_revision": MAIN, + "captured_at": "2026-08-15T14:22:27Z", + "synthetic": True, + "expected_record_count": 2, + "records": records, + } + return planner_input, plan(planner_input) + + def intake(self): + planner_input, planner_result = self.planner_pair() + return build_semantic_acceptance_intake(planner_input, planner_result) + + def submission(self, intake=None, verdict="ACCEPT"): + intake = intake or self.intake() + packet = intake["packets"][0] + return { + "schema": "cml.memory-proposal-queue.semantic-review-submission.v0.4", + "packet_id": packet["packet_id"], + "decision_id": packet["decision_id"], + "pack_id": packet["pack_id"], + "observed_main_revision": packet["current_main_revision"], + "reviewer_id": "human:maintainer-1", + "reviewed_at": "2026-08-15T15:00:00Z", + "verdict": verdict, + "rationale": "Reviewed the frozen evidence packet against current main.", + "reviewed_gate_evidence_refs": list(packet["gate_evidence_refs"]), + "additional_evidence_refs": ["review-note:manual-001"], + } + + def test_builds_one_pending_packet_per_review_required_decision(self): + intake = self.intake() + self.assertEqual(intake["packet_count"], 1) + self.assertEqual(intake["pending_human_review_count"], 1) + self.assertEqual(intake["completed_human_review_count"], 0) + packet = intake["packets"][0] + self.assertEqual(packet["status"], "PENDING_HUMAN_SEMANTIC_REVIEW") + self.assertTrue(packet["human_review_required"]) + self.assertEqual(packet["allowed_human_verdicts"], ["ACCEPT", "REJECT", "DEFER"]) + self.assertFalse(packet["authority_granted"]) + self.assertFalse(packet["acceptance_authority"]) + self.assertEqual(verify_semantic_acceptance_intake(intake), intake["intake_digest"]) + + def test_intake_is_deterministic_for_same_machine_evidence(self): + planner_input, planner_result = self.planner_pair() + first = build_semantic_acceptance_intake(planner_input, planner_result) + second = build_semantic_acceptance_intake( + copy.deepcopy(planner_input), copy.deepcopy(planner_result) + ) + self.assertEqual(first, second) + self.assertTrue(first["intake_digest"].startswith("sha256:")) + + def test_duplicate_planner_decision_is_rejected_as_detached_result(self): + planner_input, planner_result = self.two_record_planner_pair() + planner_result["decisions"][1] = copy.deepcopy(planner_result["decisions"][0]) + with self.assertRaisesRegex(SemanticAcceptanceError, "canonical recomputation"): + build_semantic_acceptance_intake(planner_input, planner_result) + + def test_altered_planner_fitness_with_original_plan_digest_fails_closed(self): + planner_input, planner_result = self.planner_pair() + original_digest = planner_result["plan_digest"] + planner_result["decisions"][0]["canonical_fitness"]["status"] = "NOT_FIT" + self.assertEqual(planner_result["plan_digest"], original_digest) + with self.assertRaisesRegex(SemanticAcceptanceError, "canonical recomputation"): + build_semantic_acceptance_intake(planner_input, planner_result) + + def test_tampered_frozen_packet_with_old_digests_fails_closed(self): + intake = self.intake() + submission = self.submission(intake) + intake["packets"][0]["machine_gate"]["review_route"] = "FORGED_ROUTE" + with self.assertRaisesRegex(SemanticAcceptanceError, "packet identity digest"): + verify_semantic_acceptance_intake(intake) + with self.assertRaisesRegex(SemanticAcceptanceError, "packet identity digest"): + validate_human_submission(intake, submission) + + def test_accept_records_support_but_grants_no_acceptance_authority(self): + intake = self.intake() + record = validate_human_submission(intake, self.submission(intake, "ACCEPT")) + self.assertEqual(record["semantic_effect"], "SEMANTIC_SUPPORT_RECORDED") + self.assertEqual( + record["next_route"], "SEPARATE_ACCEPTANCE_AUTHORITY_CHECK_REQUIRED" + ) + self.assertFalse(record["state_mutation_performed"]) + self.assertFalse(record["authority_granted"]) + self.assertFalse(record["acceptance_authority"]) + self.assertFalse(record["merge_authority"]) + + def test_reject_records_rejection_but_cannot_close_proposal(self): + intake = self.intake() + record = validate_human_submission(intake, self.submission(intake, "REJECT")) + self.assertEqual(record["semantic_effect"], "SEMANTIC_REJECTION_RECORDED") + self.assertEqual( + record["next_route"], + "SEPARATE_REJECTION_OR_CLOSURE_AUTHORITY_CHECK_REQUIRED", + ) + self.assertFalse(record["close_authority"]) + + def test_defer_records_no_semantic_conclusion(self): + intake = self.intake() + record = validate_human_submission(intake, self.submission(intake, "DEFER")) + self.assertEqual(record["semantic_effect"], "SEMANTIC_REVIEW_DEFERRED") + self.assertEqual( + record["next_route"], "AWAIT_NEW_EVIDENCE_OR_FURTHER_HUMAN_REVIEW" + ) + + def test_stale_current_main_binding_fails_closed(self): + intake = self.intake() + submission = self.submission(intake) + submission["observed_main_revision"] = "3" * 40 + with self.assertRaisesRegex(SemanticAcceptanceError, "current main is stale"): + validate_human_submission(intake, submission) + + def test_reviewed_gate_refs_must_exactly_match_frozen_packet(self): + intake = self.intake() + submission = self.submission(intake) + submission["reviewed_gate_evidence_refs"] = ["different:evidence"] + with self.assertRaisesRegex(SemanticAcceptanceError, "must exactly match"): + validate_human_submission(intake, submission) + + def test_decision_and_pack_bindings_fail_closed(self): + intake = self.intake() + submission = self.submission(intake) + submission["decision_id"] = "sha256:" + "f" * 64 + with self.assertRaisesRegex(SemanticAcceptanceError, "decision_id does not match"): + validate_human_submission(intake, submission) + + submission = self.submission(intake) + submission["pack_id"] = "f" * 64 + with self.assertRaisesRegex(SemanticAcceptanceError, "pack_id does not match"): + validate_human_submission(intake, submission) + + def test_not_fit_packet_cannot_be_used_as_semantic_acceptance_shortcut(self): + observation = self.observation() + observation["observed_source_core_digest"] = SOURCE_CORE_DRIFT + observation["changed_evidence_components"] = ["source-files"] + planner_input, planner_result = self.planner_pair(observation) + intake = build_semantic_acceptance_intake(planner_input, planner_result) + packet = intake["packets"][0] + self.assertEqual(packet["status"], "BLOCKED_PENDING_NEW_EVIDENCE_OR_CONTEXT") + self.assertFalse(packet["human_review_required"]) + submission = self.submission(intake) + with self.assertRaisesRegex(SemanticAcceptanceError, "not eligible"): + validate_human_submission(intake, submission) + + def test_authority_escalation_in_planner_result_fails_closed(self): + planner_input, planner_result = self.planner_pair() + planner_result["acceptance_authority"] = True + with self.assertRaisesRegex(SemanticAcceptanceError, "acceptance_authority must be false"): + build_semantic_acceptance_intake(planner_input, planner_result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_memory_queue_collectors.py b/tests/test_memory_queue_collectors.py new file mode 100644 index 00000000..a454723d --- /dev/null +++ b/tests/test_memory_queue_collectors.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import unittest +from unittest import mock +from urllib.error import URLError + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_DIR = ROOT / ".github" / "trust-root" / "scripts" +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + + +def _load_script(name: str, filename: str): + spec = importlib.util.spec_from_file_location(name, SCRIPT_DIR / filename) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load {filename}") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +queue_collector = _load_script( + "test_memory_queue_revalidation_collect", + "memory_queue_revalidation_collect.py", +) +workbench_collector = _load_script( + "test_memory_review_workbench_collect", + "memory_review_workbench_collect.py", +) + + +class MemoryQueueCollectorHardeningTests(unittest.TestCase): + def test_queue_repository_validation_rejects_ambiguous_root_before_network(self): + with mock.patch.object(queue_collector, "GitHubReader") as reader: + with self.assertRaisesRegex(queue_collector.CollectionError, "owner/name"): + queue_collector.collect("owner/name/../../repos/other/name", "token") + reader.assert_not_called() + + def test_queue_repository_validation_rejects_query_and_fragment(self): + for value in ("owner/name?ref=other", "owner/name#other", "owner/name/extra"): + with self.subTest(value=value): + with self.assertRaisesRegex(queue_collector.CollectionError, "owner/name"): + queue_collector._repository(value) + + def test_queue_transport_errors_become_structured_collection_errors(self): + reader = queue_collector.GitHubReader("token") + with mock.patch.object( + queue_collector, + "urlopen", + side_effect=URLError("dns unavailable"), + ): + with self.assertRaisesRegex(queue_collector.CollectionError, "transport failure"): + reader.get("/repos/owner/name") + + def test_queue_reader_rejects_non_github_absolute_urls(self): + reader = queue_collector.GitHubReader("token") + with self.assertRaisesRegex(queue_collector.CollectionError, "api.github.com"): + reader.get("https://example.test/redirected") + + def test_workbench_repository_validation_rejects_ambiguous_root(self): + with self.assertRaisesRegex(workbench_collector.WorkbenchCollectionError, "owner/name"): + workbench_collector._repository("owner/name/../../repos/other/name") + + def test_workbench_current_main_requires_exact_commit_sha(self): + with self.assertRaisesRegex( + workbench_collector.WorkbenchCollectionError, + "40-char lowercase hex SHA", + ): + workbench_collector._sha40("main", "intake.current_main_revision") + + def test_workbench_context_coverage_rejects_duplicate_decision_identity(self): + first = { + "packet_id": "packet-1", + "decision_id": "decision-shared", + "pack_id": "pack-1", + "proposal_pr": 191, + "source_pr": 190, + } + second = { + "packet_id": "packet-2", + "decision_id": "decision-shared", + "pack_id": "pack-2", + "proposal_pr": 194, + "source_pr": 193, + } + with self.assertRaisesRegex( + workbench_collector.WorkbenchCollectionError, + "duplicate decision_id", + ): + workbench_collector._validate_context_coverage([first, second], 2) + + def test_workbench_transport_errors_become_structured_collection_errors(self): + reader = workbench_collector.GitHubReader("token") + with mock.patch.object( + workbench_collector, + "urlopen", + side_effect=TimeoutError("timed out"), + ): + with self.assertRaisesRegex( + workbench_collector.WorkbenchCollectionError, + "transport failure", + ): + reader.get("/repos/owner/name") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_memory_queue_dependency_contract.py b/tests/test_memory_queue_dependency_contract.py new file mode 100644 index 00000000..3c478edd --- /dev/null +++ b/tests/test_memory_queue_dependency_contract.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import tempfile +import unittest + +import yaml +from unittest.mock import patch + + +SCRIPT = ( + Path(__file__).resolve().parents[1] + / ".github/trust-root/scripts/verify_memory_queue_dependency_contract.py" +) +SPEC = importlib.util.spec_from_file_location("memory_queue_dependency_contract", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +CONTRACT = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CONTRACT) + + +class MemoryQueueDependencyContractTests(unittest.TestCase): + def _write_workflow(self, root: Path, content: str) -> Path: + path = Path(".github/workflows/test-memory-queue.yml") + target = root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return path + + def _workflow_with_install_commands( + self, commands: list[str], *, extra_steps: list[str] | None = None + ) -> str: + return ( + "\n".join( + [ + "jobs:", + " test:", + " steps:", + " - name: Run unrelated tests", + " run: |", + " python -m pytest \\", + " tests/test_memory_proposal_queue.py \\", + " tests/test_memory_proposal_queue_revalidation.py", + f" - name: {CONTRACT.INSTALL_STEP_NAME}", + " run: |", + *[f" {command}" for command in commands], + *(extra_steps or []), + ] + ) + + "\n" + ) + + def test_exact_protected_requirement_commands_are_accepted(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + content = self._workflow_with_install_commands( + list(CONTRACT.CANONICAL_INSTALL_COMMANDS), + extra_steps=[ + " - name: Verify authority boundary", + " run: |", + " python - <<'PY'", + " import json", + ' assert json.loads(\'{"authority_granted": false}\')["authority_granted"] is False', + " PY", + ], + ) + parsed = yaml.safe_load(content) + heredoc_script = parsed["jobs"]["test"]["steps"][-1]["run"] + self.assertEqual(heredoc_script.splitlines()[-1], "PY") + workflow = self._write_workflow(root, content) + with patch.object(CONTRACT, "ROOT", root): + CONTRACT._require_hash_enforced_workflow(workflow) + + def test_commented_expected_commands_and_untrusted_suffix_fail_closed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + workflow = self._write_workflow( + root, + self._workflow_with_install_commands( + [ + f"# {CONTRACT.CANONICAL_INSTALL_COMMANDS[0]}", + f"# {CONTRACT.CANONICAL_INSTALL_COMMANDS[1]}", + "python3 -m pip install --require-hashes --only-binary=:all: --requirement .github/trust-root/memory_queue_pip_bootstrap.txt.untrusted", + ] + ), + ) + with patch.object(CONTRACT, "ROOT", root): + with self.assertRaisesRegex( + CONTRACT.DependencyContractError, + "commands must exactly match the protected install contract", + ): + CONTRACT._require_hash_enforced_workflow(workflow) + + def test_dynamic_installer_after_canonical_commands_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + workflow = self._write_workflow( + root, + self._workflow_with_install_commands( + [ + *CONTRACT.CANONICAL_INSTALL_COMMANDS, + "INSTALLER=python", + "PIP_MODULE=pip", + '"$INSTALLER" -m "$PIP_MODULE" install --require-hashes --only-binary=:all: --requirement .github/trust-root/memory_queue_ci_requirements.txt', + ] + ), + ) + with patch.object(CONTRACT, "ROOT", root): + with self.assertRaisesRegex( + CONTRACT.DependencyContractError, + "commands must exactly match the protected install contract", + ): + CONTRACT._require_hash_enforced_workflow(workflow) + + def test_dynamic_installer_in_separate_step_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + workflow = self._write_workflow( + root, + self._workflow_with_install_commands( + list(CONTRACT.CANONICAL_INSTALL_COMMANDS), + extra_steps=[ + " - name: Hidden installer", + " run: |", + " PYTHON=python", + " PIP_MODULE=pip", + " SUBCOMMAND=install", + ' "$PYTHON" -m "$PIP_MODULE" "$SUBCOMMAND" --no-require-hashes -r attacker.txt', + ], + ), + ) + with patch.object(CONTRACT, "ROOT", root): + with self.assertRaisesRegex( + CONTRACT.DependencyContractError, + "must not construct commands through shell assignments", + ): + CONTRACT._require_hash_enforced_workflow(workflow) + + def test_multiline_plain_run_scalar_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + workflow = self._write_workflow( + root, + self._workflow_with_install_commands( + list(CONTRACT.CANONICAL_INSTALL_COMMANDS), + extra_steps=[ + " - name: Continued plain scalar", + " run: python .github/trust-root/scripts/memory_queue_revalidation_collect.py", + " ; python -m pip install --no-require-hashes -r attacker.txt", + ], + ), + ) + with patch.object(CONTRACT, "ROOT", root): + with self.assertRaisesRegex( + CONTRACT.DependencyContractError, + "must not use a multi-line plain run scalar", + ): + CONTRACT._require_hash_enforced_workflow(workflow) + + def test_non_python_shell_command_outside_install_step_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + workflow = self._write_workflow( + root, + self._workflow_with_install_commands( + list(CONTRACT.CANONICAL_INSTALL_COMMANDS), + extra_steps=[ + " - name: Hidden shell", + " run: |", + ' bash -c "$COMMAND"', + ], + ), + ) + with patch.object(CONTRACT, "ROOT", root): + with self.assertRaisesRegex( + CONTRACT.DependencyContractError, + "outside the restricted Python grammar", + ): + CONTRACT._require_hash_enforced_workflow(workflow) + + +if __name__ == "__main__": + unittest.main()